254 lines
8.1 KiB
TypeScript
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: "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<StyleName, EmailStyle>;
|