Files
livewire-material/tests/Feature/TokensTest.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 2feb623ec0 Take the typescale's tracking from Compose, and reset roundness
Tracking now comes from androidx's TypeScaleTokens.kt as sp/16 rem, so Display
Large is -0.0125rem, Title Medium and Body Medium 0.0125rem where material-web's
pre-Expressive numbers stood. The emphasized set gets its own
--md-sys-typescale-emphasized-<style>-tracking, which the emphasized utilities
read: M3 widens four of them and takes Display Large back to zero. Every regular
utility sets `font-variation-settings: normal`, because the property inherits and
body copy inside an emphasized heading is meant to read as itself.

bin/check-font.mjs (fontkit) prints the packaged woff2's fvar axes, so a
re-subset cannot silently drop the ROND axis the emphasized styles depend on;
`npm run check:font` runs it and tests/Feature/FontTest.php runs it through the
configured node binary, skipping when node cannot run (PHP cannot open a woff2
without Brotli).

Plan: docs/plans/material-3-alignment.md step 4, findings core C5, C6, C7.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:29:09 +02:00

167 lines
6.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 of type.css, keyed by utility name.
*
* @return array<string, string>
*/
function typeUtilities(): array
{
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(packageCss('tokens/type.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('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 to the head script, never to a media query', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toContain('prefers-color-scheme');
}
});
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;");
}
});
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',
]);
});