SchemeCommand::levels()/selectors()/blocks() move to a new SchemeStylesheet, and Scheme gains forStylesheet(), which resolves a scheme (and every profile) at all three contrast levels together. Plan step 40's fallback needs to draw the same colours material:scheme writes, without duplicating its selector logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
301 lines
13 KiB
PHP
301 lines
13 KiB
PHP
<?php
|
|
|
|
namespace NoNameWeb\LivewireMaterial\Console;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Filesystem\Filesystem;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
use JsonException;
|
|
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
|
|
#[AsCommand(name: 'material:scheme')]
|
|
class SchemeCommand extends Command
|
|
{
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $signature = 'material:scheme
|
|
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
|
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
|
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
|
|
{--contrast=0 : The standard level\'s contrast, from -1 to below 0.5; medium and high are always generated}
|
|
{--harmonize : Pull the success, warning and info colours towards the seed}
|
|
{--success=#22a06b : The source of the success colour}
|
|
{--warning=#e2a400 : The source of the warning colour}
|
|
{--info=#1d7afc : The source of the info colour}
|
|
{--output= : The stylesheet to write, resources/css/material-scheme.css by default}';
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
|
|
|
/**
|
|
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
|
|
* falls back to 2021 by itself for the variants 2025 does not define.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected const SPECS = ['2021', '2025'];
|
|
|
|
/**
|
|
* M3's other two contrast levels, generated for every scheme beside the standard one and
|
|
* keyed on <html data-contrast> (styles/color/roles, "What's new May 2025"): medium is
|
|
* 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the
|
|
* installation's — `--contrast` moves the standard block alone.
|
|
*
|
|
* @var array<string, float>
|
|
*/
|
|
protected const LEVELS = ['medium' => 0.5, 'high' => 1.0];
|
|
|
|
public function handle(Filesystem $files): int
|
|
{
|
|
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
|
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
|
$spec = (string) $this->option('spec');
|
|
|
|
if (! in_array($spec, self::SPECS, true)) {
|
|
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if (filled($this->argument('seed'))) {
|
|
if ($this->refusesContrast((float) $this->option('contrast'))) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$input = [
|
|
'seed' => (string) $this->argument('seed'),
|
|
'variant' => (string) $this->option('variant'),
|
|
'spec' => $spec,
|
|
'contrast' => (float) $this->option('contrast'),
|
|
'harmonize' => (bool) $this->option('harmonize'),
|
|
'success' => (string) $this->option('success'),
|
|
'warning' => (string) $this->option('warning'),
|
|
'info' => (string) $this->option('info'),
|
|
];
|
|
|
|
$scheme = $this->generate($input);
|
|
|
|
if ($scheme === null) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme);
|
|
}
|
|
|
|
$profiles = config('livewire-material.profiles');
|
|
|
|
if (! is_array($profiles) || $profiles === []) {
|
|
$this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$generated = [];
|
|
|
|
foreach ($profiles as $name => $profile) {
|
|
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
|
|
$this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
// A profile's own spec and state colours win; without them, the command's options apply.
|
|
$scheme = $this->generate([
|
|
'seed' => (string) ($profile['seed'] ?? ''),
|
|
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
|
'spec' => (string) ($profile['spec'] ?? $spec),
|
|
'contrast' => (float) ($profile['contrast'] ?? 0),
|
|
'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')),
|
|
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
|
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
|
'info' => (string) ($profile['info'] ?? $this->option('info')),
|
|
], "Profile \"{$name}\": ");
|
|
|
|
if ($scheme === null) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme];
|
|
}
|
|
|
|
$configured = config('livewire-material.profile');
|
|
$default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated);
|
|
|
|
return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [
|
|
...collect($generated[$default])->except('label')->all(),
|
|
'default' => $default,
|
|
'profiles' => $generated,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Whether the given standard contrast level is one this command generates, having said why
|
|
* it is not. Medium and high are M3's own levels and always generated; `--contrast` moves
|
|
* the standard block, which has to stay below them.
|
|
*/
|
|
protected function refusesContrast(float $contrast, string $context = ''): bool
|
|
{
|
|
if ($contrast >= self::LEVELS['medium'] || $contrast < -1) {
|
|
$this->components->error($context.sprintf(
|
|
'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.',
|
|
$contrast,
|
|
self::LEVELS['medium'],
|
|
self::LEVELS['medium'],
|
|
self::LEVELS['high'],
|
|
));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
|
*
|
|
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
|
|
* @return array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array{standard: float, medium: array{light: array<string, string>, dark: array<string, string>}, high: array{light: array<string, string>, dark: array<string, string>}}, light: array<string, string>, dark: array<string, string>}|null
|
|
*/
|
|
protected function generate(array $input, string $context = ''): ?array
|
|
{
|
|
$result = Process::run([
|
|
config('livewire-material.node', 'node'),
|
|
__DIR__.'/../../resources/node/scheme.mjs',
|
|
json_encode($input),
|
|
]);
|
|
|
|
if ($result->failed()) {
|
|
$this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
|
|
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
|
} catch (JsonException) {
|
|
$this->components->error($context.'The scheme generator answered with something other than JSON.');
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scheme
|
|
*/
|
|
protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int
|
|
{
|
|
$files->ensureDirectoryExists(dirname($stylesheet));
|
|
$files->put($stylesheet, $css);
|
|
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
|
|
|
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* The stylesheet, headed by the command that regenerates it: every option that differs from
|
|
* its default is written out.
|
|
*
|
|
* @param array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
|
|
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
|
|
*/
|
|
protected function stylesheet(array $scheme, array $input): string
|
|
{
|
|
$states = collect(['success', 'warning', 'info'])
|
|
->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault()))
|
|
->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state])))
|
|
->implode('');
|
|
|
|
$command = sprintf(
|
|
'php artisan material:scheme "%s" --variant=%s%s%s%s%s',
|
|
$scheme['seed'],
|
|
$scheme['variant'],
|
|
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
|
$scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '',
|
|
$scheme['harmonize'] ? ' --harmonize' : '',
|
|
$states,
|
|
);
|
|
|
|
$blocks = SchemeStylesheet::levels($scheme);
|
|
|
|
return <<<CSS
|
|
/*
|
|
* Material 3 colour roles, generated by Google's material-color-utilities (spec {$scheme['spec']}).
|
|
*
|
|
* {$command}
|
|
*
|
|
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
|
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
|
* first paint; the light block also stands without it.
|
|
*
|
|
* M3's medium and high contrast levels follow the standard blocks, keyed on
|
|
* data-contrast — which the head script writes from the visitor's choice, or from the
|
|
* operating system's contrast setting while that choice is `system`. No media query
|
|
* decides here, as none decides the theme.
|
|
*/
|
|
|
|
{$blocks}
|
|
CSS;
|
|
}
|
|
|
|
/**
|
|
* The default profile as the plain blocks, then every profile under its own data-scheme. A
|
|
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
|
|
* comes after `:root`, which it only ties with: the order is part of the format. The
|
|
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
|
* page's profile.
|
|
*
|
|
* @param array<string, array{label: string, seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>}> $profiles
|
|
*/
|
|
protected function profilesStylesheet(array $profiles, string $default): string
|
|
{
|
|
$list = collect($profiles)
|
|
->map(fn (array $profile, string $name): string => sprintf(
|
|
' * %-12s %s, %s%s%s%s',
|
|
$name,
|
|
$profile['seed'],
|
|
$profile['variant'],
|
|
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
|
$profile['contrast']['standard'] != 0 ? ', contrast '.$profile['contrast']['standard'] : '',
|
|
$profile['harmonize'] ? ', harmonized' : '',
|
|
))
|
|
->implode("\n");
|
|
|
|
$css = <<<CSS
|
|
/*
|
|
* Material 3 colour profiles, generated by Google's material-color-utilities (spec {$profiles[$default]['spec']}).
|
|
*
|
|
* php artisan material:scheme
|
|
*
|
|
* From livewire-material.profiles. "{$default}" is the default, and also stands without a
|
|
* data-scheme attribute:
|
|
*
|
|
{$list}
|
|
*
|
|
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
|
* M3's contrast guarantee only as generated. The head script sets data-theme, data-scheme
|
|
* and data-contrast before the first paint.
|
|
*/
|
|
|
|
CSS;
|
|
|
|
$css .= "\n".SchemeStylesheet::levels($profiles[$default]);
|
|
|
|
foreach ($profiles as $name => $profile) {
|
|
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
}
|