Files
livewire-material/tests/Feature/SchemeTest.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 4263982369 Give success, warning and info the 2025 spec and three contrast levels
The three state colours are DynamicColors on their own tonal palette now, built
exactly as Google builds error/on-error/error-container/on-error-container in both
specs, so the scheme's contrast level, dark tones, spec version and platform reach
them as they reach every other role; --harmonize (and a profile's harmonize) pulls
each source towards the seed, off by default. material:scheme also writes M3's
medium (0.5) and high (1.0) levels for both themes and every profile, keyed on
data-contrast and never on a media query, and --contrast now moves the standard
block alone. Scheme::load() takes the level; the mail theme stays on standard.
Plan: docs/plans/material-3-alignment.md, steps 6 and 7 (core C1, C2, C15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:30:48 +02:00

109 lines
5.1 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Scheme;
/**
* A scheme file with colour profiles, as `material:scheme` writes one from config.
*
* @param array<string, string> $primaries profile => light primary
*/
function writeProfiles(string $path, array $primaries, string $default): void
{
$profiles = collect($primaries)->map(fn (string $primary, string $name): array => [
'label' => Str::headline($name),
'seed' => $primary,
'variant' => 'vibrant',
'spec' => '2025',
'contrast' => 0,
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
'contrast' => [
'standard' => 0,
'medium' => ['light' => ['primary' => '#332f8a'], 'dark' => ['surface' => '#0a0a0a']],
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff', 'surface' => '#000000']],
],
])->all();
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
}
beforeEach(function () {
$this->path = sys_get_temp_dir().'/material-profiles-'.Str::random(8).'.json';
writeProfiles($this->path, ['indigo' => '#4f46e5', 'teal' => '#00897b', 'rose' => '#c2185b'], 'indigo');
});
afterEach(function () {
Scheme::resolveProfileUsing(null);
File::delete($this->path);
});
it('lists the generated profiles with their labels and roles, filling missing roles from the default', function () {
$profiles = Scheme::profiles($this->path);
expect(array_keys($profiles))->toBe(['indigo', 'teal', 'rose'])
->and($profiles['teal']['label'])->toBe('Teal')
->and($profiles['teal']['light']['primary'])->toBe('#00897b')
->and($profiles['teal']['light'])->toHaveKey('on-surface-variant')
->and(Scheme::profiles(sys_get_temp_dir().'/missing-scheme.json'))->toBe([]);
});
it('follows the resolver to the active profile, and falls back to the default', function () {
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::profile($this->path))->toBe('teal')
->and(Scheme::light($this->path)['primary'])->toBe('#00897b')
->and(Scheme::load($this->path)['dark']['surface'])->toBe('#111111');
Scheme::resolveProfileUsing(fn (): string => 'ocean');
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): ?string => null);
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn () => throw new RuntimeException('The database is down.'));
expect(Scheme::profile($this->path))->toBe('indigo');
});
it('loads a profile by name, whatever is active', function () {
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::light($this->path, 'rose')['primary'])->toBe('#c2185b')
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
});
it('loads a contrast level, falling back to the standard roles it does not name', function () {
expect(Scheme::load($this->path, 'teal', 'high')['light']['primary'])->toBe('#000000')
->and(Scheme::load($this->path, 'teal', 'high')['dark']['surface'])->toBe('#000000')
// The level names no surface in light, so the standard one stands.
->and(Scheme::light($this->path, 'teal', 'high')['surface'])->toBe('#fafafa')
->and(Scheme::load($this->path, 'teal', 'medium')['light']['primary'])->toBe('#332f8a')
->and(Scheme::load($this->path, 'teal')['light']['primary'])->toBe('#00897b')
// A level nobody generated, and a name that is not one, are the standard scheme.
->and(Scheme::load($this->path, 'teal', 'extreme')['light']['primary'])->toBe('#00897b')
->and(Scheme::profiles($this->path, 'high')['rose']['light']['primary'])->toBe('#000000')
->and(Scheme::profiles($this->path)['rose']['light']['primary'])->toBe('#c2185b');
});
it('draws a high contrast level from the package default for a scheme file without one', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
$package = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
expect(Scheme::light($this->path, null, 'high')['primary'])->toBe('#123456')
->and(Scheme::light($this->path, null, 'high')['surface'])->toBe($package['contrast']['high']['light']['surface'])
->and(Scheme::light($this->path)['surface'])->toBe($package['light']['surface']);
});
it('has no profile for a single scheme, and loads it as before', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::profile($this->path))->toBeNull()
->and(Scheme::profiles($this->path))->toBe([])
->and(Scheme::light($this->path)['primary'])->toBe('#123456')
->and(Scheme::light($this->path))->toHaveKey('on-surface');
});