Plan step 39 (parts 1-3, folded into one commit: PHP tests read the deleted files directly, so they cannot land apart from it). Tailwind leaves the whole stack: - Delete resources/css/tailwind.css, tokens/theme.css and tokens/utilities.css. Neither token file declared an --md-sys-* custom property of its own (both only referenced tokens declared elsewhere), so nothing loses a value; the md-* interaction and text classes already mirror utilities.css's declarations exactly (foundation/interaction.css, text.css). - npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the plugin and its import; composer.json drops the tailwindcss keyword (no lock change — keywords are outside Composer's content hash). - The Workbench now builds one CSS entry, workbench/resources/css/app.css (all.css, showcase.css and the scheme; package.css is folded in and removed) instead of two, used by ErrorPage::assets() and every browser-test probe page's raw @vite() call; the showcase's own pages still take their CSS from the bundle route. - TokensTest and StylesheetsTest: the two facts theme.css and utilities.css carried (every scheme role becomes a colour, resolved on the element; md-type-* matches the type-* utilities) are asserted directly against the scheme and text.css now that there is no second copy to cross-check; StylesheetsTest gained a full-tree scan (every .css file under resources/css/ is reached from all.css or showcase.css, no exclusions left for Tailwind); the Workbench-entry test and every "moved out of tailwind.css" assertion updated for the single entry and its removal. - DesignGuard.php's comments and the development skill's setup section no longer name the deleted files or a second Tailwind entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
221 lines
9.9 KiB
PHP
221 lines
9.9 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 `.md-type-…` class in text.css, keyed by class name without the `md-` prefix
|
|
* (`type-display-lg`, `type-emphasized-display-lg`, …). Plain CSS now (plan step 39 deleted the
|
|
* Tailwind utilities they used to be); 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);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Tailwind's theme.css used to turn every scheme role into a `--color-*` Tailwind colour, in an
|
|
// inline theme block so it resolved on the element and a nested data-theme still repainted it
|
|
// rather than reading a value computed once on :root. Both went with Tailwind (plan step 39): the
|
|
// first fact is already the scheme test above (every role a `--md-sys-color-*` custom property,
|
|
// light and dark); the second no longer has anything to hold, because no file replaces theme.css's
|
|
// alias — every package rule reads `--md-sys-color-*` directly at its point of use, so there is no
|
|
// second custom property that could be computed once on :root and go stale under a nested
|
|
// data-theme in the first place.
|
|
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}"]);
|
|
}
|
|
});
|