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
+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;');
});