The three state colours are DynamicColors on their own tonal palette now, built exactly as Google builds error/on-error/error-container/on-error-container in both specs, so the scheme's contrast level, dark tones, spec version and platform reach them as they reach every other role; --harmonize (and a profile's harmonize) pulls each source towards the seed, off by default. material:scheme also writes M3's medium (0.5) and high (1.0) levels for both themes and every profile, keyed on data-contrast and never on a media query, and --contrast now moves the standard block alone. Scheme::load() takes the level; the mail theme stays on standard. Plan: docs/plans/material-3-alignment.md, steps 6 and 7 (core C1, C2, C15). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
119 lines
4.6 KiB
PHP
119 lines
4.6 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
|
|
function packageCss(string $path = ''): string
|
|
{
|
|
return __DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path);
|
|
}
|
|
|
|
/**
|
|
* The custom properties one selector's block declares.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
function declarations(string $css, string $selector): array
|
|
{
|
|
preg_match_all('/(--[\w-]+):\s*([^;]+);/', Str::of($css)->after($selector)->before('}')->toString(), $matches);
|
|
|
|
return array_combine($matches[1], $matches[2]);
|
|
}
|
|
|
|
it('ships a default scheme that declares every role in both themes', function () {
|
|
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
|
|
$css = File::get(packageCss('tokens/scheme.css'));
|
|
|
|
$light = declarations($css, "[data-theme='light']");
|
|
$dark = declarations($css, "[data-theme='dark']");
|
|
|
|
expect(array_keys($light))->toEqual(array_keys($dark))
|
|
->and(count($light))->toBe(count($scheme['light']))
|
|
->and(Str::of($css)->after("[data-theme='light']")->before('}')->toString())->toContain('color-scheme: light;')
|
|
->and(Str::of($css)->after("[data-theme='dark']")->before('}')->toString())->toContain('color-scheme: dark;');
|
|
|
|
foreach ($scheme['light'] as $role => $hex) {
|
|
expect($light["--md-sys-color-{$role}"])->toBe($hex)
|
|
->and($dark["--md-sys-color-{$role}"])->toBe($scheme['dark'][$role]);
|
|
}
|
|
});
|
|
|
|
it('ships M3\'s medium and high contrast levels beside the standard one', function () {
|
|
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
|
|
$css = File::get(packageCss('tokens/scheme.css'));
|
|
|
|
foreach (['medium', 'high'] as $level) {
|
|
foreach (['light', 'dark'] as $theme) {
|
|
$declared = declarations($css, "[data-contrast='{$level}'] [data-theme='{$theme}'] {");
|
|
|
|
expect(array_keys($declared))->toEqual(array_map(fn (string $role): string => "--md-sys-color-{$role}", array_keys($scheme['light'])));
|
|
|
|
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
|
|
expect($declared["--md-sys-color-{$role}"])->toBe($hex);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('turns every role into a Tailwind colour', function () {
|
|
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
|
|
$theme = File::get(packageCss('tokens/theme.css'));
|
|
|
|
foreach ($roles as $role) {
|
|
expect($theme)->toContain("--color-{$role}: var(--md-sys-color-{$role});");
|
|
}
|
|
});
|
|
|
|
it('resolves colour utilities on the element, so a nested data-theme repaints them', function () {
|
|
$theme = File::get(packageCss('tokens/theme.css'));
|
|
|
|
preg_match_all('/@theme inline \{(.*?)\}/s', $theme, $inline);
|
|
|
|
expect(implode("\n", $inline[1]))
|
|
->toContain('--color-primary: var(--md-sys-color-primary);')
|
|
->toContain('--color-body: var(--md-sys-color-on-surface-variant);')
|
|
->not->toMatch('/--[\w-]+:\s*#/');
|
|
});
|
|
|
|
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
|
|
foreach (File::allFiles(packageCss()) as $file) {
|
|
expect($file->getContents())
|
|
->not->toContain('prefers-color-scheme')
|
|
->not->toContain('prefers-contrast');
|
|
}
|
|
});
|
|
|
|
it('reads every safe-area inset through a variable that can replace it', function () {
|
|
$files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js'])
|
|
->flatMap(fn (string $path): array => File::allFiles($path));
|
|
|
|
$insets = 0;
|
|
|
|
foreach ($files as $file) {
|
|
preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE);
|
|
|
|
foreach ($matches[1] as [$side, $offset]) {
|
|
$insets++;
|
|
|
|
expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-')))
|
|
->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly");
|
|
}
|
|
}
|
|
|
|
expect($insets)->toBeGreaterThan(10)
|
|
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
|
|
});
|
|
|
|
it('makes every motion token instant under reduced motion', function () {
|
|
$motion = File::get(packageCss('tokens/motion.css'));
|
|
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
|
|
|
preg_match_all('/(--md-sys-motion-[\w-]*duration[\w-]*):/', Str::before($motion, '@media'), $durations);
|
|
|
|
expect($durations[1])->not->toBeEmpty();
|
|
|
|
foreach ($durations[1] as $duration) {
|
|
expect($reduced)->toContain("{$duration}: 0ms;");
|
|
}
|
|
});
|