Files
livewire-material/src/Console/SchemeCommand.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb42f004b1 Add colour profiles
An application lists named schemes in livewire-material.profiles, and
material:scheme without a seed generates them all into one stylesheet:
the default profile as the plain blocks, every profile under
<html data-scheme>, with descendant selectors so a nested data-theme
panel keeps the page's profile. The JSON keeps the 1.0 top level.

Scheme::resolveProfileUsing() lets the application name the active
profile; it is asked on every use and falls back to the default for an
unknown name or a resolver that throws. The head script writes it before
the first paint and keeps it through wire:navigate, and the mail theme
and the error pages' fallback draw it. <x-scheme-picker> chooses one and
previews it through $store.theme.previewScheme(); the showcase previews
the Workbench's profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:35:49 +02:00

230 lines
9.0 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 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}
{--contrast=0 : The contrast level, from -1 to 1}
{--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';
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
if (filled($this->argument('seed'))) {
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
if ($scheme === null) {
return self::FAILURE;
}
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $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;
}
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "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,
]);
}
/**
* One scheme from Google's colour utilities, or null once the reason has been shown.
*
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
{
$result = Process::run([
config('livewire-material.node', 'node'),
__DIR__.'/../../resources/node/scheme.mjs',
json_encode([
'seed' => $seed,
'variant' => $variant,
'contrast' => $contrast,
'success' => $this->option('success'),
'warning' => $this->option('warning'),
'info' => $this->option('info'),
]),
]);
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;
}
/**
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function stylesheet(array $scheme): string
{
$command = sprintf(
'php artisan material:scheme "%s" --variant=%s%s',
$scheme['seed'],
$scheme['variant'],
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
);
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $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.
*/
{$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, contrast: float, 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',
$name,
$profile['seed'],
$profile['variant'],
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
))
->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 and data-scheme
* before the first paint.
*/
CSS;
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
foreach ($profiles as $name => $profile) {
$css .= "\n".$this->blocks(
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
$profile,
);
}
return $css;
}
/**
* A light and a dark block of roles under the given selectors.
*
* @param list<string> $light
* @param list<string> $dark
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function blocks(array $light, array $dark, array $scheme): string
{
$roles = fn (array $roles): string => collect($roles)
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
}
}