*/ 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 */ 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 */ 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('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); } } } }); 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 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}"]); } });