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 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
/* 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
| CSS is a bundle of their own (all.css, showcase.css and the scheme,
| served by ShowcaseAssetController), so they need nothing from the
| application's build. The error pages have no such bundle, so
| ErrorPage::assets() passes this whole list, CSS included, to @vite().
| application's build. The error pages pass this whole list, CSS
| 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.
- 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`.
- 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)
* [data-md-error-actions]
*
* 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, 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 carries this file itself: `src/Support/ErrorPage.php` inlines it, with its imports, into
* every error page (`Stylesheets::bundle()`) beside the application's Vite tags when there is a
* build (`layoutStyles()`), inside the whole fallback stylesheet when there is none
* (`fallbackStyles()`, plan step 40) so an application imports nothing for its error pages
* (plan step 46). The body is still matched only when it holds the layout
* (`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),
* 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
the way here, a text Back.
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 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. --}}
The page carries its own component rules, so an application imports nothing for it: its CSS
entry imports the stylesheets its own views render, and none of them renders this layout.
With a build, the app's own Vite entries (`livewire-material.showcase.vite`) bring the
foundation, tokens, scheme and font, and a `<style data-md-error-styles>` beside them inlines
this layout's own resources/css/components/error-page.css with the button and shape
stylesheets it imports (`ErrorPage::layoutStyles()`, `Stylesheets::bundle()`, cached). An app
whose entry imports them too (`all.css`) gets the same layered rules twice, which is harmless:
the second copy repeats the first, in the same layers. 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
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
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
@@ -40,6 +45,7 @@
@if ($assets !== null)
{{ $assets }}
<style data-md-error-styles>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::layoutStyles() }}</style>
@else
<style data-md-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
@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
* 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
{
/**
* 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)
* 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.
* and the error layout's own stylesheet 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 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:
@@ -34,7 +49,9 @@ class ErrorPage
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
* 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;
}
/**
* 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
* `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
{
$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());
}
/**
* 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
* 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\Vite;
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.
@@ -95,3 +98,55 @@ it('draws the button and the shape from the inlined fallback stylesheet, and rep
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\Support\ErrorPage;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
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);
});
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::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],
@@ -104,11 +111,27 @@ it('loads the application\'s Vite entries', function () {
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
$this->get('/abort/404')
$html = $this->get('/abort/404')
->assertNotFound()
->assertSee('build/assets/app-probe.css', 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 () {
@@ -132,6 +155,20 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
->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 () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'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('--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;');
});