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>
This commit is contained in:
khannurien
2026-10-05 18:21:45 +00:00
parent 7960d74c55
commit fd0d81fe66
23 changed files with 1419 additions and 1402 deletions

View File

@@ -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"]

View File

@@ -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" })

View File

@@ -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/<lang>.po` beside the interface's and `lingui compile` turns into
* the `locales/<lang>.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/<lang>.po` only. `lingui extract` lists the ids it
* finds there beside the interface's messages, and `lingui compile` turns
* the catalogs into the `locales/<lang>.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
*/

View File

@@ -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<keyof EmailPalette, "spice">;
export type PaletteRole = Exclude<keyof EmailPalette, "gradient">;
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 =
`<div style="height:3px;line-height:3px;font-size:0;background-color:${
RAINBOW[0]
};background-image:${
gradient(RAINBOW)
};margin:0 0 26px;">&nbsp;</div>`;
`<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
@@ -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()}
</td></tr>
${
bar
? `<tr><td class="bar" style="height:5px;line-height:5px;font-size:0;background-color:${
? `<tr><td class="grad" style="height:5px;line-height:5px;font-size:0;background-color:${
bar[0]
};background-image:${
gradient(bar)
@@ -818,10 +808,10 @@ ${body}
`.sh-soft{box-shadow:0 14px 34px -10px ${d.shadow}!important}`,
);
}
if (this.style.topBar === "spice" && d.spice) {
if (d.gradient) {
rules.push(
`.bar{background-color:${d.spice[0]}!important;background-image:${
gradient(d.spice)
`.grad{background-color:${d.gradient[0]}!important;background-image:${
gradient(d.gradient)
}!important}`,
);
}

View File

@@ -26,59 +26,23 @@ export function renderPasswordResetEmail(
hours: number,
): RenderedEmail {
const markdown = [
`# ${
escMd(i18n._({ id: "email.reset.title", message: "Reset your password" }))
}`,
escMd(i18n._({
id: "email.reset.hi",
message: "Hi {username},",
values: { username },
})),
escMd(i18n._({
id: "email.reset.body",
message:
"Someone asked to reset the password of your {site} account. The button below lets you choose a new one. It works once, for {hours, plural, one {# hour} other {# hours}}.",
values: { site: OG_SITE_NAME, hours },
})),
`# ${escMd(i18n._("email.reset.title"))}`,
escMd(i18n._("email.reset.hi", { username })),
escMd(i18n._("email.reset.body", { site: OG_SITE_NAME, hours })),
mdLink(
i18n._({ id: "email.reset.button", message: "Reset password" }),
i18n._("email.reset.button"),
resetUrl,
),
`*${
escMd(i18n._({
id: "email.reset.fallback",
message:
"If the button doesn't work, paste this link into your browser:",
}))
}*`,
`*${escMd(i18n._("email.reset.fallback"))}*`,
"```\n" + resetUrl + "\n```",
`*${
escMd(i18n._({
id: "email.reset.ignore",
message:
"If you didn't ask for this, ignore this email. Your password stays the same.",
}))
}*`,
`*${escMd(i18n._("email.reset.ignore"))}*`,
"---",
escMd(i18n._({
id: "email.reset.reason",
message:
"You're getting this email because a password reset was requested for this address.",
})),
escMd(i18n._("email.reset.reason")),
].join("\n\n");
return new EmailKit(lookOf()).render({
subject: i18n._({
id: "email.reset.subject",
message: "Reset your {site} password",
values: { site: OG_SITE_NAME },
}),
preheader: i18n._({
id: "email.reset.preheader",
message:
"The link works once, for {hours, plural, one {# hour} other {# hours}}.",
values: { hours },
}),
subject: i18n._("email.reset.subject", { site: OG_SITE_NAME }),
preheader: i18n._("email.reset.preheader", { hours }),
markdown,
});
}

View File

