Bundle the error page's fallback instead of hand-writing it
Plan step 40. ErrorPage::fallbackStyles() now inlines Stylesheets::bundle() of the foundation and error-page.css (which imports button.css and shape.css) rather than a hand-copied stylesheet, so the fallback can never drift from the built version. Every @font-face block is dropped structurally (withoutFontFace(), brace-balanced, not a text search) since there is no build to serve the font file; --md-ref-typeface-brand already lists ui-sans-serif, system-ui and sans-serif after the brand name, so the page still gets a sensible system stack. The scheme half comes from Scheme::forStylesheet() drawn through SchemeStylesheet::levels(), in material-scheme.css's own selector shape: standard, the medium and high contrast levels, and a block per colour profile keyed on [data-scheme] (the theme script has already resolved and written the active one to <html> before this stylesheet is read, so nothing here picks one in PHP). Both halves are cached per worker, the scheme half by the scheme file's path and mtime. Tests: both render paths, the inlined CSS's shape (no @import, no relative url(), no @font-face, the button/shape/error-page rules, the scheme's roles, [data-contrast='high'] and a profile block), and a new browser test that forces the fallback and checks the button, the shape and a dark-mode repaint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3b9a889e7c
commit
e1fe573182
+105
-35
@@ -14,12 +14,24 @@ use Throwable;
|
||||
class ErrorPage
|
||||
{
|
||||
/**
|
||||
* The roles the fallback stylesheet draws with.
|
||||
* The package stylesheets the fallback bundles: the foundation (every application needs it)
|
||||
* and the error layout's own stylesheet, which imports button.css and shape.css for it — the
|
||||
* same rules a Vite build would serve the page, `Stylesheets::bundle()` inlining them in PHP
|
||||
* instead. Not `all.css`: that would bundle every component's rules for a page that draws
|
||||
* three of them.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const array ROLES = [
|
||||
'surface', 'on-surface', 'on-surface-variant', 'primary', 'on-primary',
|
||||
'primary-container', 'on-primary-container', 'secondary',
|
||||
];
|
||||
protected const array FILES = ['foundation.css', 'components/error-page.css'];
|
||||
|
||||
/**
|
||||
* The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache:
|
||||
* by the scheme file's path and mtime, so a request that never regenerates it never rebuilds
|
||||
* this either, and a fresh `material:scheme` run is picked up the moment its mtime changes.
|
||||
*
|
||||
* @var array<string, array{css: string, mtime: int|false}>
|
||||
*/
|
||||
protected static array $schemeCache = [];
|
||||
|
||||
/**
|
||||
* The application's Vite tags, or null when they cannot be made.
|
||||
@@ -59,40 +71,98 @@ class ErrorPage
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet for a page without its build: the application's scheme in both themes, a
|
||||
* system font, and the layout's `data-md-error-*` hooks drawn to match the built version
|
||||
* (resources/css/components/error-page.css), the shape's slow turn included. Inlined into the
|
||||
* error page alone, so its `body` rule needs no scope. Still hand-built, not `Stylesheets::bundle()` —
|
||||
* plan step 40 replaces this method's own body, once the design guard exists to catch a hook
|
||||
* the two drift apart on.
|
||||
* A stylesheet for a page without its build: the foundation and the error layout's own rules —
|
||||
* `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
|
||||
* can never drift from `resources/css/components/error-page.css` the way a hand-written copy
|
||||
* did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve
|
||||
* the font file, and a relative `url()` a request 404s on is worse than none) and the
|
||||
* application's colours appended in `material-scheme.css`'s own shape (`schemeStylesheet()`):
|
||||
* the standard level, the medium and high contrast levels under `[data-contrast]`, and a block
|
||||
* per colour profile under `[data-scheme]`. `<x-theme-script>` has already written
|
||||
* `data-theme`, `data-contrast` and `data-scheme` onto `<html>` by the time this tag is parsed
|
||||
* (it renders first), so the browser resolves the right block on its own — nothing here decides
|
||||
* an active profile in PHP.
|
||||
*/
|
||||
public static function fallbackStyles(): HtmlString
|
||||
{
|
||||
$scheme = Scheme::load();
|
||||
$files = array_map(fn (string $file): string => dirname(__DIR__, 2)."/resources/css/{$file}", self::FILES);
|
||||
|
||||
$roles = fn (array $theme): string => implode('', array_map(
|
||||
fn (string $role): string => "--md-sys-color-{$role}: {$theme[$role]}; ",
|
||||
self::ROLES,
|
||||
));
|
||||
return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
|
||||
}
|
||||
|
||||
return new HtmlString(<<<CSS
|
||||
:root, [data-theme='light'] { color-scheme: light; {$roles($scheme['light'])}}
|
||||
[data-theme='dark'] { color-scheme: dark; {$roles($scheme['dark'])}}
|
||||
*, ::before, ::after { box-sizing: border-box; }
|
||||
body { margin: 0; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font: 400 1rem/1.5rem ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
[data-md-error-page] { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; max-width: 36rem; margin: 0 auto; padding: 3rem 1.5rem; text-align: center; }
|
||||
[data-md-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; }
|
||||
[data-md-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); }
|
||||
[data-md-error-shape] svg { display: block; width: 100%; height: 100%; }
|
||||
[data-md-error-code] { position: relative; margin: 0; font-size: 3.5625rem; font-weight: 500; line-height: 4rem; color: var(--md-sys-color-on-primary-container); font-variant-numeric: tabular-nums; }
|
||||
[data-md-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; }
|
||||
[data-md-error-message] { margin: 0.75rem 0 0; color: var(--md-sys-color-on-surface-variant); text-wrap: balance; }
|
||||
[data-md-error-actions] { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
|
||||
[data-md-error-actions] :is(a, button) { display: inline-flex; align-items: center; height: 3.5rem; padding: 0 1.5rem; border: 0; border-radius: 9999px; background: none; color: var(--md-sys-color-primary); font-family: inherit; font-size: 1rem; font-weight: 500; line-height: 1.5rem; text-decoration: none; cursor: pointer; }
|
||||
[data-md-error-actions] > :first-child { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
|
||||
[data-md-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
|
||||
@keyframes material-error-turn { to { transform: rotate(1turn); } }
|
||||
@media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } }
|
||||
CSS);
|
||||
/**
|
||||
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's
|
||||
* text (fragile the moment a comment or a value is reworded) but a structural read — find
|
||||
* `@font-face` outside nothing else looks at here, then remove the balanced `{ … }` that
|
||||
* follows it, brace for brace, however the block itself is written. `tokens/font.css`'s is the
|
||||
* only one the bundle carries, and it is also the bundle's only relative `url()` (button.css,
|
||||
* icon.css, shape.css and the rest reference nothing on disk), so this removal is also what
|
||||
* leaves the fallback with no `url()` to a font the fallback cannot serve. The typeface itself
|
||||
* degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`,
|
||||
* `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve
|
||||
* it the browser skips straight to that system stack — nothing here has to name one.
|
||||
*/
|
||||
protected static function withoutFontFace(string $css): string
|
||||
{
|
||||
$result = '';
|
||||
$offset = 0;
|
||||
$length = strlen($css);
|
||||
|
||||
while (($start = stripos($css, '@font-face', $offset)) !== false) {
|
||||
$result .= substr($css, $offset, $start - $offset);
|
||||
$open = strpos($css, '{', $start);
|
||||
|
||||
if ($open === false) {
|
||||
$offset = $start + strlen('@font-face');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$depth = 1;
|
||||
$i = $open + 1;
|
||||
|
||||
while ($i < $length && $depth > 0) {
|
||||
$depth += match ($css[$i]) {
|
||||
'{' => 1,
|
||||
'}' => -1,
|
||||
default => 0,
|
||||
};
|
||||
$i++;
|
||||
}
|
||||
|
||||
$offset = $i;
|
||||
}
|
||||
|
||||
return $result.substr($css, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's colours, in `material-scheme.css`'s own selector shape
|
||||
* (`SchemeStylesheet::levels()`), from `Scheme::forStylesheet()` rather than a generated file
|
||||
* that may not exist yet. Cached by the scheme file's path and mtime (`self::$schemeCache`):
|
||||
* `Scheme` itself reads on every call and keeps nothing, so a resolver or a regenerated file
|
||||
* applies at once everywhere else it is asked — this cache is safe only because the CSS built
|
||||
* here never depends on which profile is *active*, only on the file's own content.
|
||||
*/
|
||||
protected static function schemeStylesheet(): string
|
||||
{
|
||||
$path = (string) config('livewire-material.scheme');
|
||||
clearstatcache(true, $path);
|
||||
$mtime = is_file($path) ? filemtime($path) : false;
|
||||
|
||||
if (isset(self::$schemeCache[$path]) && self::$schemeCache[$path]['mtime'] === $mtime) {
|
||||
return self::$schemeCache[$path]['css'];
|
||||
}
|
||||
|
||||
$scheme = Scheme::forStylesheet($path);
|
||||
$css = SchemeStylesheet::levels($scheme['scheme']);
|
||||
|
||||
foreach ($scheme['profiles'] as $name => $profile) {
|
||||
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
|
||||
}
|
||||
|
||||
self::$schemeCache[$path] = ['css' => $css, 'mtime' => $mtime];
|
||||
|
||||
return $css;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user