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
224 lines
8.3 KiB
PHP
224 lines
8.3 KiB
PHP
<?php
|
|
|
|
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, 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
|
|
* file — or with one that is not a scheme — the package's own default applies, the same one
|
|
* tokens/scheme.css carries. A role the application's file lacks (a scheme generated before
|
|
* 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.
|
|
*
|
|
* The roles at the top level are the standard contrast level; M3's medium and high levels sit
|
|
* under `contrast` and are asked for by name (`load($path, $profile, 'high')`). A file written
|
|
* before the levels existed — or one whose level is missing a role — falls back to the standard
|
|
* roles, so a mail or an error page never draws a hole.
|
|
*
|
|
* 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
|
|
{
|
|
/**
|
|
* M3's contrast levels, as `material:scheme` writes them and as <html data-contrast> names
|
|
* them. `standard` is the scheme's own level and has no attribute.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const LEVELS = ['standard', 'medium', 'high'];
|
|
|
|
/**
|
|
* @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, at
|
|
* the given contrast level (`standard`, `medium` or `high`; anything else is standard).
|
|
*
|
|
* @return array{light: array<string, string>, dark: array<string, string>}
|
|
*/
|
|
public static function load(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
|
|
{
|
|
$data = static::data($path);
|
|
$profiles = static::profilesFrom($data, $contrast);
|
|
|
|
if ($profiles !== []) {
|
|
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
|
|
|
|
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
|
}
|
|
|
|
return static::scheme($data, $contrast);
|
|
}
|
|
|
|
/**
|
|
* The light roles: what a mail wears.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public static function light(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
|
|
{
|
|
return static::load($path, $profile, $contrast)['light'];
|
|
}
|
|
|
|
/**
|
|
* 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>}>
|
|
*/
|
|
public static function profiles(?string $path = null, string $contrast = 'standard'): array
|
|
{
|
|
return static::profilesFrom(static::data($path), $contrast);
|
|
}
|
|
|
|
/**
|
|
* 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 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, string $contrast = 'standard'): array
|
|
{
|
|
if (! is_array($data['profiles'] ?? null)) {
|
|
return [];
|
|
}
|
|
|
|
$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),
|
|
...static::scheme($profile, $contrast),
|
|
];
|
|
}
|
|
|
|
return $profiles;
|
|
}
|
|
|
|
/**
|
|
* One scheme's roles at one contrast level: the level's own, over the standard ones it does
|
|
* not name, over the package's default. A scheme file written before the levels existed has
|
|
* only the standard roles, and draws them at every level rather than nothing.
|
|
*
|
|
* @param array<mixed> $data
|
|
* @return array{light: array<string, string>, dark: array<string, string>}
|
|
*/
|
|
protected static function scheme(array $data, string $contrast): array
|
|
{
|
|
$default = static::defaultScheme($contrast);
|
|
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
|
|
|
|
return [
|
|
'light' => [...$default['light'], ...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
|
|
'dark' => [...$default['dark'], ...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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 at one contrast level, which fills any role a file lacks.
|
|
*
|
|
* @return array{light: array<string, string>, dark: array<string, string>}
|
|
*/
|
|
protected static function defaultScheme(string $contrast = 'standard'): array
|
|
{
|
|
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
|
|
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
|
|
|
|
return [
|
|
'light' => [...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
|
|
'dark' => [...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
protected static function roles(mixed $roles): array
|
|
{
|
|
if (! is_array($roles)) {
|
|
return [];
|
|
}
|
|
|
|
return array_filter(
|
|
$roles,
|
|
fn (mixed $hex, mixed $role): bool => is_string($role)
|
|
&& preg_match('/^[a-z-]+$/', $role) === 1
|
|
&& is_string($hex)
|
|
&& preg_match('/^#[0-9a-fA-F]{6}$/', $hex) === 1,
|
|
ARRAY_FILTER_USE_BOTH,
|
|
);
|
|
}
|
|
}
|