@@ -1,38 +1,48 @@
/**
* 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 spelled out here: its palettes,
* copied from its theme under `src/themes/` and flattened to plain colors, and
* the few touches that make it recognisable once inlined. `layout.ts` reads
* them. A test fails when a copied color no longer matches its theme's.
* 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 type { StyleName } from "../../shared/theme.ts";
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. Most are the theme's own
* tokens; the optional ones carry a style's particular touches.
* 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. */
/** Text on an accent fill (`--color-on-accent`). */
onAccent: string;
/** Dividers and hairlines. */
/** Dividers and hairlines (`--color-border`). */
rule: string;
/** Pills and the reset link's box. */
/** Pills and the reset link's box, as the site's badges (`--color-badge-bg`). */
soft: string;
/** Quoted comments and messages. */
/** Quoted comments and messages (`--color-text-secondary` unless set). */
quote: string;
/** Kanagawa's fujiGray dates; others use `muted`. */
date?: string;
@@ -40,27 +50,34 @@ export interface EmailPalette {
brand?: string;
/** GeoCities' ridged border. */
border?: string;
/** Melange's warm card shadow. */
/** Melange's warm card shadow, translucent. */
shadow?: string;
/** Melange's spice gradient, left to right. */
spice?: 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 entry here.
* adding a style to `shared/theme.ts` means adding its {@link Recipe} here.
*/
export interface EmailStyle {
/**
* The font stack. Its first family is a web font only Apple's mail apps
* load, through {@link webFont}; other clients fall back along the stack.
* 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. */
/**
* 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;
@@ -80,256 +97,157 @@ export interface EmailStyle {
heavy: number;
/** How the card stands out from the page. */
frame: "none" | "hard" | "ridge" | "hairline" | "soft";
/** The divider between sections. */
rule: "hairline" | "thick" | "rainbow";
/** A colored bar along the card's top. */
topBar?: "rainbow" | "spice";
/** 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;
}
/** GeoCities' rainbow, as its theme draws its dividers. */
export const RAINBOW = [
"#ff004d",
"#ff8a00",
"#ffee00",
"#00d11a",
"#00b3ff",
"#b300ff",
];
/** 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>>;
/** Every style's email look, keyed by its name. */
export const EMAIL_STYLES: Record<StyleName, EmailStyle> = {
/**
* 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: {
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<StyleName, EmailStyle>;

300
api/mail/theme-tokens.ts Normal file
View File

@@ -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/<style>.css`, so `styles.ts` names a token wherever it needs a
* color, a gradient, a font or a radius, and a change to a theme reaches its
* emails with nothing copied to keep in step.
*
* Mail clients know no custom properties, so a token comes back resolved for
* one scheme: its `var()`s followed, its `light-dark()` picked, its
* `color-mix()` computed. Only the rules that set tokens on the root element
* are read (`:root`, the style's `[data-style]`, and either of those with the
* scheme's `[data-color-scheme]`); a theme states its forced schemes that way
* beside each `prefers-color-scheme` block, so the media blocks are skipped.
*
* @module
*/
import { BASE_STYLE, type StyleName } from "../../shared/theme.ts";
/** A scheme a token is resolved for. "auto" is drawn from both. */
export type Scheme = "light" | "dark";
/** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Returned by {@link readTokens}: the tokens of one style in one scheme, read
* as the kind of value each is.
*/
export interface ThemeTokens {
/** Returns the resolved value of `token`, or throws when it isn't set. */
value(token: string): string;
/**
* Returns the color of `token` as `#rrggbb`. A translucent one is laid over
* `over` (another token) first, as it shows on the site; without `over` it
* stays `rgba()`, as a shadow's must.
*/
color(token: string, over?: string): string;
/** Returns the colors of the gradient `token`, in order, without positions. */
stops(token: string): string[];
/**
* Returns the font stack of `token`, single-quoted for a `style` attribute.
* A family the site bundles as "X Variable" is named "X", as a mail client
* or Google Fonts knows it.
*/
font(token: string): string;
/** Returns the length of `token` in pixels. */
px(token: string): number;
}
const SOURCES = ["../../src/tokens.css", "../../src/themes/{style}.css"];
/** Returns `css` without its comments. */
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, "");
}
/** Returns the top-level rules of `css` as their selector and body. */
function topRules(css: string): { selector: string; body: string }[] {
const rules = [];
let i = 0;
while (i < css.length) {
const open = css.indexOf("{", i);
if (open < 0) break;
let depth = 1;
let j = open + 1;
while (depth && j < css.length) {
if (css[j] === "{") depth++;
else if (css[j] === "}") depth--;
j++;
}
rules.push({
selector: css.slice(i, open).trim(),
body: css.slice(open + 1, j - 1),
});
i = j;
}
return rules;
}
/**
* Splits `text` at each `sep` outside parentheses and quotes, trimming the
* parts: the arguments of a CSS function, or the declarations of a rule.
*/
function splitTop(text: string, sep: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) {
if (c === quote) quote = "";
} else if (c === '"' || c === "'") quote = c;
else if (c === "(") depth++;
else if (c === ")") depth--;
else if (c === sep && depth === 0) {
parts.push(text.slice(start, i).trim());
start = i + 1;
}
}
parts.push(text.slice(start).trim());
return parts.filter((part) => part !== "");
}
/**
* Replaces each call of the CSS function `name` in `value`, innermost last
* first, with what `replace` returns for its arguments.
*/
function replaceCalls(
value: string,
name: string,
replace: (args: string[]) => string,
): string {
for (;;) {
const at = value.lastIndexOf(`${name}(`);
if (at < 0) return value;
let depth = 0;
let end = at + name.length;
do {
if (value[end] === "(") depth++;
else if (value[end] === ")") depth--;
end++;
} while (depth && end < value.length);
const args = splitTop(value.slice(at + name.length + 1, end - 1), ",");
value = value.slice(0, at) + replace(args) + value.slice(end);
}
}
/** Parses a CSS color in the forms the themes write, or throws. */
function parseColor(text: string): Rgba {
const value = text.trim().toLowerCase();
if (value === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
const hex = value.match(/^#([0-9a-f]{3,8})$/)?.[1];
if (hex && hex.length !== 5 && hex.length !== 7) {
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
const channel = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16);
return {
r: channel(0),
g: channel(1),
b: channel(2),
a: full.length === 8 ? channel(3) / 255 : 1,
};
}
const rgb = value.match(/^rgba?\((.*)\)$/)?.[1];
if (rgb) {
const [r, g, b, a = "1"] = rgb.split(/[\s,/]+/).filter(Boolean);
const alpha = a.endsWith("%") ? parseFloat(a) / 100 : parseFloat(a);
return { r: +r, g: +g, b: +b, a: alpha };
}
throw new Error(`Unreadable color: ${text}`);
}
/** Writes `color` as `#rrggbb`, or as `rgba()` when it is translucent. */
function formatColor({ r, g, b, a }: Rgba): string {
const [R, G, B] = [r, g, b].map((c) => Math.round(c));
if (a < 1) return `rgba(${R},${G},${B},${+a.toFixed(3)})`;
return `#${[R, G, B].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
}
/** Returns `top` laid over `under`, both straight (not premultiplied) alpha. */
function over(top: Rgba, under: Rgba): Rgba {
const a = top.a + under.a * (1 - top.a);
if (a === 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (t: number, u: number) =>
(t * top.a + u * under.a * (1 - top.a)) / a;
return {
r: mix(top.r, under.r),
g: mix(top.g, under.g),
b: mix(top.b, under.b),
a,
};
}
/**
* Computes `color-mix(in srgb, …)` from its arguments, the two colors with
* their optional percentages, as CSS does: premultiplied, and scaled down
* when the percentages sum under 100%.
*/
function colorMix(args: string[]): string {
const [space, ...colors] = args;
if (space !== "in srgb" || colors.length !== 2) {
throw new Error(`Unsupported color-mix: ${args.join(", ")}`);
}
const parsed = colors.map((arg) => {
const pct = arg.match(/\s([\d.]+)%$/);
return {
color: parseColor(pct ? arg.slice(0, pct.index) : arg),
pct: pct ? parseFloat(pct[1]) / 100 : null,
};
});
const [p1, p2] = [
parsed[0].pct ?? 1 - (parsed[1].pct ?? 0.5),
parsed[1].pct ?? 1 - (parsed[0].pct ?? 0.5),
];
const sum = p1 + p2;
const [w1, w2] = [p1 / sum, p2 / sum];
const [c1, c2] = [parsed[0].color, parsed[1].color];
const a = c1.a * w1 + c2.a * w2;
const mix = (x: number, y: number) =>
a === 0 ? 0 : (x * c1.a * w1 + y * c2.a * w2) / a;
return formatColor({
r: mix(c1.r, c2.r),
g: mix(c1.g, c2.g),
b: mix(c1.b, c2.b),
a: a * Math.min(sum, 1),
});
}
/**
* Returns the tokens of `style` in `scheme`, read from the site's stylesheets
* at the time of the call. `styles.ts` reads each style once, at load.
*/
export function readTokens(style: StyleName, scheme: Scheme): ThemeTokens {
const styleSel = style === BASE_STYLE
? `(?:\\[data-style="${style}"\\]|:not\\(\\[data-style\\]\\))`
: `\\[data-style="${style}"\\]`;
const rootRule = new RegExp(
`^(?::root)?(?:${styleSel})?(?:\\[data-color-scheme="${scheme}"\\])?$`,
);
// Later declarations win, as in the cascade: the base tokens, then the
// theme's, then its forced scheme's.
const declared = new Map<string, string>();
for (const source of SOURCES) {
const css = stripComments(Deno.readTextFileSync(
new URL(source.replace("{style}", style), import.meta.url),
));
for (const { selector, body } of topRules(css)) {
if (selector.startsWith("@")) continue;
if (!selector.split(",").some((part) => rootRule.test(part.trim()))) {
continue;
}
for (const declaration of splitTop(body, ";")) {
const colon = declaration.indexOf(":");
const name = declaration.slice(0, colon).trim();
if (!name.startsWith("--")) continue;
declared.set(
name,
declaration.slice(colon + 1).replace(/\s+/g, " ").trim(),
);
}
}
}
const resolved = new Map<string, string>();
const value = (token: string): string => {
const known = resolved.get(token);
if (known !== undefined) return known;
const raw = declared.get(token);
if (raw === undefined) throw new Error(`${style} sets no ${token}`);
let result = replaceCalls(
raw,
"var",
([name, fallback]) =>
declared.has(name) ? value(name) : fallback ?? value(name),
);
result = replaceCalls(
result,
"light-dark",
([light, dark]) => scheme === "light" ? light : dark,
);
result = replaceCalls(result, "color-mix", colorMix);
resolved.set(token, result);
return result;
};
return {
value,
color(token, under) {
const color = parseColor(value(token));
return formatColor(under ? over(color, parseColor(value(under))) : color);
},
stops(token) {
const gradient = value(token).match(/^linear-gradient\((.*)\)$/)?.[1];
if (!gradient) throw new Error(`${style} ${token} is no linear gradient`);
return splitTop(gradient, ",")
.filter((stop) => !/^(?:to\s|[\d.-]+(?:deg|turn|rad)$)/.test(stop))
.map((stop) => formatColor(parseColor(splitTop(stop, " ")[0])));
},
font(token) {
return splitTop(value(token), ",")
.map((family) => family.replace(/["']/g, "").replace(/ Variable$/, ""))
.map((family) => /^[\w-]+$/.test(family) ? family : `'${family}'`)
.join(", ");
},
px(token) {
const length = value(token).match(/^([\d.]+)(?:px)?$/)?.[1];
if (length === undefined) {
throw new Error(`${style} ${token} is no pixel length`);
}
return parseFloat(length);
},
};
}

View File

@@ -29,48 +29,24 @@ export function renderWelcomeEmail(username: string): RenderedEmail {
? WELCOME_EMAIL_BODY
.replaceAll("{{username}}", escMd(username))
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
: i18n._({
id: "email.welcome.body",
message:
"# Welcome to {site}!\n\nHi **{username}**,\n\nYour account has been created successfully. Welcome aboard!",
values: { site: escMd(OG_SITE_NAME), username: escMd(username) },
: i18n._("email.welcome.body", {
site: escMd(OG_SITE_NAME),
username: escMd(username),
});
const markdown = [
body,
mdLink(
i18n._({
id: "email.welcome.button",
message: "Open {site}",
values: { site: OG_SITE_NAME },
}),
i18n._("email.welcome.button", { site: OG_SITE_NAME }),
FRONTEND_URL,
),
`*${
escMd(i18n._({
id: "email.welcome.digests",
message:
"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.",
}))
}*`,
`*${escMd(i18n._("email.welcome.digests"))}*`,
"---",
escMd(i18n._({
id: "email.welcome.reason",
message:
"You're getting this email because an account was created on {site} with this address.",
values: { site: OG_SITE_NAME },
})),
escMd(i18n._("email.welcome.reason", { site: OG_SITE_NAME })),
].join("\n\n");
return new EmailKit(lookOf()).render({
subject: i18n._({
id: "email.welcome.subject",
message: "Welcome to {site}",
values: { site: OG_SITE_NAME },
}),
preheader: i18n._({
id: "email.welcome.preheader",
message: "Your account is ready.",
}),
subject: i18n._("email.welcome.subject", { site: OG_SITE_NAME }),
preheader: i18n._("email.welcome.preheader"),
markdown,
});
}

View File

@@ -70,6 +70,13 @@ Deno.test("EmailKit: every style draws its own card and font", () => {
geo.section("A", "", false),
"linear-gradient(90deg,#ff004d",
);
// On "auto" the dark rainbow replaces the light one, divider and bar alike.
const geoAuto = doc(new EmailKit({ style: "geocities", scheme: "auto" }));
assertStringIncludes(
geoAuto,
`.grad{background-color:#ff004d!important;background-image:linear-gradient(90deg,#ff004d`,
);
assertStringIncludes(geoAuto, `class="grad"`);
const brutal = doc(
new EmailKit({ style: "brutalist", scheme: "light" }),
);

View File

@@ -1,40 +0,0 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { EMAIL_STYLES, type EmailPalette } from "../../mail/styles.ts";
import { STYLE_NAMES } from "../../../shared/theme.ts";
// The email palettes are copies of the themes' tokens, flattened for mail
// clients. The roles that map one to one must stay equal to the theme's.
const TOKENS: [keyof EmailPalette, string][] = [
["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"],
];
Deno.test("email palettes keep their theme's colors", () => {
for (const name of STYLE_NAMES) {
const css = Deno.readTextFileSync(
new URL(`../../../src/themes/${name}.css`, import.meta.url),
);
for (const [role, token] of TOKENS) {
const match = css.match(
new RegExp(
`${token}:\\s*light-dark\\(\\s*(#[0-9a-f]+),\\s*(#[0-9a-f]+)\\s*\\)`,
"i",
),
);
if (!match) throw new Error(`${name} has no ${token}`);
const { light, dark } = EMAIL_STYLES[name];
assertEquals(
[light[role], dark[role]],
[match[1].toLowerCase(), match[2].toLowerCase()],
`${name} ${role} (${token})`,
);
}
}
});

View File

@@ -0,0 +1,81 @@
import "../setup.ts";
import {
assert,
assertEquals,
assertMatch,
assertNotEquals,
} from "@std/assert";
import { readTokens } from "../../mail/theme-tokens.ts";
import { EMAIL_STYLES } from "../../mail/styles.ts";
import { STYLE_NAMES } from "../../../shared/theme.ts";
Deno.test("readTokens: light-dark() picks the scheme's value", () => {
assertEquals(readTokens("smooth", "light").color("--color-bg"), "#faf5e9");
assertEquals(readTokens("smooth", "dark").color("--color-bg"), "#01152a");
});
Deno.test("readTokens: a forced scheme's block overrides the theme's", () => {
// GeoCities draws a darker rainbow on its light pages.
assertEquals(readTokens("geocities", "light").stops("--geo-rainbow"), [
"#ff004d",
"#ff8a00",
"#ffcc00",
"#00aa1a",
"#0077dd",
"#8800cc",
]);
assertEquals(
readTokens("geocities", "dark").stops("--geo-rainbow")[2],
"#ffee00",
);
});
Deno.test("readTokens: var() follows tokens, color-mix() is computed", () => {
const melange = readTokens("melange", "light");
// The spice starts with var(--color-accent) and drops its stops' positions.
assertEquals(melange.stops("--mel-spice"), [
"#a06d00",
"#bc5c00",
"#c77b8b",
"#be79bb",
"#6e9b72",
]);
// 18% of the muted text over the card, as a badge shows on the site.
const smooth = readTokens("smooth", "light");
assertEquals(smooth.color("--color-badge-bg", "--color-surface"), "#dfe4e1");
assertEquals(smooth.color("--color-badge-bg"), "rgba(77,113,120,0.18)");
});
Deno.test("readTokens: fonts, lengths and the base tokens", () => {
assertEquals(
readTokens("smooth", "light").font("--font-body"),
"Saira, sans-serif",
);
assertEquals(
readTokens("kanagawa", "dark").font("--font-body"),
"'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
);
// Smooth keeps tokens.css's radii; Brutalist squares them.
assertEquals(readTokens("smooth", "light").px("--radius-xl"), 12);
assertEquals(readTokens("brutalist", "light").px("--radius-xl"), 0);
});
Deno.test("EMAIL_STYLES: every style reads plain colors from its theme", () => {
for (const name of STYLE_NAMES) {
const style = EMAIL_STYLES[name];
for (const palette of [style.light, style.dark]) {
for (const [role, color] of Object.entries(palette)) {
if (role === "gradient") continue;
assertMatch(
color,
role === "shadow" ? /^rgba\(/ : /^#[0-9a-f]{6}$/,
`${name} ${role}`,
);
}
}
assertNotEquals(style.light.card, style.dark.card, name);
if (style.rule === "gradient" || style.bar) {
assert((style.light.gradient?.length ?? 0) > 1, name);
}
}
});

View File

@@ -5,8 +5,8 @@
"server:start": "deno run --env-file -A --watch api/main.ts",
"icons": "deno run --env-file -A api/scripts/generate-icons.ts",
"serve": "deno task build && deno task icons dist && deno task server:start",
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --strict --workers 1",
"start": "deno task icons dist && deno run --env-file -A api/main.ts",
"test": "deno test -A api/ shared/",
"test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/",

View File

@@ -8,7 +8,9 @@ const config: LinguiConfig = {
catalogs: [
{
// One catalog per language for the whole app: the interface's messages
// and the emails' (api/mail/), which the server imports compiled.
// and the emails' (api/mail/), which the server imports compiled. A
// message named by id has its English in en.po only, which is why
// extract runs without --overwrite (it would write the id there).
path: "locales/{locale}",
include: ["src", "api/mail"],
},

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

9
mod.ts
View File

@@ -24,7 +24,8 @@
* - `remote/` is the only place that fetches from other sites, and its
* `providers/` build the previews of each kind of site.
* - `mail/` renders each email, one template per email on a shared layout
* that takes every site style, and `services/email-service.ts` sends them.
* that takes every site style, its colors and fonts read from the themes'
* stylesheets, and `services/email-service.ts` sends them.
* - `lib/` holds the helpers that belong to no layer.
* - `db/` opens, creates and migrates the database, and cleans it up at
* startup. `db/migrations/` are applied in order, each once.
@@ -41,7 +42,11 @@
*
* Beside it, `locales/` holds one Lingui catalogue per language for the whole
* app: the interface's messages, and the emails' that `api/mail/i18n.ts`
* loads compiled.
* loads compiled. The interface writes its English in place (`t`, `Trans`)
* and Lingui keys the message by it. A message with a named id, which every
* email's is, along with the labels the interface shares with them, has its
* English in `locales/en.po` alone and the code names only the id; the build
* fails when a language lacks one.
*
* ## Frontend, in `src/`
*

View File

@@ -9,7 +9,8 @@
/**
* Lists every site style. Each has a stylesheet under `src/themes/`, keyed on
* the root element's `data-style`, and an email style in `api/mail/styles.ts`.
* the root element's `data-style`, and an email style in `api/mail/styles.ts`
* read from that stylesheet.
*/
export const STYLE_NAMES = [
"smooth",

View File

@@ -5286,7 +5286,7 @@ body.has-player .chat-fab {
letter-spacing: 0.04em;
padding: 0.1em 0.45em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
background: var(--color-badge-bg);
color: var(--color-text-muted);
}

View File

@@ -7,6 +7,9 @@
import { useState } from "react";
import { Trans } from "@lingui/react/macro";
// A label shared with the emails is named by its id, its words being in
// locales/ only; the macro's Trans takes the English in place.
import { Trans as Message } from "@lingui/react";
import type { ColorScheme, StyleName } from "../../../shared/theme.ts";
import type { FeedTab } from "../../config/feedTabs.ts";
import { useTheme } from "../../hooks/theme/useTheme.ts";
@@ -125,7 +128,7 @@ export function ProfileSettingsTab(): React.JSX.Element {
</ProfileSection>
<ProfileSection title={<Trans>Email</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans id="digest.weekly">Weekly digest</Trans>}>
<SettingRow label={<Message id="digest.weekly" />}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestWeekly ?? true}
@@ -134,7 +137,7 @@ export function ProfileSettingsTab(): React.JSX.Element {
updateSettings({ digestWeekly: on }).catch(() => {})}
/>
</SettingRow>
<SettingRow label={<Trans id="digest.monthly">Monthly recap</Trans>}>
<SettingRow label={<Message id="digest.monthly" />}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestMonthly ?? true}

View File

@@ -9,6 +9,9 @@ import { useState } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
// A label shared with the emails is named by its id, its words being in
// locales/ only; the macro's Trans takes the English in place.
import { Trans as Message } from "@lingui/react";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../config/api.ts";
@@ -111,12 +114,8 @@ export function Unsubscribe(): React.JSX.Element {
<FormError title={t`Couldn't unsubscribe`} />
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
{weekly
? <Trans id="digest.weekly.off">Turn off weekly digests</Trans>
: (
<Trans id="digest.monthly.off">
Turn off monthly recaps
</Trans>
)}
? <Message id="digest.weekly.off" />
: <Message id="digest.monthly.off" />}
</SubmitButton>
</form>
</FormProvider>

View File

@@ -2,6 +2,9 @@
Design tokens — shared by every theme.
Themes (src/themes/*.css) set the palette (`--color-*`) and may override
any of the shape/type/depth tokens below; App.css only consumes tokens.
The emails read them too (api/mail/theme-tokens.ts), from the rules on
the root element only: a token a `prefers-color-scheme` block sets must
also be set under `[data-color-scheme]`, as the themes do.
══════════════════════════════════════════════════════════════════════ */
:root {
@@ -110,6 +113,8 @@
--color-surface-sunken: var(--color-bg);
--color-accent-soft: color-mix(in srgb, var(--color-accent) 14%, transparent);
--color-focus-ring: color-mix(in srgb, var(--color-accent) 45%, transparent);
/* Small labels such as "private" on one's own dump; the emails' pills too. */
--color-badge-bg: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
--color-warning: #d4a017;
--color-on-danger: #fff;