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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:29:09 +02:00
co-authored by Claude Fable 5.1
parent b1fc0c9cfa
commit 2feb623ec0
7 changed files with 366 additions and 25 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');
});
+67
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'));
@@ -97,3 +125,42 @@ it('makes every motion token instant under reduced motion', function () {
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',
]);
});