From e1fe573182b87cd1e48c3630558bf09d3f7ae9c1 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 07:30:05 +0200 Subject: [PATCH] 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 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/error-page.css | 6 +- .../error-pages/errors/minimal.blade.php | 11 +- src/Support/ErrorPage.php | 140 +++++++++++++----- tests/Browser/ErrorPagesTest.php | 43 ++++++ tests/Feature/ErrorPagesTest.php | 70 ++++++++- 5 files changed, 223 insertions(+), 47 deletions(-) diff --git a/resources/css/components/error-page.css b/resources/css/components/error-page.css index 5b4d982f..982f4600 100644 --- a/resources/css/components/error-page.css +++ b/resources/css/components/error-page.css @@ -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. diff --git a/resources/views/error-pages/errors/minimal.blade.php b/resources/views/error-pages/errors/minimal.blade.php index b2a2299c..2ca29a86 100644 --- a/resources/views/error-pages/errors/minimal.blade.php +++ b/resources/views/error-pages/errors/minimal.blade.php @@ -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(); diff --git a/src/Support/ErrorPage.php b/src/Support/ErrorPage.php index f0694466..6dff4044 100644 --- a/src/Support/ErrorPage.php +++ b/src/Support/ErrorPage.php @@ -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 */ - 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 + */ + 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]`. `` has already written + * `data-theme`, `data-contrast` and `data-scheme` onto `` 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(<< :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; } } diff --git a/tests/Browser/ErrorPagesTest.php b/tests/Browser/ErrorPagesTest.php index 74e8bc71..33e6f1d7 100644 --- a/tests/Browser/ErrorPagesTest.php +++ b/tests/Browser/ErrorPagesTest.php @@ -1,5 +1,9 @@ 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 . + ->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); + } +}); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php index 151f76ec..027250c0 100644 --- a/tests/Feature/ErrorPagesTest.php +++ b/tests/Feature/ErrorPagesTest.php @@ -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 — `` + * has already embedded it (`Scheme::profile()`, the resolver included) and writes it to + * `` 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 ) 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'); });