Files
gerbeur/api/mail/styles.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

254 lines
8.1 KiB
TypeScript

/**
* Carries each site style into email, so a digest looks like the site its
* reader chose and the other emails like the instance's style. A mail client
* takes no stylesheet of ours, so each style is resolved here into plain
* values, which `layout.ts` inlines.
*
* What a theme defines is read from it through `theme-tokens.ts`, never
* copied: the colors, the gradient, the font and the corner radii. What is
* the email's own is stated here: how large it sets its type, how its card
* stands out, which divider it draws, and which of the theme's own tokens
* carry the touches that make it recognisable (Kanagawa's syntax hues,
* GeoCities' rainbow, Melange's spice and warm shadow).
*
* @module
*/
import { STYLE_NAMES, type StyleName } from "../../shared/theme.ts";
import { readTokens, type Scheme } from "./theme-tokens.ts";
/**
* The colors of one style in one scheme, by role, each read from a token of
* its theme. The optional ones carry a style's particular touches.
*/
export interface EmailPalette {
/** The page around the card (`--color-bg`). */
outer: string;
/** The card holding the content (`--color-surface`). */
card: string;
/** `--color-text`. */
text: string;
/** `--color-text-secondary`. */
sec: string;
/** `--color-text-muted`. */
muted: string;
/** Section titles, ranks and button fills (`--color-accent`). */
accent: string;
/** `--color-link`. */
link: string;
/** Text on an accent fill (`--color-on-accent`). */
onAccent: string;
/** Dividers and hairlines (`--color-border`). */
rule: string;
/** Pills and the reset link's box, as the site's badges (`--color-badge-bg`). */
soft: string;
/** Quoted comments and messages (`--color-text-secondary` unless set). */
quote: string;
/** Kanagawa's fujiGray dates; others use `muted`. */
date?: string;
/** Kanagawa's oniViolet wordmark; others use `text`. */
brand?: string;
/** GeoCities' ridged border. */
border?: string;
/** Melange's warm card shadow, translucent. */
shadow?: string;
/** The colors of GeoCities' rainbow or Melange's spice, left to right. */
gradient?: string[];
}
/**
* How a style draws an email. `layout.ts` builds every block from it, so
* adding a style to `shared/theme.ts` means adding its {@link Recipe} here.
*/
export interface EmailStyle {
/**
* The theme's font stack (`--font-body`). Its first family is a web font
* only Apple's mail apps load, through {@link webFont}; other clients fall
* back along the stack.
*/
font: string;
/** The Google Fonts `family` parameter of the web font, if it is one. */
webFont?: string;
/** Multiplies every font size: Times sets its serif larger, Kanagawa its monospace smaller. */
scale: number;
/**
* Corner radii, in pixels: the card's as the site's cards
* (`--radius-xl`), the images' as its thumbnails (`--radius-md`), the
* buttons' as its buttons (`--radius-lg`), the pills' as its badges
* (`--radius-sm`). The avatars' is the email's own, since the site rounds
* them with a literal 50% that some styles square.
*/
radius: {
card: number;
img: number;
btn: number;
pill: number;
avatar: number;
};
/** The heading: its size in pixels, weight, and Brutalist's uppercase and spacing. */
title: {
size: number;
weight: number;
upper?: boolean;
spacing?: string;
accent?: boolean;
};
/** The weight of titles of items, figures and names. */
heavy: number;
/** How the card stands out from the page. */
frame: "none" | "hard" | "ridge" | "hairline" | "soft";
/** The divider between sections; "gradient" draws the palette's. */
rule: "hairline" | "thick" | "gradient";
/** Draws the palette's gradient as a bar along the card's top. */
bar?: boolean;
/** Underlines links, for styles whose links are the color of the text. */
linkUnderline?: boolean;
light: EmailPalette;
dark: EmailPalette;
}
/** The roles every style reads, and the token each is read from. */
const PALETTE_TOKENS = {
outer: "--color-bg",
card: "--color-surface",
text: "--color-text",
sec: "--color-text-secondary",
muted: "--color-text-muted",
accent: "--color-accent",
link: "--color-link",
onAccent: "--color-on-accent",
rule: "--color-border",
soft: "--color-badge-bg",
quote: "--color-text-secondary",
} satisfies Partial<Record<keyof EmailPalette, string>>;
/**
* What a style states about its email, beside what its theme defines: the
* {@link EmailStyle} less the values read from the theme, the axes of its
* web font, its avatars' radius, and the tokens of its own touches.
*/
interface Recipe
extends Omit<EmailStyle, "font" | "webFont" | "radius" | "light" | "dark"> {
/** The Google Fonts axes of the theme's first family; none for a system font. */
webFontAxes?: string;
avatarRadius: number;
/** The tokens of the palette's optional roles, or of a base role it reads elsewhere. */
tokens?: Partial<Record<keyof EmailPalette, string>>;
}
const RECIPES: Record<StyleName, Recipe> = {
smooth: {
webFontAxes: "wght@400;600;700",
scale: 1,
avatarRadius: 16,
title: { size: 30, weight: 600 },
heavy: 600,
frame: "none",
rule: "hairline",
},
brutalist: {
webFontAxes: "wght@400;700",
scale: 1,
avatarRadius: 0,
title: { size: 30, weight: 900, upper: true, spacing: "0.03em" },
heavy: 700,
frame: "hard",
rule: "thick",
},
geocities: {
scale: 1,
avatarRadius: 0,
title: { size: 30, weight: 700, accent: true },
heavy: 700,
frame: "ridge",
rule: "gradient",
bar: true,
linkUnderline: true,
tokens: { border: "--color-border", gradient: "--geo-rainbow" },
},
nyt: {
webFontAxes: "ital@0;1",
scale: 1.12,
avatarRadius: 16,
title: { size: 38, weight: 400 },
heavy: 400,
frame: "hairline",
rule: "hairline",
linkUnderline: true,
},
kanagawa: {
webFontAxes: "ital,wght@0,400;0,600;1,400",
scale: 0.9,
avatarRadius: 4,
title: { size: 26, weight: 600 },
heavy: 600,
frame: "hairline",
rule: "hairline",
tokens: {
quote: "--kngw-string",
date: "--kngw-comment",
brand: "--kngw-keyword",
},
},
melange: {
webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700",
scale: 1,
avatarRadius: 16,
title: { size: 32, weight: 600 },
heavy: 600,
frame: "soft",
rule: "hairline",
bar: true,
tokens: { shadow: "--shadow-color", gradient: "--mel-spice" },
},
};
/**
* Returns the palette of `style` in `scheme` from its theme's tokens. Every
* color is flattened onto the card, as a translucent one shows on the site,
* but the shadow, which falls on the page.
*/
function readPalette(
style: StyleName,
scheme: Scheme,
own: Recipe["tokens"] = {},
): EmailPalette {
const tokens = readTokens(style, scheme);
const roles = { ...PALETTE_TOKENS, ...own };
const palette = Object.fromEntries(
Object.entries(roles)
.filter(([role]) => role !== "shadow" && role !== "gradient")
.map(([role, token]) => [role, tokens.color(token, "--color-surface")]),
) as unknown as EmailPalette;
if (own.shadow) palette.shadow = tokens.color(own.shadow);
if (own.gradient) palette.gradient = tokens.stops(own.gradient);
return palette;
}
/** Returns the email look of `style`, its theme read in both schemes. */
function readStyle(style: StyleName): EmailStyle {
const { webFontAxes, avatarRadius, tokens: own, ...recipe } = RECIPES[style];
const tokens = readTokens(style, "light");
const font = tokens.font("--font-body");
const family = font.split(",")[0].replace(/'/g, "").replaceAll(" ", "+");
return {
...recipe,
font,
webFont: webFontAxes ? `${family}:${webFontAxes}` : undefined,
radius: {
card: tokens.px("--radius-xl"),
img: tokens.px("--radius-md"),
btn: tokens.px("--radius-lg"),
pill: tokens.px("--radius-sm"),
avatar: avatarRadius,
},
light: readPalette(style, "light", own),
dark: readPalette(style, "dark", own),
};
}
/** Every style's email look, keyed by its name, read once at load. */
export const EMAIL_STYLES = Object.fromEntries(
STYLE_NAMES.map((style) => [style, readStyle(style)]),
) as Record<StyleName, EmailStyle>;