diff --git a/resources/css/components/error-page.css b/resources/css/components/error-page.css index 5b4d982f..982f4600 100644 --- a/resources/css/components/error-page.css +++ b/resources/css/components/error-page.css @@ -20,9 +20,9 @@ * 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, is inlined into the error page alone, so it keeps - * a bare `body`; it still needs updating by hand when this file changes, until step 40 replaces - * how it is built. + * 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'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 b2a2299c..2ca29a86 100644 --- a/resources/views/error-pages/errors/minimal.blade.php +++ b/resources/views/error-pages/errors/minimal.blade.php @@ -15,11 +15,12 @@ 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 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. --}} + 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. --}} @php $assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets(); diff --git a/src/Support/ErrorPage.php b/src/Support/ErrorPage.php index f0694466..6dff4044 100644 --- a/src/Support/ErrorPage.php +++ b/src/Support/ErrorPage.php @@ -14,12 +14,24 @@ use Throwable; class ErrorPage { /** - * The roles the fallback stylesheet draws with. + * 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. + * + * @var list */ - protected const array ROLES = [ - 'surface', 'on-surface', 'on-surface-variant', 'primary', 'on-primary', - 'primary-container', 'on-primary-container', 'secondary', - ]; + protected const array FILES = ['foundation.css', 'components/error-page.css']; + + /** + * The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache: + * by the scheme file's path and mtime, so a request that never regenerates it never rebuilds + * this either, and a fresh `material:scheme` run is picked up the moment its mtime changes. + * + * @var array + */ + protected static array $schemeCache = []; /** * The application's Vite tags, or null when they cannot be made. @@ -59,40 +71,98 @@ 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-md-error-*` hooks drawn to match the built version - * (resources/css/components/error-page.css), the shape's slow turn included. Inlined into the - * error page alone, so its `body` rule needs no scope. 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. + * 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 + * can never drift from `resources/css/components/error-page.css` the way a hand-written copy + * did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve + * the font file, and a relative `url()` a request 404s on is worse than none) and the + * application's colours appended in `material-scheme.css`'s own shape (`schemeStylesheet()`): + * the standard level, the medium and high contrast levels under `[data-contrast]`, and a block + * per colour profile under `[data-scheme]`. `` has already written + * `data-theme`, `data-contrast` and `data-scheme` onto `` by the time this tag is parsed + * (it renders first), so the browser resolves the right block on its own — nothing here decides + * an active profile in PHP. */ public static function fallbackStyles(): HtmlString { - $scheme = Scheme::load(); + $files = array_map(fn (string $file): string => dirname(__DIR__, 2)."/resources/css/{$file}", self::FILES); - $roles = fn (array $theme): string => implode('', array_map( - fn (string $role): string => "--md-sys-color-{$role}: {$theme[$role]}; ", - self::ROLES, - )); + return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet()); + } - return new HtmlString(<< :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; } - @keyframes material-error-turn { to { transform: rotate(1turn); } } - @media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } } - CSS); + /** + * `$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 + * `@font-face` outside nothing else looks at here, then remove the balanced `{ … }` that + * follows it, brace for brace, however the block itself is written. `tokens/font.css`'s is the + * only one the bundle carries, and it is also the bundle's only relative `url()` (button.css, + * icon.css, shape.css and the rest reference nothing on disk), so this removal is also what + * leaves the fallback with no `url()` to a font the fallback cannot serve. The typeface itself + * degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`, + * `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve + * it the browser skips straight to that system stack — nothing here has to name one. + */ + protected static function withoutFontFace(string $css): string + { + $result = ''; + $offset = 0; + $length = strlen($css); + + while (($start = stripos($css, '@font-face', $offset)) !== false) { + $result .= substr($css, $offset, $start - $offset); + $open = strpos($css, '{', $start); + + if ($open === false) { + $offset = $start + strlen('@font-face'); + + continue; + } + + $depth = 1; + $i = $open + 1; + + while ($i < $length && $depth > 0) { + $depth += match ($css[$i]) { + '{' => 1, + '}' => -1, + default => 0, + }; + $i++; + } + + $offset = $i; + } + + return $result.substr($css, $offset); + } + + /** + * The application's colours, in `material-scheme.css`'s own selector shape + * (`SchemeStylesheet::levels()`), from `Scheme::forStylesheet()` rather than a generated file + * that may not exist yet. Cached by the scheme file's path and mtime (`self::$schemeCache`): + * `Scheme` itself reads on every call and keeps nothing, so a resolver or a regenerated file + * applies at once everywhere else it is asked — this cache is safe only because the CSS built + * here never depends on which profile is *active*, only on the file's own content. + */ + protected static function schemeStylesheet(): string + { + $path = (string) config('livewire-material.scheme'); + clearstatcache(true, $path); + $mtime = is_file($path) ? filemtime($path) : false; + + if (isset(self::$schemeCache[$path]) && self::$schemeCache[$path]['mtime'] === $mtime) { + return self::$schemeCache[$path]['css']; + } + + $scheme = Scheme::forStylesheet($path); + $css = SchemeStylesheet::levels($scheme['scheme']); + + foreach ($scheme['profiles'] as $name => $profile) { + $css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']"); + } + + self::$schemeCache[$path] = ['css' => $css, 'mtime' => $mtime]; + + return $css; } } diff --git a/tests/Browser/ErrorPagesTest.php b/tests/Browser/ErrorPagesTest.php index 74e8bc71..33e6f1d7 100644 --- a/tests/Browser/ErrorPagesTest.php +++ b/tests/Browser/ErrorPagesTest.php @@ -1,5 +1,9 @@ assertScript('document.documentElement.scrollWidth <= window.innerWidth') ->assertScript("document.querySelector('[data-md-error-actions] a').getBoundingClientRect().right <= 400"); }); + +/** + * Plan step 40: the fallback path, the same server serving the request `LaravelHttpServer` runs + * in-process, so a config change here reaches it exactly as it reaches a Feature test's client — + * an empty temporary directory has no `build/manifest.json` and no `hot` file, so `ErrorPage::assets()` + * throws and returns null (tests/Feature/ErrorPagesTest.php does the same for the HTTP client). + */ +it('draws the button and the shape from the inlined fallback stylesheet, and repaints in dark mode', function () { + $public = sys_get_temp_dir().'/livewire-material-fallback-'.Str::random(8); + File::ensureDirectoryExists($public); + + $originalPublicPath = app()->publicPath(); + app()->usePublicPath($public); + Vite::useHotFile($public.'/hot'); + + try { + missingPage('light') + ->assertScript("document.querySelector('style[data-md-error-fallback]') !== null") + ->assertScript("document.querySelector('link[href*=\"/build/\"]') === null") + // The button: button.css's round, filled shape, not a bare unstyled . + ->assertScript("getComputedStyle(document.querySelector('[data-md-button]')).borderRadius !== '0px'") + ->assertScript(paintedIn('[data-md-button][data-md-variant=filled]', 'primary')) + // The shape: shape.css's sizing and the primary-container tint error-page.css paints its svg with. + ->assertScript("getComputedStyle(document.querySelector('[data-md-error-shape] svg')).color !== getComputedStyle(document.body).color") + ->assertScript(paintedIn('body', 'surface')) + ->assertNoJavaScriptErrors(); + + // The [data-theme='dark'] block the fallback also inlines repaints the page, exactly as the + // built stylesheet does for the test above. + missingPage('dark') + ->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'") + ->assertScript(paintedIn('body', 'surface')) + ->assertScript("getComputedStyle(document.body).backgroundColor !== 'rgb(253, 247, 254)'"); + } finally { + app()->usePublicPath($originalPublicPath); + Vite::useHotFile($originalPublicPath.'/hot'); + File::deleteDirectory($public); + } +}); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php index 151f76ec..027250c0 100644 --- a/tests/Feature/ErrorPagesTest.php +++ b/tests/Feature/ErrorPagesTest.php @@ -132,7 +132,38 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi ->assertDontSee('/build/', false); }); -it('draws the active colour profile when the Vite manifest is missing', function () { +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'], + 'dark' => ['surface' => '#101010'], + 'contrast' => [ + 'standard' => 0, + 'medium' => ['light' => ['primary' => '#334455']], + 'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff']], + ], + ])); + + config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']); + app()->usePublicPath($this->temporary); + Vite::useHotFile($this->temporary.'/hot'); + + $this->get('/abort/500') + ->assertStatus(500) + ->assertSee("[data-contrast='medium']", false) + ->assertSee("[data-contrast='high']", false) + ->assertSee('--md-sys-color-primary: #334455;', false) + ->assertSee('--md-sys-color-primary: #000000;', false) + ->assertSee('--md-sys-color-primary: #ffffff;', false); +}); + +/** + * Plan step 40: the fallback no longer resolves the active profile in PHP — `` + * has already embedded it (`Scheme::profile()`, the resolver included) and writes it to + * `` before this stylesheet is read — so the fallback draws every profile, in + * `material-scheme.css`'s own `[data-scheme]` shape, and lets the attribute already on the page + * pick between them. + */ +it('draws every colour profile when the Vite manifest is missing, keyed on data-scheme', function () { File::put($this->temporary.'/material-scheme.json', json_encode([ 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff'], @@ -151,9 +182,13 @@ it('draws the active colour profile when the Vite manifest is missing', function try { $this->get('/abort/500') ->assertStatus(500) + ->assertSee("[data-scheme='indigo']", false) + ->assertSee("[data-scheme='teal']", false) + ->assertSee('--md-sys-color-primary: #4f46e5;', false) ->assertSee('--md-sys-color-primary: #00897b;', false) - ->assertSee('--md-sys-color-primary: #80cbc4;', false) - ->assertDontSee('#4f46e5', false); + // The theme script resolved and embedded the active profile server-side; the CSS above + // only has to let [data-scheme] (which it sets on ) pick between the two blocks. + ->assertSee('"scheme":"teal"', false); } finally { Scheme::resolveProfileUsing(null); } @@ -285,5 +320,32 @@ it('styles the body only when it holds the error layout, and turns the shape on ->and(ComponentStylesheet::read('error-page')->declarations('body:has(> [data-md-error-page])'))->not->toHaveKey('font-family') ->and((string) ErrorPage::fallbackStyles()) ->toContain('@keyframes material-error-turn') - ->toContain('@media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } }'); + ->toContain('animation: material-error-turn 60s linear infinite;'); +}); + +/** + * Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout + * (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries + * their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the + * bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face` + * itself — the fallback has no build to serve the font file from. + */ +it('bundles the foundation and the error layout into the fallback, without an import, a relative url, or a font face', function () { + withoutSkeletonErrorViews(); + + $this->withoutVite(); + $css = (string) ErrorPage::fallbackStyles(); + $plain = (string) preg_replace('~/\*.*?\*/~s', '', $css); + + expect($plain)->not->toContain('@import') + ->not->toContain('@font-face') + ->and(preg_match('/url\(\s*(?!["\']?(?:data:|https?:|\/))/i', $plain))->toBe(0, 'a relative url() remains in the fallback') + // The error layout's own rules, and the button and shape stylesheets it imports. + ->and($plain)->toContain('[data-md-error-page]') + ->toContain('[data-md-error-shape]') + ->toContain('[data-md-button]') + ->toContain('[data-md-shape]') + // The foundation's reset and tokens travelled in too, not only the error layout. + ->toContain('box-sizing: border-box') + ->toContain('--md-sys-color-surface'); });