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:
Andreas Reinhold / reini
2026-09-15 07:30:05 +02:00
co-authored by Claude Sonnet 5
parent 3b9a889e7c
commit e1fe573182
5 changed files with 223 additions and 47 deletions
+3 -3
View File
@@ -20,9 +20,9 @@
* The body is matched only when it holds the layout (`body:has(> [data-md-error-page])`): this
* file sits in the application's bundle beside every other page, where a bare `body` rule would
* restyle them all. `src/Support/ErrorPage.php`'s `fallbackStyles()`, the stylesheet an
* application without a Vite build gets instead, is inlined into the error page alone, so it keeps
* a bare `body`; it still needs updating by hand when this file changes, until step 40 replaces
* how it is built.
* application without a Vite build gets instead, inlines this file itself (`Stylesheets::bundle()`,
* plan step 40) rather than a hand-written copy, so it keeps the same scoped `body` rule and never
* drifts from it.
*
* The page's font is the foundation's, `--md-ref-typeface-brand` on `html` (foundation/base.css),
* as the old `font-sans` was; only the fallback, which has no `@font-face`, uses a system stack.
@@ -15,11 +15,12 @@
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
the package's stylesheets, including this layout's own,
resources/css/components/error-page.css. But an error page is also what shows while a deploy
has no build yet, so when those tags cannot be made the page brings a small stylesheet of its
own instead: the app's scheme from its scheme data, drawn onto the same `data-md-error-*`
hooks (`src/Support/ErrorPage.php::fallbackStyles()`). Keep the hooks in step across both
when changing the markup. The shape turns once a minute, unless the visitor asks for reduced
motion. --}}
has no build yet, so when those tags cannot be made the page brings a stylesheet built in PHP
instead: the foundation and this layout's own rules, `Stylesheets::bundle()`-inlined rather
than copied by hand, plus the app's scheme from its scheme data in the same selector shape
`material:scheme` writes (`src/Support/ErrorPage.php::fallbackStyles()`). The two paths draw
the same `data-md-error-*` hooks by construction, nothing to keep in step by hand. The shape
turns once a minute, unless the visitor asks for reduced motion. --}}
@php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
+105 -35
View File
@@ -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;
}
}
+43
View File
@@ -1,5 +1,9 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
/**
* The 404 page, reached the way a visitor reaches it: an address nothing answers.
*/
@@ -52,3 +56,42 @@ it('fits a 400px screen without scrolling sideways', function () {
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
->assertScript("document.querySelector('[data-md-error-actions] a').getBoundingClientRect().right <= 400");
});
/**
* Plan step 40: the fallback path, the same server serving the request `LaravelHttpServer` runs
* in-process, so a config change here reaches it exactly as it reaches a Feature test's client —
* an empty temporary directory has no `build/manifest.json` and no `hot` file, so `ErrorPage::assets()`
* throws and returns null (tests/Feature/ErrorPagesTest.php does the same for the HTTP client).
*/
it('draws the button and the shape from the inlined fallback stylesheet, and repaints in dark mode', function () {
$public = sys_get_temp_dir().'/livewire-material-fallback-'.Str::random(8);
File::ensureDirectoryExists($public);
$originalPublicPath = app()->publicPath();
app()->usePublicPath($public);
Vite::useHotFile($public.'/hot');
try {
missingPage('light')
->assertScript("document.querySelector('style[data-md-error-fallback]') !== null")
->assertScript("document.querySelector('link[href*=\"/build/\"]') === null")
// The button: button.css's round, filled shape, not a bare unstyled <a>.
->assertScript("getComputedStyle(document.querySelector('[data-md-button]')).borderRadius !== '0px'")
->assertScript(paintedIn('[data-md-button][data-md-variant=filled]', 'primary'))
// The shape: shape.css's sizing and the primary-container tint error-page.css paints its svg with.
->assertScript("getComputedStyle(document.querySelector('[data-md-error-shape] svg')).color !== getComputedStyle(document.body).color")
->assertScript(paintedIn('body', 'surface'))
->assertNoJavaScriptErrors();
// The [data-theme='dark'] block the fallback also inlines repaints the page, exactly as the
// built stylesheet does for the test above.
missingPage('dark')
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
->assertScript(paintedIn('body', 'surface'))
->assertScript("getComputedStyle(document.body).backgroundColor !== 'rgb(253, 247, 254)'");
} finally {
app()->usePublicPath($originalPublicPath);
Vite::useHotFile($originalPublicPath.'/hot');
File::deleteDirectory($public);
}
});
+66 -4
View File
@@ -132,7 +132,38 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
->assertDontSee('/build/', false);
});
it('draws the active colour profile when the Vite manifest is missing', function () {
it('draws the standard, medium and high contrast levels when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
'dark' => ['surface' => '#101010'],
'contrast' => [
'standard' => 0,
'medium' => ['light' => ['primary' => '#334455']],
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
$this->get('/abort/500')
->assertStatus(500)
->assertSee("[data-contrast='medium']", false)
->assertSee("[data-contrast='high']", false)
->assertSee('--md-sys-color-primary: #334455;', false)
->assertSee('--md-sys-color-primary: #000000;', false)
->assertSee('--md-sys-color-primary: #ffffff;', false);
});
/**
* Plan step 40: the fallback no longer resolves the active profile in PHP — `<x-theme-script>`
* has already embedded it (`Scheme::profile()`, the resolver included) and writes it to
* `<html data-scheme>` before this stylesheet is read — so the fallback draws every profile, in
* `material-scheme.css`'s own `[data-scheme]` shape, and lets the attribute already on the page
* pick between them.
*/
it('draws every colour profile when the Vite manifest is missing, keyed on data-scheme', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
@@ -151,9 +182,13 @@ it('draws the active colour profile when the Vite manifest is missing', function
try {
$this->get('/abort/500')
->assertStatus(500)
->assertSee("[data-scheme='indigo']", false)
->assertSee("[data-scheme='teal']", false)
->assertSee('--md-sys-color-primary: #4f46e5;', false)
->assertSee('--md-sys-color-primary: #00897b;', false)
->assertSee('--md-sys-color-primary: #80cbc4;', false)
->assertDontSee('#4f46e5', false);
// The theme script resolved and embedded the active profile server-side; the CSS above
// only has to let [data-scheme] (which it sets on <html>) pick between the two blocks.
->assertSee('"scheme":"teal"', false);
} finally {
Scheme::resolveProfileUsing(null);
}
@@ -285,5 +320,32 @@ it('styles the body only when it holds the error layout, and turns the shape on
->and(ComponentStylesheet::read('error-page')->declarations('body:has(> [data-md-error-page])'))->not->toHaveKey('font-family')
->and((string) ErrorPage::fallbackStyles())
->toContain('@keyframes material-error-turn')
->toContain('@media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } }');
->toContain('animation: material-error-turn 60s linear infinite;');
});
/**
* Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
* itself — the fallback has no build to serve the font file from.
*/
it('bundles the foundation and the error layout into the fallback, without an import, a relative url, or a font face', function () {
withoutSkeletonErrorViews();
$this->withoutVite();
$css = (string) ErrorPage::fallbackStyles();
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
expect($plain)->not->toContain('@import')
->not->toContain('@font-face')
->and(preg_match('/url\(\s*(?!["\']?(?:data:|https?:|\/))/i', $plain))->toBe(0, 'a relative url() remains in the fallback')
// The error layout's own rules, and the button and shape stylesheets it imports.
->and($plain)->toContain('[data-md-error-page]')
->toContain('[data-md-error-shape]')
->toContain('[data-md-button]')
->toContain('[data-md-shape]')
// The foundation's reset and tokens travelled in too, not only the error layout.
->toContain('box-sizing: border-box')
->toContain('--md-sys-color-surface');
});