From a8c2d88fbb679ac19f26575df28bb7c87475727d Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 21:44:55 +0200 Subject: [PATCH] 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 (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/.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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/error-page.css | 150 ++++++++++++++++++ .../error-pages/errors/minimal.blade.php | 36 ++--- src/Support/ErrorPage.php | 27 ++-- tests/Browser/ErrorPagesTest.php | 10 +- tests/Feature/ErrorPagesTest.php | 84 +++++++++- 6 files changed, 265 insertions(+), 43 deletions(-) create mode 100644 resources/css/components/error-page.css diff --git a/resources/css/components.css b/resources/css/components.css index d1eecd23..0c3c7b29 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -66,5 +66,6 @@ @import './components/bottom-sheet.css'; @import './components/carousel-item.css'; @import './components/carousel.css'; +@import './components/error-page.css'; /* Navigation */ diff --git a/resources/css/components/error-page.css b/resources/css/components/error-page.css new file mode 100644 index 00000000..e6767592 --- /dev/null +++ b/resources/css/components/error-page.css @@ -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 , turning once a minute unless reduced motion asks + * otherwise (its `svg` fills the art box and is coloured here, + * since 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; + } +} diff --git a/resources/views/error-pages/errors/minimal.blade.php b/resources/views/error-pages/errors/minimal.blade.php index 02bba91b..b2a2299c 100644 --- a/resources/views/error-pages/errors/minimal.blade.php +++ b/resources/views/error-pages/errors/minimal.blade.php @@ -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 - + @endif - - - -
-
-
- + +
+
+
+
-

@yield('code')

+

@yield('code')

-

+

@hasSection('headline') @yield('headline') @else @@ -64,10 +62,10 @@

@hasSection('headline') -

@yield('message')

+

@yield('message')

@endif -
+
@hasSection('actions') @yield('actions') @else diff --git a/src/Support/ErrorPage.php b/src/Support/ErrorPage.php index d9f8ea83..ce03b2f0 100644 --- a/src/Support/ErrorPage.php +++ b/src/Support/ErrorPage.php @@ -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); } } diff --git a/tests/Browser/ErrorPagesTest.php b/tests/Browser/ErrorPagesTest.php index b55bf551..74e8bc71 100644 --- a/tests/Browser/ErrorPagesTest.php +++ b/tests/Browser/ErrorPagesTest.php @@ -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"); }); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php index 9077cb1e..308652ed 100644 --- a/tests/Feature/ErrorPagesTest.php +++ b/tests/Feature/ErrorPagesTest.php @@ -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}

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