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
+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