Files
gerbeur/api/mail/layout.ts
khannurien fd0d81fe66 v4: the emails' words live in the catalogs alone and their look is read from the site's stylesheets — every email message was written as i18n._({ id, message }), so its English sat in the template and again in en.po, and api/mail/styles.ts held a copy of each theme's palette, fonts and radii that a test chased. The templates now name each message by its id only (i18n._("email.reset.title", values)) and its English is written in locales/en.po, its French in fr.po; the four labels the settings tab and the unsubscribe page share with the emails read the same ids through @lingui/react's Trans instead of restating the English. i18n:extract runs without --overwrite, which would write the id where the English is, and i18n:compile runs --strict, so a language lacking a message fails the build; it caught email.digest.hl.mentionIn, never translated into French. The new api/mail/theme-tokens.ts reads src/tokens.css and src/themes/<style>.css and resolves a token for one scheme (var(), light-dark(), color-mix(), translucent colors laid over the card), and styles.ts names tokens for every color, GeoCities' rainbow, Melange's spice and shadow, Kanagawa's syntax hues, the font stack and the corner radii, keeping only what is the email's own (type scale, title, frame, divider, avatar rounding). The copies had drifted: GeoCities' stack had lost Marker Felt and its light emails drew the dark rainbow, which now also recolors its dividers on the "auto" scheme. The pills take the site's badge color, now the --color-badge-bg token that the private badge reads too. The Dockerfile copies src/tokens.css and src/themes/ into the runtime image, which reads them at load. The catalogs were re-extracted, having been stale against the code. 240 backend tests (new: the token reader, the dark rainbow; the palette-copy test is gone) and 661 frontend tests pass, and tsc, deno check and doc:lint are clean; sendDueDigests' empty-digest test fails now and then, as it did before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:21:45 +00:00

