Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s

Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:55:58 +02:00
co-authored by Claude Opus 5
parent ab692b66bb
commit 8f174520eb
29 changed files with 1595 additions and 2 deletions
+426
View File
@@ -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 `<style>` in a published layout, and the framework layout declares
`color-scheme: light` in two meta tags besides. Clients that force dark invert the light
tones, which are mid-tone enough to survive it.
- M3's typescale on the bare tags CommonMark writes (`h1` headline-sm, `p` body-lg, a table
head title-sm), in px, with a system font stack: web fonts and
`font-variation-settings` reach too few clients to be worth an asset.
- No elevation. The card separates from the page by tone, as M3 surfaces do.
- The button is M3's filled button at the `md` size (56px, title-md label, full corners),
written as borders because padding on an `<a>` is not honoured everywhere. Outlook
Classic squares the corners; it is still a button. `x-mail::button`'s `color` takes any
role with a filled pair — primary, secondary, tertiary, error, success, warning, info —
and the framework's blue, green and red. --}}
@php
$role = \NoNameWeb\LivewireMaterial\Support\Scheme::light();
$page = $role['surface-container'];
$card = $role['surface-container-lowest'];
$tint = $role['surface-container'];
$buttons = [
'primary' => ['primary', 'on-primary'],
'secondary' => ['secondary', 'on-secondary'],
'tertiary' => ['tertiary', 'on-tertiary'],
'error' => ['error', 'on-error'],
'success' => ['success', 'on-success'],
'warning' => ['warning', 'on-warning'],
'info' => ['info', 'on-info'],
'blue' => ['primary', 'on-primary'],
'green' => ['success', 'on-success'],
'red' => ['error', 'on-error'],
];
@endphp
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: {{ $page }};
color: {{ $role['on-surface-variant'] }};
height: 100%;
line-height: 1.5;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.5;
text-align: start;
}
a {
color: {{ $role['primary'] }};
}
a img {
border: none;
}
/* Typography: M3's typescale */
h1 {
color: {{ $role['on-surface'] }};
font-size: 24px;
font-weight: 400;
letter-spacing: 0;
line-height: 32px;
margin-top: 0;
text-align: start;
}
h2 {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 400;
letter-spacing: 0;
line-height: 28px;
margin-top: 0;
text-align: start;
}
h3 {
color: {{ $role['on-surface'] }};
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
p {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
ul,
ol {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin: 0 0 16px;
padding-left: 24px;
}
li {
margin: 0 0 4px;
}
/* A `->line()` per item makes a loose list, each item wrapped in a paragraph with a
paragraph's margin; without this it reads as numbered paragraphs, not one list. */
li p {
margin: 0;
}
/* Bold stays bold: a 500 would fall to 400 in the many system fonts without a medium weight. */
strong {
color: {{ $role['on-surface'] }};
}
blockquote {
border-left: 4px solid {{ $role['outline-variant'] }};
margin: 0 0 16px;
padding-left: 16px;
}
code {
background-color: {{ $tint }};
border-radius: 4px;
color: {{ $role['on-surface'] }};
font-size: 14px;
padding: 2px 4px;
}
hr {
border: 0;
border-top: 1px solid {{ $role['outline-variant'] }};
margin: 24px 0;
}
p.sub {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header: the app name in title-lg, or the configured logo */
.header {
padding: 32px 0 24px;
text-align: center;
}
.header a {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 500;
letter-spacing: 0;
line-height: 28px;
text-decoration: none;
}
.logo {
border: 0;
display: block;
height: auto;
margin: 0 auto;
}
/* Body: the card, separated from the page by tone alone */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
border: 0;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: {{ $card }};
border: 0;
border-radius: 24px;
margin: 0 auto;
padding: 0;
width: 570px;
}
/* A long URL breaks where it has to; `break-all`, the framework's value, breaks ordinary words too. */
.inner-body a {
overflow-wrap: anywhere;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Subcopy */
.subcopy {
border-top: 1px solid {{ $role['outline-variant'] }};
margin-top: 24px;
padding-top: 24px;
}
.subcopy p {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
/* Footer: body-sm */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: {{ $role['on-surface-variant'] }};
font-size: 12px;
font-weight: 400;
letter-spacing: 0.4px;
line-height: 16px;
text-align: center;
}
.footer a {
color: {{ $role['on-surface-variant'] }};
text-decoration: underline;
}
/* Tables: a title-sm head over an outline-variant rule, body-md cells, a rule between rows */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: collapse;
margin: 24px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface-variant'] }};
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1px;
line-height: 20px;
margin: 0;
padding: 0 12px 12px;
text-align: start;
}
/* A Markdown table's column alignment arrives as an `align` attribute, which an inline
`text-align` would override; the head follows it here as the cells do by themselves. */
.table th[align="center"] {
text-align: center;
}
.table th[align="right"] {
text-align: right;
}
.table td {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface'] }};
font-size: 14px;
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: 0.25px;
line-height: 20px;
margin: 0;
padding: 12px;
}
.table tr:last-child td {
border-bottom: 0;
}
/* Buttons: M3's filled button, md */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
float: unset;
margin: 32px auto;
padding: 0;
text-align: center;
width: 100%;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 9999px;
display: inline-block;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
overflow: hidden;
text-decoration: none;
}
@foreach ($buttons as $name => [$container, $content])
.button-{{ $name }} {
background-color: {{ $role[$container] }};
border-bottom: 16px solid {{ $role[$container] }};
border-left: 24px solid {{ $role[$container] }};
border-right: 24px solid {{ $role[$container] }};
border-top: 16px solid {{ $role[$container] }};
color: {{ $role[$content] }};
}
@endforeach
/* Panel: a tinted container, no rule down its edge */
.panel {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: separate;
margin: 24px 0;
width: 100%;
}
.panel-content {
background-color: {{ $tint }};
border-radius: 16px;
color: {{ $role['on-surface'] }};
padding: 16px 20px;
}
.panel-content p {
color: {{ $role['on-surface'] }};
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
overflow-wrap: anywhere;
}