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
109 lines
5.1 KiB
PHP
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');
|
|
});
|