Inline the error layout's own rules beside the app's build

Plan step 46, from SealShare's breakpoint walk: an application imports
the stylesheets its own views render, and none renders the error layout,
so under a real build its 403/404/500 pages drew unstyled. With a build
the page now keeps the app's Vite tags (foundation, tokens, scheme, font)
and inlines Stylesheets::bundle() of components/error-page.css and its
imports beside them (ErrorPage::layoutStyles(), cached). Without a build
the fallback is unchanged. An application imports nothing for its error
pages; one that imports all.css gets the same layered rules twice, which
is harmless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 16:10:01 +02:00
co-authored by Claude Opus 5
parent 05f115ab36
commit 70c983b7bb
8 changed files with 194 additions and 29 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ composer require nonameweb/livewire-material
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets. The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
The package's stylesheets are plain CSS, no build step of its own and no Tailwind anywhere in the stack. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The package's stylesheets are plain CSS, no build step of its own and no Tailwind anywhere in the stack. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
```css ```css
/* resources/css/app.css */ /* resources/css/app.css */
+4 -2
View File
@@ -197,8 +197,10 @@ return [
| pages pass @vite() only the entries that are not a stylesheet: their | pages pass @vite() only the entries that are not a stylesheet: their
| CSS is a bundle of their own (all.css, showcase.css and the scheme, | CSS is a bundle of their own (all.css, showcase.css and the scheme,
| served by ShowcaseAssetController), so they need nothing from the | served by ShowcaseAssetController), so they need nothing from the
| application's build. The error pages have no such bundle, so | application's build. The error pages pass this whole list, CSS
| ErrorPage::assets() passes this whole list, CSS included, to @vite(). | included, to @vite() (ErrorPage::assets()) for the foundation, scheme
| and font, and inline their own layout's rules beside it
| (ErrorPage::layoutStyles()), so the entry needs no import for them.
| |
*/ */
@@ -146,7 +146,7 @@ The methods are protected. They dispatch a `toast` browser event (`assertDispatc
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins. Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`. - The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme, and inline the error layout's own stylesheet beside them. The application imports nothing for them: no `error-page.css` in the CSS entry (the design guard's `missingStylesheets()` does not ask for it, and an entry that imports it through `all.css` does no harm). While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`. - `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`: - To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
+7 -6
View File
@@ -17,12 +17,13 @@
* section (otherwise `message` doubles as the headline) * section (otherwise `message` doubles as the headline)
* [data-md-error-actions] * [data-md-error-actions]
* *
* The body is matched only when it holds the layout (`body:has(> [data-md-error-page])`): this * The page carries this file itself: `src/Support/ErrorPage.php` inlines it, with its imports, into
* file sits in the application's bundle beside every other page, where a bare `body` rule would * every error page (`Stylesheets::bundle()`) beside the application's Vite tags when there is a
* restyle them all. `src/Support/ErrorPage.php`'s `fallbackStyles()`, the stylesheet an * build (`layoutStyles()`), inside the whole fallback stylesheet when there is none
* application without a Vite build gets instead, inlines this file itself (`Stylesheets::bundle()`, * (`fallbackStyles()`, plan step 40) so an application imports nothing for its error pages
* plan step 40) rather than a hand-written copy, so it keeps the same scoped `body` rule and never * (plan step 46). The body is still matched only when it holds the layout
* drifts from it. * (`body:has(> [data-md-error-page])`): an application that imports `all.css` has this file in
* its bundle beside every other page, where a bare `body` rule would restyle them all.
* *
* 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.
@@ -12,15 +12,20 @@
`actions` the buttons; by default a filled Home and, when the visitor came from a page on `actions` the buttons; by default a filled Home and, when the visitor came from a page on
the way here, a text Back. the way here, a text Back.
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and The page carries its own component rules, so an application imports nothing for it: its CSS
the package's stylesheets, including this layout's own, entry imports the stylesheets its own views render, and none of them renders this layout.
resources/css/components/error-page.css. But an error page is also what shows while a deploy With a build, the app's own Vite entries (`livewire-material.showcase.vite`) bring the
has no build yet, so when those tags cannot be made the page brings a stylesheet built in PHP foundation, tokens, scheme and font, and a `<style data-md-error-styles>` beside them inlines
instead: the foundation and this layout's own rules, `Stylesheets::bundle()`-inlined rather this layout's own resources/css/components/error-page.css with the button and shape
than copied by hand, plus the app's scheme from its scheme data in the same selector shape stylesheets it imports (`ErrorPage::layoutStyles()`, `Stylesheets::bundle()`, cached). An app
`material:scheme` writes (`src/Support/ErrorPage.php::fallbackStyles()`). The two paths draw whose entry imports them too (`all.css`) gets the same layered rules twice, which is harmless:
the same `data-md-error-*` hooks by construction, nothing to keep in step by hand. The shape the second copy repeats the first, in the same layers. But an error page is also what shows
turns once a minute, unless the visitor asks for reduced motion. --}} while a deploy 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, inlined the same
way 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 @php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets(); $assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
@@ -40,6 +45,7 @@
@if ($assets !== null) @if ($assets !== null)
{{ $assets }} {{ $assets }}
<style data-md-error-styles>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::layoutStyles() }}</style>
@else @else
<style data-md-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style> <style data-md-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
@endif @endif
+51 -7
View File
@@ -10,19 +10,34 @@ use Throwable;
/** /**
* What the error layout needs from PHP, written so that nothing in it can fail the page: an * What the error layout needs from PHP, written so that nothing in it can fail the page: an
* error page is shown precisely when something else already went wrong. * error page is shown precisely when something else already went wrong.
*
* The page carries its own component rules whatever the application imports. An application's
* CSS entry imports the stylesheets of the components its own views render, and no view of its
* renders this layout the framework does, from the package's error-view root so nothing ever
* told an application to import `components/error-page.css`: `DesignGuard::missingStylesheets()`
* reads the application's views, not the package's, and `unusedStylesheets()` would call such an
* import unused. So an application imports nothing for its error pages: with a build the layout
* takes the foundation, tokens, scheme and font from the application's Vite tags (`assets()`) and
* inlines its own rules beside them (`layoutStyles()`); without one it inlines everything
* (`fallbackStyles()`).
*/ */
class ErrorPage class ErrorPage
{ {
/**
* The error layout's own stylesheet, which imports button.css and shape.css (and through
* button.css, icon.css, loading.css and tooltip.css) for what the layout renders.
*/
protected const string LAYOUT = 'components/error-page.css';
/** /**
* The package stylesheets the fallback bundles: the foundation (every application needs it) * 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 * and the error layout's own stylesheet the same rules a Vite build would serve the page,
* same rules a Vite build would serve the page, `Stylesheets::bundle()` inlining them in PHP * `Stylesheets::bundle()` inlining them in PHP instead. Not `all.css`: that would bundle every
* instead. Not `all.css`: that would bundle every component's rules for a page that draws * component's rules for a page that draws three of them.
* three of them.
* *
* @var list<string> * @var list<string>
*/ */
protected const array FILES = ['foundation.css', 'components/error-page.css']; protected const array FILES = ['foundation.css', self::LAYOUT];
/** /**
* The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache: * The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache:
@@ -34,7 +49,9 @@ class ErrorPage
protected static array $schemeCache = []; 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. They bring the foundation,
* the tokens, the scheme and the font; the layout's own rules come from `layoutStyles()`
* beside them, since the application's entry need not import them.
* *
* A 500 during a deploy is the moment the manifest is missing, half-written or names files * A 500 during a deploy is the moment the manifest is missing, half-written or names files
* that are not there yet a missing manifest throws a ViteException, a truncated one a * that are not there yet a missing manifest throws a ViteException, a truncated one a
@@ -70,6 +87,25 @@ class ErrorPage
return $previous === $current || $isHome ? null : $previous; return $previous === $current || $isHome ? null : $previous;
} }
/**
* The error layout's own rules for a page with its build: `Stylesheets::bundle()` of
* `components/error-page.css` and the stylesheets it imports, inlined beside the application's
* Vite tags, which bring everything else. `bundle()` caches it for the worker by its files'
* mtimes, as it does the fallback's half.
*
* Nothing is dropped from it, because nothing in it needs a build: the package's only
* `@font-face` is `tokens/font.css`'s, which only the foundation reaches and the application's
* build serves, and no stylesheet the layout imports writes a `url()` a request would have to
* resolve (tests/Feature/ErrorPagesTest.php checks both). An application whose entry also
* imports this layout (through `all.css`, say) gets the same rules twice, in the same layers and
* with the same declarations; the second copy decides nothing the first had not, so that is
* harmless.
*/
public static function layoutStyles(): HtmlString
{
return new HtmlString(Stylesheets::bundle([self::path(self::LAYOUT)]));
}
/** /**
* A stylesheet for a page without its build: the foundation and the error layout's own rules * 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 * `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
@@ -85,11 +121,19 @@ class ErrorPage
*/ */
public static function fallbackStyles(): HtmlString public static function fallbackStyles(): HtmlString
{ {
$files = array_map(fn (string $file): string => dirname(__DIR__, 2)."/resources/css/{$file}", self::FILES); $files = array_map(self::path(...), self::FILES);
return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet()); return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
} }
/**
* A package stylesheet's absolute path, from its path under `resources/css/`.
*/
protected static function path(string $file): string
{
return dirname(__DIR__, 2)."/resources/css/{$file}";
}
/** /**
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's * `$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 * text (fragile the moment a comment or a value is reworded) but a structural read find
+55
View File
@@ -3,6 +3,9 @@
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Vite; use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use function Orchestra\Testbench\workbench_path;
/** /**
* 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.
@@ -95,3 +98,55 @@ it('draws the button and the shape from the inlined fallback stylesheet, and rep
File::deleteDirectory($public); File::deleteDirectory($public);
} }
}); });
/**
* Plan step 46: an application's own build, which imports the stylesheets its views render and so
* never `error-page.css` SealShare's didn't, and its error pages drew a shape filling the window
* with the headline unstyled in a corner. The probe build here is the foundation and the Workbench's
* scheme and nothing else, so everything the layout draws must come from the rules the page inlines
* beside the application's `<link>`.
*/
it('draws the layout from its own inlined rules under a build that never imports them', function () {
$public = sys_get_temp_dir().'/livewire-material-build-'.Str::random(8);
File::ensureDirectoryExists($public.'/build/assets');
// The foundation and the scheme, as an application's bundle would carry them; without the font
// face, whose relative url would 404 from here and is no part of what this test proves.
$css = Stylesheets::bundle([
realpath(__DIR__.'/../../resources/css/foundation.css'),
workbench_path('resources/css/material-scheme.css'),
]);
File::put($public.'/build/assets/app-probe.css', (string) preg_replace('/@font-face\s*\{[^}]*\}/', '', $css));
File::put($public.'/build/manifest.json', json_encode([
'resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'resources/css/app.css', 'isEntry' => true],
]));
$originalPublicPath = app()->publicPath();
$originalVite = config('livewire-material.showcase.vite');
app()->usePublicPath($public);
Vite::useHotFile($public.'/hot');
config(['livewire-material.showcase.vite' => ['resources/css/app.css']]);
try {
missingPage('light')
->resize(1280, 800)
->assertScript("document.querySelector('link[href*=\"/build/assets/app-probe.css\"]') !== null")
->assertScript("document.querySelector('style[data-md-error-styles]') !== null")
->assertScript("document.querySelector('style[data-md-error-fallback]') === null")
// The layout: a centred column, the art at its 240px from medium, not a shape filling the window.
->assertScript("getComputedStyle(document.querySelector('[data-md-error-page]')).display === 'flex'")
->assertScript("Math.round(document.querySelector('[data-md-error-art]').getBoundingClientRect().width) === 240")
->assertScript("document.querySelector('[data-md-error-shape] svg').getBoundingClientRect().width <= 240")
->assertScript("(() => { const r = document.querySelector('[data-md-error-headline]').getBoundingClientRect(); return Math.abs((r.left + r.right) / 2 - window.innerWidth / 2) <= 2; })()")
// The button and shape rules the layout imports, and the scheme from the application's build.
->assertScript("getComputedStyle(document.querySelector('[data-md-button]')).borderRadius !== '0px'")
->assertScript(paintedIn('[data-md-button][data-md-variant=filled]', 'primary'))
->assertScript(paintedIn('body', 'surface'))
->assertNoJavaScriptErrors();
} finally {
config(['livewire-material.showcase.vite' => $originalVite]);
app()->usePublicPath($originalPublicPath);
Vite::useHotFile($originalPublicPath.'/hot');
File::deleteDirectory($public);
}
});
+60 -3
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider; use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\ErrorPage; use NoNameWeb\LivewireMaterial\Support\ErrorPage;
use NoNameWeb\LivewireMaterial\Support\Scheme; use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses; use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
@@ -94,7 +95,13 @@ it('lets the application\'s own error view win', function () {
$this->get('/abort/403')->assertSee('data-md-error-page', false); $this->get('/abort/403')->assertSee('data-md-error-page', false);
}); });
it('loads the application\'s Vite entries', function () { /**
* Plan step 46: an application's entry imports the stylesheets its own views render, and none of
* them renders this layout, so with a build the page inlines its own layout's rules beside the
* application's tags rather than hoping the entry imported `error-page.css` — SealShare's didn't,
* and its 403/404/500 pages drew unstyled under its real build.
*/
it('loads the application\'s Vite entries and inlines the error layout\'s own rules beside them', function () {
File::ensureDirectoryExists($this->temporary.'/build'); File::ensureDirectoryExists($this->temporary.'/build');
File::put($this->temporary.'/build/manifest.json', json_encode([ File::put($this->temporary.'/build/manifest.json', json_encode([
'workbench/resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'workbench/resources/css/app.css', 'isEntry' => true], 'workbench/resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'workbench/resources/css/app.css', 'isEntry' => true],
@@ -104,11 +111,27 @@ it('loads the application\'s Vite entries', function () {
app()->usePublicPath($this->temporary); app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot'); Vite::useHotFile($this->temporary.'/hot');
$this->get('/abort/404') $html = $this->get('/abort/404')
->assertNotFound() ->assertNotFound()
->assertSee('build/assets/app-probe.css', false) ->assertSee('build/assets/app-probe.css', false)
->assertSee('build/assets/app-probe.js', false) ->assertSee('build/assets/app-probe.js', false)
->assertDontSee('data-md-error-fallback', false); ->assertSee('<style data-md-error-styles>'.ErrorPage::layoutStyles().'</style>', false)
->assertDontSee('data-md-error-fallback', false)
->getContent();
preg_match('~<style data-md-error-styles>(.*?)</style>~s', $html, $inlined);
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $inlined[1] ?? '');
// After the application's stylesheet, so its layer statement has already set the order.
expect(strpos($html, '<style data-md-error-styles>'))->toBeGreaterThan(strpos($html, 'build/assets/app-probe.css'))
// The layout's rules, and those of the button and shape stylesheets it imports.
->and($plain)->toContain('body:has(> [data-md-error-page])')
->toContain('[data-md-error-shape] svg')
->toContain('[data-md-button]')
->toContain('[data-md-shape]')
// Not the foundation or a scheme: the application's build brings those.
->not->toContain('box-sizing: border-box')
->not->toMatch('/--md-sys-color-surface\s*:/');
}); });
it('still renders, in the application\'s scheme, when the Vite manifest is missing', function () { it('still renders, in the application\'s scheme, when the Vite manifest is missing', function () {
@@ -132,6 +155,20 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
->assertDontSee('/build/', false); ->assertDontSee('/build/', false);
}); });
it('keeps the fallback as the only stylesheet when the Vite manifest is missing', function () {
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
$html = $this->get('/abort/404')
->assertNotFound()
->assertSee('<style data-md-error-fallback>'.ErrorPage::fallbackStyles().'</style>', false)
->assertDontSee('data-md-error-styles', false)
->getContent();
expect(substr_count($html, '<style'))->toBe(1)
->and($html)->not->toContain('<link rel="stylesheet"');
});
it('draws the standard, medium and high contrast levels 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([ File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'], 'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
@@ -349,3 +386,23 @@ it('bundles the foundation and the error layout into the fallback, without an im
->toContain('box-sizing: border-box') ->toContain('box-sizing: border-box')
->toContain('--md-sys-color-surface'); ->toContain('--md-sys-color-surface');
}); });
/**
* Plan step 46: what the page inlines beside a build is `Stylesheets::bundle()` of the error layout
* alone. It reaches no `@font-face` the package's only one is tokens/font.css's, which only the
* foundation imports, and the application's build serves it and no `url()` a request from the
* page would have to resolve, so nothing needs dropping from it the way the fallback drops the face.
*/
it('inlines beside a build the error layout\'s bundle, which holds no font face, url or import', function () {
$layout = realpath(__DIR__.'/../../resources/css/components/error-page.css');
$css = (string) ErrorPage::layoutStyles();
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
expect($css)->toBe(Stylesheets::bundle([$layout]))
->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout])))
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css'])
->and($plain)->not->toContain('@font-face')
->not->toContain('@import')
->not->toMatch('/\burl\(/i')
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;');
});