From fd0d81fe6610f7a91ad9745960de3f71b13c2db4 Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 5 Oct 2026 18:21:45 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20the=20emails'=20words=20live=20in=20the?= =?UTF-8?q?=20catalogs=20alone=20and=20their=20look=20is=20read=20from=20t?= =?UTF-8?q?he=20site's=20stylesheets=20=E2=80=94=20every=20email=20message?= =?UTF-8?q?=20was=20written=20as=20i18n.=5F({=20id,=20message=20}),=20so?= =?UTF-8?q?=20its=20English=20sat=20in=20the=20template=20and=20again=20in?= =?UTF-8?q?=20en.po,=20and=20api/mail/styles.ts=20held=20a=20copy=20of=20e?= =?UTF-8?q?ach=20theme's=20palette,=20fonts=20and=20radii=20that=20a=20tes?= =?UTF-8?q?t=20chased.=20The=20templates=20now=20name=20each=20message=20b?= =?UTF-8?q?y=20its=20id=20only=20(i18n.=5F("email.reset.title",=20values))?= =?UTF-8?q?=20and=20its=20English=20is=20written=20in=20locales/en.po,=20i?= =?UTF-8?q?ts=20French=20in=20fr.po;=20the=20four=20labels=20the=20setting?= =?UTF-8?q?s=20tab=20and=20the=20unsubscribe=20page=20share=20with=20the?= =?UTF-8?q?=20emails=20read=20the=20same=20ids=20through=20@lingui/react's?= =?UTF-8?q?=20Trans=20instead=20of=20restating=20the=20English.=20i18n:ext?= =?UTF-8?q?ract=20runs=20without=20--overwrite,=20which=20would=20write=20?= =?UTF-8?q?the=20id=20where=20the=20English=20is,=20and=20i18n:compile=20r?= =?UTF-8?q?uns=20--strict,=20so=20a=20language=20lacking=20a=20message=20f?= =?UTF-8?q?ails=20the=20build;=20it=20caught=20email.digest.hl.mentionIn,?= =?UTF-8?q?=20never=20translated=20into=20French.=20The=20new=20api/mail/t?= =?UTF-8?q?heme-tokens.ts=20reads=20src/tokens.css=20and=20src/themes/.css=20and=20resolves=20a=20token=20for=20one=20scheme=20(va?= =?UTF-8?q?r(),=20light-dark(),=20color-mix(),=20translucent=20colors=20la?= =?UTF-8?q?id=20over=20the=20card),=20and=20styles.ts=20names=20tokens=20f?= =?UTF-8?q?or=20every=20color,=20GeoCities'=20rainbow,=20Melange's=20spice?= =?UTF-8?q?=20and=20shadow,=20Kanagawa's=20syntax=20hues,=20the=20font=20s?= =?UTF-8?q?tack=20and=20the=20corner=20radii,=20keeping=20only=20what=20is?= =?UTF-8?q?=20the=20email's=20own=20(type=20scale,=20title,=20frame,=20div?= =?UTF-8?q?ider,=20avatar=20rounding).=20The=20copies=20had=20drifted:=20G?= =?UTF-8?q?eoCities'=20stack=20had=20lost=20Marker=20Felt=20and=20its=20li?= =?UTF-8?q?ght=20emails=20drew=20the=20dark=20rainbow,=20which=20now=20als?= =?UTF-8?q?o=20recolors=20its=20dividers=20on=20the=20"auto"=20scheme.=20T?= =?UTF-8?q?he=20pills=20take=20the=20site's=20badge=20color,=20now=20the?= =?UTF-8?q?=20--color-badge-bg=20token=20that=20the=20private=20badge=20re?= =?UTF-8?q?ads=20too.=20The=20Dockerfile=20copies=20src/tokens.css=20and?= =?UTF-8?q?=20src/themes/=20into=20the=20runtime=20image,=20which=20reads?= =?UTF-8?q?=20them=20at=20load.=20The=20catalogs=20were=20re-extracted,=20?= =?UTF-8?q?having=20been=20stale=20against=20the=20code.=20240=20backend?= =?UTF-8?q?=20tests=20(new:=20the=20token=20reader,=20the=20dark=20rainbow?= =?UTF-8?q?;=20the=20palette-copy=20test=20is=20gone)=20and=20661=20fronte?= =?UTF-8?q?nd=20tests=20pass,=20and=20tsc,=20deno=20check=20and=20doc:lint?= =?UTF-8?q?=20are=20clean;=20sendDueDigests'=20empty-digest=20test=20fails?= =?UTF-8?q?=20now=20and=20then,=20as=20it=20did=20before.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 3 + api/mail/digest.ts | 295 ++----- api/mail/i18n.ts | 12 +- api/mail/layout.ts | 34 +- api/mail/password-reset.ts | 54 +- api/mail/styles.ts | 356 ++++---- api/mail/theme-tokens.ts | 300 +++++++ api/mail/welcome.ts | 40 +- api/test/mail/layout.test.ts | 7 + api/test/mail/styles.test.ts | 40 - api/test/mail/theme-tokens.test.ts | 81 ++ deno.json | 4 +- lingui.config.ts | 4 +- locales/en.mjs | 2 +- locales/en.po | 775 +++++++++--------- locales/fr.mjs | 2 +- locales/fr.po | 775 +++++++++--------- mod.ts | 9 +- shared/theme.ts | 3 +- src/App.css | 2 +- src/components/profile/ProfileSettingsTab.tsx | 7 +- src/pages/Unsubscribe.tsx | 11 +- src/tokens.css | 5 + 23 files changed, 1419 insertions(+), 1402 deletions(-) create mode 100644 api/mail/theme-tokens.ts delete mode 100644 api/test/mail/styles.test.ts create mode 100644 api/test/mail/theme-tokens.test.ts diff --git a/Dockerfile b/Dockerfile index f6054c02..d40bcdef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,9 @@ COPY api/ ./api/ COPY shared/ ./shared/ COPY --from=builder /app/dist/ ./dist/ COPY --from=builder /app/locales/ ./locales/ +# The emails read their colors, fonts and radii from the site's stylesheets. +COPY src/tokens.css ./src/ +COPY src/themes/ ./src/themes/ # Persistent data: database and uploaded/generated files must be mounted as volumes. VOLUME ["/app/api/sql", "/app/api/uploads"] diff --git a/api/mail/digest.ts b/api/mail/digest.ts index 6b25e7b5..5e82519d 100644 --- a/api/mail/digest.ts +++ b/api/mail/digest.ts @@ -121,41 +121,17 @@ const ACTIVITY_ORDER: ActivityKind[] = [ function countLabel(kind: ActivityKind, n: number): string { switch (kind) { case "upvotes": - return i18n._({ - id: "email.digest.count.upvotes", - message: "{n, plural, one {upvote} other {upvotes}}", - values: { n }, - }); + return i18n._("email.digest.count.upvotes", { n }); case "followers": - return i18n._({ - id: "email.digest.count.followers", - message: "{n, plural, one {new follower} other {new followers}}", - values: { n }, - }); + return i18n._("email.digest.count.followers", { n }); case "comments": - return i18n._({ - id: "email.digest.count.comments", - message: "{n, plural, one {comment} other {comments}}", - values: { n }, - }); + return i18n._("email.digest.count.comments", { n }); case "mentions": - return i18n._({ - id: "email.digest.count.mentions", - message: "{n, plural, one {mention} other {mentions}}", - values: { n }, - }); + return i18n._("email.digest.count.mentions", { n }); case "likes": - return i18n._({ - id: "email.digest.count.likes", - message: "{n, plural, one {comment like} other {comment likes}}", - values: { n }, - }); + return i18n._("email.digest.count.likes", { n }); case "playlistFollows": - return i18n._({ - id: "email.digest.count.playlistFollows", - message: "{n, plural, one {playlist follow} other {playlist follows}}", - values: { n }, - }); + return i18n._("email.digest.count.playlistFollows", { n }); } } @@ -171,85 +147,34 @@ function highlightSentence( const values = { who, target }; switch (kind) { case "mentionComment": - return i18n._({ - id: "email.digest.hl.mentionComment", - message: "{who} mentioned you in a comment on {target}", - values, - }); + return i18n._("email.digest.hl.mentionComment", values); case "mentionDump": case "mentionChat": - return i18n._({ - id: "email.digest.hl.mentionIn", - message: "{who} mentioned you in {target}", - values, - }); + return i18n._("email.digest.hl.mentionIn", values); case "mentionPlaylist": - return i18n._({ - id: "email.digest.hl.mentionPlaylist", - message: "{who} mentioned you in the playlist {target}", - values, - }); + return i18n._("email.digest.hl.mentionPlaylist", values); case "comment": - return i18n._({ - id: "email.digest.hl.comment", - message: "{who} commented on your dump {target}", - values, - }); + return i18n._("email.digest.hl.comment", values); case "playlistFollow": - return i18n._({ - id: "email.digest.hl.playlistFollow", - message: "{who} followed your playlist {target}", - values, - }); + return i18n._("email.digest.hl.playlistFollow", values); case "userFollow": - return i18n._({ - id: "email.digest.hl.userFollow", - message: "{who} started following you", - values, - }); + return i18n._("email.digest.hl.userFollow", values); case "commentLike": - return i18n._({ - id: "email.digest.hl.commentLike", - message: "{who} liked your comment on {target}", - values, - }); + return i18n._("email.digest.hl.commentLike", values); } } -const byLine = (username: string) => - i18n._({ - id: "email.digest.by", - message: "by @{username}", - values: { username }, - }); +const byLine = (username: string) => i18n._("email.digest.by", { username }); const addedToLine = (playlist: string) => - i18n._({ - id: "email.digest.addedTo", - message: "added to {playlist}", - values: { playlist }, - }); -const uploadLabel = () => - i18n._({ id: "email.digest.upload", message: "upload" }); + i18n._("email.digest.addedTo", { playlist }); +const uploadLabel = () => i18n._("email.digest.upload"); const invitedLine = (inviter: string, reader: string) => inviter === reader - ? i18n._({ id: "email.digest.invitedByYou", message: "invited by you" }) - : i18n._({ - id: "email.digest.invitedBy", - message: "invited by @{username}", - values: { username: inviter }, - }); -const andMoreLine = (n: number) => - i18n._({ - id: "email.digest.andMore", - message: "and {n} more", - values: { n }, - }); + ? i18n._("email.digest.invitedByYou") + : i18n._("email.digest.invitedBy", { username: inviter }); +const andMoreLine = (n: number) => i18n._("email.digest.andMore", { n }); const playlistMeta = (owner: string, n: number) => - i18n._({ - id: "email.digest.playlistMeta", - message: "by @{username} · {n, plural, one {# dump} other {# dumps}}", - values: { username: owner, n }, - }); + i18n._("email.digest.playlistMeta", { username: owner, n }); /** * Returns the digest `content` describes, written as Markdown and drawn in @@ -272,7 +197,7 @@ export function renderDigestEmail( new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d); const figure = (n: number, label: string) => `**${nf.format(n)}** ${escMd(label)}`; - const yours = i18n._({ id: "email.digest.yours", message: "yours" }); + const yours = i18n._("email.digest.yours"); /** A dump as a list item: its thumbnail, title, pill and detail. */ const dumpItem = (d: DigestDump, bullet: string, image = true) => `${bullet} ${image && d.thumbnail ? `![](${d.thumbnail}) ` : ""}${ @@ -290,32 +215,16 @@ export function renderDigestEmail( const md: string[] = [ `*${ escMd( - `${ - monthly - ? i18n._({ - id: "digest.monthly", - message: "Monthly recap", - }) - : i18n._({ - id: "digest.weekly", - message: "Weekly digest", - }) - } · ${range(true)}`, + `${monthly ? i18n._("digest.monthly") : i18n._("digest.weekly")} · ${ + range(true) + }`, ) }*`, `# ${ escMd( monthly - ? i18n._({ - id: "email.digest.monthly.title", - message: "The month on {site}", - values: { site: OG_SITE_NAME }, - }) - : i18n._({ - id: "email.digest.weekly.title", - message: "Your week, {username}", - values: { username: content.reader }, - }), + ? i18n._("email.digest.monthly.title", { site: OG_SITE_NAME }) + : i18n._("email.digest.weekly.title", { username: content.reader }), ) }`, ]; @@ -323,7 +232,7 @@ export function renderDigestEmail( const { activity, network, community } = content; if (activity) { md.push( - `## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`, + `## ${escMd(i18n._("email.digest.forYou"))}`, ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0) .map((k) => figure(activity.counts[k]!, countLabel(k, activity.counts[k]!)) @@ -332,7 +241,7 @@ export function renderDigestEmail( activity.highlights.map((h) => { const target = h.kind === "mentionChat" ? mdLink( - i18n._({ id: "email.digest.theChat", message: "the chat" }), + i18n._("email.digest.theChat"), content.links.chat, ) : h.target @@ -345,12 +254,7 @@ export function renderDigestEmail( }).join("\n"), `${ mdLink( - i18n._({ - id: "email.digest.seeAll", - message: - "{n, plural, one {See the notification} other {See all # notifications}}", - values: { n: activity.total }, - }), + i18n._("email.digest.seeAll", { n: activity.total }), content.links.notifications, ) } →`, @@ -359,22 +263,13 @@ export function renderDigestEmail( if (network) { md.push( - `## ${ - escMd(i18n._({ - id: "email.digest.network", - message: "From people you follow", - })) - }`, + `## ${escMd(i18n._("email.digest.network"))}`, network.dumps.map((d) => dumpItem(d, "-")).join("\n"), ); if (network.more > 0) { md.push(`${ mdLink( - i18n._({ - id: "email.digest.more", - message: "{n} more on your Followed feed", - values: { n: network.more }, - }), + i18n._("email.digest.more", { n: network.more }), content.links.followed, ) } →`); @@ -387,8 +282,8 @@ export function renderDigestEmail( `## ${ escMd( monthly - ? i18n._({ id: "email.digest.topMonth", message: "Top this month" }) - : i18n._({ id: "email.digest.topWeek", message: "Top this week" }), + ? i18n._("email.digest.topMonth") + : i18n._("email.digest.topWeek"), ) }`, ); @@ -404,30 +299,20 @@ export function renderDigestEmail( if (community) { const { dumps, upvotes, comments } = community.totals; md.push( - `## ${ - escMd(i18n._({ id: "email.digest.community", message: "Community" })) - }`, + `## ${escMd(i18n._("email.digest.community"))}`, ); if (monthly) { md.push( [ figure( dumps, - i18n._({ - id: "email.digest.stat.dumps", - message: "{n, plural, one {dump} other {dumps}}", - values: { n: dumps }, - }), + i18n._("email.digest.stat.dumps", { n: dumps }), ), figure(upvotes, countLabel("upvotes", upvotes)), figure(comments, countLabel("comments", comments)), figure( community.chatMessages, - i18n._({ - id: "email.digest.stat.chat", - message: "{n, plural, one {chat message} other {chat messages}}", - values: { n: community.chatMessages }, - }), + i18n._("email.digest.stat.chat", { n: community.chatMessages }), ), ].join(" · "), ); @@ -436,7 +321,7 @@ export function renderDigestEmail( md.push( `### ${ escMd( - i18n._({ id: "email.digest.newMembers", message: "New members" }), + i18n._("email.digest.newMembers"), ) }`, [ @@ -455,12 +340,7 @@ export function renderDigestEmail( } if (community.playlists.length > 0) { md.push( - `### ${ - escMd(i18n._({ - id: "email.digest.newPlaylists", - message: "New public playlists", - })) - }`, + `### ${escMd(i18n._("email.digest.newPlaylists"))}`, [ ...community.playlists.map((p) => `- ${mdLink(p.title, p.url)} · ${ @@ -475,33 +355,23 @@ export function renderDigestEmail( } if (!monthly && community.chatMessages > 0) { md.push( - `### ${ - escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" })) - }`, + `### ${escMd(i18n._("email.digest.inChat"))}`, `${ - escMd(i18n._({ - id: "email.digest.chatWeek", - message: - "{n, plural, one {# message this week} other {# messages this week}}", - values: { n: community.chatMessages }, - })) + escMd(i18n._("email.digest.chatWeek", { n: community.chatMessages })) } · ${ mdLink( - i18n._({ id: "email.digest.openChat", message: "Open the chat" }), + i18n._("email.digest.openChat"), content.links.chat, ) }`, ); } if (!monthly && dumps + upvotes + comments > 0) { - md.push(`*${ - escMd(i18n._({ - id: "email.digest.totals", - message: - "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.", - values: { dumps, upvotes, comments }, - })) - }*`); + md.push( + `*${ + escMd(i18n._("email.digest.totals", { dumps, upvotes, comments })) + }*`, + ); } } @@ -509,33 +379,17 @@ export function renderDigestEmail( "---", escMd( monthly - ? i18n._({ - id: "email.digest.monthly.reason", - message: - "You're getting the monthly recap because it's on in your settings.", - }) - : i18n._({ - id: "email.digest.weekly.reason", - message: - "You're getting the weekly digest because it's on in your settings.", - }), + ? i18n._("email.digest.monthly.reason") + : i18n._("email.digest.weekly.reason"), ), `${ mdLink( - monthly - ? i18n._({ - id: "digest.monthly.off", - message: "Turn off monthly recaps", - }) - : i18n._({ - id: "digest.weekly.off", - message: "Turn off weekly digests", - }), + monthly ? i18n._("digest.monthly.off") : i18n._("digest.weekly.off"), content.links.unsubscribe, ) } · ${ mdLink( - i18n._({ id: "email.settings", message: "Settings" }), + i18n._("email.settings"), content.links.settings, ) }`, @@ -543,15 +397,13 @@ export function renderDigestEmail( return new EmailKit(look).render({ subject: monthly - ? i18n._({ - id: "email.digest.monthly.subject", - message: "The month on {site}, {range}", - values: { site: OG_SITE_NAME, range: range(false) }, + ? i18n._("email.digest.monthly.subject", { + site: OG_SITE_NAME, + range: range(false), }) - : i18n._({ - id: "email.digest.weekly.subject", - message: "Your week on {site}, {range}", - values: { site: OG_SITE_NAME, range: range(false) }, + : i18n._("email.digest.weekly.subject", { + site: OG_SITE_NAME, + range: range(false), }), preheader: preheader(content), markdown: md.join("\n\n"), @@ -572,12 +424,7 @@ function preheader(content: DigestContent): string { const n = (content.network?.dumps.length ?? 0) + (content.network?.more ?? 0); if (n > 0) { - parts.push(i18n._({ - id: "email.digest.pre.network", - message: - "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}", - values: { n }, - })); + parts.push(i18n._("email.digest.pre.network", { n })); } } else { const members = (content.community?.members.length ?? 0) + @@ -585,35 +432,19 @@ function preheader(content: DigestContent): string { const playlists = (content.community?.playlists.length ?? 0) + (content.community?.playlistsMore ?? 0); if (content.top.length > 0) { - parts.push(i18n._({ - id: "email.digest.pre.topMonth", - message: - "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}", - values: { n: content.top.length }, - })); + parts.push( + i18n._("email.digest.pre.topMonth", { n: content.top.length }), + ); } if (members > 0) { - parts.push(i18n._({ - id: "email.digest.pre.members", - message: "{n, plural, one {# new member} other {# new members}}", - values: { n: members }, - })); + parts.push(i18n._("email.digest.pre.members", { n: members })); } if (playlists > 0) { - parts.push(i18n._({ - id: "email.digest.pre.playlists", - message: "{n, plural, one {# new playlist} other {# new playlists}}", - values: { n: playlists }, - })); + parts.push(i18n._("email.digest.pre.playlists", { n: playlists })); } } if (parts.length === 0 && content.top.length > 0) { - parts.push(i18n._({ - id: "email.digest.pre.topWeek", - message: - "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}", - values: { n: content.top.length }, - })); + parts.push(i18n._("email.digest.pre.topWeek", { n: content.top.length })); } if (parts.length === 0) return ""; const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" }) diff --git a/api/mail/i18n.ts b/api/mail/i18n.ts index b9c0da9d..c38aef2b 100644 --- a/api/mail/i18n.ts +++ b/api/mail/i18n.ts @@ -1,10 +1,12 @@ /** * Lets the emails speak the instance's language from the same catalogs as - * the interface. Their messages are written in `api/mail/` as - * `i18n._({ id, message }, values)`, which `lingui extract` collects into - * `locales/.po` beside the interface's and `lingui compile` turns into - * the `locales/.mjs` imported here. The ids are spelled out, since the - * server runs no Lingui macro to hash them. + * the interface. A template names each message by its id, as + * `i18n._("email.reset.title", values)`, and its words, English included, + * are written in `locales/.po` only. `lingui extract` lists the ids it + * finds there beside the interface's messages, and `lingui compile` turns + * the catalogs into the `locales/.mjs` imported here, failing when a + * language lacks one. The ids are named rather than hashed from the English, + * since the server runs no Lingui macro. * * @module */ diff --git a/api/mail/layout.ts b/api/mail/layout.ts index 9e9f83eb..71d3bf16 100644 --- a/api/mail/layout.ts +++ b/api/mail/layout.ts @@ -27,12 +27,7 @@ import { type StyleName, } from "../../shared/theme.ts"; import type { UserSettings } from "../../shared/settings.ts"; -import { - EMAIL_STYLES, - type EmailPalette, - type EmailStyle, - RAINBOW, -} from "./styles.ts"; +import { EMAIL_STYLES, type EmailPalette, type EmailStyle } from "./styles.ts"; /** * Returned by each template to its caller, which hands it to `sendEmail` @@ -75,7 +70,7 @@ export function lookOf( * 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; +export type PaletteRole = Exclude; const ROLES: PaletteRole[] = [ "outer", @@ -325,13 +320,12 @@ export class EmailKit { let divider = ""; let border = ""; if (!first) { - if (s.rule === "rainbow") { + const stops = this.palette.gradient; + if (s.rule === "gradient" && stops) { divider = - `
 
