Files
livewire-material/tests/Feature/TokensTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

216 lines
9.5 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
function packageCss(string $path = ''): string
{
return ComponentStylesheet::cssPath($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 `.md-type-…` class in text.css, keyed by class name without the `md-` prefix
* (`type-display-lg`, `type-emphasized-display-lg`, …). Plain CSS; text.css is theirs alone.
*
* @return array<string, string>
*/
function typeUtilities(): array
{
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(packageCss('text.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);
}
}
}
});
// Every package rule reads a role's `--md-sys-color-*` custom property directly at its point of
// use: no second alias computed once on `:root` that a nested `data-theme` could leave stale. The
// scheme test above already proves every role is `--md-sys-color-*`, light and dark.
it('never aliases a colour role to a second custom property resolved once on :root', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toMatch('/--color-[\w-]+:\s*var\(--md-sys-color-/', $file->getRelativePathname());
}
});
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}"]);
}
});