821 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Every email the site sends shares one layout: the site's emoji and name,
* then a card holding the content, then a footer. The templates beside this
* module (welcome, password reset, digest) build their content with an
* {@link EmailKit}, which draws each block in a style from `styles.ts`, and
* `email-service.ts` sends what they return.
*
* Mail clients drop stylesheets, so every block carries inline styles. A
* reader on the "auto" scheme gets the light palette inline and the dark one
* in a `prefers-color-scheme` block that Apple's mail apps honour; elsewhere
* the light one shows.
*
* @module
*/
import { Marked, Parser, type Token, type Tokens } from "marked";
import {
DEFAULT_STYLE,
EMAIL_LOCALE,
OG_SITE_NAME,
SITE_EMOJI,
} from "../config.ts";
import {
type ColorScheme,
DEFAULT_COLOR_SCHEME,
type StyleName,
} from "../../shared/theme.ts";
import type { UserSettings } from "../../shared/settings.ts";
import { EMAIL_STYLES, type EmailPalette, type EmailStyle } from "./styles.ts";
/**
* Returned by each template to its caller, which hands it to `sendEmail`
* with a recipient.
*/
export interface RenderedEmail {
subject: string;
html: string;
/**
* The plain-text part, for clients that show no HTML. It is Markdown, so it
* reads as text and renders as the email's outline.
*/
text: string;
}
/**
* Passed to {@link EmailKit}, made by {@link lookOf}: the instance's style for
* the emails that aren't a user's own, their settings for a digest.
*/
export interface EmailLook {
style: StyleName;
scheme: ColorScheme;
}
/**
* Returns the look of a reader's settings. An unset style is the instance's,
* as on the site, and an unset scheme the system's; with no settings at all,
* as for the welcome and reset emails, both are.
*/
export function lookOf(
settings: Pick<UserSettings, "style" | "colorScheme"> | null = null,
): EmailLook {
return {
style: settings?.style ?? DEFAULT_STYLE,
scheme: settings?.colorScheme ?? DEFAULT_COLOR_SCHEME,
};
}
/**
* Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes
* it, so that the dark block can recolor the element by the same name.
*/
export type PaletteRole = Exclude<keyof EmailPalette, "gradient">;
const ROLES: PaletteRole[] = [
"outer",
"card",
"text",
"sec",
"muted",
"accent",
"link",
"onAccent",
"rule",
"soft",
"quote",
"date",
"brand",
"border",
];
/**
* Escapes `value` for HTML text and attributes. Everything users wrote (names,
* titles, quotes) goes through it, since none of it may become markup.
*/
export function esc(value: string): string {
return value.replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
]!,
);
}
/**
* Escapes `value` for Markdown, so that user text in a plain-text part (a
* title with `*` or `[`, say) stays text.
*/
export function escMd(value: string): string {
return value.replace(/[\\`*_[\]<>#!|~]/g, "\\$&");
}
/** Returns a Markdown link to `url` labelled with `text`, escaped. */
export function mdLink(text: string, url: string): string {
const safeUrl = url.replace(
/[()\s]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
);
return `[${escMd(text)}](${safeUrl})`;
}
const gradient = (colors: string[]) =>
`linear-gradient(90deg,${colors.join(",")})`;
/**
* Draws an email in one {@link EmailLook}. A template writes the email as
* Markdown and hands it to {@link EmailKit.render}, which draws each block in
* the style. The block methods return HTML: text passed to them must already
* be escaped with {@link esc}.
*/
export class EmailKit {
/** The style being drawn. */
readonly style: EmailStyle;
/** The palette inlined into the HTML. */
readonly palette: EmailPalette;
/** The palette that replaces it in dark mode, on the "auto" scheme only. */
readonly darkPalette: EmailPalette | null;
/** The font stack, for templates that set their own blocks. */
readonly font: string;
constructor(look: EmailLook) {
this.style = EMAIL_STYLES[look.style];
this.palette = look.scheme === "dark" ? this.style.dark : this.style.light;
this.darkPalette = look.scheme === DEFAULT_COLOR_SCHEME
? this.style.dark
: null;
this.font = this.style.font;
}
/** Returns `px`, scaled for the style, as a CSS length. */
fs(px: number): string {
return `${Math.round(px * this.style.scale * 2) / 2}px`;
}
/**
* Returns the `class` and `style` attributes of an element colored by
* roles: `fg` for its text, `bg` for its fill, `bd` for its border. The
* classes let the dark block recolor it; `css` is the rest of its style.
*/
paint(
roles: {
fg?: PaletteRole;
bg?: PaletteRole;
bd?: PaletteRole;
extra?: string[];
},
css = "",
): string {
const p = this.palette;
const classes: string[] = [...(roles.extra ?? [])];
let style = "";
if (roles.fg) {
classes.push(`f-${roles.fg}`);
style += `color:${p[roles.fg] ?? p.text};`;
}
if (roles.bg) {
classes.push(`b-${roles.bg}`);
style += `background-color:${p[roles.bg] ?? p.card};`;
}
if (roles.bd) {
classes.push(`d-${roles.bd}`);
style += `border-color:${p[roles.bd] ?? p.rule};`;
}
const cls = classes.length ? ` class="${classes.join(" ")}"` : "";
// The colors come last: a shorthand such as `border-top` in `css` would
// otherwise reset them.
return `${cls} style="${css}${style}"`;
}
/** Returns a link to `href` around `html`, in the link color. */
link(html: string, href: string, strong = false): string {
const s = this.style;
return `<a href="${esc(href)}"${
this.paint(
{ fg: "link" },
`text-decoration:${s.linkUnderline ? "underline" : "none"};${
strong ? `font-weight:${s.heavy};` : ""
}`,
)
}>${html}</a>`;
}
/** Returns the email's heading, in the style's title type. */
heading(html: string, marginBottom = 24): string {
const t = this.style.title;
return `<h1${
this.paint(
{ fg: t.accent ? "accent" : "text" },
`font-family:${this.font};font-size:${
this.fs(t.size)
};line-height:1.15;font-weight:${t.weight};${
t.upper ? "text-transform:uppercase;" : ""
}${
t.spacing ? `letter-spacing:${t.spacing};` : ""
}margin:0 0 ${marginBottom}px;`,
)
}>${html}</h1>`;
}
/** Returns a paragraph. `css` adds to or overrides its style. */
para(html: string, css = ""): string {
return `<p${
this.paint(
{ fg: "text" },
`font-family:${this.font};font-size:${
this.fs(15)
};line-height:1.6;margin:0 0 16px;${css}`,
)
}>${html}</p>`;
}
/** Returns a smaller paragraph in the muted color. */
note(html: string, css = ""): string {
return `<p${
this.paint(
{ fg: "muted" },
`font-family:${this.font};font-size:${
this.fs(13.5)
};line-height:1.6;margin:0 0 16px;${css}`,
)
}>${html}</p>`;
}
/** Returns a button linking to `href`. */
button(label: string, href: string): string {
const s = this.style;
const frame = s.frame === "hard"
? `border:2px solid ${this.palette.text};box-shadow:2px 2px 0 ${this.palette.text};`
: "";
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr><td${
this.paint(
{
bg: "accent",
bd: s.frame === "hard" ? "text" : undefined,
extra: s.frame === "hard" ? ["sh-btn"] : [],
},
`border-radius:${s.radius.btn}px;${frame}`,
)
}><a href="${esc(href)}"${
this.paint(
{ fg: "onAccent" },
`display:inline-block;padding:13px 22px;font-family:${this.font};font-size:${
this.fs(13.5)
};font-weight:${
Math.max(600, s.heavy)
};letter-spacing:0.08em;text-transform:uppercase;line-height:1;text-decoration:none;`,
)
}>${label}</a></td></tr></table>`;
}
/** Returns a small uppercase pill, as on one's own dump in a list. */
pill(label: string): string {
return `<span${
this.paint(
{ fg: "sec", bg: "soft" },
`display:inline-block;margin-left:6px;padding:1px 7px;border-radius:${this.style.radius.pill}px;font-size:${
this.fs(11)
};font-weight:600;letter-spacing:0.08em;text-transform:uppercase;vertical-align:2px;`,
)
}>${label}</span>`;
}
/** Returns an image block, rounded as the style rounds images. */
image(
src: string,
alt: string,
width: number,
height: number,
href?: string,
): string {
const img = `<img src="${
esc(src)
}" width="${width}" height="${height}" alt="${
esc(alt)
}" style="display:block;max-width:100%;height:auto;border:0;border-radius:${
Math.min(this.style.radius.img, width / 8)
}px;">`;
return href ? `<a href="${esc(href)}">${img}</a>` : img;
}
/** Returns a small bold heading inside a section. */
subhead(html: string): string {
return `<div${
this.paint(
{ fg: "text" },
`font-family:${this.font};font-size:${this.fs(14)};font-weight:${
Math.max(600, this.style.heavy)
};margin:0 0 8px;`,
)
}>${html}</div>`;
}
/**
* Returns a section of the card: a divider in the style's rule (none before
* the first), an uppercase title in the accent color, and `html`.
*/
section(title: string, html: string, first = false): string {
const s = this.style;
let divider = "";
let border = "";
if (!first) {
const stops = this.palette.gradient;
if (s.rule === "gradient" && stops) {
divider =
`<div class="grad" style="height:3px;line-height:3px;font-size:0;background-color:${
stops[0]
};background-image:${gradient(stops)};margin:0 0 26px;">&nbsp;</div>`;
} else {
border = `border-top:${
s.rule === "thick" ? 2 : 1
}px solid;padding-top:26px;`;
}
}
return `<div${
this.paint(
{ bd: border ? (s.rule === "thick" ? "text" : "rule") : undefined },
`${border}padding-bottom:26px;`,
)
}>${divider}<div${
this.paint(
{ fg: "accent" },
`font-family:${this.font};font-size:${this.fs(12)};font-weight:${
Math.max(600, s.heavy)
};letter-spacing:0.12em;text-transform:uppercase;margin:0 0 16px;`,
)
}>${title}</div>${html}</div>`;
}
/**
* Returns the email written as `markdown`: the Markdown itself is the
* plain-text part, and its blocks, drawn in this kit's style, are the HTML.
* Each template writes its email once this way. The blocks map as follows.
*
* - `# Title` is the heading; an `*emphasised*` paragraph above it, the
* eyebrow. Elsewhere an emphasised paragraph is a note.
* - `## Section` starts a section, with its divider; `### Name` a subhead.
* - A link alone in a paragraph is a button, an image alone a wide image,
* and a fenced block a box for a raw link.
* - A list item whose text starts with an image, then a link, is a row
* with that thumbnail and the link as its title; what follows is its
* detail, and an emphasised word right after the link a pill. Other items
* are sentences, where emphasis marks the date. A numbered list ranks its
* rows, and a quote under an item is set as one.
* - A bold number is a figure.
* - What follows the last `---` is the footer.
*/
render(
{ subject, preheader, markdown }: {
subject: string;
preheader: string;
markdown: string;
},
): RenderedEmail {
const cut = markdown.lastIndexOf("\n---\n");
const body = cut === -1 ? markdown : markdown.slice(0, cut);
const foot = cut === -1 ? "" : markdown.slice(cut + 5);
return {
subject,
html: this.document({
subject,
preheader,
body: this.blocks(this.marked.lexer(body)),
footer: this.marked.lexer(foot)
.filter((t): t is Tokens.Paragraph => t.type === "paragraph")
.map((t) => this.inline(t.tokens, "footer")).join("<br>"),
}),
text: markdown,
};
}
/** How {@link inline} draws emphasis and links, by where they sit. */
private mode: "text" | "footer" | "row" | "sentence" = "text";
/** Draws Markdown's inline tokens with the kit's colors. */
private readonly marked: Marked = (() => {
// deno-lint-ignore no-this-alias
const kit = this;
return new Marked({
renderer: {
link({ href, tokens }) {
const html = this.parser.parseInline(tokens);
return kit.mode === "footer"
? `<a href="${esc(href)}"${
kit.paint({ fg: "muted" }, "text-decoration:underline;")
}>${html}</a>`
: kit.link(html, href);
},
strong({ tokens }) {
const html = this.parser.parseInline(tokens);
const figure = /^[\d\s.,  ]+$/.test(html);
return `<b${
kit.paint(
{ fg: "text" },
figure
? `font-size:${kit.fs(22)};font-weight:${kit.style.heavy};`
: `font-weight:${Math.max(600, kit.style.heavy)};`,
)
}>${html}</b>`;
},
em({ tokens }) {
const html = this.parser.parseInline(tokens);
if (kit.mode === "row") return kit.pill(html);
if (kit.mode === "sentence") {
return `<span${
kit.paint(
{ fg: kit.palette.date ? "date" : "muted" },
`font-size:${kit.fs(12.5)};white-space:nowrap;${
kit.palette.date ? "font-style:italic;" : ""
}`,
)
}>${html}</span>`;
}
return `<i>${html}</i>`;
},
codespan({ text }) {
return `<code style="font-family:Menlo,Consolas,'Courier New',monospace;">${text}</code>`;
},
image({ href, text }) {
return kit.image(href, text, 104, 78);
},
},
});
})();
private inline(tokens: Token[], mode: EmailKit["mode"]): string {
this.mode = mode;
try {
return Parser.parseInline(tokens, this.marked.defaults);
} finally {
this.mode = "text";
}
}
/** Draws the body's blocks, gathering each `##` section with its content. */
private blocks(tokens: Token[]): string {
const out: string[] = [];
let section: { title: string; parts: string[] } | null = null;
let first = true;
let headed = false;
const push = (html: string) =>
section ? section.parts.push(html) : out.push(html);
const close = () => {
if (!section) return;
out.push(this.section(section.title, section.parts.join(""), first));
first = false;
section = null;
};
for (const token of tokens) {
switch (token.type) {
case "heading": {
const html = this.inline(token.tokens ?? [], "text");
if (token.depth === 1) {
headed = true;
push(this.heading(html));
} else if (token.depth === 2) {
close();
section = { title: html, parts: [] };
} else {
push(this.subhead(html));
}
break;
}
case "paragraph": {
const kids = (token.tokens ?? []).filter((t) =>
!(t.type === "text" && !t.raw.trim())
);
const only = kids.length === 1 ? kids[0] : null;
if (only?.type === "link") {
push(
this.button(this.inline(only.tokens ?? [], "text"), only.href),
);
} else if (only?.type === "image") {
push(
`${
this.image(only.href, only.text, 528, 264)
}<div style="height:14px;line-height:14px;font-size:0;">&nbsp;</div>`,
);
} else if (only?.type === "em") {
const html = this.inline(only.tokens ?? [], "text");
push(headed ? this.note(html) : this.eyebrow(html));
} else {
push(this.para(this.inline(token.tokens ?? [], "text")));
}
break;
}
case "list":
push(this.list(token as Tokens.List));
break;
case "blockquote":
push(this.quote(this.inline(
(token.tokens ?? []).flatMap((t) =>
"tokens" in t ? t.tokens ?? [] : []
),
"text",
)));
break;
case "code":
push(this.linkBox(esc(token.text)));
break;
case "html":
push(token.text);
break;
}
}
close();
return out.join("");
}
/** Draws a list as rows, ranked when it is numbered. */
private list(list: Tokens.List): string {
const start = typeof list.start === "number" ? list.start : 1;
return list.items.map((item, i) => {
const text = item.tokens.find((t) =>
t.type === "text" || t.type === "paragraph"
) as Tokens.Text | Tokens.Paragraph | undefined;
const quote = item.tokens.find((t) => t.type === "blockquote") as
| Tokens.Blockquote
| undefined;
let rest: Token[] = [...(text?.tokens ?? [])];
const skipSpace = () => {
while (rest[0]?.type === "text" && !rest[0].raw.trim()) {
rest = rest.slice(1);
}
};
skipSpace();
const image = rest[0]?.type === "image"
? rest.shift() as Tokens.Image
: null;
skipSpace();
const link = rest[0]?.type === "link"
? rest.shift() as Tokens.Link
: null;
let content: string;
if (link) {
const pills: Token[] = [];
skipSpace();
while (rest[0]?.type === "em") {
pills.push(rest.shift()!);
skipSpace();
}
// The detail follows the title after a " · ".
if (rest[0]?.type === "text") {
const t = rest[0] as Tokens.Text;
const trimmed = t.text.replace(/^\s*·\s*/, "");
rest[0] = { ...t, text: trimmed, raw: trimmed, tokens: undefined };
}
content = `<a href="${esc(link.href)}"${
this.paint(
{ fg: "text" },
`font-size:${
this.fs(15)
};line-height:1.35;font-weight:${this.style.heavy};text-decoration:none;`,
)
}>${this.inline(link.tokens, "text")}</a>${this.inline(pills, "row")}${
rest.length
? `<div${
this.paint(
{ fg: "muted" },
`font-size:${this.fs(13)};line-height:1.5;margin-top:3px;`,
)
}>${this.inline(rest, "text")}</div>`
: ""
}`;
} else {
content = `<div${
this.paint(
{ fg: "sec" },
`font-size:${this.fs(14.5)};line-height:1.5;`,
)
}>${this.inline(rest, "sentence")}</div>`;
}
if (quote) {
content += this.quote(this.inline(
quote.tokens.flatMap((t) => "tokens" in t ? t.tokens ?? [] : []),
"text",
));
}
const size = list.ordered ? [64, 48] : [104, 78];
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 ${
link || image ? 16 : 10
}px;"><tr>${
list.ordered
? `<td width="30" valign="top"${
this.paint(
{ fg: "accent" },
`font-family:${this.font};font-size:${this.fs(20)};font-weight:${
Math.max(600, this.style.heavy)
};line-height:1.2;`,
)
}>${start + i}</td>`
: ""
}${
image
? `<td width="${size[0]}" valign="top" style="padding-right:${
list.ordered ? 12 : 14
}px;">${
this.image(image.href, image.text, size[0], size[1], link?.href)
}</td>`
: ""
}<td valign="top" style="font-family:${this.font};">${content}</td></tr></table>`;
}).join("");
}
/** Returns the small uppercase line above the heading. */
private eyebrow(html: string): string {
return `<div${
this.paint(
{ fg: "muted" },
`font-family:${this.font};font-size:${
this.fs(12)
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
)
}>${html}</div>`;
}
/** Returns a quoted comment or message. */
private quote(html: string): string {
return `<div${
this.paint(
{ fg: "quote" },
`font-family:${this.font};font-size:${
this.fs(14.5)
};line-height:1.5;font-style:italic;margin:4px 0 0;`,
)
}>“${html}”</div>`;
}
/** Returns a raw link set in a box, for clients that break buttons. */
private linkBox(html: string): string {
return `<div${
this.paint(
{ fg: "sec", bg: "soft" },
`font-family:Menlo,Consolas,'Courier New',monospace;font-size:12px;line-height:1.5;border-radius:${
Math.min(this.style.radius.img, 6)
}px;padding:10px 12px;margin:0 0 22px;word-break:break-all;`,
)
}>${html}</div>`;
}
/**
* Returns the whole HTML document: the brand, the card holding `body`, and
* the `footer`. `preheader` is the hidden first line inboxes show beside the
* subject.
*/
private document(
{ subject, preheader, body, footer }: {
subject: string;
preheader: string;
body: string;
footer: string;
},
): string {
const s = this.style;
const p = this.palette;
const R = s.radius.card;
const bar = s.bar ? p.gradient ?? null : null;
const frame = s.frame === "hard"
? `border:2px solid ${p.text};box-shadow:5px 5px 0 ${p.text};`
: s.frame === "ridge"
? `border:3px ridge ${p.border};`
: s.frame === "hairline"
? `border:1px solid ${p.rule};`
: s.frame === "soft"
? `box-shadow:0 14px 34px -10px ${p.shadow};`
: "";
const frameRoles = s.frame === "hard"
? { bd: "text" as const, extra: ["sh-card"] }
: s.frame === "ridge"
? { bd: "border" as const }
: s.frame === "hairline"
? { bd: "rule" as const }
: s.frame === "soft"
? { extra: ["sh-soft"] }
: {};
const scheme = this.darkPalette
? "light dark"
: p === s.dark
? "dark"
: "light";
return `<!doctype html>
<html lang="${EMAIL_LOCALE}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="${scheme}">
<meta name="supported-color-schemes" content="${scheme}">
<title>${esc(subject)}</title>
${
s.webFont
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${s.webFont}&display=swap">`
: ""
}
<style>
body{margin:0;padding:0;}
@media (max-width:620px){.wrap{padding:20px 10px 28px!important}.card{padding:24px 20px 8px!important}}
${this.darkCss()}
</style>
</head>
<body${this.paint({ bg: "outer" }, "margin:0;padding:0;")}>
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${
esc(preheader)
}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"${
this.paint({ bg: "outer" })
}>
<tr><td align="center" class="wrap" style="padding:32px 16px 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
<tr><td style="padding:0 6px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
<td style="padding-right:10px;vertical-align:middle;font-size:24px;line-height:1;">${
esc(SITE_EMOJI)
}</td>
<td${
this.paint(
{ fg: p.brand ? "brand" : "text" },
`vertical-align:middle;font-family:${this.font};font-weight:700;font-size:${
this.fs(21)
};line-height:1;letter-spacing:-0.01em;`,
)
}>${esc(OG_SITE_NAME)}</td>
</tr></table>
</td></tr>
${
bar
? `<tr><td class="grad" style="height:5px;line-height:5px;font-size:0;background-color:${
bar[0]
};background-image:${
gradient(bar)
};border-radius:${R}px ${R}px 0 0;">&nbsp;</td></tr>`
: ""
}
<tr><td${
this.paint(
{
bg: "card",
...frameRoles,
extra: ["card", ...(frameRoles.extra ?? [])],
},
`border-radius:${
bar ? `0 0 ${R}px ${R}px` : `${R}px`
};${frame}padding:34px 36px 12px;`,
)
}>
${body}
</td></tr>
<tr><td${
this.paint(
{ fg: "muted" },
`padding:22px 6px 0;font-family:${this.font};font-size:${
this.fs(12.5)
};line-height:1.6;`,
)
}>${footer}</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
/**
* Returns the CSS that recolors every painted element in dark mode, or
* nothing off the "auto" scheme.
*/
private darkCss(): string {
const d = this.darkPalette;
if (!d) return "";
const rules: string[] = [];
for (const role of ROLES) {
const color = d[role];
if (!color) continue;
rules.push(
`.f-${role}{color:${color}!important}`,
`.b-${role}{background-color:${color}!important}`,
`.d-${role}{border-color:${color}!important}`,
);
}
rules.push(
`.sh-card{box-shadow:5px 5px 0 ${d.text}!important}`,
`.sh-btn{box-shadow:2px 2px 0 ${d.text}!important}`,
);
if (d.shadow) {
rules.push(
`.sh-soft{box-shadow:0 14px 34px -10px ${d.shadow}!important}`,
);
}
if (d.gradient) {
rules.push(
`.grad{background-color:${d.gradient[0]}!important;background-image:${
gradient(d.gradient)
}!important}`,
);
}
return `@media (prefers-color-scheme:dark){${rules.join("")}}`;
}
}