hexdec($channel) / 255, str_split(ltrim($hex, '#'), 2)); $chroma = max($r, $g, $b) - min($r, $g, $b); if ($chroma === 0.0) { return 0.0; } $sector = match (max($r, $g, $b)) { $r => fmod(($g - $b) / $chroma, 6), $g => (($b - $r) / $chroma) + 2, default => (($r - $g) / $chroma) + 4, }; return fmod(($sector * 60) + 360, 360); } /** * How far apart two colours are on the hue circle, the short way round. */ function schemeHueDistance(string $one, string $other): float { $apart = abs(schemeHue($one) - schemeHue($other)); return min($apart, 360 - $apart); } beforeEach(function () { $this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css'; $this->data = Str::replaceLast('.css', '.json', $this->stylesheet); }); afterEach(function () { File::delete([$this->stylesheet, $this->data]); }); it('writes the scheme as a stylesheet and as data', function () { $this->artisan('material:scheme', ['seed' => '#4F46E5', '--output' => $this->stylesheet]) ->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); $stylesheet = File::get($this->stylesheet); expect($scheme) ->seed->toBe('#4f46e5') ->variant->toBe('tonal-spot') ->spec->toBe('2025') ->harmonize->toBeFalse() ->and($scheme['contrast']['standard'])->toBe(0) ->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light'])) ->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container']) ->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface']) ->and($scheme['light']['inverse-success'])->toBe($scheme['dark']['success']); foreach (['light' => "[data-theme='light']", 'dark' => "[data-theme='dark']"] as $theme => $selector) { $block = Str::of($stylesheet)->after($selector)->before('}')->toString(); expect($block)->toContain("color-scheme: {$theme};"); foreach ($scheme[$theme] as $role => $hex) { expect($block)->toContain("--md-sys-color-{$role}: {$hex};"); } } expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot'); }); it('writes M3\'s three contrast levels for both themes, keyed on data-contrast', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet]) ->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); $stylesheet = File::get($this->stylesheet); // The standard level keeps the 1.x shape at the top; the other two sit under `contrast`. expect(array_keys($scheme['contrast']))->toBe(['standard', 'medium', 'high']) ->and(array_keys($scheme['contrast']['medium']['light']))->toEqual(array_keys($scheme['light'])) ->and(array_keys($scheme['contrast']['high']['dark']))->toEqual(array_keys($scheme['dark'])); foreach (['medium', 'high'] as $level) { foreach (['light' => "[data-contrast='{$level}'] [data-theme='light'] {", 'dark' => "[data-contrast='{$level}'] [data-theme='dark'] {"] as $theme => $selector) { $block = Str::of($stylesheet)->after($selector)->before('}')->toString(); expect($block)->toContain("color-scheme: {$theme};"); foreach ($scheme['contrast'][$level][$theme] as $role => $hex) { expect($block)->toContain("--md-sys-color-{$role}: {$hex};"); } } expect($stylesheet) ->toContain("[data-contrast='{$level}'],\n[data-contrast='{$level}'][data-theme='light'],") ->toContain("[data-contrast='{$level}'][data-theme='dark'],"); } // Every level differs from the one below it, in both themes and for the state colours too. foreach (['light', 'dark'] as $theme) { foreach (['on-surface-variant', 'outline', 'success', 'info', 'success-container'] as $role) { expect($scheme['contrast']['medium'][$theme][$role]) ->not->toBe($scheme[$theme][$role], "medium {$theme} {$role}") ->not->toBe($scheme['contrast']['high'][$theme][$role], "high {$theme} {$role}"); } // The state colours are dynamic colours now, so the level reaches them as it does a // built-in role. (A container whose tone already clears the level's ratio stays put, // exactly as error's does — hence the pairs rather than every one of the four.) foreach (['success', 'warning', 'info'] as $state) { foreach ([$state, "on-{$state}", "on-{$state}-container"] as $role) { expect($scheme['contrast']['high'][$theme][$role])->not->toBe($scheme[$theme][$role], "high {$theme} {$role}"); } } } expect($stylesheet)->not->toContain('prefers-contrast'); }); it('puts a level\'s nested light blocks after the dark ones of the level below', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet]) ->assertSuccessful(); $stylesheet = File::get($this->stylesheet); $at = fn (string $selector): int => strpos($stylesheet, $selector); // A light panel inside a dark high-contrast page: its rule carries one attribute more than // the plain dark block and stands after it, so it wins on specificity and on order. expect($at("[data-theme='dark'] {"))->toBeLessThan($at("[data-contrast='high'] [data-theme='light'] {")) ->and($at("[data-contrast='medium'] [data-theme='light'] {"))->toBeLessThan($at("[data-contrast='medium'][data-theme='dark'],")) ->and($at("[data-contrast='medium'][data-theme='dark'],"))->toBeLessThan($at("[data-contrast='high'],")); }); it('generates the levels for every profile, under its own data-scheme', function () { config(['livewire-material.profiles' => [ 'indigo' => ['seed' => '#4f46e5', 'variant' => 'vibrant'], 'teal' => ['seed' => '#00897b', 'variant' => 'vibrant'], ]]); $this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); $stylesheet = File::get($this->stylesheet); foreach ($scheme['profiles'] as $name => $profile) { foreach (['medium', 'high'] as $level) { $selector = "[data-scheme='{$name}'][data-contrast='{$level}'] [data-theme='dark'] {"; expect(Str::of($stylesheet)->after($selector)->before('}')->toString()) ->toContain("--md-sys-color-primary: {$profile['contrast'][$level]['dark']['primary']};") ->and($profile['contrast'][$level]['light']['success'])->not->toBe($profile['light']['success']); } } // The default profile's plain blocks come first, its levels next, then every profile's. expect(strpos($stylesheet, "[data-contrast='high'],"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],")); }); it('refuses a standard contrast level at or above the medium one', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.5', '--output' => $this->stylesheet]) ->expectsOutputToContain('Medium (0.5) and high (1) are always generated') ->assertFailed(); expect(File::exists($this->stylesheet))->toBeFalse(); config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'contrast' => 1]]]); $this->artisan('material:scheme', ['--output' => $this->stylesheet]) ->expectsOutputToContain('Profile "indigo": The contrast level 1') ->assertFailed(); }); it('keeps a standard level below medium, and records it in the header', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.3', '--output' => $this->stylesheet]) ->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); expect($scheme['contrast']['standard'])->toBe(0.3) ->and($scheme['light']['primary'])->not->toBe($scheme['contrast']['medium']['light']['primary']) ->and(File::get($this->stylesheet))->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot --contrast=0.3'); }); it('harmonises the state colours towards the seed only when asked', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])->assertSuccessful(); $plain = json_decode(File::get($this->data), true); $this->artisan('material:scheme', ['seed' => '#4f46e5', '--harmonize' => true, '--output' => $this->stylesheet])->assertSuccessful(); $harmonized = json_decode(File::get($this->data), true); expect($harmonized['harmonize'])->toBeTrue() ->and($plain['harmonize'])->toBeFalse() ->and(File::get($this->stylesheet))->toContain('--variant=tonal-spot --harmonize'); foreach (['success', 'warning', 'info'] as $state) { expect(schemeHueDistance($harmonized['light'][$state], '#4f46e5')) ->toBeLessThan(schemeHueDistance($plain['light'][$state], '#4f46e5'), "{$state} moved towards the seed") ->and($harmonized['contrast']['high']['dark'][$state])->not->toBe($plain['contrast']['high']['dark'][$state]); } }); it('generates a different scheme per variant', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet]) ->assertSuccessful(); $vibrant = json_decode(File::get($this->data), true); $this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet]) ->assertSuccessful(); $tonalSpot = json_decode(File::get($this->data), true); expect($vibrant['variant'])->toBe('vibrant') ->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']); }); it('generates with the 2021 colour spec when asked, and records it', function () { $this->artisan('material:scheme', [ 'seed' => '#00bc7d', '--variant' => 'vibrant', '--success' => '#00d390', '--warning' => '#fcb700', '--info' => '#00bafe', '--spec' => '2021', '--output' => $this->stylesheet, ])->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); expect($scheme['spec'])->toBe('2021') ->and($scheme['dark'])->toMatchArray([ 'surface' => '#0b1610', 'primary' => '#00e297', 'on-primary' => '#003822', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff', ]) ->and($scheme['light'])->toMatchArray([ 'surface' => '#f0fdf2', 'primary' => '#006c46', 'success' => '#006c48', 'warning' => '#7c5800', 'info' => '#00658c', ]) ->and(File::get($this->stylesheet)) ->toContain('(spec 2021)') ->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n"); }); it('refuses an unknown colour spec', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet]) ->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.') ->assertFailed(); expect(File::exists($this->stylesheet))->toBeFalse(); }); it('refuses a seed that is not a colour', function () { $this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet]) ->expectsOutputToContain('#rrggbb') ->assertFailed(); expect(File::exists($this->stylesheet))->toBeFalse(); }); it('refuses an unknown variant', function () { $this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'loud', '--output' => $this->stylesheet]) ->expectsOutputToContain('Unknown variant "loud"') ->assertFailed(); }); it('fails without writing anything when node cannot run', function () { config(['livewire-material.node' => '/nonexistent/node']); $this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet]) ->assertFailed(); expect(File::exists($this->stylesheet))->toBeFalse(); }); it('generates every configured profile into one stylesheet keyed by data-scheme', function () { config([ 'livewire-material.profiles' => [ 'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'], 'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'], 'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'], ], 'livewire-material.profile' => 'teal', ]); $this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful(); $scheme = json_decode(File::get($this->data), true); $stylesheet = File::get($this->stylesheet); expect($scheme['default'])->toBe('teal') ->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite']) ->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral') ->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary']) // The top level is the default profile, as a 1.0 reader expects. ->and($scheme['light'])->toBe($scheme['profiles']['teal']['light']) ->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark']) ->and($scheme['seed'])->toBe('#00897b') ->and($stylesheet)->toContain('php artisan material:scheme'."\n"); // The default's plain blocks come first, so a profile's single-attribute selector follows :root. expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],")); foreach ($scheme['profiles'] as $name => $profile) { foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) { $block = Str::of($stylesheet)->after($selector)->before('}')->toString(); expect($block)->toContain("color-scheme: {$theme};") ->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};") ->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};"); } } }); it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () { config([ 'livewire-material.profiles' => [ 'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'], 'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'], ], ]); $this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful(); $profiles = json_decode(File::get($this->data), true)['profiles']; expect($profiles['classic'])->spec->toBe('2021') ->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff']) ->and($profiles['expressive'])->spec->toBe('2025') ->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297') ->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c') // The state colours follow the spec now, so the same source is not the same colour. ->and($profiles['expressive']['dark']['info'])->not->toBe('#80cfff') ->and(File::get($this->stylesheet)) ->toContain('(spec 2025)') ->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/') ->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/'); }); it('names the profile whose spec is unknown', function () { config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]); $this->artisan('material:scheme', ['--output' => $this->stylesheet]) ->expectsOutputToContain('Profile "indigo": Unknown spec "2019"') ->assertFailed(); }); it('names the profile a generator error belongs to', function () { config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]); $this->artisan('material:scheme', ['--output' => $this->stylesheet]) ->expectsOutputToContain('Profile "broken"') ->assertFailed(); expect(File::exists($this->stylesheet))->toBeFalse(); }); it('asks for a seed or profiles when it has neither', function () { config(['livewire-material.profiles' => []]); $this->artisan('material:scheme', ['--output' => $this->stylesheet]) ->expectsOutputToContain('livewire-material.profiles') ->assertFailed(); }); it('refuses a profile name that cannot be an attribute value', function () { config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]); $this->artisan('material:scheme', ['--output' => $this->stylesheet]) ->expectsOutputToContain('"Ocean Blue" is not') ->assertFailed(); });