Merge branch 'worktree-agent-afa50210f14592c5f'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:30:12 +02:00
9 changed files with 655 additions and 53 deletions
+41
View File
@@ -0,0 +1,41 @@
<?php
use Illuminate\Support\Facades\Process;
/**
* PHP cannot open a woff2 without the Brotli extension, so the axes are read by Node:
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node the test is skipped, the
* way `material:scheme` reports the same missing binary instead of guessing.
*/
it('keeps the variable axes the typescale depends on in the packaged font', function () {
$node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed()) {
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
}
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs']);
expect($result->successful())->toBeTrue($result->errorOutput());
$font = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
expect($font['axes'])->toHaveKeys(['wght', 'ROND'])
// font.css declares `font-weight: 400 700`; every typescale weight is inside it.
->and($font['axes']['wght'])->toMatchArray(['min' => 400, 'max' => 700])
// The axis every `type-emphasized-*` utility sets to 100; a re-subset must keep it.
->and($font['axes']['ROND'])->toMatchArray(['min' => 0, 'max' => 100]);
});
it('fails loudly when the font has no variable axes at all', function () {
$node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed()) {
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
}
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
expect($result->failed())->toBeTrue()
->and($result->errorOutput())->toContain('no-such-font.woff2');
});
+108 -2
View File
@@ -20,6 +20,34 @@ function declarations(string $css, string $selector): array
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'));
@@ -85,15 +113,93 @@ it('reads every safe-area inset through a variable that can replace it', functio
->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 () {
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();
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}"]);
}
});