Rewrite the error pages without Tailwind

Plan step 36 (containment group, error pages): errors::minimal's class
lists move into resources/css/components/error-page.css, keyed on
data-md-error-page/-art/-shape/-code/-headline/-message/-actions;
body carries no hook of its own and is styled by a bare `body`
selector, matching how ErrorPage::fallbackStyles() already styles it
(no other rewritten view is a full HTML document). The section
contract (title, code, headline, message, shape, actions) and the
framework's 401/402 compatibility are untouched, since neither depends
on a class or a hook. The shape's <x-shape> (a component outside this
batch, and one with no "fill the parent" prop) is coloured and sized
to its container by a plain `svg` descendant selector rather than a
class or a new prop. The page's font stays a literal system stack, not
var(--md-ref-typeface-brand): Tailwind's own `font-sans` utility this
replaces was never the brand font either
(resources/css/tokens/theme.css's --font-sans is the only utility
that is), and an error page must read before any webfont has loaded.

ErrorPage::fallbackStyles() draws onto the same hooks, renamed the
same way (data-error-* -> data-md-error-*, data-error-fallback ->
data-md-error-fallback) so the no-build path keeps working; how it is
built (a hand-written heredoc, not Stylesheets::bundle()) is
unchanged, per step 40. Updated in the same commit:
tests/Feature/ErrorPagesTest.php and tests/Browser/ErrorPagesTest.php.

