9 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 d805dbc407 Lay the scheme picker out as tiles and pass its attributes to the group
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m12s
tests / feature (8.5) (push) Successful in 1m16s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m43s
tests / browser (safari, webkit) (push) Successful in 5m22s
The check sat beside the name and truncated it at four columns; it now
sits in the tile's corner under the swatch dots. Attributes other than
the binding (data-test, class) reach the fieldset, and the dots' ring
follows the selected tile's colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:39:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb42f004b1 Add colour profiles
An application lists named schemes in livewire-material.profiles, and
material:scheme without a seed generates them all into one stylesheet:
the default profile as the plain blocks, every profile under
<html data-scheme>, with descendant selectors so a nested data-theme
panel keeps the page's profile. The JSON keeps the 1.0 top level.

Scheme::resolveProfileUsing() lets the application name the active
profile; it is asked on every use and falls back to the default for an
unknown name or a resolver that throws. The head script writes it before
the first paint and keeps it through wire:navigate, and the mail theme
and the error pages' fallback draw it. <x-scheme-picker> chooses one and
previews it through $store.theme.previewScheme(); the showcase previews
the Workbench's profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:35:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7798352cfc Compile unprefixed components to the same view on every machine
tests / lint (push) Successful in 1m3s
tests / feature (8.4) (push) Successful in 1m14s
tests / feature (8.5) (push) Successful in 1m14s
tests / browser (chrome, chromium) (push) Successful in 3m16s
tests / browser (firefox, firefox) (push) Successful in 4m36s
tests / browser (safari, webkit) (push) Successful in 5m8s
Without a prefix, Blade named the components' view namespace after the
folder's absolute path, while compiled views are named relative to the
application. A view compiled on a laptop and served from a container
sharing storage/framework/views (or from another release directory)
printed 'eb5f…::input' as text. The components now register under the
prefix livewire-material when none is configured, which unprefixed tags
still resolve through, and the path's namespace stays registered for
views compiled before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:16:52 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cb3aa798a0 Keep a full-screen dialog's subtitle on a phone
tests / lint (push) Successful in 1m20s
tests / feature (8.4) (push) Successful in 1m13s
tests / feature (8.5) (push) Successful in 1m13s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m10s
The bar names the dialog there, so only the headline hides; the subtitle
used to disappear with it, and a subtitle without a title never showed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:57:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 648a4cfe0f Show a plain form field's errors under its name
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m9s
tests / feature (8.5) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m19s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / browser (firefox, firefox) (push) Successful in 4m34s
Fields read their errors only under the wire:model name, so a Fortify
login form (name="email", no wire:model) never showed "These
credentials do not match". Without wire:model they now read the name,
turning brackets into dots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:41:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 51bf034b74 Add a search to the showcase
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
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
Andreas Reinhold / reiniandClaude Opus 5 7ebcb0565f Send a carousel scroll on when it comes to rest short of its item
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit twice left the carousel on its first item after Next. A
scroll started by the buttons or keys that comes to rest anywhere but
its item is now sent there once more; a scroll the person starts
themselves cancels that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:40:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 889f8a8aa1 Put the showcase in the app shell, with a page per section
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
The showcase is now an overview and one page per section behind a
grouped navigation rail, moved between with wire:navigate, instead of
one long page under a scrolling top bar. The switch exposed a WebKit
bug in the menu: inside a focusable region, WebKit moves focus out of
the closing popover before its toggle event, so Escape no longer
returned focus to the trigger. The menu now reads it on beforetoggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 23218431bf Add a clear button to the date picker
`clearable` empties a date, or both ends of a range, closes an open
picker and hands focus back to the field, as the time picker's does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
63 changed files with 2720 additions and 163 deletions
+6 -1
View File
@@ -74,6 +74,10 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file. Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) 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 ### Configuration
```bash ```bash
@@ -82,6 +86,7 @@ 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. - `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.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`. - `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`). - `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`. - `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
@@ -124,7 +129,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
## Showcase ## Showcase
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` renders every token and component, in every variant, in the application's own scheme and theme. 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 app shell), with a search over every section, example and component (press `/`).
## Testing the design ## Testing the design
+18
View File
@@ -107,6 +107,24 @@ return [
'scheme' => resource_path('css/material-scheme.json'), 'scheme' => resource_path('css/material-scheme.json'),
/*
|--------------------------------------------------------------------------
| Colour profiles
|--------------------------------------------------------------------------
|
| Named schemes an installation can switch between. Each one is a 'label',
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'. Without a
| seed, `php artisan material:scheme` generates every profile into one
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
| (else the first); the application says which is active with
| Scheme::resolveProfileUsing(). Regenerate after changing either.
|
*/
'profiles' => [],
'profile' => null,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Mail | Mail
+12 -2
View File
@@ -589,12 +589,22 @@ pages and the mail theme by an agent in its own worktree. What changed from the
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and `__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
compiled views keep that name. compiled views keep that name.
- **A test checks that every component appears in the showcase.** - **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 - **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`): 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 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. a toast works without console errors; `/material` and the 404 page render in its scheme.
- **Known gap:** `<x-datepicker>` has no clear button (`<x-timepicker clearable>` does); a date can be - **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
emptied through its text input. does.
### Phase 11 — SealShare 2.0.0 ### Phase 11 — SealShare 2.0.0
+1 -1
View File
@@ -5,7 +5,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component. - Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`). They compile to nothing and fail silently. - Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`). They compile to nothing and fail silently.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand. - Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase). - While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`. - HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
@endverbatim @endverbatim
@@ -46,6 +46,34 @@ 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. 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.
### Colour profiles
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
```php
'profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
],
'profile' => 'indigo', // the default; else the first
```
```bash
php artisan material:scheme
```
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
```php
use NoNameWeb\LivewireMaterial\Support\Scheme;
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
```
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
## Tokens ## 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. 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.
@@ -62,7 +90,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
## Theme ## Theme
`config/livewire-material.php``theme.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. `config/livewire-material.php``theme.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. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
## Toasts ## Toasts
@@ -467,7 +495,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>` ### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus. M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`. - `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`. - `<x-password>`: a reveal button; `icon`, `size`.
@@ -523,7 +551,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
```blade ```blade
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" /> <x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" /> <x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" /> <x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
``` ```
| Prop | Default | | | Prop | Default | |
@@ -534,6 +562,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| `label`, `hint`, `icon`, `variant`, `size` | | the field's | | `label`, `hint`, `icon`, `variant`, `size` | | the field's |
| `value` | `null` | the initial value without `wire:model` | | `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) | | `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field. Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
@@ -723,6 +752,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store. Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
### `<x-scheme-picker>`
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
```blade
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
```
### `<x-table>`, `<x-sort-header>` ### `<x-table>`, `<x-sort-header>`
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`. A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
+38 -1
View File
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
/** A programmatic scroll still counts as where the carousel is going for this long. */ /** A programmatic scroll still counts as where the carousel is going for this long. */
const TARGET_MS = 700 const TARGET_MS = 700
// How long the row must go without a scroll event to count as having come to rest.
const SETTLE_MS = 150
const ITEM = '[data-material-carousel-item]' const ITEM = '[data-material-carousel-item]'
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])' const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
frame: null, frame: null,
target: null, target: null,
targetAt: 0, targetAt: 0,
settle: null,
listeners: [], listeners: [],
mutations: null, mutations: null,
resizes: null, resizes: null,
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true }) this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
// A scroll the person makes themselves is theirs to end wherever it ends.
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
this.listen(scroller, type, () => (state.target = null), { passive: true })
}
this.listen(scroller, 'keydown', (event) => this.navigate(event)) this.listen(scroller, 'keydown', (event) => this.navigate(event))
this.listen(scroller, 'focusin', (event) => this.reveal(event)) this.listen(scroller, 'focusin', (event) => this.reveal(event))
this.listen(scroller, 'click', (event) => this.open(event)) this.listen(scroller, 'click', (event) => this.open(event))
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
state.frame ??= requestAnimationFrame(() => this.render()) state.frame ??= requestAnimationFrame(() => this.render())
}, },
/**
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
* when the masks change the layout under it, so an arrival somewhere else is sent on
* again, once, at once.
*/
scrolled() {
this.schedule()
clearTimeout(state.settle)
state.settle = setTimeout(() => {
const target = state.target
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
return
}
state.target = null
const left = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
}
}, SETTLE_MS)
},
/** Carousel.kt's carouselItem layer block, for every item. */ /** Carousel.kt's carouselItem layer block, for every item. */
render() { render() {
cancelAnimationFrame(state.frame) cancelAnimationFrame(state.frame)
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
destroy() { destroy() {
cancelAnimationFrame(state.frame) cancelAnimationFrame(state.frame)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove()) state.listeners.forEach((remove) => remove())
state.resizes?.disconnect() state.resizes?.disconnect()
state.mutations?.disconnect() state.mutations?.disconnect()
+12
View File
@@ -366,6 +366,18 @@ document.addEventListener('alpine:init', () => {
} }
}, },
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
clear() {
if (this.open) {
this.cancel(false)
}
this.fieldError = ''
this.text = ''
this.write(null)
this.$refs.input.focus()
},
fieldProblem(value) { fieldProblem(value) {
if (!config.range) { if (!config.range) {
return this.problem(value) return this.problem(value)
+11 -1
View File
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({ window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity, closedAt: -Infinity,
returnFocus: true, returnFocus: true,
focusWasInside: false,
listeners: [], listeners: [],
init() { init() {
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
// and close() have already done their part, synchronously, because this event is // and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told // queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut. // the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
})
this.listen(menu, 'toggle', (event) => { this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open' const opened = event.newState === 'open'
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
this.closedAt = performance.now() this.closedAt = performance.now()
if (this.returnFocus && menu.contains(document.activeElement)) { if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus() this.control()?.focus()
} }
this.focusWasInside = false
}) })
// A press outside closes the menu without pulling focus back to the trigger. // A press outside closes the menu without pulling focus back to the trigger.
+14
View File
@@ -7,6 +7,10 @@
* *
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
* through the same write as `set()` and `toggle()`: the attributes, then localStorage. * through the same write as `set()` and `toggle()`: the attributes, then localStorage.
*
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and
* `previewScheme(name)` shows another one on this page without storing anything the
* application saves a choice itself, and the next full load draws what it saved.
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
const root = document.documentElement const root = document.documentElement
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.store('theme', { window.Alpine.store('theme', {
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
scheme: root.dataset.scheme || null,
get value() { get value() {
return this.choice return this.choice
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
toggle() { toggle() {
this.set(this.resolved === 'dark' ? 'light' : 'dark') this.set(this.resolved === 'dark' ? 'light' : 'dark')
}, },
previewScheme(name) {
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
return
}
this.scheme = name
root.dataset.scheme = name
},
}) })
// The head script repaints on an OS change while the choice is `system`; this keeps the // The head script repaints on an OS change while the choice is `system`; this keeps the
@@ -18,7 +18,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12); $id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
+4 -2
View File
@@ -35,8 +35,10 @@
@php @php
$model = $attributes->wire('model')->value() ?: null; $model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors) // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: []; : [];
$choices = collect($options)->map(fn ($option): array => [ $choices = collect($options)->map(fn ($option): array => [
'value' => data_get($option, $optionValue), 'value' => data_get($option, $optionValue),
@@ -21,7 +21,8 @@
with, and the errors for `period`, `period.start` and `period.end` all belong to this field. with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the `min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size` keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read. the `wire:model` name replace the hint, and so does a typed date that cannot be read.
@@ -48,6 +49,7 @@
'min' => null, 'min' => null,
'max' => null, 'max' => null,
'value' => null, 'value' => null,
'clearable' => false,
]) ])
@php @php
@@ -55,8 +57,10 @@
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked'; $mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id); $anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
$messages = $model !== null && isset($errors) // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: []; : [];
$locale = str_replace('_', '-', app()->getLocale()); $locale = str_replace('_', '-', app()->getLocale());
@@ -179,6 +183,19 @@
/> />
<x-slot:trailing> <x-slot:trailing>
@if ($clearable)
<button
type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
x-on:click="clear()"
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-field-clear
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button <button
type="button" type="button"
class="field-trailing field-button" class="field-trailing field-button"
+4 -2
View File
@@ -21,8 +21,10 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors) // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: []; : [];
@endphp @endphp
+3 -1
View File
@@ -33,7 +33,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model; $name ??= $model;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$segment = [ $segment = [
+3 -1
View File
@@ -30,7 +30,9 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
+5 -4
View File
@@ -78,18 +78,19 @@
@endif @endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])> <div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
@if (filled($title) || $icon) @if (filled($title) || filled($subtitle) || $icon)
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])> {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon) @if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" /> <x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif @endif
@if (filled($title)) @if (filled($title))
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2> <h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif @endif
@if (filled($subtitle)) @if (filled($subtitle))
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p> <p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif @endif
@if ($separator) @if ($separator)
@@ -17,7 +17,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
+3 -1
View File
@@ -23,7 +23,9 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12); $name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12); $id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<fieldset <fieldset
@@ -0,0 +1,79 @@
{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
arrow keys move the choice. Choosing one shows it on the page at once
(`$store.theme.previewScheme`); storing it and telling `Scheme::resolveProfileUsing()` is
the application's. The dots are the only colours not drawn from tokens: they show other
profiles than the page's, so they are custom properties set inline from the scheme file's
checked hexes, light or dark with the page. Without profiles it renders nothing.
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
profile). A validation message for the bound property replaces the hint. --}}
@props([
'label' => null,
'hint' => null,
'name' => null,
'profiles' => null,
])
@php
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model ?: 'scheme';
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
@if ($profiles !== [])
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
@foreach ($profiles as $profile => $scheme)
<label
data-scheme-option="{{ $profile }}"
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="radio"
name="{{ $name }}"
value="{{ $profile }}"
x-on:change="$store.theme.previewScheme($event.target.value)"
class="peer sr-only"
/>
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
@foreach (['primary', 'secondary', 'tertiary'] as $role)
<span
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
></span>
@endforeach
</span>
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
<x-livewire-material::icon name="check" class="size-5" />
</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</fieldset>
@endif
+3 -1
View File
@@ -29,7 +29,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
@@ -19,7 +19,9 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
@@ -11,6 +11,10 @@
JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a 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. visitor who never chose, so changing `theme.default` later reaches them too.
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
active one `Scheme::profile()`, which asks the application's resolver is written to
<html data-scheme>, which the generated stylesheet keys each profile on.
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed` The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible (`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
@@ -27,6 +31,7 @@
$rail = config('livewire-material.rail'); $rail = config('livewire-material.rail');
$settings = [ $settings = [
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system', 'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme', 'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []), 'legacy' => array_values($theme['legacy_keys'] ?? []),
@@ -78,6 +83,10 @@
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current); root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
}; };
if (settings.scheme) {
root.setAttribute('data-scheme', settings.scheme);
}
root.setAttribute('data-theme-key', settings.key); root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice); root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-rail-key', settings.rail.key); root.setAttribute('data-rail-key', settings.rail.key);
@@ -87,7 +96,7 @@
media.addEventListener('change', apply); media.addEventListener('change', apply);
document.addEventListener('livewire:navigating', function (event) { document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) { var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)]; return [name, root.getAttribute(name)];
}); });
@@ -58,7 +58,9 @@
@php @php
$model = $attributes->wire('model')->value() ?: null; $model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12); $id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null; $cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
@@ -18,7 +18,7 @@
} }
@endphp @endphp
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4"> <div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title) @if ($title)
<h3 class="type-title-md">{{ $title }}</h3> <h3 class="type-title-md">{{ $title }}</h3>
@endif @endif
+30 -25
View File
@@ -1,31 +1,36 @@
@extends('livewire-material::showcase.layout') @extends('livewire-material::showcase.layout')
@section('content') @section('content')
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10"> <div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<p class="max-w-3xl type-body-lg text-on-surface-variant"> <div class="max-w-3xl space-y-3">
Every token and component, in this application's own scheme. <h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
<p class="type-body-lg text-on-surface-variant">
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
</p> </p>
</div>
@include('livewire-material::showcase.sections.colour') @foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
@include('livewire-material::showcase.sections.type') <section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
@include('livewire-material::showcase.sections.shape') <h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion') <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@include('livewire-material::showcase.sections.icons') @foreach ($entries as $key => $entry)
@include('livewire-material::showcase.sections.buttons') <x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
@include('livewire-material::showcase.sections.menus') <div class="flex items-start gap-4">
@include('livewire-material::showcase.sections.communication') <span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
@include('livewire-material::showcase.sections.progress') <x-livewire-material::icon :name="$entry['icon']" />
@include('livewire-material::showcase.sections.containment') </span>
@include('livewire-material::showcase.sections.carousel') <div class="min-w-0">
@include('livewire-material::showcase.sections.fields') <a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
@include('livewire-material::showcase.sections.chips') <p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
@include('livewire-material::showcase.sections.sliders') </div>
@include('livewire-material::showcase.sections.pickers') </div>
@include('livewire-material::showcase.sections.timepickers') </x-livewire-material::card>
@include('livewire-material::showcase.sections.bars') @endforeach
@include('livewire-material::showcase.sections.navigation') </div>
@include('livewire-material::showcase.sections.data') </section>
@include('livewire-material::showcase.sections.pages') @endforeach
</main> </div>
@endsection @endsection
+158 -26
View File
@@ -1,47 +1,179 @@
{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section,
collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone.
Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}}
@php
$sections ??= \NoNameWeb\LivewireMaterial\Showcase\Sections::all();
$section ??= null;
$title = $section !== null ? $sections[$section]['title'] : 'Livewire Material';
$destinations = [[
'title' => 'Overview',
'icon' => 'home',
'url' => route('livewire-material.showcase'),
'active' => $section === null,
'bar' => false,
]];
foreach ($sections as $key => $entry) {
$destinations[] = [
'title' => $entry['title'],
'icon' => $entry['icon'],
'url' => route('livewire-material.section', $key),
'active' => $key === $section,
'section' => $entry['group'],
'bar' => false,
];
}
@endphp
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>@yield('title', 'Livewire Material')</title> <title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
<x-livewire-material::theme-script /> <x-livewire-material::theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
</head> </head>
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased"> <body class="bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container"> <x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3"> <x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a> <a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections"> <x-slot:top>
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data', 'pages' => 'Pages'] as $anchor => $section) <x-livewire-material::app-bar variant="search">
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a> <x-slot:navigation>
@endforeach <span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
</nav> </x-slot:navigation>
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak> {{-- The search looks through every section, example and component: the index is
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label) fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
<button keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
type="button" <div
data-test="theme-{{ $choice }}" class="mx-auto w-full max-w-2xl"
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full" x-data="{
x-on:click="$store.theme.set('{{ $choice }}')" query: '',
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'" entries: null,
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()" async load() {
>{{ $label }}</button> this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
},
get matches() {
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
if (! this.entries || words.length === 0) return [];
const phrase = words.join(' ');
return this.entries
.map((entry) => {
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
if (! words.every((word) => text.includes(word))) return null;
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
return { ...entry, rank };
})
.filter(Boolean)
.sort((a, b) => a.rank - b.rank)
.slice(0, 30);
},
go(event, result) {
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault();
const url = new URL(result.url, window.location.href);
const search = this.$root.querySelector('[data-search]');
this.query = '';
if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) {
window.location.hash = url.hash;
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
} else {
window.Livewire.navigate(url.pathname + url.hash);
}
},
shortcut(event) {
const typing = event.target.closest('input, textarea, select, [contenteditable]');
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
event.preventDefault();
document.getElementById('showcase-search').focus();
}
},
}"
x-on:keydown.window="shortcut($event)"
>
<x-livewire-material::search
id="showcase-search"
placeholder="Search components, examples and sections"
x-model="query"
x-on:focus.once="load()"
x-on:keydown.enter.prevent="go($event, matches[0])"
>
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
</p>
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
Nothing matches <span x-text="query.trim()"></span>.
</p>
<template x-for="result in matches" x-bind:key="result.url + result.title">
<a
x-bind:href="result.url"
x-on:click="go($event, result)"
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
data-showcase-result
>
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
</a>
</template>
</x-livewire-material::search>
</div>
<x-slot:actions>
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
<x-slot:trigger>
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
</x-slot:trigger>
@foreach ($profiles as $name => $profile)
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
@endforeach @endforeach
</div> </x-livewire-material::menu>
</div> @endif
</header>
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
</x-slot:actions>
</x-livewire-material::app-bar>
</x-slot:top>
@yield('content') @yield('content')
</x-livewire-material::app-shell>
<x-livewire-material::toast />
@livewireScripts @livewireScripts
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
example on another page lands on the example. --}}
<script data-navigate-once>
document.addEventListener('livewire:navigated', () => {
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
const land = () => target()?.scrollIntoView({ block: 'start' });
// After the swap's own scroll to the top and the page transition, and once more when
// the page's components have settled their size.
requestAnimationFrame(() => requestAnimationFrame(land));
setTimeout(() => {
const box = target()?.getBoundingClientRect();
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
land();
}
}, 400);
});
</script>
</body> </body>
</html> </html>
@@ -0,0 +1,36 @@
{{-- One section of the showcase on a page of its own, with the way on to the next. The page's
heading names the section, so the section's own heading is only for a screen reader. --}}
@extends('livewire-material::showcase.layout')
@php
$keys = array_keys($sections);
$at = array_search($section, $keys, true);
$previous = $keys[$at - 1] ?? null;
$next = $keys[$at + 1] ?? null;
@endphp
@section('content')
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
<header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
</header>
<div class="space-y-16 [&>section>h2]:sr-only">
@include('livewire-material::showcase.sections.'.$section)
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
@if ($previous)
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
@else
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
@endif
@if ($next)
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
@endif
</nav>
</div>
</div>
@endsection
@@ -10,6 +10,14 @@
'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'], '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'], 'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
]; ];
$examples = [
'Colour profiles' => <<<'BLADE'
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
</div>
BLADE,
];
@endphp @endphp
<section id="colour" class="scroll-mt-24 space-y-6"> <section id="colour" class="scroll-mt-24 space-y-6">
@@ -20,6 +28,16 @@
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing. <code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
</p> </p>
<p class="max-w-3xl type-body-md text-on-surface-variant">
With colour profiles in <code>livewire-material.profiles</code>, the command generates each one under
<code>&lt;html data-scheme&gt;</code>, and <code>&lt;x-scheme-picker&gt;</code> chooses between them. Without
profiles the picker draws nothing.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
@foreach (['light', 'dark'] as $theme) @foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface"> <div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
@@ -1,9 +1,9 @@
@php @php
$examples = [ $examples = [
'docked' => <<<'BLADE' 'Docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }"> <div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" /> <x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p> <p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
</div> </div>
@@ -13,7 +13,7 @@
</div> </div>
</div> </div>
BLADE, BLADE,
'modal and modal input' => <<<'BLADE' 'Modal and modal input' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }"> <div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" /> <x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
@@ -26,7 +26,7 @@
</div> </div>
</div> </div>
BLADE, BLADE,
'range' => <<<'BLADE' 'Range' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }"> <div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" /> <x-datepicker label="Trip" range x-model="trip" />
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
BLADE, BLADE,
'limits, errors and states' => <<<'BLADE' 'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" /> <x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
@@ -1,6 +1,6 @@
@php @php
$examples = [ $examples = [
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE' '12 and 24 hours, outlined and filled' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" /> <x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
@@ -15,7 +15,7 @@
</div> </div>
</div> </div>
BLADE, BLADE,
'Time pickers: steps and limits' => <<<'BLADE' 'Steps and limits' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" /> <x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
+1 -1
View File
@@ -77,7 +77,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" /> <x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate /> <x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
</div> </div>
</div> </div>
</x-livewire-material::app-shell> </x-livewire-material::app-shell>
+9 -1
View File
@@ -1,10 +1,12 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController; use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController; use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
use NoNameWeb\LivewireMaterial\Showcase\Sections;
Route::view('/', 'livewire-material::showcase.index')->name('showcase'); Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols'); Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol'); Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
@@ -17,3 +19,9 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503']) ->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
->name('error'); ->name('error');
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail'); Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
Route::get('search.json', [ShowcaseController::class, 'search'])->name('search');
Route::get('{section}', [ShowcaseController::class, 'section'])
->whereIn('section', array_keys(Sections::all()))
->name('section');
+142 -28
View File
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use JsonException; use JsonException;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
* @var string * @var string
*/ */
protected $signature = 'material:scheme protected $signature = 'material:scheme
{seed : The source colour, as #rrggbb} {seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad} {--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
{--contrast=0 : The contrast level, from -1 to 1} {--contrast=0 : The contrast level, from -1 to 1}
{--success=#22a06b : The source of the success colour} {--success=#22a06b : The source of the success colour}
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
/** /**
* @var string * @var string
*/ */
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour'; protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
public function handle(Filesystem $files): int public function handle(Filesystem $files): int
{ {
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css'); $stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json'; $data = preg_replace('/\.css$/', '', $stylesheet).'.json';
if (filled($this->argument('seed'))) {
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
if ($scheme === null) {
return self::FAILURE;
}
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
}
$profiles = config('livewire-material.profiles');
if (! is_array($profiles) || $profiles === []) {
$this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.');
return self::FAILURE;
}
$generated = [];
foreach ($profiles as $name => $profile) {
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
$this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not.");
return self::FAILURE;
}
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
if ($scheme === null) {
return self::FAILURE;
}
$generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme];
}
$configured = config('livewire-material.profile');
$default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated);
return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [
...collect($generated[$default])->except('label')->all(),
'default' => $default,
'profiles' => $generated,
]);
}
/**
* One scheme from Google's colour utilities, or null once the reason has been shown.
*
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
{
$result = Process::run([ $result = Process::run([
config('livewire-material.node', 'node'), config('livewire-material.node', 'node'),
__DIR__.'/../../resources/node/scheme.mjs', __DIR__.'/../../resources/node/scheme.mjs',
json_encode([ json_encode([
'seed' => $this->argument('seed'), 'seed' => $seed,
'variant' => $this->option('variant'), 'variant' => $variant,
'contrast' => (float) $this->option('contrast'), 'contrast' => $contrast,
'success' => $this->option('success'), 'success' => $this->option('success'),
'warning' => $this->option('warning'), 'warning' => $this->option('warning'),
'info' => $this->option('info'), 'info' => $this->option('info'),
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
]); ]);
if ($result->failed()) { if ($result->failed()) {
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'); $this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
return self::FAILURE; return null;
} }
try { try {
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */ return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) { } catch (JsonException) {
$this->components->error('The scheme generator answered with something other than JSON.'); $this->components->error($context.'The scheme generator answered with something other than JSON.');
return self::FAILURE; return null;
}
} }
/**
* @param array<string, mixed> $scheme
*/
protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int
{
$files->ensureDirectoryExists(dirname($stylesheet)); $files->ensureDirectoryExists(dirname($stylesheet));
$files->put($stylesheet, $this->stylesheet($scheme)); $files->put($stylesheet, $css);
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); $files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
$this->components->info("Wrote {$stylesheet} and {$data}."); $this->components->info("Wrote {$stylesheet} and {$data}.");
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '', $scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
); );
$block = fn (array $roles): string => collect($roles) $blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return <<<CSS return <<<CSS
/* /*
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
* first paint; the light block also stands without it. * first paint; the light block also stands without it.
*/ */
:root, {$blocks}
[data-theme='light'] {
color-scheme: light;
{$block($scheme['light'])}
}
[data-theme='dark'] {
color-scheme: dark;
{$block($scheme['dark'])}
}
CSS; CSS;
} }
/**
* The default profile as the plain blocks, then every profile under its own data-scheme. A
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
* comes after `:root`, which it only ties with: the order is part of the format. The
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
* page's profile.
*
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
*/
protected function profilesStylesheet(array $profiles, string $default): string
{
$list = collect($profiles)
->map(fn (array $profile, string $name): string => sprintf(
' * %-12s %s, %s%s',
$name,
$profile['seed'],
$profile['variant'],
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
))
->implode("\n");
$css = <<<CSS
/*
* Material 3 colour profiles, generated by Google's material-color-utilities (spec {$profiles[$default]['spec']}).
*
* php artisan material:scheme
*
* From livewire-material.profiles. "{$default}" is the default, and also stands without a
* data-scheme attribute:
*
{$list}
*
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
* before the first paint.
*/
CSS;
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
foreach ($profiles as $name => $profile) {
$css .= "\n".$this->blocks(
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
$profile,
);
}
return $css;
}
/**
* A light and a dark block of roles under the given selectors.
*
* @param list<string> $light
* @param list<string> $dark
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function blocks(array $light, array $dark, array $scheme): string
{
$roles = fn (array $roles): string => collect($roles)
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
}
} }
@@ -0,0 +1,34 @@
<?php
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use NoNameWeb\LivewireMaterial\Showcase\Sections;
/**
* The showcase: an overview of every section, and a page for each.
*/
class ShowcaseController
{
public function index(): View
{
return view('livewire-material::showcase.index', ['sections' => Sections::all()]);
}
public function section(string $section): View
{
return view('livewire-material::showcase.section', [
'sections' => Sections::all(),
'section' => $section,
]);
}
/**
* The search's index, fetched when the search is first focused rather than written into every page.
*/
public function search(): JsonResponse
{
return response()->json(Sections::index());
}
}
+13 -2
View File
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class LivewireMaterialServiceProvider extends ServiceProvider class LivewireMaterialServiceProvider extends ServiceProvider
@@ -113,7 +114,15 @@ class LivewireMaterialServiceProvider extends ServiceProvider
} }
/** /**
* Register the components as anonymous Blade components under the configured prefix. * Register the components as anonymous Blade components under the configured prefix, or
* under `livewire-material` without one.
*
* A compiled `<x-button>` names the view namespace Blade derives from the prefix, or from the
* component folder's absolute path when there is none. That path differs between a laptop and
* a container sharing `storage/framework/views`, or between release directories, while the
* compiled file's name does not so a view compiled on one showed `a1b2…::button` as text on
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
* registered, so views compiled before this release still render until they are recompiled.
* *
* The package's own views never go through this registration: they write * The package's own views never go through this registration: they write
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the * `<x-livewire-material::button>`, which resolves through the view namespace whatever the
@@ -123,8 +132,10 @@ class LivewireMaterialServiceProvider extends ServiceProvider
{ {
Blade::anonymousComponentPath( Blade::anonymousComponentPath(
static::componentPath(), static::componentPath(),
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null, filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
); );
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
} }
/** /**
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace NoNameWeb\LivewireMaterial\Showcase;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
/**
* The showcase's pages: one per section, grouped as the rail groups them.
*/
class Sections
{
/**
* What the showcase's search looks through: every section, every example in it (linked to the
* example's anchor), and every component, linked to the first section that introduces it.
*
* @return list<array{title: string, kind: string, context: string, url: string, keywords: string}>
*/
public static function index(): array
{
$sections = static::all();
$components = collect(glob(LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
->map(fn (string $path): string => basename($path, '.blade.php'))
->all();
$sources = collect($sections)->map(fn (array $section, string $key): string => (string) file_get_contents(static::path($key)));
$entries = [];
$homes = [];
foreach ($sections as $key => $section) {
$url = route('livewire-material.section', $key, false);
$entries[] = ['title' => $section['title'], 'kind' => 'Section', 'context' => $section['group'], 'url' => $url, 'keywords' => $section['description']];
preg_match_all("/^\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*=>\\s*<<<'BLADE'/m", $sources[$key], $examples);
foreach ($examples[1] as $title) {
$title = stripslashes($title);
$entries[] = ['title' => $title, 'kind' => 'Example', 'context' => $section['title'], 'url' => $url.'#'.static::anchor($title), 'keywords' => ''];
}
}
// A section that names a component in its introduction is its home; otherwise the first that uses it.
foreach (['/&lt;x-([a-z0-9-]+)&gt;/', '/<x-(?:livewire-material::)?([a-z0-9-]+)(?=[\\s\\/>])/'] as $pattern) {
foreach ($sources as $key => $source) {
preg_match_all($pattern, $source, $tags);
foreach ($tags[1] as $tag) {
if (in_array($tag, $components, true)) {
$homes[$tag] ??= $key;
}
}
}
}
foreach ($homes as $tag => $key) {
$entries[] = ['title' => "<x-{$tag}>", 'kind' => 'Component', 'context' => $sections[$key]['title'], 'url' => route('livewire-material.section', $key, false), 'keywords' => str_replace('-', ' ', $tag)];
}
return $entries;
}
/**
* The anchor of an example on its section's page.
*/
public static function anchor(string $title): string
{
return 'example-'.Str::slug($title);
}
/**
* A section's view file.
*/
public static function path(string $key): string
{
return dirname(__DIR__, 2).'/resources/views/showcase/sections/'.$key.'.blade.php';
}
/**
* @return array<string, array{title: string, icon: string, group: string, description: string}>
*/
public static function all(): array
{
return [
'colour' => ['title' => 'Colour', 'icon' => 'palette', 'group' => 'Foundations', 'description' => 'Every colour role of the generated scheme, light and dark.'],
'type' => ['title' => 'Type', 'icon' => 'text_fields', 'group' => 'Foundations', 'description' => 'The typescale, emphasized styles included, in Google Sans Flex.'],
'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'],
'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'],
'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'],
'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'],
'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'],
'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'],
'chips' => ['title' => 'Chips', 'icon' => 'sell', 'group' => 'Actions', 'description' => 'Assist, filter, input and suggestion chips.'],
'communication' => ['title' => 'Communication', 'icon' => 'notifications', 'group' => 'Communication', 'description' => 'Badges, snackbars, tooltips, alerts, stats and empty states.'],
'progress' => ['title' => 'Progress', 'icon' => 'progress_activity', 'group' => 'Communication', 'description' => 'Linear, circular and wavy progress, and the loading indicator.'],
'containment' => ['title' => 'Containment', 'icon' => 'web_asset', 'group' => 'Containment', 'description' => 'Cards, lists, dividers, dialogs and sheets.'],
'carousel' => ['title' => 'Carousel', 'icon' => 'view_carousel', 'group' => 'Containment', 'description' => 'Multi-browse, hero, uncontained and full-screen carousels.'],
'fields' => ['title' => 'Text fields', 'icon' => 'edit_note', 'group' => 'Inputs', 'description' => 'Text fields, selects, checkboxes, radios, switches, choices and search.'],
'sliders' => ['title' => 'Sliders', 'icon' => 'tune', 'group' => 'Inputs', 'description' => 'Standard, centered and range sliders in five sizes.'],
'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'],
'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'],
'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'],
'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'],
'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'],
'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'],
];
}
}
+131 -13
View File
@@ -2,9 +2,14 @@
namespace NoNameWeb\LivewireMaterial\Support; namespace NoNameWeb\LivewireMaterial\Support;
use Closure;
use Illuminate\Support\Str;
use Throwable;
/** /**
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client * The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
* resolves none, and an error page whose build is missing has no stylesheet to declare them. * resolves none, an error page whose build is missing has no stylesheet to declare them, and the
* head script has to name the active colour profile before the stylesheet applies.
* *
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet * The data is the JSON `php artisan material:scheme` writes beside the stylesheet
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that * (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
@@ -13,18 +18,51 @@ namespace NoNameWeb\LivewireMaterial\Support;
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is * the role existed) is taken from the default, and a value that is not a #rrggbb hex is
* refused, so nothing but a colour ever reaches a stylesheet. * refused, so nothing but a colour ever reaches a stylesheet.
* *
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running * A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
* worker holds nothing. * default under `default`, and the default's roles at the top level as a single scheme does. Which
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
* asked on every call, and a name it gives that is not in the file or a resolver that throws, as
* one reading a database may while an error page for that very database renders falls back to
* the file's default.
*
* Read on every call and never kept: a regenerated scheme or a newly chosen profile applies at
* once, and a long-running worker holds nothing but the resolver itself.
*/ */
class Scheme class Scheme
{ {
/** /**
* @var (Closure(): ?string)|null
*/
protected static ?Closure $profileResolver = null;
/**
* Name the active colour profile with the given closure, asked each time a colour is drawn.
*
* @param (Closure(): ?string)|null $resolver
*/
public static function resolveProfileUsing(?Closure $resolver): void
{
static::$profileResolver = $resolver;
}
/**
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
*
* @return array{light: array<string, string>, dark: array<string, string>} * @return array{light: array<string, string>, dark: array<string, string>}
*/ */
public static function load(?string $path = null): array public static function load(?string $path = null, ?string $profile = null): array
{ {
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'); $data = static::data($path);
$scheme = static::read($path ?? (string) config('livewire-material.scheme')); $profiles = static::profilesFrom($data);
if ($profiles !== []) {
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
}
$default = static::defaultScheme();
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
return [ return [
'light' => [...$default['light'], ...$scheme['light']], 'light' => [...$default['light'], ...$scheme['light']],
@@ -37,24 +75,104 @@ class Scheme
* *
* @return array<string, string> * @return array<string, string>
*/ */
public static function light(?string $path = null): array public static function light(?string $path = null, ?string $profile = null): array
{ {
return static::load($path)['light']; return static::load($path, $profile)['light'];
} }
/** /**
* @return array{light: array<string, string>, dark: array<string, string>} * The generated colour profiles, in the order they were configured; empty for a single scheme.
*
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/ */
protected static function read(string $path): array public static function profiles(?string $path = null): array
{ {
return static::profilesFrom(static::data($path));
}
/**
* The active profile's name, or null for a single scheme.
*/
public static function profile(?string $path = null): ?string
{
$data = static::data($path);
$profiles = static::profilesFrom($data);
return $profiles === [] ? null : static::activeFrom($data, $profiles);
}
/**
* @return array<mixed>
*/
protected static function data(?string $path): array
{
$path ??= (string) config('livewire-material.scheme');
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; $data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
return [ return is_array($data) ? $data : [];
'light' => static::roles($data['light'] ?? null), }
'dark' => static::roles($data['dark'] ?? null),
/**
* @param array<mixed> $data
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
protected static function profilesFrom(array $data): array
{
if (! is_array($data['profiles'] ?? null)) {
return [];
}
$default = static::defaultScheme();
$profiles = [];
foreach ($data['profiles'] as $name => $profile) {
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
continue;
}
$profiles[$name] = [
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
]; ];
} }
return $profiles;
}
/**
* @param array<mixed> $data
* @param non-empty-array<string, mixed> $profiles
*/
protected static function activeFrom(array $data, array $profiles): string
{
try {
$resolved = static::$profileResolver ? (static::$profileResolver)() : null;
} catch (Throwable) {
$resolved = null;
}
foreach ([$resolved, $data['default'] ?? null] as $candidate) {
if (is_string($candidate) && isset($profiles[$candidate])) {
return $candidate;
}
}
return (string) array_key_first($profiles);
}
/**
* The package's own scheme, which fills any role a file lacks.
*
* @return array{light: array<string, string>, dark: array<string, string>}
*/
protected static function defaultScheme(): array
{
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
+7 -7
View File
@@ -2,9 +2,9 @@
const MORE = '#menus [aria-label="More"]'; const MORE = '#menus [aria-label="More"]';
function showcase() function showcase(string $section = 'buttons')
{ {
return visit('/material')->waitForEvent('networkidle') return visit("/material/{$section}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
@@ -14,7 +14,7 @@ function focused(string $expression): string
} }
it('opens a menu on the first item and walks it with the keyboard', function () { it('opens a menu on the first item and walks it with the keyboard', function () {
$page = showcase() $page = showcase('menus')
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->click(MORE) ->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true') ->assertAttribute(MORE, 'aria-expanded', 'true')
@@ -38,7 +38,7 @@ it('opens a menu on the first item and walks it with the keyboard', function ()
}); });
it('opens a menu from the keyboard, on the last item with ArrowUp', function () { it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
$page = showcase(); $page = showcase('menus');
$page->script("document.querySelector('".MORE."').focus()"); $page->script("document.querySelector('".MORE."').focus()");
@@ -50,13 +50,13 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
it('opens a menu the moment the page can be used', function () { it('opens a menu the moment the page can be used', function () {
// The guard against a light-dismiss press reopening the menu once measured from the page's // The guard against a light-dismiss press reopening the menu once measured from the page's
// time origin, and swallowed every click in the first quarter second. // time origin, and swallowed every click in the first quarter second.
visit('/material') visit('/material/menus')
->click(MORE) ->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true'); ->assertAttribute(MORE, 'aria-expanded', 'true');
}); });
it('closes a menu when an item is chosen, but not one that keeps it open', function () { it('closes a menu when an item is chosen, but not one that keeps it open', function () {
$page = showcase(); $page = showcase('menus');
$page->click(MORE) $page->click(MORE)
->click('#menus [role="menuitem"]:has-text("Download")') ->click('#menus [role="menuitem"]:has-text("Download")')
@@ -76,7 +76,7 @@ it('shows a tooltip on keyboard focus and hides it on Escape', function () {
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly // Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order // rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
// unless full keyboard access is on. // unless full keyboard access is on.
$page->keys('body', 'Tab'); $page->keys('#content', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()"); $page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'")) $page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
->assertScript("{$tooltip}.matches(':popover-open')"); ->assertScript("{$tooltip}.matches(':popover-open')");
+1 -1
View File
@@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
function carouselShowcase(array $options = []) function carouselShowcase(array $options = [])
{ {
return visit('/material', $options) return visit('/material/carousel', $options)
->waitForEvent('networkidle') ->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined'"); ->assertScript("typeof window.Alpine !== 'undefined'");
} }
+2 -2
View File
@@ -74,7 +74,7 @@ function chipProbe()
function chipShowcase() function chipShowcase()
{ {
return visit('/material')->waitForEvent('networkidle') return visit('/material/chips')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
@@ -99,7 +99,7 @@ it('toggles a filter chip with a click and with Space, and grows its check in',
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to, // A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on. // because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
$page->keys('body', 'Tab'); $page->keys('#content', 'Tab');
$page->script(filterInput('archives').'.focus()'); $page->script(filterInput('archives').'.focus()');
$page->keys(':focus', 'Space') $page->keys(':focus', 'Space')
->assertScript(filterInput('archives').'.checked') ->assertScript(filterInput('archives').'.checked')
+88
View File
@@ -0,0 +1,88 @@
<?php
use NoNameWeb\LivewireMaterial\Support\Scheme;
use function Orchestra\Testbench\workbench_path;
/*
* The Workbench's three profiles (baseline, teal, rose), generated into its stylesheet. Tests other
* than these run without profiles, so each one here points the package at the scheme file.
*/
beforeEach(function () {
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
$this->profiles = json_decode(file_get_contents(workbench_path('resources/css/material-scheme.json')), true)['profiles'];
});
afterEach(function () {
Scheme::resolveProfileUsing(null);
});
/**
* A #rrggbb hex as the rgb() a computed style reports.
*/
function rgb(string $hex): string
{
[$red, $green, $blue] = sscanf($hex, '#%02x%02x%02x');
return "rgb({$red}, {$green}, {$blue})";
}
/**
* The page's primary colour role, as <html> resolves it.
*/
function pagePrimary(): string
{
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim()";
}
function profilesReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('draws each profile in light and dark, and the default without the attribute', function () {
$page = profilesReady(visit('/material/colour')->inLightMode())
->assertScript("document.documentElement.getAttribute('data-scheme') === 'baseline'")
->assertScript(pagePrimary()." === '{$this->profiles['baseline']['light']['primary']}'");
$page->script("document.documentElement.setAttribute('data-scheme', 'teal')");
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['light']['primary']}'");
$page->script("document.documentElement.setAttribute('data-theme', 'dark')");
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['dark']['primary']}'")
// A panel that sets its own theme keeps the page's profile.
->assertScript("getComputedStyle(document.querySelector('#colour [data-theme=\"light\"] .bg-primary')).backgroundColor === '".rgb($this->profiles['teal']['light']['primary'])."'");
$page->script("document.documentElement.removeAttribute('data-scheme')");
$page->assertScript(pagePrimary()." === '{$this->profiles['baseline']['dark']['primary']}'")
->assertNoJavaScriptErrors();
});
it('previews a profile from the picker and the showcase menu, and keeps it through wire:navigate', function () {
$page = profilesReady(visit('/material/colour')->inLightMode());
$page->click('#colour [data-scheme-option="rose"]')
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
->assertScript(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'")
->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'");
$page->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]')
->click('[data-scheme-preview="teal"]')
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
->assertScript("location.pathname.endsWith('/material/buttons')")
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
->assertNoJavaScriptErrors();
});
it('paints the profile the application resolves from the first frame', function () {
Scheme::resolveProfileUsing(fn (): string => 'rose');
profilesReady(visit('/material')->inDarkMode())
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
->assertScript(pagePrimary()." === '{$this->profiles['rose']['dark']['primary']}'");
});
+4 -4
View File
@@ -3,7 +3,7 @@
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')"; const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
it('shows a toast as a snackbar, then the next in turn', function () { it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material')->waitForEvent('networkidle') $page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })"); $page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
@@ -18,7 +18,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
}); });
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () { it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material')->waitForEvent('networkidle') $page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate // Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
@@ -29,7 +29,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
}); });
it('runs a toast\'s action and dismisses it', function () { it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material')->waitForEvent('networkidle') $page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })"); $page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
@@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () {
it('opens a persistent rich tooltip on press', function () { it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material')->waitForEvent('networkidle') visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#communication button:has-text("Press for details")') ->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')"); ->assertScript("{$bubble}.matches(':popover-open')");
+1 -1
View File
@@ -66,7 +66,7 @@ function overlayProbe()
function containment() function containment()
{ {
return visit('/material')->waitForEvent('networkidle') return visit('/material/containment')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
+17 -2
View File
@@ -33,10 +33,10 @@ class DateProbe extends Component
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p> <p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p> <p>renders: <span id="renders">{{ $renders }}</span></p>
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" /> <x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" /> <x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" /> <x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" /> <x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
</div> </div>
BLADE; BLADE;
} }
@@ -296,3 +296,18 @@ it('opens a docked picker as a modal one on a compact window', function () {
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')") ->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'"); ->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
}); });
it('empties a date, or both ends of a range, with its clear button', function () {
$page = dateProbe()
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', '');
});
+1 -1
View File
@@ -55,7 +55,7 @@ function progressShowcase(array $options = [])
{ {
// networkidle alone can return before a repeated visit has even loaded in Firefox; the // networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started. // assertion retries until the page is complete and Alpine has started.
return visit('/material', $options)->waitForEvent('networkidle') return visit('/material/progress', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
+48
View File
@@ -5,3 +5,51 @@ it('opens the showcase in a real browser', function () {
->assertSee('Livewire Material') ->assertSee('Livewire Material')
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
}); });
it('moves between sections through the rail and the next-section link, keeping the rail', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
->assertSee('Variants')
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
->assertScript("document.title === 'Buttons · Livewire Material'");
$page->click('[data-test="next-section"]')
->assertScript("location.pathname.endsWith('/material/menus')")
->assertNoJavaScriptErrors();
});
it('finds a component from anywhere and opens its section', function () {
$page = visit('/material/buttons')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->keys('#content', '/')
->assertScript("document.activeElement.id === 'showcase-search'");
$page->type('#showcase-search', 'datepicker')
->assertSeeIn('[data-search-view]', '<x-datepicker>');
$page->keys('#showcase-search', 'Enter')
->assertScript("location.pathname.endsWith('/material/pickers')")
->assertScript("document.title === 'Date pickers · Livewire Material'");
});
it('opens an example on another page at the example', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('#showcase-search')
->type('#showcase-search', 'split button')
->click('[data-showcase-result]:has-text("Example")')
->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')")
->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()');
});
it('says so when nothing matches', function () {
visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#showcase-search')
->type('#showcase-search', 'zzzz')
->assertSeeIn('[data-search-view]', 'Nothing matches');
});
+1 -1
View File
@@ -59,7 +59,7 @@ function sliderProbe()
function sliderShowcase() function sliderShowcase()
{ {
return visit('/material')->waitForEvent('networkidle') return visit('/material/sliders')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
+3 -3
View File
@@ -25,9 +25,9 @@ it('keeps the visitor\'s choice over the operating system', function () {
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertScript(theme('data-theme', 'dark')); ->assertScript(theme('data-theme', 'dark'));
$page->click('@theme-light') $page->click('[data-theme-option="light"]')
->assertScript(theme('data-theme', 'light')) ->assertScript(theme('data-theme', 'light'))
->assertAttribute('@theme-light', 'aria-pressed', 'true') ->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
->assertScript("localStorage.getItem('material-theme') === 'light'"); ->assertScript("localStorage.getItem('material-theme') === 'light'");
$page->refresh() $page->refresh()
@@ -52,7 +52,7 @@ it('adopts an earlier toggle\'s choice once', function () {
it('repaints a section that sets its own theme', function () { it('repaints a section that sets its own theme', function () {
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor"; $swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
visit('/material')->inLightMode() visit('/material/colour')->inLightMode()
->assertScript(theme('data-theme', 'light')) ->assertScript(theme('data-theme', 'light'))
->assertScript("{$swatch(0)} !== {$swatch(1)}"); ->assertScript("{$swatch(0)} !== {$swatch(1)}");
}); });
@@ -163,3 +163,12 @@ it('replaces the hint with the errors for the property and its start and end', f
->toContain('data-datepicker-error') ->toContain('data-datepicker-error')
->and(substr_count($html, 'data-invalid=""'))->toBe(2); ->and(substr_count($html, 'data-invalid=""'))->toBe(2);
}); });
it('offers a clear button on request', function () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-field-clear')
->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"')
->and((string) $this->blade('<x-datepicker label="Expires" />'))
->not->toContain('data-field-clear');
});
+11
View File
@@ -44,6 +44,17 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
->toContain('aria-label="Close"'); ->toContain('aria-label="Close"');
}); });
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () {
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
->not->toContain('<div class="mb-4 max-sm:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
});
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () { it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>')) expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"') ->toContain('x-trap.inert.noscroll="open && ! wide"')
@@ -0,0 +1,24 @@
<?php
it('shows the errors of a plain form field under its name', function (string $blade, string $key) {
$html = (string) $this->withViewErrors([$key => ['Something is wrong here.']])->blade($blade);
expect($html)
->toContain('Something is wrong here.')
->toContain('aria-invalid="true"');
})->with([
'input' => ['<x-input name="email" label="Email" />', 'email'],
'password' => ['<x-password name="password" label="Password" />', 'password'],
'textarea' => ['<x-textarea name="message" label="Message" />', 'message'],
'select' => ['<x-select name="hours" label="Expires" :options="[[\'id\' => 1, \'name\' => \'1 hour\']]" />', 'hours'],
'checkbox' => ['<x-checkbox name="terms" label="Terms" />', 'terms'],
'radio' => ['<x-radio name="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />', 'audience'],
'file with brackets' => ['<x-file name="photos[]" label="Photos" multiple />', 'photos'],
'nested name' => ['<x-input name="address[city]" label="City" />', 'address.city'],
'timepicker' => ['<x-timepicker name="starts_at" label="Starts at" />', 'starts_at'],
]);
it('keeps reading the wire:model name when both are there', function () {
expect((string) $this->withViewErrors(['form.email' => ['Taken.']])->blade('<x-input name="email" wire:model="form.email" label="Email" />'))
->toContain('Taken.');
});
@@ -0,0 +1,68 @@
<?php
use Illuminate\Support\Facades\File;
beforeEach(function () {
$this->path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json';
File::put($this->path, json_encode([
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'], 'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7']],
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b', 'secondary' => '#4a635f', 'tertiary' => '#456179'], 'dark' => ['primary' => '#80cbc4', 'secondary' => '#b1ccc6', 'tertiary' => '#adcae5']],
],
]));
config(['livewire-material.scheme' => $this->path]);
});
afterEach(function () {
File::delete($this->path);
});
it('draws a radio per generated profile, bound, named and previewing on change', function () {
$html = (string) $this->blade('<x-scheme-picker label="Colour profile" hint="Applies after saving" wire:model="colorProfile" />');
expect($html)
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Colour profile</legend>')
->toContain('Applies after saving')
->toContain('data-scheme-option="indigo"')
->toContain('data-scheme-option="teal"')
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
->toContain('>Teal</span>')
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
});
it('puts its other attributes on the group and the binding on the radios', function () {
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
expect($html)->toMatch('/<fieldset x-data data-scheme-picker class="min-w-0 mt-4" data-test="color-profile">/')
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
});
it('keeps its inline styles to the scheme file\'s checked colours', function () {
File::put($this->path, json_encode([
'default' => 'indigo',
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => ['primary' => 'red; background: url(x)'], 'dark' => []]],
]));
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
preg_match_all('/style="([^"]*)"/', $html, $styles);
expect($styles[1])->not->toBeEmpty()
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
});
it('shows a validation message for the bound property instead of the hint', function () {
$this->withViewErrors(['colorProfile' => ['Choose one of the colour profiles.']])
->blade('<x-scheme-picker wire:model="colorProfile" hint="Applies after saving" />')
->assertSee('Choose one of the colour profiles.')
->assertDontSee('Applies after saving');
});
it('renders nothing for a single scheme', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => []]));
expect(trim((string) $this->blade('<x-scheme-picker wire:model="colorProfile" />')))->toBe('');
});
+27 -2
View File
@@ -1,8 +1,11 @@
<?php <?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Scheme;
it('follows the operating system until the visitor chooses, by default', function () { it('follows the operating system until the visitor chooses, by default', function () {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false); ->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
}); });
it('takes the application\'s default, key and legacy keys', function () { it('takes the application\'s default, key and legacy keys', function () {
@@ -39,6 +42,28 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
it('puts its attributes back on <html> when wire:navigate swaps the page', function () { it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee("document.addEventListener('livewire:navigating'", false) ->assertSee("document.addEventListener('livewire:navigating'", false)
->assertSee("['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false) ->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
->assertSee('event.detail.onSwap(', false); ->assertSee('event.detail.onSwap(', false);
}); });
it('names the active colour profile for <html data-scheme>, and none for a single scheme', function () {
$path = sys_get_temp_dir().'/theme-script-profiles-'.uniqid().'.json';
File::put($path, json_encode([
'default' => 'indigo',
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => [], 'dark' => []], 'teal' => ['label' => 'Teal', 'light' => [], 'dark' => []]],
]));
config(['livewire-material.scheme' => $path]);
try {
$this->blade('<x-theme-script />')
->assertSee('({"scheme":"indigo",', false)
->assertSee("root.setAttribute('data-scheme', settings.scheme);", false);
Scheme::resolveProfileUsing(fn (): string => 'teal');
$this->blade('<x-theme-script />')->assertSee('({"scheme":"teal",', false);
} finally {
Scheme::resolveProfileUsing(null);
File::delete($path);
}
});
+28
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite; use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider; use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
beforeEach(function () { beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8); $this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
@@ -128,6 +129,33 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
->assertDontSee('/build/', false); ->assertDontSee('/build/', false);
}); });
it('draws the active colour profile when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b'], 'dark' => ['primary' => '#80cbc4']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
Scheme::resolveProfileUsing(fn (): string => 'teal');
try {
$this->get('/abort/500')
->assertStatus(500)
->assertSee('--md-sys-color-primary: #00897b;', false)
->assertSee('--md-sys-color-primary: #80cbc4;', false)
->assertDontSee('#4f46e5', false);
} finally {
Scheme::resolveProfileUsing(null);
}
});
it('shows the message an application passed for 403 and 503', function (int $code, string $message) { it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
withoutSkeletonErrorViews(); withoutSkeletonErrorViews();
+22
View File
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider; use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
class ThemedProbeMail extends Mailable class ThemedProbeMail extends Mailable
{ {
@@ -113,6 +114,27 @@ it('inlines the application\'s scheme onto the mail', function () {
->not->toContain('color-mix'); ->not->toContain('color-mix');
}); });
it('inlines the active colour profile onto the mail', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
'rose' => ['label' => 'Rose', 'light' => ['primary' => '#c2185b'], 'dark' => ['primary' => '#ffb0c8']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
Scheme::resolveProfileUsing(fn (): string => 'rose');
try {
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))->toContain('background-color: #c2185b');
} finally {
Scheme::resolveProfileUsing(null);
}
});
it('takes the theme from a mailable too', function () { it('takes the theme from a mailable too', function () {
config(['mail.markdown.theme' => 'default']); config(['mail.markdown.theme' => 'default']);
+65
View File
@@ -78,3 +78,68 @@ it('fails without writing anything when node cannot run', function () {
expect(File::exists($this->stylesheet))->toBeFalse(); expect(File::exists($this->stylesheet))->toBeFalse();
}); });
it('generates every configured profile into one stylesheet keyed by data-scheme', function () {
config([
'livewire-material.profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'],
],
'livewire-material.profile' => 'teal',
]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
expect($scheme['default'])->toBe('teal')
->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite'])
->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral')
->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary'])
// The top level is the default profile, as a 1.0 reader expects.
->and($scheme['light'])->toBe($scheme['profiles']['teal']['light'])
->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark'])
->and($scheme['seed'])->toBe('#00897b')
->and($stylesheet)->toContain('php artisan material:scheme'."\n");
// The default's plain blocks come first, so a profile's single-attribute selector follows :root.
expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};")
->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};")
->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};");
}
}
});
it('names the profile a generator error belongs to', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "broken"')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('asks for a seed or profiles when it has neither', function () {
config(['livewire-material.profiles' => []]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('livewire-material.profiles')
->assertFailed();
});
it('refuses a profile name that cannot be an attribute value', function () {
config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('"Ocean Blue" is not')
->assertFailed();
});
+80
View File
@@ -0,0 +1,80 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Scheme;
/**
* A scheme file with colour profiles, as `material:scheme` writes one from config.
*
* @param array<string, string> $primaries profile => light primary
*/
function writeProfiles(string $path, array $primaries, string $default): void
{
$profiles = collect($primaries)->map(fn (string $primary, string $name): array => [
'label' => Str::headline($name),
'seed' => $primary,
'variant' => 'vibrant',
'spec' => '2025',
'contrast' => 0,
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
])->all();
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
}
beforeEach(function () {
$this->path = sys_get_temp_dir().'/material-profiles-'.Str::random(8).'.json';
writeProfiles($this->path, ['indigo' => '#4f46e5', 'teal' => '#00897b', 'rose' => '#c2185b'], 'indigo');
});
afterEach(function () {
Scheme::resolveProfileUsing(null);
File::delete($this->path);
});
it('lists the generated profiles with their labels and roles, filling missing roles from the default', function () {
$profiles = Scheme::profiles($this->path);
expect(array_keys($profiles))->toBe(['indigo', 'teal', 'rose'])
->and($profiles['teal']['label'])->toBe('Teal')
->and($profiles['teal']['light']['primary'])->toBe('#00897b')
->and($profiles['teal']['light'])->toHaveKey('on-surface-variant')
->and(Scheme::profiles(sys_get_temp_dir().'/missing-scheme.json'))->toBe([]);
});
it('follows the resolver to the active profile, and falls back to the default', function () {
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::profile($this->path))->toBe('teal')
->and(Scheme::light($this->path)['primary'])->toBe('#00897b')
->and(Scheme::load($this->path)['dark']['surface'])->toBe('#111111');
Scheme::resolveProfileUsing(fn (): string => 'ocean');
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): ?string => null);
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn () => throw new RuntimeException('The database is down.'));
expect(Scheme::profile($this->path))->toBe('indigo');
});
it('loads a profile by name, whatever is active', function () {
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::light($this->path, 'rose')['primary'])->toBe('#c2185b')
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
});
it('has no profile for a single scheme, and loads it as before', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::profile($this->path))->toBeNull()
->and(Scheme::profiles($this->path))->toBe([])
->and(Scheme::light($this->path)['primary'])->toBe('#123456')
->and(Scheme::light($this->path))->toHaveKey('on-surface');
});
+16 -2
View File
@@ -20,9 +20,23 @@ function packageComponentPaths(): Collection
->values(); ->values();
} }
it('registers the components without a prefix', function () { it('registers the components under its own name when no prefix is configured', function () {
expect(packageComponentPaths())->toHaveCount(1) expect(packageComponentPaths())->toHaveCount(1)
->and(packageComponentPaths()->first()['prefix'])->toBeNull(); ->and(packageComponentPaths()->first()['prefix'])->toBe('livewire-material')
->and(Blade::render('<x-badge value="New" tonal />'))->toContain('New');
});
it('compiles an unprefixed component to the same view on every machine', function () {
$compiled = Blade::compileString('<x-input label="Name" />');
expect($compiled)->toContain("'view' => '".hash('xxh128', 'livewire-material')."::input'")
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
});
it('still renders a view compiled when the namespace came from the component path', function () {
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
expect(view()->exists($legacy.'::input'))->toBeTrue();
}); });
it('registers the components under a configured prefix', function () { it('registers the components under a configured prefix', function () {
+38 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Showcase\Sections;
use NoNameWeb\LivewireMaterial\Support\SvgFile; use NoNameWeb\LivewireMaterial\Support\SvgFile;
/** /**
@@ -28,6 +29,42 @@ it('mounts the showcase when enabled', function () {
->assertSee('Livewire Material'); ->assertSee('Livewire Material');
}); });
it('gives every section a page of its own, linked from the overview and the rail', function () {
$overview = $this->withoutVite()->get('/material')->assertOk();
foreach (Sections::all() as $key => $section) {
$overview->assertSee(route('livewire-material.section', $key), false);
$this->withoutVite()
->get("/material/{$key}")
->assertOk()
->assertSee("<title>{$section['title']} · Livewire Material</title>", false)
->assertSee('id="'.$key.'"', false);
}
$this->get('/material/not-a-section')->assertNotFound();
});
it('indexes every section, example and component for the search, each linking to a place that exists', function () {
$index = collect($this->getJson('/material/search.json')->assertOk()->json());
expect($index->where('kind', 'Section')->pluck('title')->all())
->toBe(collect(Sections::all())->pluck('title')->all())
->and($index->where('kind', 'Component')->pluck('title')->all())
->toContain('<x-datepicker>', '<x-button>', '<x-app-shell>')
->and($index->firstWhere('title', '<x-datepicker>')['url'])->toBe('/material/pickers');
$index->where('kind', 'Example')
->groupBy(fn (array $entry): string => strtok($entry['url'], '#'))
->each(function ($examples, string $page): void {
$html = $this->withoutVite()->get($page)->assertOk()->getContent();
foreach ($examples as $example) {
expect($html)->toContain('id="'.substr($example['url'], strpos($example['url'], '#') + 1).'"');
}
});
});
it('does not mount the showcase when disabled', function () { it('does not mount the showcase when disabled', function () {
rebootWithShowcase(false); rebootWithShowcase(false);
@@ -77,7 +114,7 @@ it('shows every component somewhere in the showcase', function () {
$absent = collect(File::files(__DIR__.'/../../resources/views/components')) $absent = collect(File::files(__DIR__.'/../../resources/views/components'))
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php')) ->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
->reject(fn (string $name): bool => preg_match('/<x-(?:livewire-material::)?'.preg_quote($name, '/').'[\s\/>]/', $showcase) === 1) ->reject(fn (string $name): bool => preg_match('/(?:<|&lt;)x-(?:livewire-material::)?'.preg_quote($name, '/').'(?:[\s\/>]|&gt;)/', $showcase) === 1)
->values() ->values()
->all(); ->all();
@@ -23,6 +23,23 @@ class WorkbenchServiceProvider extends ServiceProvider
'workbench/resources/js/app.js', 'workbench/resources/js/app.js',
]]); ]]);
// Three colour profiles, generated into workbench/resources/css/material-scheme.css with
// `vendor/bin/testbench material:scheme --output=workbench/resources/css/material-scheme.css`.
// Baseline is the package's own default, so the stylesheet draws it as before; the showcase
// offers the others. Tests start without profiles and point at the file when they need it.
config([
'livewire-material.profiles' => [
'baseline' => ['label' => 'Baseline', 'seed' => '#6750a4', 'variant' => 'tonal-spot'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
],
'livewire-material.profile' => 'baseline',
]);
if (! $this->app->runningUnitTests()) {
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
}
Vite::useHotFile(workbench_path('public/hot')); Vite::useHotFile(workbench_path('public/hot'));
} }
} }
+1
View File
@@ -2,6 +2,7 @@
sources so the plans in docs/ are not scanned for class names. */ sources so the plans in docs/ are not scanned for class names. */
@import 'tailwindcss' source(none); @import 'tailwindcss' source(none);
@import '../../../resources/css/material.css'; @import '../../../resources/css/material.css';
@import './material-scheme.css';
@source '../../../resources/views'; @source '../../../resources/views';
@source '../../../src'; @source '../../../src';
+586
View File
@@ -0,0 +1,586 @@
/*
* Material 3 colour profiles, generated by Google's material-color-utilities (spec 2025).
*
* php artisan material:scheme
*
* From livewire-material.profiles. "baseline" is the default, and also stands without a
* data-scheme attribute:
*
* baseline #6750a4, tonal-spot
* teal #00897b, vibrant
* rose #c2185b, vibrant
*
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
* before the first paint.
*/
:root,
[data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #34313a;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #34313a;
--md-sys-color-on-surface-variant: #615d68;
--md-sys-color-outline: #7d7983;
--md-sys-color-outline-variant: #b5b0bb;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #a09ba1;
--md-sys-color-primary: #655789;
--md-sys-color-primary-dim: #594b7c;
--md-sys-color-on-primary: #fdf7ff;
--md-sys-color-primary-container: #d4c3fd;
--md-sys-color-on-primary-container: #493c6c;
--md-sys-color-primary-fixed: #d4c3fd;
--md-sys-color-primary-fixed-dim: #c6b6ee;
--md-sys-color-on-primary-fixed: #352857;
--md-sys-color-on-primary-fixed-variant: #524576;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #625c71;
--md-sys-color-secondary-dim: #565065;
--md-sys-color-on-secondary: #fdf7ff;
--md-sys-color-secondary-container: #e8def8;
--md-sys-color-on-secondary-container: #554f63;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #423c50;
--md-sys-color-on-secondary-fixed-variant: #5f586e;
--md-sys-color-tertiary: #7b5270;
--md-sys-color-tertiary-dim: #6e4664;
--md-sys-color-on-tertiary: #fff7f9;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #5f3956;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #4a2642;
--md-sys-color-on-tertiary-fixed-variant: #694260;
--md-sys-color-error: #a8364b;
--md-sys-color-error-dim: #6b0221;
--md-sys-color-on-error: #fff7f7;
--md-sys-color-error-container: #f97386;
--md-sys-color-on-error-container: #6e0523;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-inverse-error: #f97386;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
}
[data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #eae3ef;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #eae3ef;
--md-sys-color-on-surface-variant: #aea9b4;
--md-sys-color-outline: #78737e;
--md-sys-color-outline-variant: #4a4650;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #575459;
--md-sys-color-primary: #cdc0ec;
--md-sys-color-primary-dim: #bfb2de;
--md-sys-color-on-primary: #443a5f;
--md-sys-color-primary-container: #574d72;
--md-sys-color-on-primary-container: #e9deff;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #3c3256;
--md-sys-color-on-primary-fixed-variant: #594e74;
--md-sys-color-inverse-primary: #645980;
--md-sys-color-secondary: #cbc2db;
--md-sys-color-secondary-dim: #beb5cd;
--md-sys-color-on-secondary: #433d51;
--md-sys-color-secondary-container: #3e384c;
--md-sys-color-on-secondary-container: #c4bbd4;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #423c50;
--md-sys-color-on-secondary-fixed-variant: #5f586e;
--md-sys-color-tertiary: #ffcfef;
--md-sys-color-tertiary-dim: #f4bfe3;
--md-sys-color-on-tertiary: #69415f;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #5f3956;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #4a2642;
--md-sys-color-on-tertiary-fixed-variant: #694260;
--md-sys-color-error: #f97386;
--md-sys-color-error-dim: #c44b5f;
--md-sys-color-on-error: #490013;
--md-sys-color-error-container: #871c34;
--md-sys-color-on-error-container: #ff97a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-inverse-error: #a8364b;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
}
[data-scheme='baseline'],
[data-scheme='baseline'][data-theme='light'],
[data-scheme='baseline'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #34313a;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #34313a;
--md-sys-color-on-surface-variant: #615d68;
--md-sys-color-outline: #7d7983;
--md-sys-color-outline-variant: #b5b0bb;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #a09ba1;
--md-sys-color-primary: #655789;
--md-sys-color-primary-dim: #594b7c;
--md-sys-color-on-primary: #fdf7ff;
--md-sys-color-primary-container: #d4c3fd;
--md-sys-color-on-primary-container: #493c6c;
--md-sys-color-primary-fixed: #d4c3fd;
--md-sys-color-primary-fixed-dim: #c6b6ee;
--md-sys-color-on-primary-fixed: #352857;
--md-sys-color-on-primary-fixed-variant: #524576;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #625c71;
--md-sys-color-secondary-dim: #565065;
--md-sys-color-on-secondary: #fdf7ff;
--md-sys-color-secondary-container: #e8def8;
--md-sys-color-on-secondary-container: #554f63;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #423c50;
--md-sys-color-on-secondary-fixed-variant: #5f586e;
--md-sys-color-tertiary: #7b5270;
--md-sys-color-tertiary-dim: #6e4664;
--md-sys-color-on-tertiary: #fff7f9;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #5f3956;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #4a2642;
--md-sys-color-on-tertiary-fixed-variant: #694260;
--md-sys-color-error: #a8364b;
--md-sys-color-error-dim: #6b0221;
--md-sys-color-on-error: #fff7f7;
--md-sys-color-error-container: #f97386;
--md-sys-color-on-error-container: #6e0523;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-inverse-error: #f97386;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
}
[data-scheme='baseline'][data-theme='dark'],
[data-scheme='baseline'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #eae3ef;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #eae3ef;
--md-sys-color-on-surface-variant: #aea9b4;
--md-sys-color-outline: #78737e;
--md-sys-color-outline-variant: #4a4650;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #575459;
--md-sys-color-primary: #cdc0ec;
--md-sys-color-primary-dim: #bfb2de;
--md-sys-color-on-primary: #443a5f;
--md-sys-color-primary-container: #574d72;
--md-sys-color-on-primary-container: #e9deff;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #3c3256;
--md-sys-color-on-primary-fixed-variant: #594e74;
--md-sys-color-inverse-primary: #645980;
--md-sys-color-secondary: #cbc2db;
--md-sys-color-secondary-dim: #beb5cd;
--md-sys-color-on-secondary: #433d51;
--md-sys-color-secondary-container: #3e384c;
--md-sys-color-on-secondary-container: #c4bbd4;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #423c50;
--md-sys-color-on-secondary-fixed-variant: #5f586e;
--md-sys-color-tertiary: #ffcfef;
--md-sys-color-tertiary-dim: #f4bfe3;
--md-sys-color-on-tertiary: #69415f;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #5f3956;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #4a2642;
--md-sys-color-on-tertiary-fixed-variant: #694260;
--md-sys-color-error: #f97386;
--md-sys-color-error-dim: #c44b5f;
--md-sys-color-on-error: #490013;
--md-sys-color-error-container: #871c34;
--md-sys-color-on-error-container: #ff97a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-inverse-error: #a8364b;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
}
[data-scheme='teal'],
[data-scheme='teal'][data-theme='light'],
[data-scheme='teal'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #d3fffd;
--md-sys-color-on-background: #003534;
--md-sys-color-surface: #d3fffd;
--md-sys-color-surface-dim: #87e4e1;
--md-sys-color-surface-bright: #d3fffd;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #bafdfa;
--md-sys-color-surface-container: #adf5f2;
--md-sys-color-surface-container-high: #a2f0ed;
--md-sys-color-surface-container-highest: #96ece8;
--md-sys-color-on-surface: #003534;
--md-sys-color-on-surface-variant: #296463;
--md-sys-color-outline: #46807e;
--md-sys-color-outline-variant: #7db7b5;
--md-sys-color-inverse-surface: #001111;
--md-sys-color-inverse-on-surface: #6da7a5;
--md-sys-color-primary: #00675c;
--md-sys-color-primary-dim: #005a50;
--md-sys-color-on-primary: #c0fff3;
--md-sys-color-primary-container: #00f7df;
--md-sys-color-on-primary-container: #00594f;
--md-sys-color-primary-fixed: #00f7df;
--md-sys-color-primary-fixed-dim: #00e8d1;
--md-sys-color-on-primary-fixed: #00443c;
--md-sys-color-on-primary-fixed-variant: #006359;
--md-sys-color-inverse-primary: #00fee5;
--md-sys-color-secondary: #006765;
--md-sys-color-secondary-dim: #005958;
--md-sys-color-on-secondary: #bcfffc;
--md-sys-color-secondary-container: #38fbf7;
--md-sys-color-on-secondary-container: #005c5a;
--md-sys-color-secondary-fixed: #38fbf7;
--md-sys-color-secondary-fixed-dim: #10ece8;
--md-sys-color-on-secondary-fixed: #004746;
--md-sys-color-on-secondary-fixed-variant: #006765;
--md-sys-color-tertiary: #006386;
--md-sys-color-tertiary-dim: #005675;
--md-sys-color-on-tertiary: #e7f5ff;
--md-sys-color-tertiary-container: #20c0ff;
--md-sys-color-on-tertiary-container: #00374d;
--md-sys-color-tertiary-fixed: #20c0ff;
--md-sys-color-tertiary-fixed-dim: #00b2ee;
--md-sys-color-on-tertiary-fixed: #001e2b;
--md-sys-color-on-tertiary-fixed-variant: #004059;
--md-sys-color-error: #b31b25;
--md-sys-color-error-dim: #9f0519;
--md-sys-color-on-error: #ffefee;
--md-sys-color-error-container: #fb5151;
--md-sys-color-on-error-container: #570008;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-inverse-error: #ff716c;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
}
[data-scheme='teal'][data-theme='dark'],
[data-scheme='teal'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #001111;
--md-sys-color-on-background: #b8f3f1;
--md-sys-color-surface: #001111;
--md-sys-color-surface-dim: #001111;
--md-sys-color-surface-bright: #003231;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #001716;
--md-sys-color-surface-container: #001e1d;
--md-sys-color-surface-container-high: #002424;
--md-sys-color-surface-container-highest: #002b2a;
--md-sys-color-on-surface: #b8f3f1;
--md-sys-color-on-surface-variant: #7bb5b3;
--md-sys-color-outline: #457f7d;
--md-sys-color-outline-variant: #0e504f;
--md-sys-color-inverse-surface: #e3fffd;
--md-sys-color-inverse-on-surface: #215e5c;
--md-sys-color-primary: #b4fff1;
--md-sys-color-primary-dim: #00fee5;
--md-sys-color-on-primary: #00665b;
--md-sys-color-primary-container: #00fee5;
--md-sys-color-on-primary-container: #005c53;
--md-sys-color-primary-fixed: #00f7df;
--md-sys-color-primary-fixed-dim: #00e8d1;
--md-sys-color-on-primary-fixed: #00443c;
--md-sys-color-on-primary-fixed-variant: #006359;
--md-sys-color-inverse-primary: #006b60;
--md-sys-color-secondary: #38fbf7;
--md-sys-color-secondary-dim: #10ece8;
--md-sys-color-on-secondary: #005c5a;
--md-sys-color-secondary-container: #006a68;
--md-sys-color-on-secondary-container: #dafffd;
--md-sys-color-secondary-fixed: #38fbf7;
--md-sys-color-secondary-fixed-dim: #10ece8;
--md-sys-color-on-secondary-fixed: #004746;
--md-sys-color-on-secondary-fixed-variant: #006765;
--md-sys-color-tertiary: #68ccff;
--md-sys-color-tertiary-dim: #20c0ff;
--md-sys-color-on-tertiary: #00415a;
--md-sys-color-tertiary-container: #20c0ff;
--md-sys-color-on-tertiary-container: #00374d;
--md-sys-color-tertiary-fixed: #20c0ff;
--md-sys-color-tertiary-fixed-dim: #00b2ee;
--md-sys-color-on-tertiary-fixed: #001e2b;
--md-sys-color-on-tertiary-fixed-variant: #004059;
--md-sys-color-error: #ff716c;
--md-sys-color-error-dim: #d7383b;
--md-sys-color-on-error: #490006;
--md-sys-color-error-container: #9f0519;
--md-sys-color-on-error-container: #ffa8a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-inverse-error: #b31b25;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
}
[data-scheme='rose'],
[data-scheme='rose'][data-theme='light'],
[data-scheme='rose'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fff4f6;
--md-sys-color-on-background: #492136;
--md-sys-color-surface: #fff4f6;
--md-sys-color-surface-dim: #ffc4de;
--md-sys-color-surface-bright: #fff4f6;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #ffecf2;
--md-sys-color-surface-container: #ffe0ec;
--md-sys-color-surface-container-high: #ffd8e8;
--md-sys-color-surface-container-highest: #ffd0e4;
--md-sys-color-on-surface: #492136;
--md-sys-color-on-surface-variant: #7c4d64;
--md-sys-color-outline: #9b6880;
--md-sys-color-outline-variant: #d69db7;
--md-sys-color-inverse-surface: #220215;
--md-sys-color-inverse-on-surface: #c58da6;
--md-sys-color-primary: #ae1d53;
--md-sys-color-primary-dim: #9e0b47;
--md-sys-color-on-primary: #ffeff0;
--md-sys-color-primary-container: #ff7198;
--md-sys-color-on-primary-container: #4d001e;
--md-sys-color-primary-fixed: #ff7198;
--md-sys-color-primary-fixed-dim: #f95a8a;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #5e0027;
--md-sys-color-inverse-primary: #f65787;
--md-sys-color-secondary: #99366e;
--md-sys-color-secondary-dim: #8a2a62;
--md-sys-color-on-secondary: #ffeff3;
--md-sys-color-secondary-container: #ffc0dc;
--md-sys-color-on-secondary-container: #7f2159;
--md-sys-color-secondary-fixed: #ffc0dc;
--md-sys-color-secondary-fixed-dim: #ffabd2;
--md-sys-color-on-secondary-fixed: #670845;
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
--md-sys-color-tertiary: #5c4bb4;
--md-sys-color-tertiary-dim: #503ea7;
--md-sys-color-on-tertiary: #f6f0ff;
--md-sys-color-tertiary-container: #b3a5ff;
--md-sys-color-on-tertiary-container: #301887;
--md-sys-color-tertiary-fixed: #b3a5ff;
--md-sys-color-tertiary-fixed-dim: #a595ff;
--md-sys-color-on-tertiary-fixed: #170059;
--md-sys-color-on-tertiary-fixed-variant: #39248f;
--md-sys-color-error: #b31b25;
--md-sys-color-error-dim: #9f0519;
--md-sys-color-on-error: #ffefee;
--md-sys-color-error-container: #fb5151;
--md-sys-color-on-error-container: #570008;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-inverse-error: #ff716c;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
}
[data-scheme='rose'][data-theme='dark'],
[data-scheme='rose'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #220215;
--md-sys-color-on-background: #ffdcea;
--md-sys-color-surface: #220215;
--md-sys-color-surface-dim: #220215;
--md-sys-color-surface-bright: #4e1737;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #2a041b;
--md-sys-color-surface-container: #330822;
--md-sys-color-surface-container-high: #3c0d28;
--md-sys-color-surface-container-highest: #45122f;
--md-sys-color-on-surface: #ffdcea;
--md-sys-color-on-surface-variant: #d49bb4;
--md-sys-color-outline: #99667e;
--md-sys-color-outline-variant: #663a50;
--md-sys-color-inverse-surface: #fff8f8;
--md-sys-color-inverse-on-surface: #75475d;
--md-sys-color-primary: #ff8aa7;
--md-sys-color-primary-dim: #ff6b95;
--md-sys-color-on-primary: #620029;
--md-sys-color-primary-container: #ff7198;
--md-sys-color-on-primary-container: #4d001e;
--md-sys-color-primary-fixed: #ff7198;
--md-sys-color-primary-fixed-dim: #f95a8a;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #5e0027;
--md-sys-color-inverse-primary: #b32257;
--md-sys-color-secondary: #ff8ac6;
--md-sys-color-secondary-dim: #ef7db9;
--md-sys-color-on-secondary: #620241;
--md-sys-color-secondary-container: #7f2058;
--md-sys-color-on-secondary-container: #ffbedb;
--md-sys-color-secondary-fixed: #ffc0dc;
--md-sys-color-secondary-fixed-dim: #ffabd2;
--md-sys-color-on-secondary-fixed: #670845;
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
--md-sys-color-tertiary: #aa9bff;
--md-sys-color-tertiary-dim: #9f8ffc;
--md-sys-color-on-tertiary: #290c80;
--md-sys-color-tertiary-container: #9d8cfa;
--md-sys-color-on-tertiary-container: #1c0068;
--md-sys-color-tertiary-fixed: #b3a5ff;
--md-sys-color-tertiary-fixed-dim: #a595ff;
--md-sys-color-on-tertiary-fixed: #170059;
--md-sys-color-on-tertiary-fixed-variant: #39248f;
--md-sys-color-error: #ff716c;
--md-sys-color-error-dim: #d7383b;
--md-sys-color-on-error: #490006;
--md-sys-color-error-container: #9f0519;
--md-sys-color-on-error-container: #ffa8a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-inverse-error: #b31b25;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
}
@@ -0,0 +1,566 @@
{
"seed": "#6750a4",
"variant": "tonal-spot",
"spec": "2025",
"contrast": 0,
"light": {
"background": "#fdf7fe",
"on-background": "#34313a",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#34313a",
"on-surface-variant": "#615d68",
"outline": "#7d7983",
"outline-variant": "#b5b0bb",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#a09ba1",
"primary": "#655789",
"primary-dim": "#594b7c",
"on-primary": "#fdf7ff",
"primary-container": "#d4c3fd",
"on-primary-container": "#493c6c",
"primary-fixed": "#d4c3fd",
"primary-fixed-dim": "#c6b6ee",
"on-primary-fixed": "#352857",
"on-primary-fixed-variant": "#524576",
"inverse-primary": "#d4c3fd",
"secondary": "#625c71",
"secondary-dim": "#565065",
"on-secondary": "#fdf7ff",
"secondary-container": "#e8def8",
"on-secondary-container": "#554f63",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#423c50",
"on-secondary-fixed-variant": "#5f586e",
"tertiary": "#7b5270",
"tertiary-dim": "#6e4664",
"on-tertiary": "#fff7f9",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#5f3956",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#4a2642",
"on-tertiary-fixed-variant": "#694260",
"error": "#a8364b",
"error-dim": "#6b0221",
"on-error": "#fff7f7",
"error-container": "#f97386",
"on-error-container": "#6e0523",
"success": "#006c45",
"on-success": "#ffffff",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"inverse-error": "#f97386",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#eae3ef",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#eae3ef",
"on-surface-variant": "#aea9b4",
"outline": "#78737e",
"outline-variant": "#4a4650",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#575459",
"primary": "#cdc0ec",
"primary-dim": "#bfb2de",
"on-primary": "#443a5f",
"primary-container": "#574d72",
"on-primary-container": "#e9deff",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#3c3256",
"on-primary-fixed-variant": "#594e74",
"inverse-primary": "#645980",
"secondary": "#cbc2db",
"secondary-dim": "#beb5cd",
"on-secondary": "#433d51",
"secondary-container": "#3e384c",
"on-secondary-container": "#c4bbd4",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#423c50",
"on-secondary-fixed-variant": "#5f586e",
"tertiary": "#ffcfef",
"tertiary-dim": "#f4bfe3",
"on-tertiary": "#69415f",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#5f3956",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#4a2642",
"on-tertiary-fixed-variant": "#694260",
"error": "#f97386",
"error-dim": "#c44b5f",
"on-error": "#490013",
"error-container": "#871c34",
"on-error-container": "#ff97a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"inverse-error": "#a8364b",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
},
"default": "baseline",
"profiles": {
"baseline": {
"label": "Baseline",
"seed": "#6750a4",
"variant": "tonal-spot",
"spec": "2025",
"contrast": 0,
"light": {
"background": "#fdf7fe",
"on-background": "#34313a",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#34313a",
"on-surface-variant": "#615d68",
"outline": "#7d7983",
"outline-variant": "#b5b0bb",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#a09ba1",
"primary": "#655789",
"primary-dim": "#594b7c",
"on-primary": "#fdf7ff",
"primary-container": "#d4c3fd",
"on-primary-container": "#493c6c",
"primary-fixed": "#d4c3fd",
"primary-fixed-dim": "#c6b6ee",
"on-primary-fixed": "#352857",
"on-primary-fixed-variant": "#524576",
"inverse-primary": "#d4c3fd",
"secondary": "#625c71",
"secondary-dim": "#565065",
"on-secondary": "#fdf7ff",
"secondary-container": "#e8def8",
"on-secondary-container": "#554f63",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#423c50",
"on-secondary-fixed-variant": "#5f586e",
"tertiary": "#7b5270",
"tertiary-dim": "#6e4664",
"on-tertiary": "#fff7f9",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#5f3956",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#4a2642",
"on-tertiary-fixed-variant": "#694260",
"error": "#a8364b",
"error-dim": "#6b0221",
"on-error": "#fff7f7",
"error-container": "#f97386",
"on-error-container": "#6e0523",
"success": "#006c45",
"on-success": "#ffffff",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"inverse-error": "#f97386",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#eae3ef",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#eae3ef",
"on-surface-variant": "#aea9b4",
"outline": "#78737e",
"outline-variant": "#4a4650",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#575459",
"primary": "#cdc0ec",
"primary-dim": "#bfb2de",
"on-primary": "#443a5f",
"primary-container": "#574d72",
"on-primary-container": "#e9deff",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#3c3256",
"on-primary-fixed-variant": "#594e74",
"inverse-primary": "#645980",
"secondary": "#cbc2db",
"secondary-dim": "#beb5cd",
"on-secondary": "#433d51",
"secondary-container": "#3e384c",
"on-secondary-container": "#c4bbd4",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#423c50",
"on-secondary-fixed-variant": "#5f586e",
"tertiary": "#ffcfef",
"tertiary-dim": "#f4bfe3",
"on-tertiary": "#69415f",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#5f3956",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#4a2642",
"on-tertiary-fixed-variant": "#694260",
"error": "#f97386",
"error-dim": "#c44b5f",
"on-error": "#490013",
"error-container": "#871c34",
"on-error-container": "#ff97a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"inverse-error": "#a8364b",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
}
},
"teal": {
"label": "Teal",
"seed": "#00897b",
"variant": "vibrant",
"spec": "2025",
"contrast": 0,
"light": {
"background": "#d3fffd",
"on-background": "#003534",
"surface": "#d3fffd",
"surface-dim": "#87e4e1",
"surface-bright": "#d3fffd",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#bafdfa",
"surface-container": "#adf5f2",
"surface-container-high": "#a2f0ed",
"surface-container-highest": "#96ece8",
"on-surface": "#003534",
"on-surface-variant": "#296463",
"outline": "#46807e",
"outline-variant": "#7db7b5",
"inverse-surface": "#001111",
"inverse-on-surface": "#6da7a5",
"primary": "#00675c",
"primary-dim": "#005a50",
"on-primary": "#c0fff3",
"primary-container": "#00f7df",
"on-primary-container": "#00594f",
"primary-fixed": "#00f7df",
"primary-fixed-dim": "#00e8d1",
"on-primary-fixed": "#00443c",
"on-primary-fixed-variant": "#006359",
"inverse-primary": "#00fee5",
"secondary": "#006765",
"secondary-dim": "#005958",
"on-secondary": "#bcfffc",
"secondary-container": "#38fbf7",
"on-secondary-container": "#005c5a",
"secondary-fixed": "#38fbf7",
"secondary-fixed-dim": "#10ece8",
"on-secondary-fixed": "#004746",
"on-secondary-fixed-variant": "#006765",
"tertiary": "#006386",
"tertiary-dim": "#005675",
"on-tertiary": "#e7f5ff",
"tertiary-container": "#20c0ff",
"on-tertiary-container": "#00374d",
"tertiary-fixed": "#20c0ff",
"tertiary-fixed-dim": "#00b2ee",
"on-tertiary-fixed": "#001e2b",
"on-tertiary-fixed-variant": "#004059",
"error": "#b31b25",
"error-dim": "#9f0519",
"on-error": "#ffefee",
"error-container": "#fb5151",
"on-error-container": "#570008",
"success": "#006c45",
"on-success": "#ffffff",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"inverse-error": "#ff716c",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
},
"dark": {
"background": "#001111",
"on-background": "#b8f3f1",
"surface": "#001111",
"surface-dim": "#001111",
"surface-bright": "#003231",
"surface-container-lowest": "#000000",
"surface-container-low": "#001716",
"surface-container": "#001e1d",
"surface-container-high": "#002424",
"surface-container-highest": "#002b2a",
"on-surface": "#b8f3f1",
"on-surface-variant": "#7bb5b3",
"outline": "#457f7d",
"outline-variant": "#0e504f",
"inverse-surface": "#e3fffd",
"inverse-on-surface": "#215e5c",
"primary": "#b4fff1",
"primary-dim": "#00fee5",
"on-primary": "#00665b",
"primary-container": "#00fee5",
"on-primary-container": "#005c53",
"primary-fixed": "#00f7df",
"primary-fixed-dim": "#00e8d1",
"on-primary-fixed": "#00443c",
"on-primary-fixed-variant": "#006359",
"inverse-primary": "#006b60",
"secondary": "#38fbf7",
"secondary-dim": "#10ece8",
"on-secondary": "#005c5a",
"secondary-container": "#006a68",
"on-secondary-container": "#dafffd",
"secondary-fixed": "#38fbf7",
"secondary-fixed-dim": "#10ece8",
"on-secondary-fixed": "#004746",
"on-secondary-fixed-variant": "#006765",
"tertiary": "#68ccff",
"tertiary-dim": "#20c0ff",
"on-tertiary": "#00415a",
"tertiary-container": "#20c0ff",
"on-tertiary-container": "#00374d",
"tertiary-fixed": "#20c0ff",
"tertiary-fixed-dim": "#00b2ee",
"on-tertiary-fixed": "#001e2b",
"on-tertiary-fixed-variant": "#004059",
"error": "#ff716c",
"error-dim": "#d7383b",
"on-error": "#490006",
"error-container": "#9f0519",
"on-error-container": "#ffa8a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"inverse-error": "#b31b25",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
}
},
"rose": {
"label": "Rose",
"seed": "#c2185b",
"variant": "vibrant",
"spec": "2025",
"contrast": 0,
"light": {
"background": "#fff4f6",
"on-background": "#492136",
"surface": "#fff4f6",
"surface-dim": "#ffc4de",
"surface-bright": "#fff4f6",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#ffecf2",
"surface-container": "#ffe0ec",
"surface-container-high": "#ffd8e8",
"surface-container-highest": "#ffd0e4",
"on-surface": "#492136",
"on-surface-variant": "#7c4d64",
"outline": "#9b6880",
"outline-variant": "#d69db7",
"inverse-surface": "#220215",
"inverse-on-surface": "#c58da6",
"primary": "#ae1d53",
"primary-dim": "#9e0b47",
"on-primary": "#ffeff0",
"primary-container": "#ff7198",
"on-primary-container": "#4d001e",
"primary-fixed": "#ff7198",
"primary-fixed-dim": "#f95a8a",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#5e0027",
"inverse-primary": "#f65787",
"secondary": "#99366e",
"secondary-dim": "#8a2a62",
"on-secondary": "#ffeff3",
"secondary-container": "#ffc0dc",
"on-secondary-container": "#7f2159",
"secondary-fixed": "#ffc0dc",
"secondary-fixed-dim": "#ffabd2",
"on-secondary-fixed": "#670845",
"on-secondary-fixed-variant": "#8b2b63",
"tertiary": "#5c4bb4",
"tertiary-dim": "#503ea7",
"on-tertiary": "#f6f0ff",
"tertiary-container": "#b3a5ff",
"on-tertiary-container": "#301887",
"tertiary-fixed": "#b3a5ff",
"tertiary-fixed-dim": "#a595ff",
"on-tertiary-fixed": "#170059",
"on-tertiary-fixed-variant": "#39248f",
"error": "#b31b25",
"error-dim": "#9f0519",
"on-error": "#ffefee",
"error-container": "#fb5151",
"on-error-container": "#570008",
"success": "#006c45",
"on-success": "#ffffff",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"inverse-error": "#ff716c",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
},
"dark": {
"background": "#220215",
"on-background": "#ffdcea",
"surface": "#220215",
"surface-dim": "#220215",
"surface-bright": "#4e1737",
"surface-container-lowest": "#000000",
"surface-container-low": "#2a041b",
"surface-container": "#330822",
"surface-container-high": "#3c0d28",
"surface-container-highest": "#45122f",
"on-surface": "#ffdcea",
"on-surface-variant": "#d49bb4",
"outline": "#99667e",
"outline-variant": "#663a50",
"inverse-surface": "#fff8f8",
"inverse-on-surface": "#75475d",
"primary": "#ff8aa7",
"primary-dim": "#ff6b95",
"on-primary": "#620029",
"primary-container": "#ff7198",
"on-primary-container": "#4d001e",
"primary-fixed": "#ff7198",
"primary-fixed-dim": "#f95a8a",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#5e0027",
"inverse-primary": "#b32257",
"secondary": "#ff8ac6",
"secondary-dim": "#ef7db9",
"on-secondary": "#620241",
"secondary-container": "#7f2058",
"on-secondary-container": "#ffbedb",
"secondary-fixed": "#ffc0dc",
"secondary-fixed-dim": "#ffabd2",
"on-secondary-fixed": "#670845",
"on-secondary-fixed-variant": "#8b2b63",
"tertiary": "#aa9bff",
"tertiary-dim": "#9f8ffc",
"on-tertiary": "#290c80",
"tertiary-container": "#9d8cfa",
"on-tertiary-container": "#1c0068",
"tertiary-fixed": "#b3a5ff",
"tertiary-fixed-dim": "#a595ff",
"on-tertiary-fixed": "#170059",
"on-tertiary-fixed-variant": "#39248f",
"error": "#ff716c",
"error-dim": "#d7383b",
"on-error": "#490006",
"error-container": "#9f0519",
"on-error-container": "#ffa8a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"inverse-error": "#b31b25",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
}
}
}
}