diff --git a/config/livewire-material.php b/config/livewire-material.php index eaf4f2f2..a1fb8244 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -91,6 +91,43 @@ return [ 'node' => env('MATERIAL_NODE', 'node'), + /* + |-------------------------------------------------------------------------- + | Scheme data + |-------------------------------------------------------------------------- + | + | The light and dark hexes `php artisan material:scheme` writes beside the + | stylesheet. The mail theme reads its colours here, and so does an error + | page when the build is missing; without the file both use the package's + | default scheme. + | + */ + + 'scheme' => resource_path('css/material-scheme.json'), + + /* + |-------------------------------------------------------------------------- + | Mail + |-------------------------------------------------------------------------- + | + | Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME) + | is 'livewire-material::mail.theme'. 'components' puts this package's mail + | header and message after the application's own mail components — or + | publish them with `vendor:publish --tag=livewire-material-mail` instead. + | 'logo' replaces the app name in that header with an image: an absolute + | 'src', with 'width' and 'height' in pixels, which Outlook sizes it by. + | + */ + + 'mail' => [ + 'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false), + 'logo' => [ + 'src' => null, + 'width' => null, + 'height' => null, + ], + ], + /* |-------------------------------------------------------------------------- | Showcase @@ -98,7 +135,8 @@ return [ | | Every component in every variant, rendered in the application's own | scheme. Off unless the application runs locally. 'vite' names the entry - | points that import this package's CSS and JavaScript. + | points that import this package's CSS and JavaScript; the error pages + | load them too, showcase or not. | */ diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 43948aa8..e4e8e6fa 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -557,6 +557,20 @@ worktrees. What changed from the step above: `material-scheme.json` (falling back to the default scheme); `html/header` and `html/message` overrides; typescale on bare tags; filled primary button. +**Steps 30–32 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error +pages and the mail theme by an agent in its own worktree. What changed from the steps above: + +- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than + set as the default view, because Livewire sets its own default on every render; published + `vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`). +- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's + `@source` line already covers its classes), appended to `view.paths` in `register()`, before the + view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too. + Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's + skeleton ships its own `errors/503`, which wins in the Workbench. +- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are + opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail. + ### Phase 10 — `1.0.0` 33. Showcase complete (every component, variant, colour, size and state, both themes); the diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 7cabc7e2..14a0310f 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -7,4 +7,5 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo - Never write maryUI tags (``) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently. - Every layout includes `` in `` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. - While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase). +- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`. @endverbatim diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 449c3d07..8cdccc5c 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -85,6 +85,59 @@ class Settings extends Component The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests). +## Error pages + +Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins. + +- The pages load `config('livewire-material.showcase.vite')` and ``, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`. +- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`. +- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `` name) and optionally `actions`: + +```blade +@extends('errors::minimal') + +@section('title', __('Payment Required')) +@section('code', '402') +@section('shape', 'cookie-4') +@section('headline', __('Your plan has ended')) +@section('message', __('Choose a plan to keep using the app.')) + +@section('actions') + +@endsection +``` + +- Maintenance mode: `php artisan down --render="errors::503"`. +- The showcase previews each page at `/material/errors/{code}`. + +## Mail + +Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme: + +```dotenv +MAIL_MARKDOWN_THEME=livewire-material::mail.theme +``` + +or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable. + +- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies. +- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `` is a tinted container. +- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables. +- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions. +- With those components, a mail can replace the footer: + +```blade + +Your export is ready. + + +© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }}) + + +``` + +- The showcase renders a sample mail at `/material/mail`. + ## Components ### `` diff --git a/resources/views/error-pages/errors/403.blade.php b/resources/views/error-pages/errors/403.blade.php new file mode 100644 index 00000000..8c2ac866 --- /dev/null +++ b/resources/views/error-pages/errors/403.blade.php @@ -0,0 +1,10 @@ +{{-- Forbidden. A message the app passed along (`abort(403, '…')`, a policy's `Response::deny('…')`) + is the sentence, as in the framework's own page; otherwise a general one. --}} + +@extends('errors::minimal') + +@section('title', __('Forbidden')) +@section('code', '403') +@section('shape', 'gem') +@section('headline', __('You don’t have access')) +@section('message', isset($exception) && $exception->getMessage() !== '' ? __($exception->getMessage()) : __('Your account isn’t allowed to open this page.')) diff --git a/resources/views/error-pages/errors/404.blade.php b/resources/views/error-pages/errors/404.blade.php new file mode 100644 index 00000000..08fd6767 --- /dev/null +++ b/resources/views/error-pages/errors/404.blade.php @@ -0,0 +1,9 @@ +{{-- Not found. --}} + +@extends('errors::minimal') + +@section('title', __('Not Found')) +@section('code', '404') +@section('shape', 'cookie-9') +@section('headline', __('Page not found')) +@section('message', __('The page you’re looking for doesn’t exist or has moved.')) diff --git a/resources/views/error-pages/errors/419.blade.php b/resources/views/error-pages/errors/419.blade.php new file mode 100644 index 00000000..4f3af3f1 --- /dev/null +++ b/resources/views/error-pages/errors/419.blade.php @@ -0,0 +1,16 @@ +{{-- Page expired: the form's CSRF token outlived the session. Reloading the page the form was on + issues a new one, so that is the action — a link to it, never a reload of this response, + which would post the stale form again. --}} + +@extends('errors::minimal') + +@section('title', __('Page Expired')) +@section('code', '419') +@section('shape', 'clover-4') +@section('headline', __('This page has expired')) +@section('message', __('It was open for a while. Refresh it, then try again.')) + +@section('actions') + + +@endsection diff --git a/resources/views/error-pages/errors/429.blade.php b/resources/views/error-pages/errors/429.blade.php new file mode 100644 index 00000000..2f1ee053 --- /dev/null +++ b/resources/views/error-pages/errors/429.blade.php @@ -0,0 +1,9 @@ +{{-- Too many requests. --}} + +@extends('errors::minimal') + +@section('title', __('Too Many Requests')) +@section('code', '429') +@section('shape', 'cookie-12') +@section('headline', __('Slow down a little')) +@section('message', __('There were too many requests in a short time. Wait a moment, then try again.')) diff --git a/resources/views/error-pages/errors/500.blade.php b/resources/views/error-pages/errors/500.blade.php new file mode 100644 index 00000000..f323d7c1 --- /dev/null +++ b/resources/views/error-pages/errors/500.blade.php @@ -0,0 +1,9 @@ +{{-- Server error. Nothing from the exception is shown: its message is for the log, not the visitor. --}} + +@extends('errors::minimal') + +@section('title', __('Server Error')) +@section('code', '500') +@section('shape', 'soft-burst') +@section('headline', __('Something went wrong')) +@section('message', __('An error on our side stopped this page from loading. Please try again in a moment.')) diff --git a/resources/views/error-pages/errors/503.blade.php b/resources/views/error-pages/errors/503.blade.php new file mode 100644 index 00000000..1c161485 --- /dev/null +++ b/resources/views/error-pages/errors/503.blade.php @@ -0,0 +1,22 @@ +{{-- Service unavailable, usually maintenance mode. A message the app passed (`abort(503, '…')`) is + the sentence; maintenance mode's own "Service Unavailable" is not a message, so it is not. + + `php artisan down --render="errors::503"` renders this once, in the console, with no + `$exception` and no request — so the one action reloads whatever address the visitor is + on, in the browser, rather than a URL decided here. --}} + +@extends('errors::minimal') + +@php + $reason = isset($exception) ? $exception->getMessage() : ''; +@endphp + +@section('title', __('Service Unavailable')) +@section('code', '503') +@section('shape', 'puffy') +@section('headline', __('We’ll be right back')) +@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('We’re making some improvements. Please check back soon.')) + +@section('actions') + +@endsection diff --git a/resources/views/error-pages/errors/minimal.blade.php b/resources/views/error-pages/errors/minimal.blade.php new file mode 100644 index 00000000..40180ab5 --- /dev/null +++ b/resources/views/error-pages/errors/minimal.blade.php @@ -0,0 +1,83 @@ +{{-- The layout every error page extends: this package's 403, 404, 419, 429, 500 and 503, and the + framework's own 401 and 402, which extend `errors::minimal` and find this file before the + framework's (the provider appends this folder's parent to `view.paths`). + + Sections — the framework layout's `title`, `code` and `message`, plus three of its own, so + either layout renders the other's pages: + `title` the browser tab, followed by the app name; + `code` the status, drawn in display type over the shape; + `headline` what happened, in a few words (without it, `message` is the headline); + `message` one sentence on what to do about it; + `shape` an M3 Expressive shape name (`cookie-7` by default), in primary-container; + `actions` the buttons; by default a filled Home and, when the visitor came from a page on + 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. --}} + +@php + $assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets(); + $back = \NoNameWeb\LivewireMaterial\Support\ErrorPage::backUrl(); +@endphp + + + + + + + + + @yield('title') · {{ config('app.name') }} + + + + @if ($assets !== null) + {{ $assets }} + @else + + @endif + + + + +
+
+
+ +
+ +

@yield('code')

+
+ +

+ @hasSection('headline') + @yield('headline') + @else + @yield('message') + @endif +

+ + @hasSection('headline') +

@yield('message')

+ @endif + +
+ @hasSection('actions') + @yield('actions') + @else + + + @if ($back !== null) + + @endif + @endif +
+
+ + diff --git a/resources/views/mail/html/header.blade.php b/resources/views/mail/html/header.blade.php new file mode 100644 index 00000000..b1cd52d7 --- /dev/null +++ b/resources/views/mail/html/header.blade.php @@ -0,0 +1,24 @@ +{{-- The mail's masthead: the app name in title-lg, or the logo from + `livewire-material.mail.logo` (`src`, `width`, `height`). + + Replaces the framework's header, which swaps the name for Laravel's own logo whenever the + app is still called "Laravel". A logo keeps the name as its alt text, for readers with + images off; its width and height are repeated as attributes because Outlook sizes an image + from those and ignores the stylesheet. Serve the file at twice those dimensions to stay + crisp, from an absolute URL — a queued mail has no request to resolve a relative one. --}} +@props(['url', 'logo' => config('livewire-material.mail.logo')]) +@php + $name = trim(strip_tags((string) $slot)); + $src = is_array($logo) ? ($logo['src'] ?? null) : null; +@endphp + + + +@if (filled($src)) + +@else +{{ $name }} +@endif + + + diff --git a/resources/views/mail/html/message.blade.php b/resources/views/mail/html/message.blade.php new file mode 100644 index 00000000..411baf8f --- /dev/null +++ b/resources/views/mail/html/message.blade.php @@ -0,0 +1,35 @@ +{{-- Every Markdown mail passes through here. The framework's message, with the footer open to + the mail: a `` inside `` replaces the default line — for the + sender's address, a privacy link, or an unsubscribe link on mail that is not transactional. + Keep the Markdown below unindented: four spaces make a code block. --}} + +{{-- Header --}} + + +{{ config('app.name') }} + + + +{{-- Body --}} +{!! $slot !!} + +{{-- Subcopy --}} +@isset($subcopy) + + +{!! $subcopy !!} + + +@endisset + +{{-- Footer --}} + + +@isset($footer) +{!! $footer !!} +@else +© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} +@endisset + + + diff --git a/resources/views/mail/text/message.blade.php b/resources/views/mail/text/message.blade.php new file mode 100644 index 00000000..0b04fa66 --- /dev/null +++ b/resources/views/mail/text/message.blade.php @@ -0,0 +1,32 @@ +{{-- The plain-text twin of html/message.blade.php: the same footer slot, no markup. --}} + + {{-- Header --}} + + + {{ config('app.name') }} + + + + {{-- Body --}} + {{ $slot }} + + {{-- Subcopy --}} + @isset($subcopy) + + + {{ $subcopy }} + + + @endisset + + {{-- Footer --}} + + +@isset($footer) + {{ $footer }} +@else + © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') +@endisset + + + diff --git a/resources/views/mail/theme.blade.php b/resources/views/mail/theme.blade.php new file mode 100644 index 00000000..910d6527 --- /dev/null +++ b/resources/views/mail/theme.blade.php @@ -0,0 +1,426 @@ +{{-- The Markdown mail theme, as CSS rendered from the application's colour scheme. + + Select it with `mail.markdown.theme` = `livewire-material::mail.theme` (MAIL_MARKDOWN_THEME), + or `$theme` / `->theme()` on one mailable or MailMessage. Laravel renders a namespaced theme + as a view (Illuminate\Mail\Markdown::render) and hands the result to CssToInlineStyles, which + writes it onto every element's `style` — so the colours are the app's light scheme, read + from `livewire-material.scheme` (Support\Scheme, falling back to the package's default), and + a regenerated scheme reaches the next mail without a copy to keep in step. + + What a mail client can take, and so what this is: + - Hexes only. No custom properties, no `color-mix()`, no alpha: a client resolves none of + them, and Outlook drops an alpha channel. + - Light only. `Css\Processor::doCleanup()` strips every `@media` block from the theme before + inlining, so a `prefers-color-scheme` rule here would be deleted silently; it could only + live in a `