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
This commit is contained in:
co-authored by
Claude Opus 5
parent
7798352cfc
commit
fb42f004b1
+142
-28
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'material:scheme
|
||||
{seed : The source colour, as #rrggbb}
|
||||
{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}
|
||||
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour';
|
||||
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' => $this->argument('seed'),
|
||||
'variant' => $this->option('variant'),
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'seed' => $seed,
|
||||
'variant' => $variant,
|
||||
'contrast' => $contrast,
|
||||
'success' => $this->option('success'),
|
||||
'warning' => $this->option('warning'),
|
||||
'info' => $this->option('info'),
|
||||
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?');
|
||||
$this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
|
||||
|
||||
return self::FAILURE;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$this->components->error('The scheme generator answered with something other than JSON.');
|
||||
$this->components->error($context.'The scheme generator answered with something other than JSON.');
|
||||
|
||||
return self::FAILURE;
|
||||
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, $this->stylesheet($scheme));
|
||||
$files->put($stylesheet, $css);
|
||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
|
||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
);
|
||||
|
||||
$block = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
|
||||
* first paint; the light block also stands without it.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
{$block($scheme['light'])}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
{$block($scheme['dark'])}
|
||||
}
|
||||
|
||||
{$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";
|
||||
}
|
||||
}
|
||||
|
||||
+132
-14
@@ -2,9 +2,14 @@
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
||||
* resolves none, and an error page whose build is missing has no stylesheet to declare them.
|
||||
* resolves none, an error page whose build is missing has no stylesheet to declare them, and the
|
||||
* head script has to name the active colour profile before the stylesheet applies.
|
||||
*
|
||||
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
||||
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
||||
@@ -13,18 +18,51 @@ namespace NoNameWeb\LivewireMaterial\Support;
|
||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running
|
||||
* worker holds nothing.
|
||||
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
|
||||
* default under `default`, and the default's roles at the top level as a single scheme does. Which
|
||||
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
|
||||
* asked on every call, and a name it gives that is not in the file — or a resolver that throws, as
|
||||
* one reading a database may while an error page for that very database renders — falls back to
|
||||
* the file's default.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme or a newly chosen profile applies at
|
||||
* once, and a long-running worker holds nothing but the resolver itself.
|
||||
*/
|
||||
class Scheme
|
||||
{
|
||||
/**
|
||||
* @var (Closure(): ?string)|null
|
||||
*/
|
||||
protected static ?Closure $profileResolver = null;
|
||||
|
||||
/**
|
||||
* Name the active colour profile with the given closure, asked each time a colour is drawn.
|
||||
*
|
||||
* @param (Closure(): ?string)|null $resolver
|
||||
*/
|
||||
public static function resolveProfileUsing(?Closure $resolver): void
|
||||
{
|
||||
static::$profileResolver = $resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
public static function load(?string $path = null): array
|
||||
public static function load(?string $path = null, ?string $profile = null): array
|
||||
{
|
||||
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json');
|
||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
||||
$data = static::data($path);
|
||||
$profiles = static::profilesFrom($data);
|
||||
|
||||
if ($profiles !== []) {
|
||||
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
|
||||
|
||||
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
@@ -37,22 +75,102 @@ class Scheme
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function light(?string $path = null): array
|
||||
public static function light(?string $path = null, ?string $profile = null): array
|
||||
{
|
||||
return static::load($path)['light'];
|
||||
return static::load($path, $profile)['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
* The generated colour profiles, in the order they were configured; empty for a single scheme.
|
||||
*
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
protected static function read(string $path): array
|
||||
public static function profiles(?string $path = null): array
|
||||
{
|
||||
return static::profilesFrom(static::data($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile's name, or null for a single scheme.
|
||||
*/
|
||||
public static function profile(?string $path = null): ?string
|
||||
{
|
||||
$data = static::data($path);
|
||||
$profiles = static::profilesFrom($data);
|
||||
|
||||
return $profiles === [] ? null : static::activeFrom($data, $profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected static function data(?string $path): array
|
||||
{
|
||||
$path ??= (string) config('livewire-material.scheme');
|
||||
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||
|
||||
return [
|
||||
'light' => static::roles($data['light'] ?? null),
|
||||
'dark' => static::roles($data['dark'] ?? null),
|
||||
];
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
protected static function profilesFrom(array $data): array
|
||||
{
|
||||
if (! is_array($data['profiles'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$profiles = [];
|
||||
|
||||
foreach ($data['profiles'] as $name => $profile) {
|
||||
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profiles[$name] = [
|
||||
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
|
||||
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
|
||||
];
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param non-empty-array<string, mixed> $profiles
|
||||
*/
|
||||
protected static function activeFrom(array $data, array $profiles): string
|
||||
{
|
||||
try {
|
||||
$resolved = static::$profileResolver ? (static::$profileResolver)() : null;
|
||||
} catch (Throwable) {
|
||||
$resolved = null;
|
||||
}
|
||||
|
||||
foreach ([$resolved, $data['default'] ?? null] as $candidate) {
|
||||
if (is_string($candidate) && isset($profiles[$candidate])) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) array_key_first($profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's own scheme, which fills any role a file lacks.
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function defaultScheme(): array
|
||||
{
|
||||
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
|
||||
|
||||
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user