diff --git a/emails/fromPublication.ts b/emails/fromPublication.ts --- a/emails/fromPublication.ts +++ b/emails/fromPublication.ts @@ -40,6 +40,7 @@ accentText: colorToCss(theme.accentText, defaultEmailTheme.accentText), headingFont: getFontFamilyValue(getFontConfig(theme.headingFont)), bodyFont: getFontFamilyValue(getFontConfig(theme.bodyFont)), + pageWidth: theme.pageWidth ?? defaultEmailTheme.pageWidth, }; }; diff --git a/emails/post.tsx b/emails/post.tsx --- a/emails/post.tsx +++ b/emails/post.tsx @@ -1,7 +1,6 @@ import { Body, Column, - Container, Head, Heading as ReactEmailHeading, Hr, @@ -11,13 +10,11 @@ Text as ReactEmailText, Section, Row, - Button, CodeBlock as ReactEmailCodeBlock, dracula, } from "@react-email/components"; import type { PrismLanguage } from "@react-email/code-block"; -import { Tailwind, pixelBasedPreset } from "@react-email/components"; -import React from "react"; +import React, { type CSSProperties } from "react"; import { PubLeafletBlocksBlockquote, PubLeafletBlocksCode, @@ -40,6 +37,10 @@ accentText: string; headingFont: string; bodyFont: string; + // Matches the publication's web `pageWidth` (px) so subscribers see the + // post at the same column width in their inbox as on the live page. + // Default 624 mirrors `ThemeProvider.tsx`'s fallback. + pageWidth: number; }; export const defaultEmailTheme: EmailTheme = { @@ -50,7 +51,76 @@ accentText: "rgb(255, 255, 255)", headingFont: "Georgia, serif", bodyFont: "Verdana, sans-serif", + pageWidth: 624, }; + +// Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — +// theme colors come from a typed config so this is just defensive. +const parseColor = (input: string): [number, number, number] => { + const rgbMatch = input.match( + /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, + ); + if (rgbMatch) + return [ + Number(rgbMatch[1]), + Number(rgbMatch[2]), + Number(rgbMatch[3]), + ]; + const hexMatch = input.match(/^#([0-9a-f]{6})$/i); + if (hexMatch) { + const h = hexMatch[1]; + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; + } + const hex3 = input.match(/^#([0-9a-f]{3})$/i); + if (hex3) { + const h = hex3[1]; + return [ + parseInt(h[0] + h[0], 16), + parseInt(h[1] + h[1], 16), + parseInt(h[2] + h[2], 16), + ]; + } + return [0, 0, 0]; +}; + +// Linear sRGB mix of two colors. We resolve theme tints to literal rgb() at +// render time because Gmail's CSS sanitizer drops `color-mix(...)` — leaving +// borders invisible and accent text fall back to defaults. Linear mixing +// isn't perceptually identical to the oklab original, but for the +// near-grayscale tints we use it's visually indistinguishable. +const mixRgb = ( + a: string, + b: string, + bPercent: number, +): string => { + const [ar, ag, ab] = parseColor(a); + const [br, bg, bb] = parseColor(b); + const t = bPercent / 100; + const round = (x: number) => Math.round(x); + return `rgb(${round(ar * (1 - t) + br * t)}, ${round( + ag * (1 - t) + bg * t, + )}, ${round(ab * (1 - t) + bb * t)})`; +}; + +type ResolvedColors = { + primary: string; + secondary: string; + tertiary: string; + border: string; + borderLight: string; +}; + +const resolveColors = (theme: EmailTheme): ResolvedColors => ({ + primary: theme.primary, + secondary: mixRgb(theme.primary, theme.pageBackground, 25), + tertiary: mixRgb(theme.primary, theme.pageBackground, 55), + border: mixRgb(theme.primary, theme.pageBackground, 75), + borderLight: mixRgb(theme.primary, theme.pageBackground, 85), +}); export type PostEmailProps = { publicationName: string; @@ -184,178 +254,354 @@ ], }; +const BLOCK_MARGIN = "4px 0 16px"; +const HEADING_MARGIN = "4px 0 0"; + export const PostEmail = (props: Partial = {}) => { const p: PostEmailProps = { ...defaultProps, ...props }; const theme = p.theme ?? defaultEmailTheme; + const c = resolveColors(theme); const staticUrl = (filename: string) => `${p.assetsBaseUrl.replace(/\/$/, "")}/email-assets/${filename}`; const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); + const accentLink: CSSProperties = { + color: theme.accentBackground, + textDecoration: "none", + fontFamily: theme.bodyFont, + }; + + // Page width as both an HTML width attribute (px) and a CSS max-width. + // Gmail strips/ignores `max-width` in some contexts but always honors the + // HTML `width` attribute on a , so we set both: the HTML `width` + // pins the box for Gmail, and `maxWidth: 100%` lets it shrink on narrow + // viewports. The value mirrors the publication's web page width. + const pageWidth = theme.pageWidth; + return ( - + {/* Without this, iOS Mail / Gmail iOS auto-scale the email down to + fit a wider-than-viewport layout (e.g. 624px in a 400px screen) + — which makes every element appear "too small". With it, we get + 1:1 pixel sizing and our @media queries below can shrink the + card to viewport width directly. */} + + {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, + and most other clients honor @media in ; Outlook desktop + ignores it and keeps the wider inline padding, which is fine + (it's a desktop client). !important is required so the + media-query rule beats the inline `style.padding`. */} + + + - - - - - - - {p.postTitle} - - {p.postDescription ? ( - - {p.postDescription} - - ) : null} - - {byline ? ( -
- - - - {byline} - - - - - - - - - - - - - - - - -
- ) : null} -
- {p.blocks.map((b, i) => ( - - ))} -
-
- - - - styling, so + we paint the background on this table's
using both the + bgcolor HTML attribute (Outlook/Gmail bulletproof) and a + backgroundColor style (everything else). */} + + + +
)} + style={{ + backgroundColor: theme.backgroundColor, + padding: "24px 16px", + }} > - {p.unsubscribeUrl ? ( - - ) : ( - - (preview — not sent to subscribers) - - )} - - - -
+ + + + + + +
)} + style={{ + backgroundColor: theme.pageBackground, + border: `1px solid ${c.border}`, + borderRadius: 8, + padding: "20px 24px", + }} + > + + {p.publicationName} + - - - + + {p.postTitle} + + + {p.postDescription ? ( + + {p.postDescription} + + ) : null} + + {byline ? ( +
+ + + + {byline} + + + + + + See quotes + + + + + + See comments + + + + + + Open post + + + +
+ ) : null} + + {p.blocks.map((b, i) => ( + + ))} + + {/* Footer: Gmail won't reliably cascade `text-align` from a + wrapping , so each centered row is its own + + + {/* Spacer */} + + + + + {/* Horizontal rule between card and watermark.
+ margins are flaky in Gmail, so we use a 1px-tall + + + + + + + + + + + + +
— the bulletproof email-centering + pattern. `min-width: 100%` keeps Gmail iOS from + shrink-wrapping the table around the short link text. */} + + + + + + + + + +
+ + See Full Post + +
+ {p.unsubscribeUrl ? ( + + Unsubscribe + + ) : ( + + (preview — not sent to subscribers) + + )} +
+
+   +
with border-top instead. */} +
+   +
+   +
+ +
+
+ ); }; @@ -365,32 +611,82 @@ block, did, assetsBaseUrl, + theme, + colors, }: { block: PubLeafletPagesLinearDocument.Block["block"]; did: string; assetsBaseUrl: string; + theme: EmailTheme; + colors: ResolvedColors; }) => { if (PubLeafletBlocksText.isMain(block)) { - return {block.plaintext || " "}; + return ( + + {block.plaintext || " "} + + ); } if (PubLeafletBlocksHeader.isMain(block)) { const raw = Math.floor(block.level ?? 1); const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; - return {block.plaintext}; + const fontSize = clamped === 1 ? 26 : clamped === 2 ? 18 : 16; + const color = clamped === 3 ? colors.secondary : theme.primary; + return ( + + {block.plaintext} + + ); } if (PubLeafletBlocksBlockquote.isMain(block)) { return ( - - - - - {block.plaintext} - - +
+ + + + + + {block.plaintext} + + + +
); } if (PubLeafletBlocksCode.isMain(block)) { - return ; + return ( + + ); } if (PubLeafletBlocksImage.isMain(block)) { const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); @@ -403,12 +699,14 @@ {block.alt ); @@ -423,64 +721,135 @@ title={block.title} description={block.description} previewSrc={previewSrc} + theme={theme} + colors={colors} /> ); } if (PubLeafletBlocksHorizontalRule.isMain(block)) { - return
; + return ( +
+ ); } if (PubLeafletBlocksUnorderedList.isMain(block)) { return ( - + ); } if (PubLeafletBlocksOrderedList.isMain(block)) { return ( - + ); } - return ; + return ; }; export const LeafletWatermark = ({ staticUrl, + theme = defaultEmailTheme, }: { staticUrl?: (filename: string) => string; + theme?: EmailTheme; } = {}) => { + const c = resolveColors(theme); const leafletSrc = staticUrl ? staticUrl("leaflet.png") : "/email-assets/leaflet.png"; + // Shrink-to-fit table with align="center" — the bulletproof email + // pattern for centering a chunk of inline content within whatever + // container it lands in. return ( - - - + + + + + + +
+ + + + Published with{" "} + + Leaflet + + + +
); }; -const blockPadding = "mt-1 mb-3 sm:mb-4"; -const headingPadding = "mt-1 mb-0"; -const link = `text-base text-accent-contrast ${blockPadding}`; - +// Backwards-compatible helpers used by `leafletConfirmEmail.tsx` and +// `pubConfirmEmail.tsx`, which wrap themselves in their own +// context. We keep layout (margin/font-size/line-height) inline so the +// helpers still look right outside Tailwind, but leave color/font-family +// to the caller (via className inside Tailwind, or a `style` override). export const Text = (props: { children: React.ReactNode; noPadding?: boolean; small?: boolean; className?: string; + style?: CSSProperties; }) => { + const fontSize = props.small ? 14 : 16; return ( {props.children} @@ -492,11 +861,21 @@ noPadding?: boolean; as: "h1" | "h2" | "h3"; className?: string; + style?: CSSProperties; }) => { + const fontSize = + props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; return ( {props.children} @@ -519,18 +898,27 @@ style, did, assetsBaseUrl, + theme = defaultEmailTheme, }: { items: ListItem[]; style: "ordered" | "unordered"; did: string; assetsBaseUrl: string; + theme?: EmailTheme; }) => { - const listClass = `my-0 !pl-6`; - const listItemClass = `${headingPadding} !ml-2`; const Tag = style === "ordered" ? "ol" : "ul"; return ( -
- +
+ {items.map((item, i) => { const plaintext = listItemPlaintext(item); const nestedUnordered = @@ -545,7 +933,7 @@ .orderedListChildren?.children; return ( -
  • +
  • {typeof item.checked === "boolean" ? `${item.checked ? "☑ " : "☐ "}${plaintext}` : plaintext} @@ -556,6 +944,7 @@ style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} + theme={theme} /> ) : null} {nestedOrdered && nestedOrdered.length > 0 ? ( @@ -564,6 +953,7 @@ style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} + theme={theme} /> ) : null} @@ -579,12 +969,17 @@ title, description, previewSrc, + theme = defaultEmailTheme, + colors, }: { url?: string; title?: string; description?: string; previewSrc?: string; + theme?: EmailTheme; + colors?: ResolvedColors; } = {}) => { + const c = colors ?? resolveColors(theme); const displayUrl = (() => { if (!url) return "www.example.com"; try { @@ -594,52 +989,115 @@ } })(); return ( - - + - - + + {title || displayUrl} - - - {description ? ( - - {description} - - ) : null} - - {displayUrl} - - - {previewSrc ? ( - - + + {description ? ( + + {description} + + ) : null} + + {displayUrl} + - ) : null} - + {previewSrc ? ( + + + + ) : null} + +
  • ); }; export const CodeBlock = ({ code, language, + borderColor, }: { code?: string; language?: string; + borderColor?: string; } = {}) => { return ( defaults to `white-space: pre`, so long lines blow past the + // card on mobile. Wrap, and break long tokens (URLs, identifiers) + // when nothing else fits. + overflow: "hidden", + padding: 8, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} code={code ?? ""} theme={dracula} language={(language as PrismLanguage) || "text"} @@ -647,21 +1105,54 @@ ); }; -export const BlockNotSupported = () => { +export const BlockNotSupported = ({ + theme = defaultEmailTheme, + colors, +}: { + theme?: EmailTheme; + colors?: ResolvedColors; +} = {}) => { + const c = colors ?? resolveColors(theme); return ( - - + This media isn't supported in email... - - + + See full post - - + +
    ); };