Resolve the contrast level and the motion scheme before the first paint

The head script reads livewire-material.theme.contrast (standard, medium, high or
system, kept under its own storage key), asks the operating system while the choice
is system and follows it, and writes <html data-contrast> — nothing for standard,
which is what the stylesheet's plain blocks already are. It writes
<html data-motion="standard"> for motion.scheme, and both attributes, with the new
choice and key ones, survive wire:navigate. $store.theme gains contrast,
resolvedContrast and setContrast(); the theme-color meta takes its surface from the
resolved level. Plan: docs/plans/material-3-alignment.md, steps 5 and 7 (core C2, C9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:34:29 +02:00
co-authored by Claude Fable 5.1
parent 4263982369
commit 7084aac788
4 changed files with 197 additions and 19 deletions
+27
View File
@@ -35,6 +35,12 @@ return [
| wire:navigate included. A theme-color meta with a `media` attribute is | wire:navigate included. A theme-color meta with a `media` attribute is
| left alone. | left alone.
| |
| 'contrast' is M3's contrast level, the same three the scheme is generated
| in: 'standard', 'medium' (3:1) or 'high' (7:1), or 'system' to follow the
| operating system's own contrast setting until the visitor chooses. The
| head script writes it to <html data-contrast> before the first paint —
| standard, having the plain blocks, writes no attribute.
|
*/ */
'theme' => [ 'theme' => [
@@ -42,6 +48,27 @@ return [
'storage_key' => 'material-theme', 'storage_key' => 'material-theme',
'legacy_keys' => [], 'legacy_keys' => [],
'meta' => false, 'meta' => false,
'contrast' => [
'default' => 'system',
'storage_key' => 'material-contrast',
],
],
/*
|--------------------------------------------------------------------------
| Motion
|--------------------------------------------------------------------------
|
| M3's two motion schemes. 'expressive' (the default) is the bouncy one the
| library draws with; 'standard' is the restrained set, "minimal bounce,
| for utilitarian products" — the head script writes it to
| <html data-motion> before the first paint, and the spatial springs swap.
| The effects springs are the same in both.
|
*/
'motion' => [
'scheme' => 'expressive',
], ],
/* /*
+41
View File
@@ -8,6 +8,11 @@
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
* through the same write as `set()` and `toggle()`: the attributes, then localStorage. * through the same write as `set()` and `toggle()`: the attributes, then localStorage.
* *
* M3's contrast level is the same pair: `contrast` is the choice (standard, medium, high or
* system) and `resolvedContrast` the level on screen, which is `standard` wherever <html>
* carries no data-contrast — the stylesheet's plain blocks are that level. `setContrast()`
* writes both, through the same attribute the head script wrote before the first paint.
*
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and * `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 * `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. * application saves a choice itself, and the next full load draws what it saved.
@@ -15,13 +20,18 @@
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
const root = document.documentElement const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)') const media = window.matchMedia('(prefers-color-scheme: dark)')
const contrastMedia = window.matchMedia('(prefers-contrast: more)')
const choices = ['light', 'dark', 'system'] const choices = ['light', 'dark', 'system']
const levels = ['standard', 'medium', 'high', 'system']
const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice) const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice)
const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice)
window.Alpine.store('theme', { window.Alpine.store('theme', {
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
contrast: levels.includes(root.dataset.contrastChoice) ? root.dataset.contrastChoice : 'system',
resolvedContrast: root.dataset.contrast === 'medium' || root.dataset.contrast === 'high' ? root.dataset.contrast : 'standard',
scheme: root.dataset.scheme || null, scheme: root.dataset.scheme || null,
get value() { get value() {
@@ -54,6 +64,29 @@ document.addEventListener('alpine:init', () => {
this.set(this.resolved === 'dark' ? 'light' : 'dark') this.set(this.resolved === 'dark' ? 'light' : 'dark')
}, },
setContrast(choice) {
if (!levels.includes(choice)) {
return
}
this.contrast = choice
this.resolvedContrast = resolveContrast(choice)
root.dataset.contrastChoice = choice
if (this.resolvedContrast === 'standard') {
delete root.dataset.contrast
} else {
root.dataset.contrast = this.resolvedContrast
}
try {
localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice)
} catch {
// Blocked storage: the page still switches, it just will not remember.
}
},
previewScheme(name) { previewScheme(name) {
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) { if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
return return
@@ -73,4 +106,12 @@ document.addEventListener('alpine:init', () => {
theme.resolved = resolve('system') theme.resolved = resolve('system')
} }
}) })
contrastMedia.addEventListener('change', () => {
const theme = window.Alpine.store('theme')
if (theme.contrast === 'system') {
theme.resolvedContrast = resolveContrast('system')
}
})
}) })
@@ -11,6 +11,17 @@
JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a
visitor who never chose, so changing `theme.default` later reaches them too. visitor who never chose, so changing `theme.default` later reaches them too.
M3's contrast level rides the same rails (`livewire-material.theme.contrast`):
`standard`, `medium`, `high` or `system`, kept under `contrast.storage_key`. `system` asks
the operating system, now and whenever it changes, and takes `more` as high. The result is
<html data-contrast>, which the generated stylesheet keys its medium and high blocks on —
standard writes no attribute, because the plain blocks are already it. The choice is
<html data-contrast-choice>, where `$store.theme` picks it up.
`livewire-material.motion.scheme` is M3's motion scheme: `standard` is written to
<html data-motion> (and swaps the spatial springs for the restrained set), `expressive`,
the default, writes nothing.
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
active one `Scheme::profile()`, which asks the application's resolver — is written to active one `Scheme::profile()`, which asks the application's resolver — is written to
<html data-scheme>, which the generated stylesheet keys each profile on. <html data-scheme>, which the generated stylesheet keys each profile on.
@@ -24,9 +35,10 @@
With `theme.meta` on, the browser's own chrome follows too: the `content` of every With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute one is added to <head> when there is <meta name="theme-color"> without a `media` attribute one is added to <head> when there is
none is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in none is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with <html data-scheme> (else the active one) and at the level in <html data-contrast>. A
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`, MutationObserver on <html> keeps it in step with whatever changes `data-theme`,
an OS change while `system`, a profile preview, the application's own script. A layout's own `data-scheme` or `data-contrast` afterwards: `$store.theme.set()` and `toggle()`,
`setContrast()`, an OS change while `system`, a profile preview, the application's own script. A layout's own
theme-color meta belongs before this script: one written after it is only painted on theme-color meta belongs before this script: one written after it is only painted on
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted. DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
@@ -40,27 +52,44 @@
@php @php
$theme = config('livewire-material.theme'); $theme = config('livewire-material.theme');
$rail = config('livewire-material.rail'); $rail = config('livewire-material.rail');
$contrast = $theme['contrast'] ?? [];
$levels = \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS;
$settings = [ $settings = [
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(), 'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system', 'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme', 'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []), 'legacy' => array_values($theme['legacy_keys'] ?? []),
'contrast' => [
'default' => in_array($contrast['default'] ?? null, [...$levels, 'system'], true) ? $contrast['default'] : 'system',
'key' => $contrast['storage_key'] ?? 'material-contrast',
],
'motion' => (config('livewire-material.motion.scheme') === 'standard') ? 'standard' : null,
'rail' => [ 'rail' => [
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded', 'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
'key' => $rail['storage_key'] ?? 'material-rail', 'key' => $rail['storage_key'] ?? 'material-rail',
], ],
]; ];
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview. // Only the surfaces the meta can show: the active scheme's, every profile's for a preview,
// and each of those at M3's other two contrast levels. `Scheme::load()` resolves the active
// profile itself, so it answers for a single scheme and for profiles alike.
if ((bool) ($theme['meta'] ?? false)) { if ((bool) ($theme['meta'] ?? false)) {
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']]; $surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
// One read of the scheme file per level rather than one per profile and level.
$profilesAt = collect($levels)->mapWithKeys(fn (string $level): array => [$level => \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(contrast: $level)]);
$otherLevels = fn (callable $of): object => (object) collect($levels)->skip(1)->mapWithKeys(fn (string $level): array => [$level => $of($level)])->all();
$settings['meta'] = [ $settings['meta'] = [
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null. ...$surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load()),
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()), 'contrast' => $otherLevels(fn (string $level): array => $surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load(contrast: $level))),
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(), 'profiles' => (object) collect($profilesAt['standard'])
->map(fn (array $scheme, string $name): array => [
...$surfaces($scheme),
'contrast' => $otherLevels(fn (string $level): array => $surfaces($profilesAt[$level][$name])),
])
->all(),
]; ];
} }
@endphp @endphp
@@ -69,8 +98,11 @@
(function (settings) { (function (settings) {
var root = document.documentElement; var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)'); var media = window.matchMedia('(prefers-color-scheme: dark)');
var contrastMedia = window.matchMedia('(prefers-contrast: more)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; }; var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var validContrast = function (value) { return value === 'standard' || value === 'medium' || value === 'high' || value === 'system'; };
var choice = settings.default; var choice = settings.default;
var contrast = settings.contrast.default;
var rail = settings.rail.default; var rail = settings.rail.default;
try { try {
@@ -80,6 +112,12 @@
rail = storedRail; rail = storedRail;
} }
var storedContrast = localStorage.getItem(settings.contrast.key);
if (validContrast(storedContrast)) {
contrast = storedContrast;
}
var stored = localStorage.getItem(settings.key); var stored = localStorage.getItem(settings.key);
if (valid(stored)) { if (valid(stored)) {
@@ -106,28 +144,50 @@
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current); root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
}; };
// Standard is the stylesheet's plain blocks, so it is the absence of the attribute.
var applyContrast = function () {
var current = root.getAttribute('data-contrast-choice');
var level = current === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : current;
if (level === 'medium' || level === 'high') {
root.setAttribute('data-contrast', level);
} else {
root.removeAttribute('data-contrast');
}
};
if (settings.scheme) { if (settings.scheme) {
root.setAttribute('data-scheme', settings.scheme); root.setAttribute('data-scheme', settings.scheme);
} }
if (settings.motion) {
root.setAttribute('data-motion', settings.motion);
}
root.setAttribute('data-theme-key', settings.key); root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice); root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-contrast-key', settings.contrast.key);
root.setAttribute('data-contrast-choice', contrast);
root.setAttribute('data-rail-key', settings.rail.key); root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail); root.setAttribute('data-rail', rail);
apply(); apply();
applyContrast();
media.addEventListener('change', apply); media.addEventListener('change', apply);
contrastMedia.addEventListener('change', applyContrast);
@if (isset($settings['meta'])) @if (isset($settings['meta']))
var paintThemeColor = function () { var paintThemeColor = function () {
var theme = root.getAttribute('data-theme'); var theme = root.getAttribute('data-theme');
var scheme = root.getAttribute('data-scheme'); var scheme = root.getAttribute('data-scheme');
var level = root.getAttribute('data-contrast');
if ((theme !== 'light' && theme !== 'dark') || !document.head) { if ((theme !== 'light' && theme !== 'dark') || !document.head) {
return; return;
} }
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme]; var surfaces = Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta;
var colour = (Object.prototype.hasOwnProperty.call(surfaces.contrast, level) ? surfaces.contrast[level] : surfaces)[theme];
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])'); var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
if (metas.length === 0) { if (metas.length === 0) {
@@ -146,13 +206,13 @@
}; };
paintThemeColor(); paintThemeColor();
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] }); new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'data-contrast'] });
document.addEventListener('DOMContentLoaded', paintThemeColor); document.addEventListener('DOMContentLoaded', paintThemeColor);
document.addEventListener('livewire:navigated', paintThemeColor); document.addEventListener('livewire:navigated', paintThemeColor);
@endif @endif
document.addEventListener('livewire:navigating', function (event) { document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', '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-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)]; return [name, root.getAttribute(name)];
}); });
+58 -8
View File
@@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
it('follows the operating system until the visitor chooses, by default', function () { it('follows the operating system until the visitor chooses, by default', function () {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false); ->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"contrast":{"default":"system","key":"material-contrast"},"motion":null,"rail":{"default":"expanded","key":"material-rail"}})', false);
});
it('resolves the contrast level before the first paint, and leaves standard unwritten', function () {
config(['livewire-material.theme.contrast' => ['default' => 'high', 'storage_key' => 'sealshare-contrast']]);
$this->blade('<x-theme-script />')
->assertSee('"contrast":{"default":"high","key":"sealshare-contrast"}', false)
->assertSee("window.matchMedia('(prefers-contrast: more)')", false)
->assertSee("var level = current === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : current;", false)
->assertSee("root.setAttribute('data-contrast', level);", false)
->assertSee("root.removeAttribute('data-contrast');", false)
->assertSee("root.setAttribute('data-contrast-choice', contrast);", false)
->assertSee("contrastMedia.addEventListener('change', applyContrast);", false);
config(['livewire-material.theme.contrast.default' => 'extreme']);
$this->blade('<x-theme-script />')->assertSee('"contrast":{"default":"system"', false);
});
it('writes the standard motion scheme to <html>, and nothing for the expressive one', function () {
$this->blade('<x-theme-script />')
->assertSee('"motion":null', false)
->assertDontSee("root.setAttribute('data-motion', 'standard')", false);
config(['livewire-material.motion.scheme' => 'standard']);
$this->blade('<x-theme-script />')
->assertSee('"motion":"standard"', false)
->assertSee("root.setAttribute('data-motion', settings.motion);", false);
}); });
it('takes the application\'s default, key and legacy keys', function () { it('takes the application\'s default, key and legacy keys', function () {
@@ -42,7 +71,7 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
it('puts its attributes back on <html> when wire:navigate swaps the page', function () { it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee("document.addEventListener('livewire:navigating'", false) ->assertSee("document.addEventListener('livewire:navigating'", false)
->assertSee("['data-scheme', '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-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-key']", false)
->assertSee('event.detail.onSwap(', false); ->assertSee('event.detail.onSwap(', false);
}); });
@@ -79,29 +108,50 @@ it('paints the theme-color meta in the resolved theme\'s surface when asked', fu
config(['livewire-material.theme.meta' => true]); config(['livewire-material.theme.meta' => true]);
$scheme = Scheme::load(); $scheme = Scheme::load();
$surfaces = fn (string $level): string => sprintf(
'{"light":"%s","dark":"%s"}',
Scheme::load(contrast: $level)['light']['surface'],
Scheme::load(contrast: $level)['dark']['surface'],
);
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","profiles":{}}', false) ->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","contrast":{"medium":'.$surfaces('medium').',"high":'.$surfaces('high').'},"profiles":{}}', false)
->assertSee('document.head.querySelectorAll(\'meta[name="theme-color"]:not([media])\')', false) ->assertSee('document.head.querySelectorAll(\'meta[name="theme-color"]:not([media])\')', false)
->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });", false) ->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'data-contrast'] });", false)
->assertSee("document.addEventListener('livewire:navigated', paintThemeColor);", false); ->assertSee("document.addEventListener('livewire:navigated', paintThemeColor);", false);
// The profile on <html> picks the set of surfaces, the level in data-contrast the pair.
// (M3 moves the ink rather than the page, so a scheme's surface may read the same at every
// level; the meta still takes the level's own, whatever the generator made of it.)
expect((string) $this->blade('<x-theme-script />'))
->toContain('var surfaces = Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta;')
->toContain('var colour = (Object.prototype.hasOwnProperty.call(surfaces.contrast, level) ? surfaces.contrast[level] : surfaces)[theme];');
}); });
it('gives the theme-color meta every profile\'s surfaces, the active one\'s first', function () { it('gives the theme-color meta every profile\'s surfaces, at every contrast level', function () {
$path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json'; $path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json';
File::put($path, json_encode([ File::put($path, json_encode([
'default' => 'indigo', 'default' => 'indigo',
'profiles' => [ 'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['surface' => '#fbf8ff'], 'dark' => ['surface' => '#12131a']], 'indigo' => [
'label' => 'Indigo',
'light' => ['surface' => '#fbf8ff'],
'dark' => ['surface' => '#12131a'],
'contrast' => ['high' => ['light' => ['surface' => '#ffffff'], 'dark' => ['surface' => '#000000']]],
],
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']], 'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
], ],
])); ]));
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]); config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
Scheme::resolveProfileUsing(fn (): string => 'teal'); Scheme::resolveProfileUsing(fn (): string => 'indigo');
try { try {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"#f4fbf8","dark":"#0e1513","profiles":{"indigo":{"light":"#fbf8ff","dark":"#12131a"},"teal":{"light":"#f4fbf8","dark":"#0e1513"}}}', false); // The active profile first, then every profile, each with the levels it generated;
// a profile without a level keeps its standard surfaces there.
->assertSee('"meta":{"light":"#fbf8ff","dark":"#12131a","contrast":{"medium":{"light":"#fbf8ff","dark":"#12131a"},"high":{"light":"#ffffff","dark":"#000000"}},"profiles":{', false)
->assertSee('"indigo":{"light":"#fbf8ff","dark":"#12131a","contrast":{"medium":{"light":"#fbf8ff","dark":"#12131a"},"high":{"light":"#ffffff","dark":"#000000"}}}', false)
->assertSee('"teal":{"light":"#f4fbf8","dark":"#0e1513","contrast":{"medium":{"light":"#f4fbf8","dark":"#0e1513"},"high":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
} finally { } finally {
Scheme::resolveProfileUsing(null); Scheme::resolveProfileUsing(null);
File::delete($path); File::delete($path);