/** * 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>; /** * 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 { /** 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>; } const RECIPES: Record = { 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: "wght@400;600", 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;