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, '