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 * 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 * 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 * 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 * application without a Vite build gets instead, inlines this file itself (`Stylesheets::bundle()`,
* a bare `body`; it still needs updating by hand when this file changes, until step 40 replaces * plan step 40) rather than a hand-written copy, so it keeps the same scoped `body` rule and never
* how it is built. * drifts from it.
* *
* The page's font is the foundation's, `--md-ref-typeface-brand` on `html` (foundation/base.css), * 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. * 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 app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
the package's stylesheets, including this layout's own, 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 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 has no build yet, so when those tags cannot be made the page brings a stylesheet built in PHP
own instead: the app's scheme from its scheme data, drawn onto the same `data-md-error-*` instead: the foundation and this layout's own rules, `Stylesheets::bundle()`-inlined rather
hooks (`src/Support/ErrorPage.php::fallbackStyles()`). Keep the hooks in step across both than copied by hand, plus the app's scheme from its scheme data in the same selector shape
when changing the markup. The shape turns once a minute, unless the visitor asks for reduced `material:scheme` writes (`src/Support/ErrorPage.php::fallbackStyles()`). The two paths draw
motion. --}} 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 @php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets(); $assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
+105 -35
View File
@@ -14,12 +14,24 @@ use Throwable;
class ErrorPage 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 = [ protected const array FILES = ['foundation.css', 'components/error-page.css'];
'surface', 'on-surface', 'on-surface-variant', 'primary', 'on-primary',
'primary-container', 'on-primary-container', 'secondary', /**
]; * 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. * 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 * A stylesheet for a page without its build: the foundation and the error layout's own rules —
* system font, and the layout's `data-md-error-*` hooks drawn to match the built version * `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
* (resources/css/components/error-page.css), the shape's slow turn included. Inlined into the * can never drift from `resources/css/components/error-page.css` the way a hand-written copy
* error page alone, so its `body` rule needs no scope. Still hand-built, not `Stylesheets::bundle()` — * did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve
* plan step 40 replaces this method's own body, once the design guard exists to catch a hook * the font file, and a relative `url()` a request 404s on is worse than none) and the
* the two drift apart on. * 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 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( return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
fn (string $role): string => "--md-sys-color-{$role}: {$theme[$role]}; ", }
self::ROLES,
));
return new HtmlString(<<<CSS /**
:root, [data-theme='light'] { color-scheme: light; {$roles($scheme['light'])}} * `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's
[data-theme='dark'] { color-scheme: dark; {$roles($scheme['dark'])}} * text (fragile the moment a comment or a value is reworded) but a structural read — find
*, ::before, ::after { box-sizing: border-box; } * `@font-face` outside nothing else looks at here, then remove the balanced `{ … }` that
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; } * follows it, brace for brace, however the block itself is written. `tokens/font.css`'s is the
[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; } * only one the bundle carries, and it is also the bundle's only relative `url()` (button.css,
[data-md-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; } * icon.css, shape.css and the rest reference nothing on disk), so this removal is also what
[data-md-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); } * leaves the fallback with no `url()` to a font the fallback cannot serve. The typeface itself
[data-md-error-shape] svg { display: block; width: 100%; height: 100%; } * degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`,
[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; } * `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve
[data-md-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; } * it the browser skips straight to that system stack — nothing here has to name one.
[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; } protected static function withoutFontFace(string $css): string
[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); } $result = '';
[data-md-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; } $offset = 0;
@keyframes material-error-turn { to { transform: rotate(1turn); } } $length = strlen($css);
@media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } }
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 <?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. * 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.documentElement.scrollWidth <= window.innerWidth')
->assertScript("document.querySelector('[data-md-error-actions] a').getBoundingClientRect().right <= 400"); ->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); ->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([ File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'], 'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'], 'dark' => ['primary' => '#aaaaff'],
@@ -151,9 +182,13 @@ it('draws the active colour profile when the Vite manifest is missing', function
try { try {
$this->get('/abort/500') $this->get('/abort/500')
->assertStatus(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: #00897b;', false)
->assertSee('--md-sys-color-primary: #80cbc4;', false) // The theme script resolved and embedded the active profile server-side; the CSS above
->assertDontSee('#4f46e5', false); // only has to let [data-scheme] (which it sets on <html>) pick between the two blocks.
->assertSee('"scheme":"teal"', false);
} finally { } finally {
Scheme::resolveProfileUsing(null); 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(ComponentStylesheet::read('error-page')->declarations('body:has(> [data-md-error-page])'))->not->toHaveKey('font-family')
->and((string) ErrorPage::fallbackStyles()) ->and((string) ErrorPage::fallbackStyles())
->toContain('@keyframes material-error-turn') ->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');
}); });