The error pages' views live outside resources/views/components/, so
they are not in ContainmentStylesheetsTest's dataset (its "imports
what its view renders" check reads a fixed
resources/views/components/<name>.blade.php path); adapted
tests/Feature/ErrorPagesTest.php instead, with the same stylesheet-
shape, import, ViewClasses, token/px-breakpoint and containment-block
checks the dataset gives every other component.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 21:44:55 +02:00
co-authored by Claude Sonnet 5
parent a149970dd0
commit a8c2d88fbb
6 changed files with 265 additions and 43 deletions
+1
View File
@@ -66,5 +66,6 @@
@import './components/bottom-sheet.css';
@import './components/carousel-item.css';
@import './components/carousel.css';
@import './components/error-page.css';
/* Navigation */
+150
View File
@@ -0,0 +1,150 @@
/*
* `errors::minimal`: the layout the package's 403/404/419/429/500/503 pages and the framework's
* own 401/402 (which extend this layout, `src/Support/ErrorPage.php`'s docblock) render through.
* A page's own `@section`s fill `title`, `code`, `headline`, `message`, `shape` and `actions`
* (`resources/views/error-pages/errors/minimal.blade.php`'s header) — none of that is this file's
* concern, only the geometry and colour of the parts that show them:
*
* body
* [data-md-error-page]
* [data-md-error-art]
* [data-md-error-shape] an <x-shape>, turning once a minute unless reduced motion asks
* otherwise (its `svg` fills the art box and is coloured here,
* since <x-shape size> only takes a fixed px, not "fill parent")
* [data-md-error-code] the status, in display type over the shape
* [data-md-error-headline] what happened
* [data-md-error-message] one sentence on what to do, only when `headline` is its own
* section (otherwise `message` doubles as the headline)
* [data-md-error-actions]
*
* `body` carries no hook of its own — `src/Support/ErrorPage.php`'s `fallbackStyles()`, the
* stylesheet an application without a Vite build gets instead, styles the bare element the same
* way, so this file matches it rather than inventing a hook the fallback has no equivalent of.
* That fallback still needs updating by hand when this file changes, until step 40 replaces how
* it is built.
*
* The page's own font is a literal system stack, not `var(--md-ref-typeface-brand)`: an error
* page must read correctly before any webfont has had a chance to load (`@font-face` is what the
* fallback path has none of, and this file matches it even on the path that does), and Tailwind's
* `font-sans` this replaces never carried the brand font either
* (`resources/css/tokens/theme.css`'s `--font-sans` is the only utility that does).
*
* Colours from the roles (`docs/reference/m3/styles.md` § Typography, "Accessibility
* requirements": surface/on-surface); the display code over its primary-container shape is the
* pairing `docs/audits/m3-alignment/containment.md`'s "Aligned" section confirms; 24px page
* gutters and the 600px growth of the art and headline are the same numbers the audit found
* clean. `--md-sys-typescale-emphasized-display-lg` is the code's weight and roundedness; the
* shape's 60s linear turn has no motion token (`--md-sys-motion-*` pairs a duration with a spring
* for a state change, not an ambient loop) and stays a literal, gated the way the audit's
* "Aligned" section already found it: behind `prefers-reduced-motion: no-preference`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './shape.css';
@layer material.components {
body {
margin: 0;
min-height: 100dvh;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
[data-md-error-page] {
display: flex;
min-height: 100dvh;
max-width: 576px;
margin-inline: auto;
flex-direction: column;
align-items: center;
justify-content: center;
padding-inline: var(--md-sys-measurement-space300);
padding-block: var(--md-sys-measurement-space600);
text-align: center;
}
[data-md-error-art] {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
width: 192px;
height: 192px;
}
@media (width >= 600px) {
[data-md-error-art] {
width: 240px;
height: 240px;
}
}
[data-md-error-shape] {
position: absolute;
inset: 0;
}
[data-md-error-shape] svg {
width: 100%;
height: 100%;
color: var(--md-sys-color-primary-container);
}
@media (prefers-reduced-motion: no-preference) {
[data-md-error-shape] {
animation: material-error-turn 60s linear infinite;
}
}
@keyframes material-error-turn {
to {
transform: rotate(1turn);
}
}
[data-md-error-code] {
position: relative;
margin: 0;
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-emphasized-display-lg);
letter-spacing: var(--md-sys-typescale-emphasized-display-lg-tracking);
font-variation-settings: 'ROND' 100;
font-variant-numeric: tabular-nums;
}
[data-md-error-headline] {
margin: var(--md-sys-measurement-space500) 0 0;
font: var(--md-sys-typescale-headline-md);
letter-spacing: var(--md-sys-typescale-headline-md-tracking);
text-wrap: balance;
}
@media (width >= 600px) {
[data-md-error-headline] {
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
}
[data-md-error-message] {
margin: 12px 0 0;
max-width: 448px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-wrap: balance;
}
[data-md-error-actions] {
display: flex;
margin-top: var(--md-sys-measurement-space500);
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
}
}
@@ -13,10 +13,13 @@
the way here, a text Back.
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
utilities. 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 small stylesheet of its own: the app's scheme
from its scheme data, drawn onto the `data-error-*` hooks. Keep the hooks when changing
the markup. The shape turns once a minute, unless the visitor asks for reduced motion. --}}
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 small stylesheet of its
own instead: the app's scheme from its scheme data, drawn onto the same `data-md-error-*`
hooks (`src/Support/ErrorPage.php::fallbackStyles()`). Keep the hooks in step across both
when changing the markup. The shape turns once a minute, unless the visitor asks for reduced
motion. --}}
@php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
@@ -37,25 +40,20 @@
@if ($assets !== null)
{{ $assets }}
@else
<style data-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
<style data-md-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
@endif
<style>
@keyframes material-error-turn { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: no-preference) { [data-error-shape] { animation: material-error-turn 60s linear infinite; } }
</style>
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
<main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center">
<div data-error-art class="relative grid size-48 shrink-0 place-items-center medium:size-60">
<div data-error-shape class="absolute inset-0">
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
<body>
<main data-md-error-page>
<div data-md-error-art>
<div data-md-error-shape>
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" />
</div>
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
<p data-md-error-code>@yield('code')</p>
</div>
<h1 data-error-headline class="mt-10 type-headline-md text-balance medium:type-headline-lg">
<h1 data-md-error-headline>
@hasSection('headline')
@yield('headline')
@else
@@ -64,10 +62,10 @@
</h1>
@hasSection('headline')
<p data-error-message class="mt-3 max-w-md type-body-lg text-balance text-on-surface-variant">@yield('message')</p>
<p data-md-error-message>@yield('message')</p>
@endif
<div data-error-actions class="mt-10 flex flex-wrap items-center justify-center gap-3">
<div data-md-error-actions>
@hasSection('actions')
@yield('actions')
@else
+15 -12
View File
@@ -60,7 +60,10 @@ class ErrorPage
/**
* A stylesheet for a page without its build: the application's scheme in both themes, a
* system font, and the layout's `data-error-*` hooks drawn to match the Tailwind version.
* system font, and the layout's `data-md-error-*` hooks drawn to match the built version
* (resources/css/components/error-page.css). Still hand-built, not `Stylesheets::bundle()` —
* plan step 40 replaces this method's own body, once the design guard exists to catch a hook
* the two drift apart on.
*/
public static function fallbackStyles(): HtmlString
{
@@ -76,17 +79,17 @@ class ErrorPage
[data-theme='dark'] { color-scheme: dark; {$roles($scheme['dark'])}}
*, ::before, ::after { box-sizing: border-box; }
body { margin: 0; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font: 400 1rem/1.5rem ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
[data-error-page] { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; max-width: 36rem; margin: 0 auto; padding: 3rem 1.5rem; text-align: center; }
[data-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; }
[data-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); }
[data-error-shape] svg { display: block; width: 100%; height: 100%; }
[data-error-code] { position: relative; margin: 0; font-size: 3.5625rem; font-weight: 500; line-height: 4rem; color: var(--md-sys-color-on-primary-container); font-variant-numeric: tabular-nums; }
[data-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; }
[data-error-message] { margin: 0.75rem 0 0; color: var(--md-sys-color-on-surface-variant); text-wrap: balance; }
[data-error-actions] { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
[data-error-actions] :is(a, button) { display: inline-flex; align-items: center; height: 3.5rem; padding: 0 1.5rem; border: 0; border-radius: 9999px; background: none; color: var(--md-sys-color-primary); font-family: inherit; font-size: 1rem; font-weight: 500; line-height: 1.5rem; text-decoration: none; cursor: pointer; }
[data-error-actions] > :first-child { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
[data-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
[data-md-error-page] { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; max-width: 36rem; margin: 0 auto; padding: 3rem 1.5rem; text-align: center; }
[data-md-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; }
[data-md-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); }
[data-md-error-shape] svg { display: block; width: 100%; height: 100%; }
[data-md-error-code] { position: relative; margin: 0; font-size: 3.5625rem; font-weight: 500; line-height: 4rem; color: var(--md-sys-color-on-primary-container); font-variant-numeric: tabular-nums; }
[data-md-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; }
[data-md-error-message] { margin: 0.75rem 0 0; color: var(--md-sys-color-on-surface-variant); text-wrap: balance; }
[data-md-error-actions] { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
[data-md-error-actions] :is(a, button) { display: inline-flex; align-items: center; height: 3.5rem; padding: 0 1.5rem; border: 0; border-radius: 9999px; background: none; color: var(--md-sys-color-primary); font-family: inherit; font-size: 1rem; font-weight: 500; line-height: 1.5rem; text-decoration: none; cursor: pointer; }
[data-md-error-actions] > :first-child { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
[data-md-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
CSS);
}
}
+5 -5
View File
@@ -9,7 +9,7 @@ function missingPage(string $colorScheme = 'light')
$page = $colorScheme === 'dark' ? $page->inDarkMode() : $page->inLightMode();
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && document.querySelector('[data-error-page]') !== null");
->assertScript("document.readyState === 'complete' && document.querySelector('[data-md-error-page]') !== null");
}
/**
@@ -33,10 +33,10 @@ function paintedIn(string $selector, string $role): string
it('draws the 404 page in the scheme, in light and dark', function () {
missingPage('light')
->assertSee('Page not found')
->assertVisible('[data-error-headline]')
->assertVisible('[data-md-error-headline]')
->assertScript("document.documentElement.getAttribute('data-theme') === 'light'")
->assertScript(paintedIn('body', 'surface'))
->assertScript("getComputedStyle(document.querySelector('[data-error-shape] svg')).color !== getComputedStyle(document.body).color")
->assertScript("getComputedStyle(document.querySelector('[data-md-error-shape] svg')).color !== getComputedStyle(document.body).color")
->assertNoJavaScriptErrors();
missingPage('dark')
@@ -48,7 +48,7 @@ it('draws the 404 page in the scheme, in light and dark', function () {
it('fits a 400px screen without scrolling sideways', function () {
missingPage()
->resize(400, 800)
->assertVisible('[data-error-headline]')
->assertVisible('[data-md-error-headline]')
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
->assertScript("document.querySelector('[data-error-actions] a').getBoundingClientRect().right <= 400");
->assertScript("document.querySelector('[data-md-error-actions] a').getBoundingClientRect().right <= 400");
});
+77 -7
View File
@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
@@ -39,7 +41,7 @@ it('renders the package page for each status', function (int $code, string $head
$this->withoutVite()
->get("/abort/{$code}")
->assertStatus($code)
->assertSee('data-error-page', false)
->assertSee('data-md-error-page', false)
->assertSee("{$code}</p>", false)
->assertSee($headline)
->assertSee($sentence);
@@ -56,7 +58,7 @@ it('draws the framework\'s own error pages in the package layout', function () {
$this->withoutVite()
->get('/abort/401')
->assertStatus(401)
->assertSee('data-error-page', false)
->assertSee('data-md-error-page', false)
->assertSee('Unauthorized')
->assertSee('Go home');
});
@@ -86,9 +88,9 @@ it('lets the application\'s own error view win', function () {
->get('/abort/404')
->assertNotFound()
->assertSee('The application view')
->assertDontSee('data-error-page', false);
->assertDontSee('data-md-error-page', false);
$this->get('/abort/403')->assertSee('data-error-page', false);
$this->get('/abort/403')->assertSee('data-md-error-page', false);
});
it('loads the application\'s Vite entries', function () {
@@ -105,7 +107,7 @@ it('loads the application\'s Vite entries', function () {
->assertNotFound()
->assertSee('build/assets/app-probe.css', false)
->assertSee('build/assets/app-probe.js', false)
->assertDontSee('data-error-fallback', false);
->assertDontSee('data-md-error-fallback', false);
});
it('still renders, in the application\'s scheme, when the Vite manifest is missing', function () {
@@ -121,7 +123,7 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
$this->get('/abort/500')
->assertStatus(500)
->assertSee('Something went wrong')
->assertSee('<style data-error-fallback>', false)
->assertSee('<style data-md-error-fallback>', false)
->assertSee('--md-sys-color-surface: #fafaf0;', false)
->assertSee('--md-sys-color-primary: #123456;', false)
->assertSee('--md-sys-color-surface: #101010;', false)
@@ -185,7 +187,7 @@ it('prerenders the 503 page for maintenance mode, without an exception', functio
(new RegisterErrorViewPaths)();
expect(view('errors::503', ['retryAfter' => 60])->render())
->toContain('data-error-page')
->toContain('data-md-error-page')
->toContain('Well be right back')
->toContain('location.reload()');
});
@@ -209,3 +211,71 @@ it('sends an expired form back to its page to refresh', function () {
->assertSee('href="'.url('/settings').'"', false)
->assertSee('Refresh the page');
});
/**
* Plan step 36: `errors::minimal`'s class lists moved into
* resources/css/components/error-page.css, keyed on `data-md-error-*`. Its view lives outside
* resources/views/components/, so it is not in ContainmentStylesheetsTest's dataset (whose
* "imports what its view renders" check reads a fixed resources/views/components/<name>.blade.php
* path); this is its own small stylesheet-shape test instead, checking the same things.
*/
it('draws the error layout from a stylesheet shaped like every package stylesheet', function () {
$css = ComponentStylesheet::read('error-page');
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path('error-page')).'/'.$import))->toBeTrue("error-page.css imports {$import}, which does not exist");
}
});
it('imports the stylesheet of every component the error layout renders', function () {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php'), $tags);
$rendered = collect($tags[1])->unique()
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('error-page')->imports())))->toBe([]);
});
it('writes no class list into the error layout but the interaction and text classes', function () {
expect(ViewClasses::violations(File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php')))->toBe([]);
});
it('takes its values from the tokens and its breakpoints in px', function () {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
preg_match_all('/@media\s*([^{]+)\{/', $source, $queries);
foreach ($queries[1] as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query);
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
}
}
}
});
it('is imported from the containment block of components.css', function () {
$components = File::get(__DIR__.'/../../resources/css/components.css');
$block = substr($components, (int) strpos($components, '/* Containment */'));
$block = substr($block, 0, (int) strpos($block, '/* Navigation */'));
expect($block)->toContain("@import './components/error-page.css';");
});