Plan step 33. Each token file now opens with the material layer statement and wraps its declarations in material.tokens (the font face in material.base), so an application's unlayered scheme outranks the package default whatever the selectors, and the foundation can import the files with no layer() on the import. The type-* and state utilities move to tokens/utilities.css, still imported by material.css, so no file the foundation imports carries a Tailwind directive. [x-cloak] moves beside the [hidden] rule in foundation/hidden.css, outside every layer; material.css imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
226 lines
9.6 KiB
PHP
226 lines
9.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]);
|
|
}
|
|
|
|
/**
|
|
* M3's 15 type styles, in the order type.css declares them.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
function typeStyles(): array
|
|
{
|
|
return [
|
|
'display-lg', 'display-md', 'display-sm',
|
|
'headline-lg', 'headline-md', 'headline-sm',
|
|
'title-lg', 'title-md', 'title-sm',
|
|
'body-lg', 'body-md', 'body-sm',
|
|
'label-lg', 'label-md', 'label-sm',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The body of every `@utility type-…` block, keyed by utility name. The utilities live in
|
|
* tokens/utilities.css, apart from the tokens, so the foundation imports no Tailwind directive.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
function typeUtilities(): array
|
|
{
|
|
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(packageCss('tokens/utilities.css')), $matches, PREG_SET_ORDER);
|
|
|
|
return array_combine(array_column($matches, 1), array_column($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, in both schemes', 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()
|
|
// The Standard scheme sets the same variables on a selector of its own, so :root alone
|
|
// would not reach it: the block has to name both.
|
|
->and($reduced)->toMatch('/:root,\s*\[data-motion="standard"\] \{/');
|
|
|
|
foreach ($durations[1] as $duration) {
|
|
expect($reduced)->toContain("{$duration}: 0ms;");
|
|
}
|
|
});
|
|
|
|
it('resets the roundness axis on every regular type style, so emphasis stays one element', function () {
|
|
$utilities = typeUtilities();
|
|
|
|
expect(array_keys($utilities))->toHaveCount(30);
|
|
|
|
foreach (typeStyles() as $style) {
|
|
expect($utilities)->toHaveKeys(["type-{$style}", "type-emphasized-{$style}"])
|
|
// font-variation-settings inherits: without the reset, body copy inside an
|
|
// emphasized heading would stay fully rounded.
|
|
->and($utilities["type-{$style}"])->toContain('font-variation-settings: normal;')
|
|
->and($utilities["type-emphasized-{$style}"])->toContain('font-variation-settings: "ROND" 100;');
|
|
}
|
|
});
|
|
|
|
it('gives every emphasized type style its own tracking, as Compose does', function () {
|
|
$tokens = declarations(File::get(packageCss('tokens/type.css')), ':root');
|
|
$utilities = typeUtilities();
|
|
|
|
foreach (typeStyles() as $style) {
|
|
expect($tokens)->toHaveKey("--md-sys-typescale-emphasized-{$style}-tracking")
|
|
->and($utilities["type-{$style}"])->toContain("letter-spacing: var(--md-sys-typescale-{$style}-tracking);")
|
|
->and($utilities["type-emphasized-{$style}"])->toContain("letter-spacing: var(--md-sys-typescale-emphasized-{$style}-tracking);");
|
|
}
|
|
|
|
// TypeScaleTokens.kt as sp / 16, for the five styles where emphasized parts from baseline.
|
|
expect($tokens)->toMatchArray([
|
|
'--md-sys-typescale-display-lg-tracking' => '-0.0125rem',
|
|
'--md-sys-typescale-emphasized-display-lg-tracking' => '0rem',
|
|
'--md-sys-typescale-title-md-tracking' => '0.0125rem',
|
|
'--md-sys-typescale-emphasized-title-md-tracking' => '0.009375rem',
|
|
'--md-sys-typescale-body-lg-tracking' => '0.03125rem',
|
|
'--md-sys-typescale-emphasized-body-lg-tracking' => '0.009375rem',
|
|
'--md-sys-typescale-body-md-tracking' => '0.0125rem',
|
|
'--md-sys-typescale-emphasized-body-md-tracking' => '0.015625rem',
|
|
'--md-sys-typescale-label-md-tracking' => '0.03125rem',
|
|
'--md-sys-typescale-emphasized-label-md-tracking' => '0.03125rem',
|
|
]);
|
|
});
|
|
|
|
it('reads every spring over the duration M3 publishes for the web', function () {
|
|
$expressive = declarations(File::get(packageCss('tokens/motion.css')), ':root');
|
|
|
|
// docs/reference/m3/styles.md § Motion, "Web curve equivalents for springs".
|
|
$durations = [
|
|
'spatial-fast' => '350ms', 'spatial-default' => '500ms', 'spatial-slow' => '650ms',
|
|
'effects-fast' => '150ms', 'effects-default' => '200ms', 'effects-slow' => '300ms',
|
|
];
|
|
|
|
foreach ($durations as $spring => $duration) {
|
|
expect($expressive["--md-sys-motion-{$spring}-duration"])->toBe($duration)
|
|
// 49 sampled points, starting at rest and landing exactly on the target.
|
|
->and($expressive["--md-sys-motion-{$spring}"])->toStartWith('linear(0, ')->toEndWith(', 1)')
|
|
->and(explode(', ', $expressive["--md-sys-motion-{$spring}"]))->toHaveCount(49);
|
|
}
|
|
});
|
|
|
|
it('swaps only the spatial springs for the Standard motion scheme', function () {
|
|
$motion = File::get(packageCss('tokens/motion.css'));
|
|
$expressive = declarations($motion, ':root');
|
|
$standard = declarations($motion, '[data-motion="standard"] {');
|
|
|
|
// Both schemes share the effects springs, so the block carries the three spatial pairs only.
|
|
expect(array_keys($standard))->toBe([
|
|
'--md-sys-motion-spatial-fast', '--md-sys-motion-spatial-fast-duration',
|
|
'--md-sys-motion-spatial-default', '--md-sys-motion-spatial-default-duration',
|
|
'--md-sys-motion-spatial-slow', '--md-sys-motion-spatial-slow-duration',
|
|
]);
|
|
|
|
foreach (['fast' => '350ms', 'default' => '500ms', 'slow' => '750ms'] as $speed => $duration) {
|
|
expect($standard["--md-sys-motion-spatial-{$speed}-duration"])->toBe($duration)
|
|
->and($standard["--md-sys-motion-spatial-{$speed}"])->toStartWith('linear(0, ')
|
|
->not->toBe($expressive["--md-sys-motion-spatial-{$speed}"]);
|
|
}
|
|
});
|