From 70c983b7bb6583d244d90f416740a331e1838679 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 16:10:01 +0200 Subject: [PATCH] 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) --- README.md | 2 +- config/livewire-material.php | 6 +- .../livewire-material-development/SKILL.md | 2 +- resources/css/components/error-page.css | 13 ++-- .../error-pages/errors/minimal.blade.php | 24 ++++--- src/Support/ErrorPage.php | 58 ++++++++++++++--- tests/Browser/ErrorPagesTest.php | 55 ++++++++++++++++ tests/Feature/ErrorPagesTest.php | 63 ++++++++++++++++++- 8 files changed, 194 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0e00d4b3..edba6d75 100644 --- a/README.md +++ b/README.md @@ -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 */ diff --git a/config/livewire-material.php b/config/livewire-material.php index 25f52200..e3da0a48 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -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. | */ diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index ae121e59..13676b02 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 ``, 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 ``, 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 `` name) and optionally `actions`: diff --git a/resources/css/components/error-page.css b/resources/css/components/error-page.css index c43ab10e..7616c677 100644 --- a/resources/css/components/error-page.css +++ b/resources/css/components/error-page.css @@ -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. diff --git a/resources/views/error-pages/errors/minimal.blade.php b/resources/views/error-pages/errors/minimal.blade.php index 2ca29a86..319472a8 100644 --- a/resources/views/error-pages/errors/minimal.blade.php +++ b/resources/views/error-pages/errors/minimal.blade.php @@ -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 ` @else @endif diff --git a/src/Support/ErrorPage.php b/src/Support/ErrorPage.php index 6dff4044..5f03baef 100644 --- a/src/Support/ErrorPage.php +++ b/src/Support/ErrorPage.php @@ -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 */ - 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 diff --git a/tests/Browser/ErrorPagesTest.php b/tests/Browser/ErrorPagesTest.php index 33e6f1d7..2e964900 100644 --- a/tests/Browser/ErrorPagesTest.php +++ b/tests/Browser/ErrorPagesTest.php @@ -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 ``. + */ +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); + } +}); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php index 027250c0..875a3b44 100644 --- a/tests/Feature/ErrorPagesTest.php +++ b/tests/Feature/ErrorPagesTest.php @@ -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('', false) + ->assertDontSee('data-md-error-fallback', false) + ->getContent(); + + preg_match('~~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, '', false) + ->assertDontSee('data-md-error-styles', false) + ->getContent(); + + expect(substr_count($html, 'toBe(1) + ->and($html)->not->toContain('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;'); +});