Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db179e6b4e | ||
|
|
8afe9bac6a | ||
|
|
d805dbc407 | ||
|
|
fb42f004b1 | ||
|
|
7798352cfc | ||
|
|
cb3aa798a0 | ||
|
|
648a4cfe0f |
@@ -7,3 +7,6 @@
|
||||
/workbench/public/hot
|
||||
/tests/Browser/Screenshots
|
||||
.DS_Store
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -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.
|
||||
|
||||
#### 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
|
||||
|
||||
```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.
|
||||
- `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`.
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||
|
||||
@@ -107,6 +107,24 @@ return [
|
||||
|
||||
'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
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
# Livewire Material: a shared Material 3 Expressive component library
|
||||
|
||||
> Split on 2026-09-13 from SealShare's `docs/plans/livewire-material.md`, where the plan was
|
||||
> made. SealShare's adoption (Phase 11) stays in SealShare's copy.
|
||||
|
||||
## Goal
|
||||
|
||||
ReStride has just left maryUI and daisyUI for **Material 3 Expressive on its own Blade
|
||||
components** (`ReStride/docs/plans/material-expressive.md`, done 2026-09-10). Doing that
|
||||
again, by hand, in every Laravel + Livewire app is what this package avoids.
|
||||
|
||||
**`nonameweb/livewire-material`** carries the whole current M3 Expressive component catalogue
|
||||
as Blade components for Livewire, together with everything around them — colour scheme
|
||||
generation, tokens, the theme script, Material Symbols, Google Sans Flex, motion, error pages,
|
||||
a mail theme, a showcase, test helpers and AI guidelines. SealShare converts to it once it is
|
||||
complete (`1.0.0`); ReStride adopts it later, in a plan of its own.
|
||||
|
||||
## Context
|
||||
|
||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
||||
|
||||
**What ReStride already solved** (and the package generalises):
|
||||
|
||||
- 45 anonymous components in `resources/views/components/`, maryUI's names and props, M3
|
||||
styling; class components only where PHP earns it.
|
||||
- `resources/css/material/{color,shape,type,motion,elevation,field,menu,table,list}.css`;
|
||||
`@theme` (never `inline`) mapping M3 roles to utilities; `--color-*: initial` so only tokens
|
||||
compile; `:root` dark + `[data-theme=light]`, each with its own `color-scheme`.
|
||||
- Material Symbols Rounded (400/0/24) as local SVGs registered as `blade-icons` sets
|
||||
(`ms`/`msf`), `config/blade-icons.php` turning off blade-icons' own `<x-icon>`.
|
||||
- Google Sans Flex, a 63 KB Latin subset (weight 400–700, ROND 0–100).
|
||||
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
|
||||
formula into `resources/svg/shapes`.
|
||||
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
|
||||
event) and a snackbar `<x-toast>`.
|
||||
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
|
||||
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
|
||||
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
|
||||
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
|
||||
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
|
||||
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
|
||||
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
|
||||
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
|
||||
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
|
||||
tags, light only, because `CssToInlineStyles` strips `@media`.
|
||||
|
||||
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
|
||||
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
|
||||
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
|
||||
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
`share-button`, the unDraw repaint, the Ace code editor.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
|
||||
A "public" repo there cannot be installed anonymously until that changes.
|
||||
*(Resolved in step 1.)*
|
||||
- Laravel's Markdown mail accepts a namespaced view as theme
|
||||
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
|
||||
segmented button, small FAB and medium/large top app bar (material-components-android docs).
|
||||
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
|
||||
`@position-try` from Safari 18.4).
|
||||
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
|
||||
`boost:install` / `boost:update --discover`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
|
||||
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
|
||||
put at risk.
|
||||
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
|
||||
manual install and fork PRs must install the package without credentials. SealShare
|
||||
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
|
||||
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
|
||||
`NOTICE`.
|
||||
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
|
||||
`expensive` on 1.23+) — otherwise "public" is not installable.
|
||||
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
|
||||
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
|
||||
what it is and what it is for.
|
||||
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
|
||||
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
|
||||
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
|
||||
`null`, or blade-icons' class `<x-icon>` beats ours.
|
||||
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
|
||||
version; consuming Dockerfiles install Composer packages before the Vite build.
|
||||
`@source` covers the package's views and PHP, never its SVG folders.
|
||||
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
|
||||
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
|
||||
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
|
||||
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
|
||||
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
|
||||
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
|
||||
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
|
||||
overrides `--font-sans`.
|
||||
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
|
||||
primary-container included) and container is available; each app enforces its own rules
|
||||
through a configurable guard helper the package ships.
|
||||
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
|
||||
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
|
||||
both validated against fixed lists, unknown values fall back to the default. Shorthands
|
||||
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
|
||||
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
|
||||
where ReStride used it).
|
||||
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
|
||||
`material-color-utilities` from a single prebundled Node script inside the package; writes
|
||||
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
|
||||
`success`/`warning`/`info` custom colours, harmonisation off) and
|
||||
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
|
||||
committed.
|
||||
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
|
||||
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
|
||||
the OS (and follows its changes while `system`). `$store.theme` is the one state every
|
||||
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
|
||||
once, then removed).
|
||||
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
|
||||
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
|
||||
`size="sm"` renders medium).
|
||||
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
|
||||
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
|
||||
adaptive app-shell composition, error pages, mail theme.
|
||||
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
|
||||
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
|
||||
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
|
||||
pickers, carousel and search are our own.
|
||||
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
|
||||
- **Strings through `__()` with an `en` file**, publishable and overridable.
|
||||
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
|
||||
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
|
||||
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
|
||||
an app sees every component in its own scheme. Every variant, colour, size and state, a
|
||||
light/dark/system switch, and the Blade snippet beside each example.
|
||||
- **Tests: render tests for every component, browser tests against the Workbench showcase in
|
||||
Chromium, Firefox and WebKit; no screenshot diffs.**
|
||||
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
|
||||
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
|
||||
mail colours cannot drift from the app.
|
||||
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
|
||||
fails when a component is missing from the skill.
|
||||
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
|
||||
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
|
||||
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
|
||||
ReStride's templates already use, derived from M3 roles.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
|
||||
the colour maths.
|
||||
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
|
||||
navigation bar, segmented button, small FAB, medium/large top app bar).
|
||||
- Screenshot / visual regression tests.
|
||||
- Blaze optimisation of the components — a follow-up if rendering gets slow.
|
||||
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
|
||||
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
|
||||
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
|
||||
- Translations beyond `en`.
|
||||
- Browsers below the floor.
|
||||
- Non-Livewire stacks (Inertia, React, Vue).
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Phase 0 — Prerequisites
|
||||
|
||||
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
|
||||
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
|
||||
other repo or org is public. The package lives in the public org **`noNameWEB`**:
|
||||
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
|
||||
Actions on). SealShare's `vcs` repository entry uses that URL.
|
||||
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
|
||||
|
||||
### Phase 1 — Package skeleton
|
||||
|
||||
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
|
||||
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
|
||||
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
|
||||
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
|
||||
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
|
||||
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
|
||||
archives.
|
||||
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
|
||||
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
|
||||
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
|
||||
(before blade-icons boots and registers its component); register the icon and shape sets;
|
||||
append the package's error-view root (a directory holding only `errors/`) to
|
||||
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
|
||||
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
|
||||
showcase routes when enabled. No request state anywhere in the container.
|
||||
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
|
||||
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
|
||||
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
|
||||
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
|
||||
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
|
||||
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
|
||||
WebKit (Playwright installed with deps), on pushes and pull requests.
|
||||
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
|
||||
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
|
||||
component file under `resources/views/components` is named in the skill).
|
||||
|
||||
**Phase 1 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
|
||||
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
|
||||
that into the skeleton's `public/build` through the Workbench `sync` option, and
|
||||
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
|
||||
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
|
||||
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
|
||||
resolves under `components/`, which would have made the layout a public component.
|
||||
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
|
||||
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
|
||||
wipes the rest of its `components` array. The test fails with the call removed.
|
||||
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
|
||||
Testbench's per-test attributes do not reach Pest closures.
|
||||
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
|
||||
`<x-mary-*>` in its prose would compile as a component tag.
|
||||
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
|
||||
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
|
||||
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
|
||||
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
|
||||
Locally only Chromium was run; Firefox and WebKit first run on CI.
|
||||
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
|
||||
colour utilities) arrive — a notice with nothing to attribute would be wrong.
|
||||
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
|
||||
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
|
||||
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
|
||||
factory resolves, registering one Blade component per icon unless `components.disabled` —
|
||||
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
|
||||
request under PHP-FPM. Decided before step 13.
|
||||
|
||||
### Phase 2 — Foundation
|
||||
|
||||
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
|
||||
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
|
||||
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
|
||||
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
|
||||
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
|
||||
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
|
||||
the package so a fresh install renders before the command is run.
|
||||
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
|
||||
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
|
||||
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
|
||||
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
|
||||
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
|
||||
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
|
||||
line utilities; `--color-*: initial` with white and black re-added. Each theme block
|
||||
declares `color-scheme`. Values from material-web's token files, attributed.
|
||||
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
|
||||
in the entry CSS.
|
||||
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
|
||||
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
|
||||
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
|
||||
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
|
||||
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
|
||||
400/0/24 outlined and filled from `google/material-design-icons` into
|
||||
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
|
||||
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
|
||||
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
|
||||
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
|
||||
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
|
||||
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
|
||||
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
|
||||
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
|
||||
dispatching a browser event and flashing across `redirectTo`.
|
||||
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
|
||||
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
|
||||
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
|
||||
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
|
||||
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
|
||||
search).
|
||||
|
||||
**Phase 2 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
|
||||
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
|
||||
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
|
||||
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
|
||||
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
|
||||
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
|
||||
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
|
||||
into `bin/shapes.mjs` (commit in its header), each fitted to 2–98 of a 100-unit box; eight had
|
||||
control points outside the box and are split into more segments along the same outline.
|
||||
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
|
||||
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
|
||||
does not define fall back to 2021). The library's extensionless imports mean it cannot run
|
||||
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
|
||||
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
|
||||
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
|
||||
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
|
||||
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
|
||||
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
|
||||
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
|
||||
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
|
||||
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
|
||||
Blade (`type-{{ $style }}`): the showcase lists every class literally.
|
||||
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
|
||||
- **Theme script** writes nothing for a visitor who never chose (a later change of
|
||||
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
|
||||
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
|
||||
`toggle`, `value`).
|
||||
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
|
||||
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
|
||||
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 4–10 s; ReStride used 3 s).
|
||||
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
|
||||
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
|
||||
`icon-right` on the same tag).
|
||||
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
|
||||
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
|
||||
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
|
||||
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
|
||||
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
|
||||
|
||||
### Phase 3 — Actions
|
||||
|
||||
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
|
||||
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
|
||||
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
|
||||
APG menu keyboard).
|
||||
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
|
||||
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
|
||||
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
|
||||
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
|
||||
`selected` toggle (`aria-pressed`), widths.
|
||||
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
|
||||
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
|
||||
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
|
||||
`fab-menu`.
|
||||
|
||||
**Phase 3 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
|
||||
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
|
||||
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
|
||||
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
|
||||
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
|
||||
overrides them in code, so the package does too: the text button's label is `primary`, not
|
||||
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
|
||||
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
|
||||
icon-only form. A selected round button squares off; a selected square icon button rounds. A
|
||||
`corners` prop lets composite components (the split button) draw the corners themselves.
|
||||
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
|
||||
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
|
||||
corners ride a `--group-corner` variable so pressing and selecting change one value while the
|
||||
rounded outer corners stay put. The standard group's press expansion is padding moved from the
|
||||
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
|
||||
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
|
||||
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
|
||||
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
|
||||
names generated in Blade (a morph updates the trigger and the popover together). `popover`
|
||||
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
|
||||
a closed popover; use `open:flex`.
|
||||
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
|
||||
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
|
||||
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
|
||||
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
|
||||
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
|
||||
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
|
||||
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
|
||||
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
|
||||
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
|
||||
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
|
||||
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
|
||||
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
|
||||
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
|
||||
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
|
||||
so there is no 0.1–0.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
|
||||
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
|
||||
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
|
||||
enabled), so the snippet can never disagree with what is drawn.
|
||||
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
|
||||
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
|
||||
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
|
||||
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
|
||||
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
|
||||
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
|
||||
passed 4 of 4. Keep showcase pages lean.
|
||||
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
|
||||
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
|
||||
|
||||
### Phase 4 — Communication
|
||||
|
||||
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
|
||||
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
|
||||
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
|
||||
`x-figure`), `empty-state`.
|
||||
|
||||
**Phase 4 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
|
||||
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
|
||||
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
|
||||
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
|
||||
expression into motion. Flat circular indeterminate has no track, as in Compose.
|
||||
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
|
||||
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
|
||||
`@persist` keeps the host across `wire:navigate`.
|
||||
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
|
||||
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
|
||||
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
|
||||
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
|
||||
showcase index while they held the progress agent's temporary lines, without its files.
|
||||
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
|
||||
- **Browser test lessons (all three engines, locally):**
|
||||
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
|
||||
empty document, then a page still streaming in). Every helper now also asserts
|
||||
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
|
||||
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
|
||||
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
|
||||
realm with `window.eval("…")` (or `Livewire.dispatch`).
|
||||
- Pest retries a failing `assertScript`; a script that changes state must not be written so
|
||||
that a retry starts from the changed state.
|
||||
|
||||
### Phase 5 — Containment
|
||||
|
||||
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
|
||||
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
|
||||
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
|
||||
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
|
||||
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
|
||||
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
|
||||
full-screen on CSS scroll-snap), `collapse`.
|
||||
|
||||
**Phase 5 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
|
||||
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
|
||||
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
|
||||
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
|
||||
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
|
||||
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
|
||||
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
|
||||
as daisyUI's.
|
||||
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
|
||||
own component (modal or `standard`) with drag-to-dismiss.
|
||||
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
|
||||
- **The design guard also rejects Blade directives inside component tags** (`@class` on
|
||||
`<x-icon>`), which reach the browser as text.
|
||||
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
|
||||
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
|
||||
focus a button there: tests open dialogs from the keyboard.
|
||||
|
||||
### Phase 6 — Text inputs and selection
|
||||
|
||||
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
|
||||
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
|
||||
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
|
||||
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
|
||||
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
|
||||
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
|
||||
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
|
||||
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
|
||||
inputs, value label), `search` (search bar and search view, results through a Livewire
|
||||
property).
|
||||
|
||||
**Phase 6 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
|
||||
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
|
||||
corners, the outline turned into an indicator line and the label floating inside. A textarea's
|
||||
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
|
||||
instead of running through the label; it grows with `field-sizing: content` from `rows` to
|
||||
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
|
||||
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
|
||||
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
|
||||
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
|
||||
with the property by a MutationObserver, since HTML has no attribute for it.
|
||||
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
|
||||
component for four types; a filter chip is a native checkbox when bound and a toggle button
|
||||
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
|
||||
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
|
||||
because a string sent and an integer returned made Livewire move the handle back mid-drag.
|
||||
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
|
||||
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
|
||||
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
|
||||
follows the press: Chromium collapses a selection made in the focus handler.
|
||||
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
|
||||
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
|
||||
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
|
||||
does not reopen it (Escape, and the trap letting go).
|
||||
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
|
||||
`hasActualContent()`.
|
||||
|
||||
### Phase 7 — Pickers
|
||||
|
||||
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
|
||||
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
|
||||
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
|
||||
|
||||
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
|
||||
worktrees. What changed from the step above:
|
||||
|
||||
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
|
||||
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
|
||||
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
|
||||
UTC; only "today" reads the browser's zone. The typed format is androidx's
|
||||
`datePatternAsInputFormat` from `Intl` (`de` → `dd.MM.yyyy`). **A range binds one array**,
|
||||
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
|
||||
validates against the matching end, and all its errors land on one field. Docked values come from
|
||||
material-web's docked tokens, which Compose lacks.
|
||||
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 00–11 outside
|
||||
and 12–23 inside, and the period selector is two separate toggles. The selector angle is a
|
||||
registered custom property, so one transition turns the line and moves the handle.
|
||||
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
|
||||
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
|
||||
- **A Livewire public property named `$slot` renders empty** in the component's view.
|
||||
|
||||
### Phase 8 — Navigation
|
||||
|
||||
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
|
||||
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded,
|
||||
modal; badges), `tabs` / `tab` (primary and secondary; server-rendered tablist, roving
|
||||
tabindex), `toolbar` (docked and floating), `section-nav` (secondary tabs from `sm`, menu
|
||||
picker below), `account-menu` (avatar trigger, slot for items, theme row), `theme-toggle`
|
||||
(cycles or picks light/dark/system through `$store.theme`).
|
||||
29. `app-shell` — a slot-based adaptive composition: app bar + navigation bar below `sm`, rail
|
||||
`sm`–`lg`, expanded collapsible rail from `lg` (state in the store, applied before paint by
|
||||
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
|
||||
app-specific inside; apps pass destinations and extra chrome as slots.
|
||||
|
||||
**Phase 8 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
|
||||
navigation bar, rail and app shell by an agent in its own worktree.**
|
||||
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
|
||||
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
|
||||
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
|
||||
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
|
||||
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
|
||||
is right before Alpine starts.
|
||||
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
|
||||
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
|
||||
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
|
||||
- **The rail's collapsed state is applied before first paint** by the head script
|
||||
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
|
||||
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
|
||||
`$store.rail.show()`.
|
||||
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
|
||||
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
|
||||
does not exist in the Workbench build; use inline styles there.
|
||||
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
|
||||
|
||||
### Phase 9 — Data, pages, mail
|
||||
|
||||
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
|
||||
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views
|
||||
(current page in `secondary-container`, "Page 2 of 7" on a phone).
|
||||
31. Error pages: a layout and `403, 404, 419, 429, 500, 503` in the package's error-view root
|
||||
(wired through `view.paths` in step 4); publishable for per-app wording.
|
||||
32. Mail theme: `livewire-material::mail.theme` renders CSS from the app's
|
||||
`material-scheme.json` (falling back to the default scheme); `html/header` and
|
||||
`html/message` overrides; typescale on bare tags; filled primary button.
|
||||
|
||||
**Steps 30–32 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
|
||||
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
|
||||
|
||||
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
|
||||
set as the default view, because Livewire sets its own default on every render; published
|
||||
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
|
||||
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
|
||||
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
|
||||
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
|
||||
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
|
||||
skeleton ships its own `errors/503`, which wins in the Workbench.
|
||||
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
|
||||
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
|
||||
|
||||
### Phase 10 — `1.0.0`
|
||||
|
||||
33. Showcase complete (every component, variant, colour, size and state, both themes); the
|
||||
in-app mount verified inside a fresh Laravel app; Boost guideline and skill complete
|
||||
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
|
||||
guard, Docker ordering note); tag `1.0.0`.
|
||||
|
||||
**Phase 10 is done (2026-09-13).** What changed from the step above:
|
||||
|
||||
- **The package's own views write `<x-livewire-material::name>`** for the components they use. Blade
|
||||
spells a configured prefix `<x-m::button>` (not `<x-m-button>`, as the config first claimed), and
|
||||
an unqualified `<x-button>` inside a package view would have broken under a prefix and been
|
||||
shadowed by an application's own `components/button.blade.php`. A test scans every package view
|
||||
(outside comments and the showcase's example heredocs) for unqualified tags, and the showcase
|
||||
rewrites its examples to the configured prefix. The component path string stays
|
||||
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
||||
compiled views keep that name.
|
||||
- **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
|
||||
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 toast works without console errors; `/material` and the 404 page render in its scheme.
|
||||
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
|
||||
does.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0
|
||||
|
||||
Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Render tests** (`tests/Feature/Components/*Test.php`, Testbench, `$this->blade()`), per
|
||||
component: each `variant` × `color` renders its classes and falls back on an unknown value;
|
||||
shorthands (`primary`, `danger`, `caution`, `tone`) map correctly; sizes; ARIA
|
||||
(`aria-pressed`, `aria-expanded`, `aria-current`, `aria-invalid` + `aria-describedby`,
|
||||
labelled dialogs, `aria-sort`); `link` adds `wire:navigate` unless `external`; `wire:model`
|
||||
normalisation (`false` → `null`); prefix config renames the tags.
|
||||
- **Foundation tests:** every `--md-sys-color-*` role defined in both theme blocks and
|
||||
different between them; each block declares `color-scheme`; no `prefers-color-scheme` in
|
||||
the CSS; colour blocks are `@theme inline`; the scheme command writes both files for a known seed
|
||||
(snapshot of a few roles) and fails cleanly without Node; the theme script lands before
|
||||
`@vite` and resolves `system`; `<x-icon>` throws on an unknown name; every shape fills the
|
||||
100-unit box with `currentColor` only; `Toasts` dispatches and survives `redirectTo`;
|
||||
`DesignGuard` catches each forbidden pattern on fixtures; the skill drift test; the mail
|
||||
theme renders the JSON's hexes; a 404 renders the package's error view and an app's own
|
||||
`errors/404.blade.php` still wins; the showcase route is 404 when disabled and 200 when
|
||||
enabled; blade-icons' own `<x-icon>` is not registered.
|
||||
- **Browser tests** against the Workbench showcase in Chromium, Firefox and WebKit: dialog
|
||||
(open, Esc, morph survival, write-back), bottom and side sheets and the pane from `xl`,
|
||||
menu and select (keyboard, anchoring, flip), tabs (arrows, Home/End), date and time pickers
|
||||
(keyboard grid, locale, `wire:model` value), carousel (snap, keyboard), sliders, chips and
|
||||
choices, search, snackbar timing and action, theme (light/dark/system, legacy adoption, OS
|
||||
change followed), app shell at 393 / 768 / 1024 / 1512px, focus rings on keyboard focus,
|
||||
and reduced motion leaving no running animations.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app
|
||||
uses it. Mitigation: each wave is reviewed in the showcase and green on CI before the next.
|
||||
- **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG
|
||||
patterns, native elements first, ARIA in render tests, keyboard in browser tests across
|
||||
three engines.
|
||||
- **Date and time pickers without a library** are the largest single components. Mitigation:
|
||||
`Intl` for all locale data; the modal-input variant as the accessible baseline.
|
||||
- **Full M3 invites inconsistency per app.** Mitigation: the configurable `DesignGuard`; each
|
||||
app records its own rules.
|
||||
- **Livewire morphing against Alpine state** inside components. Mitigation: ReStride's
|
||||
recorded traps become package rules in the skill, with tests pinning each.
|
||||
- **~8,300 SVGs** make dist archives and `vendor/` larger (5.3 MB). Mitigation: each file is read
|
||||
only when drawn and kept per worker; `.gitattributes` keeps dev files out; `@source`
|
||||
never scans the SVG folders.
|
||||
- **Maintenance of a public package** is one person's job; breaking changes need semver
|
||||
discipline once ReStride also depends on it.
|
||||
- **Runner scope.** The package's CI needs an act_runner registered for the instance or the
|
||||
`noNameWEB` org, not only for ReStride — check before the first push in Phase 1.
|
||||
@@ -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.
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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>`
|
||||
|
||||
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-password>`: a reveal button; `icon`, `size`.
|
||||
@@ -724,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.
|
||||
|
||||
### `<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>`
|
||||
|
||||
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"`.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* `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', () => {
|
||||
const root = document.documentElement
|
||||
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.store('theme', {
|
||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
||||
scheme: root.dataset.scheme || null,
|
||||
|
||||
get value() {
|
||||
return this.choice
|
||||
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
|
||||
toggle() {
|
||||
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
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$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
|
||||
|
||||
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $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)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$choices = collect($options)->map(fn ($option): array => [
|
||||
'value' => data_get($option, $optionValue),
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
||||
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $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)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$locale = str_replace('_', '-', app()->getLocale());
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $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)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$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';
|
||||
|
||||
$segment = [
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$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
|
||||
|
||||
<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">
|
||||
|
||||
@@ -78,18 +78,19 @@
|
||||
@endif
|
||||
|
||||
<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)
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
||||
@if (filled($title) || filled($subtitle) || $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)
|
||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@if ($separator)
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$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
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 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
|
||||
|
||||
<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
|
||||
@@ -29,7 +29,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$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
|
||||
|
||||
<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();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$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
|
||||
|
||||
<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
|
||||
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`
|
||||
(`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
|
||||
@@ -27,6 +31,7 @@
|
||||
$rail = config('livewire-material.rail');
|
||||
|
||||
$settings = [
|
||||
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
@@ -78,6 +83,10 @@
|
||||
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-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
@@ -87,7 +96,7 @@
|
||||
media.addEventListener('change', apply);
|
||||
|
||||
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)];
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
|
||||
@php
|
||||
$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);
|
||||
|
||||
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
|
||||
|
||||
@@ -132,6 +132,19 @@
|
||||
</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
|
||||
</x-livewire-material::menu>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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'],
|
||||
'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
|
||||
|
||||
<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.
|
||||
</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><html data-scheme></code>, and <code><x-scheme-picker></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">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||
|
||||
+142
-28
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial\Console;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
|
||||
* @var string
|
||||
*/
|
||||
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}
|
||||
{--contrast=0 : The contrast level, from -1 to 1}
|
||||
{--success=#22a06b : The source of the success colour}
|
||||
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
$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([
|
||||
config('livewire-material.node', 'node'),
|
||||
__DIR__.'/../../resources/node/scheme.mjs',
|
||||
json_encode([
|
||||
'seed' => $this->argument('seed'),
|
||||
'variant' => $this->option('variant'),
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'seed' => $seed,
|
||||
'variant' => $variant,
|
||||
'contrast' => $contrast,
|
||||
'success' => $this->option('success'),
|
||||
'warning' => $this->option('warning'),
|
||||
'info' => $this->option('info'),
|
||||
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
|
||||
]);
|
||||
|
||||
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 {
|
||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} 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->put($stylesheet, $this->stylesheet($scheme));
|
||||
$files->put($stylesheet, $css);
|
||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
|
||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
);
|
||||
|
||||
$block = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
|
||||
* first paint; the light block also stands without it.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
{$block($scheme['light'])}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
{$block($scheme['dark'])}
|
||||
}
|
||||
|
||||
{$blocks}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\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
|
||||
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
||||
@@ -123,8 +132,10 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
{
|
||||
Blade::anonymousComponentPath(
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,12 +175,14 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function registerShowcase(): void
|
||||
{
|
||||
// Registered even with the showcase off: `php artisan view:cache` compiles every view in the
|
||||
// package's namespace, the showcase's pages included, and <x-showcase::…> must resolve there.
|
||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
|
||||
|
||||
if (! config('livewire-material.showcase.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
|
||||
|
||||
if ($this->app->routesAreCached()) {
|
||||
return;
|
||||
}
|
||||
|
||||
+132
-14
@@ -2,9 +2,14 @@
|
||||
|
||||
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
|
||||
* 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
|
||||
* (`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
|
||||
* 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
|
||||
* worker holds nothing.
|
||||
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* @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>}
|
||||
*/
|
||||
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');
|
||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
||||
$data = static::data($path);
|
||||
$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 [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
@@ -37,22 +75,102 @@ class Scheme
|
||||
*
|
||||
* @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;
|
||||
|
||||
return [
|
||||
'light' => static::roles($data['light'] ?? null),
|
||||
'dark' => static::roles($data['dark'] ?? null),
|
||||
];
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']}'");
|
||||
});
|
||||
@@ -44,6 +44,17 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
|
||||
->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 () {
|
||||
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->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('');
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
it('follows the operating system until the visitor chooses, by default', function () {
|
||||
$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 () {
|
||||
@@ -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 () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
beforeEach(function () {
|
||||
$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);
|
||||
});
|
||||
|
||||
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) {
|
||||
withoutSkeletonErrorViews();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
class ThemedProbeMail extends Mailable
|
||||
{
|
||||
@@ -113,6 +114,27 @@ it('inlines the application\'s scheme onto the mail', function () {
|
||||
->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 () {
|
||||
config(['mail.markdown.theme' => 'default']);
|
||||
|
||||
|
||||
@@ -78,3 +78,68 @@ it('fails without writing anything when node cannot run', function () {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -20,9 +20,23 @@ function packageComponentPaths(): Collection
|
||||
->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)
|
||||
->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 () {
|
||||
|
||||
@@ -71,6 +71,20 @@ it('does not mount the showcase when disabled', function () {
|
||||
$this->get('/material')->assertNotFound();
|
||||
});
|
||||
|
||||
it('compiles every package view with the showcase off, as view:cache does in production', function () {
|
||||
rebootWithShowcase(false);
|
||||
|
||||
$compiled = sys_get_temp_dir().'/livewire-material-view-cache-'.uniqid();
|
||||
config(['view.compiled' => $compiled]);
|
||||
File::ensureDirectoryExists($compiled);
|
||||
|
||||
try {
|
||||
$this->artisan('view:cache')->assertSuccessful();
|
||||
} finally {
|
||||
File::deleteDirectory($compiled);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a symbol for the icon search', function () {
|
||||
$this->get('/material/symbols/filled/favorite.svg')
|
||||
->assertOk()
|
||||
|
||||
@@ -23,6 +23,23 @@ class WorkbenchServiceProvider extends ServiceProvider
|
||||
'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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
sources so the plans in docs/ are not scanned for class names. */
|
||||
@import 'tailwindcss' source(none);
|
||||
@import '../../../resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
|
||||
@source '../../../resources/views';
|
||||
@source '../../../src';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user