`; + `
 
`; } else { border = `border-top:${ s.rule === "thick" ? 2 : 1 @@ -682,11 +676,7 @@ export class EmailKit { const s = this.style; const p = this.palette; const R = s.radius.card; - const bar = s.topBar === "rainbow" - ? RAINBOW - : s.topBar === "spice" - ? p.spice ?? null - : null; + 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" @@ -756,7 +746,7 @@ ${this.darkCss()} ${ bar - ? `>; -/** Every style's email look, keyed by its name. */ -export const EMAIL_STYLES: Record = { +/** + * 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: { - font: "Saira, 'Helvetica Neue', Helvetica, Arial, sans-serif", - webFont: "Saira:wght@400;600;700", + webFontAxes: "wght@400;600;700", scale: 1, - radius: { card: 14, img: 6, btn: 8, pill: 999, avatar: 16 }, + avatarRadius: 16, title: { size: 30, weight: 600 }, heavy: 600, frame: "none", rule: "hairline", - light: { - outer: "#faf5e9", - card: "#fffdf8", - text: "#0e3a42", - sec: "#35626b", - muted: "#4d7178", - accent: "#b54a41", - link: "#b54a41", - onAccent: "#fffaf0", - rule: "#ebe4d4", - soft: "#f3ecdd", - quote: "#5c7d83", - }, - dark: { - outer: "#01152a", - card: "#0a2139", - text: "#e9eef2", - sec: "#a3b9c7", - muted: "#7b93a6", - accent: "#f4d58d", - link: "#f4d58d", - onAccent: "#001427", - rule: "#1b3350", - soft: "#112a46", - quote: "#a3b9c7", - }, }, brutalist: { - font: "'Space Grotesk', Arial, Helvetica, sans-serif", - webFont: "Space+Grotesk:wght@400;700", + webFontAxes: "wght@400;700", scale: 1, - radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 0 }, + avatarRadius: 0, title: { size: 30, weight: 900, upper: true, spacing: "0.03em" }, heavy: 700, frame: "hard", rule: "thick", - light: { - outer: "#f4f1de", - card: "#f8f5e8", - text: "#3d405b", - sec: "#5a5e80", - muted: "#7a7e9a", - accent: "#e07a5f", - link: "#b85a3a", - onAccent: "#f4f1de", - rule: "#8a8da8", - soft: "#ede9d4", - quote: "#5a5e80", - }, - dark: { - outer: "#252838", - card: "#2e3145", - text: "#f4f1de", - sec: "#c8c5b2", - muted: "#8e8c80", - accent: "#f2cc8f", - link: "#f2cc8f", - onAccent: "#3d405b", - rule: "#4a4e6a", - soft: "#222436", - quote: "#c8c5b2", - }, }, geocities: { - font: - "'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', 'Comic Neue', cursive", scale: 1, - radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 0 }, + avatarRadius: 0, title: { size: 30, weight: 700, accent: true }, heavy: 700, frame: "ridge", - rule: "rainbow", - topBar: "rainbow", + rule: "gradient", + bar: true, linkUnderline: true, - light: { - outer: "#fff0f8", - card: "#ffffff", - text: "#2a1a4e", - sec: "#aa00aa", - muted: "#7a6a9a", - accent: "#d000d0", - link: "#0000ee", - onAccent: "#ffffff", - rule: "#f0c0f0", - soft: "#fff0fb", - quote: "#aa00aa", - border: "#d000d0", - }, - dark: { - outer: "#0a0a2e", - card: "#1b1b46", - text: "#f2f0ff", - sec: "#7fdbff", - muted: "#a99ad6", - accent: "#ff00ff", - link: "#00ffff", - onAccent: "#000000", - rule: "#34346e", - soft: "#14143a", - quote: "#7fdbff", - border: "#00ffff", - }, + tokens: { border: "--color-border", gradient: "--geo-rainbow" }, }, nyt: { - font: "'Instrument Serif', Georgia, 'Times New Roman', serif", - webFont: "Instrument+Serif:ital@0;1", + webFontAxes: "ital@0;1", scale: 1.12, - radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 16 }, + avatarRadius: 16, title: { size: 38, weight: 400 }, heavy: 400, frame: "hairline", rule: "hairline", linkUnderline: true, - light: { - outer: "#ffffff", - card: "#ffffff", - text: "#121212", - sec: "#5a5a5a", - muted: "#767676", - accent: "#121212", - link: "#1a1a1a", - onAccent: "#ffffff", - rule: "#e2e2e2", - soft: "#f3f3f3", - quote: "#5a5a5a", - }, - dark: { - outer: "#121212", - card: "#1e1e1e", - text: "#e6e6e6", - sec: "#ababab", - muted: "#8a8a8a", - accent: "#ececec", - link: "#ececec", - onAccent: "#121212", - rule: "#3a3a3a", - soft: "#2a2a2a", - quote: "#ababab", - }, }, kanagawa: { - font: "'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace", - webFont: "JetBrains+Mono:ital,wght@0,400;0,600;1,400", + webFontAxes: "ital,wght@0,400;0,600;1,400", scale: 0.9, - radius: { card: 6, img: 4, btn: 4, pill: 3, avatar: 4 }, + avatarRadius: 4, title: { size: 26, weight: 600 }, heavy: 600, frame: "hairline", rule: "hairline", - light: { - outer: "#f2ecbc", - card: "#e7dfb2", - text: "#545464", - sec: "#716e61", - muted: "#766b90", - accent: "#4d699b", - link: "#4d699b", - onAccent: "#f2ecbc", - rule: "#d0c79d", - soft: "#dcd5ac", - quote: "#6f894e", - date: "#8a8980", - brand: "#624c83", - }, - dark: { - outer: "#1f1f28", - card: "#2a2a37", - text: "#dcd7ba", - sec: "#c8c093", - muted: "#938aa9", - accent: "#7e9cd8", - link: "#7e9cd8", - onAccent: "#16161d", - rule: "#43435a", - soft: "#16161d", - quote: "#98bb6c", - date: "#727169", - brand: "#957fb8", + tokens: { + quote: "--kngw-string", + date: "--kngw-comment", + brand: "--kngw-keyword", }, }, melange: { - font: "Fraunces, Georgia, serif", - webFont: "Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700", + webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700", scale: 1, - radius: { card: 18, img: 10, btn: 12, pill: 999, avatar: 16 }, + avatarRadius: 16, title: { size: 32, weight: 600 }, heavy: 600, frame: "soft", rule: "hairline", - topBar: "spice", - light: { - outer: "#e9e1db", - card: "#f6f2ef", - text: "#54433a", - sec: "#7d6658", - muted: "#a98a78", - accent: "#a06d00", - link: "#a06d00", - onAccent: "#f7f4f1", - rule: "#e0d3ca", - soft: "#ece4dd", - quote: "#7d6658", - shadow: "rgba(84,67,58,0.28)", - spice: ["#a06d00", "#bc5c00", "#c77b8b", "#be79bb", "#6e9b72"], - }, - dark: { - outer: "#292522", - card: "#34302c", - text: "#ece1d7", - sec: "#c1a78e", - muted: "#867462", - accent: "#ebc06d", - link: "#ebc06d", - onAccent: "#292522", - rule: "#4a423b", - soft: "#403a36", - quote: "#c1a78e", - shadow: "rgba(0,0,0,0.5)", - spice: ["#ebc06d", "#e49b5d", "#bd8183", "#b380b0", "#78997a"], - }, + 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; diff --git a/api/mail/theme-tokens.ts b/api/mail/theme-tokens.ts new file mode 100644 index 00000000..9690cccf --- /dev/null +++ b/api/mail/theme-tokens.ts @@ -0,0 +1,300 @@ +/** + * Lets the emails draw with the site's own values. It reads a style's design + * tokens out of the stylesheets the client loads, `src/tokens.css` and then + * `src/themes/