Skip to content

Commit 0e08a41

Browse files
authored
improvement(emails): align the email design tokens with the platform design system (#6479)
* improvement(emails): align the email design tokens with the platform design system * improvement(emails): preview every template in a single gallery page * improvement(emails): align font stack, logo and social-icon sizing with the platform * improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test * fix(emails): restore row spacing in the payment-failed details box * fix(emails): restore row spacing in the batch-invitation workspace list
1 parent 04fd63d commit 0e08a41

31 files changed

Lines changed: 764 additions & 639 deletions

apps/sim/app/api/emails/preview/route.ts

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { NextRequest } from 'next/server'
22
import { NextResponse } from 'next/server'
33
import {
4+
renderAbandonedCheckoutEmail,
45
renderBatchInvitationEmail,
56
renderCreditPurchaseEmail,
7+
renderCreditsExhaustedEmail,
68
renderEnterpriseSubscriptionEmail,
9+
renderExistingAccountEmail,
710
renderFreeTierUpgradeEmail,
811
renderHelpConfirmationEmail,
912
renderInvitationEmail,
13+
renderLimitThresholdEmail,
14+
renderOnboardingFollowupEmail,
1015
renderOTPEmail,
1116
renderPasswordResetEmail,
1217
renderPaymentFailedEmail,
@@ -15,20 +20,27 @@ import {
1520
renderUsageLimitReachedEmail,
1621
renderUsageThresholdEmail,
1722
renderWelcomeEmail,
23+
renderWorkspaceAddedEmail,
1824
renderWorkspaceInvitationEmail,
1925
} from '@/components/emails'
26+
import { colors, typography } from '@/components/emails/_styles'
2027
import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
2128
import { validationErrorResponse } from '@/lib/api/server'
2229
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2330

2431
const emailTemplates = {
2532
// Auth emails
2633
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
34+
'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'),
2735
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
36+
'existing-account': () => renderExistingAccountEmail('John'),
2837
welcome: () => renderWelcomeEmail('John'),
38+
'onboarding-followup': () => renderOnboardingFollowupEmail('John'),
2939

3040
// Invitation emails
3141
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
42+
'workspace-added': () =>
43+
renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'),
3244
'batch-invitation': () =>
3345
renderBatchInvitationEmail(
3446
'Jane Doe',
@@ -87,6 +99,43 @@ const emailTemplates = {
8799
amount: 50,
88100
newBalance: 75,
89101
}),
102+
'credits-exhausted': () =>
103+
renderCreditsExhaustedEmail({
104+
userName: 'John',
105+
limit: 10,
106+
upgradeLink: 'https://sim.ai/settings/billing',
107+
}),
108+
'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'),
109+
'limit-threshold-storage-warning': () =>
110+
renderLimitThresholdEmail({
111+
kind: 'warning',
112+
reason: 'storage',
113+
userName: 'John',
114+
usageLabel: '4.2 GB',
115+
limitLabel: '5 GB',
116+
percentUsed: 84,
117+
upgradeLink: 'https://sim.ai/settings/billing',
118+
}),
119+
'limit-threshold-tables-reached': () =>
120+
renderLimitThresholdEmail({
121+
kind: 'reached',
122+
reason: 'tables',
123+
userName: 'John',
124+
usageLabel: '50,000 rows',
125+
limitLabel: '50,000 rows',
126+
percentUsed: 100,
127+
upgradeLink: 'https://sim.ai/settings/billing',
128+
}),
129+
'limit-threshold-seats-reached': () =>
130+
renderLimitThresholdEmail({
131+
kind: 'reached',
132+
reason: 'seats',
133+
userName: 'John',
134+
usageLabel: '10 seats',
135+
limitLabel: '10 seats',
136+
percentUsed: 100,
137+
upgradeLink: 'https://sim.ai/settings/billing',
138+
}),
90139
'payment-failed': () =>
91140
renderPaymentFailedEmail({
92141
userName: 'John',
@@ -138,6 +187,40 @@ function isEmailTemplate(template: string): template is EmailTemplate {
138187
return template in emailTemplates
139188
}
140189

190+
const CATEGORIZED = {
191+
Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'],
192+
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'],
193+
Support: ['help-confirmation'],
194+
Billing: [
195+
'usage-threshold',
196+
'usage-limit-reached',
197+
'usage-limit-reached-org',
198+
'free-tier-upgrade',
199+
'credits-exhausted',
200+
'limit-threshold-storage-warning',
201+
'limit-threshold-tables-reached',
202+
'limit-threshold-seats-reached',
203+
'payment-failed',
204+
'credit-purchase',
205+
'plan-welcome-pro',
206+
'plan-welcome-team',
207+
'enterprise-subscription',
208+
],
209+
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
210+
'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'],
211+
} satisfies Record<string, EmailTemplate[]>
212+
213+
/**
214+
* Category map for the gallery, with any template missing from {@link CATEGORIZED}
215+
* appended rather than dropped — so a newly registered template always shows up
216+
* even if nobody remembers to file it.
217+
*/
218+
const PREVIEW_CATEGORIES: Record<string, EmailTemplate[]> = (() => {
219+
const filed = new Set<string>(Object.values(CATEGORIZED).flat())
220+
const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t))
221+
return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED
222+
})()
223+
141224
export const GET = withRouteHandler(async (request: NextRequest) => {
142225
const { searchParams } = new URL(request.url)
143226
const queryValidation = emailPreviewQuerySchema.safeParse(
@@ -147,48 +230,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147230
const { template } = queryValidation.data
148231

149232
if (!template) {
150-
const categories = {
151-
Auth: ['otp', 'reset-password', 'welcome'],
152-
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
153-
Support: ['help-confirmation'],
154-
Billing: [
155-
'usage-threshold',
156-
'enterprise-subscription',
157-
'free-tier-upgrade',
158-
'plan-welcome-pro',
159-
'plan-welcome-team',
160-
'credit-purchase',
161-
'payment-failed',
162-
'usage-limit-reached',
163-
'usage-limit-reached-org',
164-
],
165-
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
166-
}
167-
168-
const categoryHtml = Object.entries(categories)
233+
const categoryHtml = Object.entries(PREVIEW_CATEGORIES)
169234
.map(
170235
([category, templates]) => `
171-
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
172-
<ul style="list-style: none; padding: 0; margin: 0;">
173-
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
174-
</ul>
175-
`
236+
<section>
237+
<h2>${category}</h2>
238+
<div class="grid">
239+
${templates
240+
.map(
241+
(t) => `
242+
<figure>
243+
<figcaption><span>${t}</span><a href="?template=${t}" target="_blank" rel="noreferrer">open ↗</a></figcaption>
244+
<iframe src="?template=${t}" title="${t}" loading="lazy"></iframe>
245+
</figure>`
246+
)
247+
.join('')}
248+
</div>
249+
</section>`
176250
)
177251
.join('')
178252

179253
return new NextResponse(
180254
`<!DOCTYPE html>
181255
<html>
182256
<head>
183-
<title>Email Previews</title>
257+
<meta charset="utf-8" />
258+
<meta name="viewport" content="width=device-width, initial-scale=1" />
259+
<title>Email Templates</title>
184260
<style>
185-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
186-
h1 { color: #333; margin-bottom: 32px; }
187-
a:hover { text-decoration: underline; }
261+
:root { color-scheme: light; }
262+
body { font-family: ${typography.systemFontFamily}; margin: 0; padding: 40px 24px 80px; background: ${colors.bgCard}; color: ${colors.textPrimary}; }
263+
h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
264+
.count { color: ${colors.textMuted}; font-size: 14px; margin: 0 0 40px; }
265+
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: ${colors.textMuted}; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid ${colors.border}; }
266+
section { max-width: 1400px; margin: 0 auto; }
267+
section > h2:first-child { margin-top: 0; }
268+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
269+
figure { margin: 0 0 32px; }
270+
figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
271+
figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: ${colors.textBody}; }
272+
figcaption a { color: ${colors.textMuted}; text-decoration: none; font-size: 12px; }
273+
figcaption a:hover { color: ${colors.textPrimary}; }
274+
iframe { width: 100%; height: 900px; border: 1px solid ${colors.border}; border-radius: 8px; background: ${colors.bgCard}; display: block; }
188275
</style>
189276
</head>
190277
<body>
191278
<h1>Email Templates</h1>
279+
<p class="count">Every email Sim sends — ${Object.keys(emailTemplates).length} previews.</p>
192280
${categoryHtml}
193281
</body>
194282
</html>`,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Email styles cannot use CSS variables — clients strip them — so `base.ts`
3+
* hardcodes hex copies of the platform tokens. Nothing else detects it when
4+
* `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies
5+
* go stale, which is exactly how they drifted before. This suite is that
6+
* detector.
7+
*
8+
* @vitest-environment node
9+
*/
10+
import { readFileSync } from 'node:fs'
11+
import { join } from 'node:path'
12+
import { describe, expect, it } from 'vitest'
13+
import { baseStyles, colors, typography } from '@/components/emails/_styles'
14+
15+
const APP_ROOT = join(__dirname, '../../..')
16+
17+
const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8')
18+
const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8')
19+
const chipChrome = readFileSync(
20+
join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'),
21+
'utf8'
22+
)
23+
24+
/**
25+
* The light-mode `:root` block. Dark mode redefines the same names later in the
26+
* file, and emails are light-only, so the FIRST definition is the one to read.
27+
*/
28+
function readCssVar(name: string): string {
29+
const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`))
30+
if (!match) throw new Error(`--${name} not found in globals.css`)
31+
return match[1].trim()
32+
}
33+
34+
function readTailwindFontSize(name: string): string {
35+
const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`))
36+
if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`)
37+
return match[1]
38+
}
39+
40+
/** Every email color token and the platform variable it copies. */
41+
const COLOR_MIRROR: Record<string, string> = {
42+
bgOuter: 'surface-1',
43+
bgCard: 'surface-2',
44+
surfaceSubtle: 'surface-3',
45+
textPrimary: 'text-primary',
46+
textBody: 'text-body',
47+
textMuted: 'text-muted',
48+
textInverse: 'text-inverse',
49+
border: 'border',
50+
errorBg: 'terminal-status-error-bg',
51+
errorBorder: 'error-muted',
52+
footerBg: 'surface-1',
53+
}
54+
55+
/**
56+
* Tokens with no single CSS variable behind them. Each needs a stated reason —
57+
* an entry here is a deliberate exception, not an oversight.
58+
*/
59+
const UNMIRRORED_COLORS: Record<string, string> = {
60+
brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.',
61+
}
62+
63+
describe('email color tokens mirror globals.css', () => {
64+
for (const [token, cssVar] of Object.entries(COLOR_MIRROR)) {
65+
it(`colors.${token} equals --${cssVar}`, () => {
66+
expect(colors[token as keyof typeof colors]).toBe(readCssVar(cssVar))
67+
})
68+
}
69+
70+
it('every color token is either mirrored or has a written exemption', () => {
71+
const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)])
72+
const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key))
73+
expect(unaccounted).toEqual([])
74+
})
75+
76+
it('exemptions state a reason', () => {
77+
for (const reason of Object.values(UNMIRRORED_COLORS)) {
78+
expect(reason.trim().length).toBeGreaterThan(0)
79+
}
80+
})
81+
})
82+
83+
describe('email type scale mirrors tailwind.config.ts', () => {
84+
it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => {
85+
expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name))
86+
})
87+
88+
it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => {
89+
expect(typography.fontSize.sm).toBe('14px')
90+
expect(chipChrome).toContain('text-sm')
91+
})
92+
93+
it('display is deliberately off-scale (no platform headline-numeral token)', () => {
94+
expect(typography.fontSize.display).toBe('24px')
95+
expect(tailwindConfig).not.toContain("'24px'")
96+
})
97+
})
98+
99+
describe('email geometry mirrors the platform', () => {
100+
it('the card radius equals --radius', () => {
101+
// --radius is authored in rem; emails need px.
102+
expect(readCssVar('radius')).toBe('0.5rem')
103+
expect(baseStyles.container.borderRadius).toBe('8px')
104+
})
105+
106+
it('the CTA transcribes chipGeometryClass', () => {
107+
const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1]
108+
expect(geometry).toBeDefined()
109+
expect(geometry).toContain('h-[30px]')
110+
expect(geometry).toContain('rounded-lg')
111+
expect(geometry).toContain('px-2')
112+
expect(geometry).toContain('text-sm')
113+
114+
expect(baseStyles.button.lineHeight).toBe('30px')
115+
expect(baseStyles.button.borderRadius).toBe('8px')
116+
expect(baseStyles.button.padding).toBe('0 8px')
117+
expect(baseStyles.button.fontSize).toBe(typography.fontSize.sm)
118+
})
119+
})
120+
121+
describe('email font weights stay on the platform scale', () => {
122+
it('no token uses a weight outside 400/500/600', () => {
123+
const offScale = Object.entries(baseStyles).filter(([, style]) => {
124+
const weight = (style as { fontWeight?: unknown }).fontWeight
125+
return weight !== undefined && ![400, 500, 600].includes(weight as number)
126+
})
127+
expect(offScale.map(([name]) => name)).toEqual([])
128+
})
129+
})

0 commit comments

Comments
 (0)