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:
co-authored by
Claude Fable 5.1
parent
4263982369
commit
7084aac788
@@ -35,6 +35,12 @@ return [
|
||||
| wire:navigate included. A theme-color meta with a `media` attribute is
|
||||
| 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' => [
|
||||
@@ -42,6 +48,27 @@ return [
|
||||
'storage_key' => 'material-theme',
|
||||
'legacy_keys' => [],
|
||||
'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',
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* `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.
|
||||
*
|
||||
* 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
|
||||
* `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.
|
||||
@@ -15,13 +20,18 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const contrastMedia = window.matchMedia('(prefers-contrast: more)')
|
||||
const choices = ['light', 'dark', 'system']
|
||||
const levels = ['standard', 'medium', 'high', 'system']
|
||||
|
||||
const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice)
|
||||
const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice)
|
||||
|
||||
window.Alpine.store('theme', {
|
||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||
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,
|
||||
|
||||
get value() {
|
||||
@@ -54,6 +64,29 @@ document.addEventListener('alpine:init', () => {
|
||||
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) {
|
||||
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
||||
return
|
||||
@@ -73,4 +106,12 @@ document.addEventListener('alpine:init', () => {
|
||||
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
|
||||
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
|
||||
active one — `Scheme::profile()`, which asks the application's resolver — is written to
|
||||
<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
|
||||
<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
|
||||
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
|
||||
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
|
||||
an OS change while `system`, a profile preview, the application's own script. A layout's own
|
||||
<html data-scheme> (else the active one) and at the level in <html data-contrast>. A
|
||||
MutationObserver on <html> keeps it in step with whatever changes `data-theme`,
|
||||
`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
|
||||
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
|
||||
|
||||
@@ -40,27 +52,44 @@
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
$rail = config('livewire-material.rail');
|
||||
$contrast = $theme['contrast'] ?? [];
|
||||
$levels = \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS;
|
||||
|
||||
$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'] ?? []),
|
||||
'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' => [
|
||||
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
||||
'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)) {
|
||||
$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'] = [
|
||||
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
|
||||
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
||||
...$surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||
'contrast' => $otherLevels(fn (string $level): array => $surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load(contrast: $level))),
|
||||
'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
|
||||
@@ -69,8 +98,11 @@
|
||||
(function (settings) {
|
||||
var root = document.documentElement;
|
||||
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 validContrast = function (value) { return value === 'standard' || value === 'medium' || value === 'high' || value === 'system'; };
|
||||
var choice = settings.default;
|
||||
var contrast = settings.contrast.default;
|
||||
var rail = settings.rail.default;
|
||||
|
||||
try {
|
||||
@@ -80,6 +112,12 @@
|
||||
rail = storedRail;
|
||||
}
|
||||
|
||||
var storedContrast = localStorage.getItem(settings.contrast.key);
|
||||
|
||||
if (validContrast(storedContrast)) {
|
||||
contrast = storedContrast;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
@@ -106,28 +144,50 @@
|
||||
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) {
|
||||
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-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', rail);
|
||||
apply();
|
||||
applyContrast();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
contrastMedia.addEventListener('change', applyContrast);
|
||||
@if (isset($settings['meta']))
|
||||
|
||||
var paintThemeColor = function () {
|
||||
var theme = root.getAttribute('data-theme');
|
||||
var scheme = root.getAttribute('data-scheme');
|
||||
var level = root.getAttribute('data-contrast');
|
||||
|
||||
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
||||
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])');
|
||||
|
||||
if (metas.length === 0) {
|
||||
@@ -146,13 +206,13 @@
|
||||
};
|
||||
|
||||
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('livewire:navigated', paintThemeColor);
|
||||
@endif
|
||||
|
||||
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)];
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
it('follows the operating system until the visitor chooses, by default', function () {
|
||||
$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 () {
|
||||
@@ -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 () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->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);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
$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 />')
|
||||
->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("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);
|
||||
|
||||
// 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';
|
||||
File::put($path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'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']],
|
||||
],
|
||||
]));
|
||||
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
Scheme::resolveProfileUsing(fn (): string => 'indigo');
|
||||
|
||||
try {
|
||||
$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 {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
File::delete($path);
|
||||
|
||||
Reference in New Issue
Block a user