Skip to content

Commit c1d59bb

Browse files
committed
improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test
1 parent f541772 commit c1d59bb

30 files changed

Lines changed: 406 additions & 358 deletions

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
renderWorkspaceAddedEmail,
2424
renderWorkspaceInvitationEmail,
2525
} from '@/components/emails'
26+
import { colors, typography } from '@/components/emails/_styles'
2627
import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
2728
import { validationErrorResponse } from '@/lib/api/server'
2829
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -258,24 +259,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
258259
<title>Email Templates</title>
259260
<style>
260261
:root { color-scheme: light; }
261-
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 40px 24px 80px; background: #fff; color: #1a1a1a; }
262+
body { font-family: ${typography.systemFontFamily}; margin: 0; padding: 40px 24px 80px; background: ${colors.bgCard}; color: ${colors.textPrimary}; }
262263
h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
263-
.count { color: #7a7a7a; font-size: 14px; margin: 0 0 40px; }
264-
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: #7a7a7a; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #d8d8d8; }
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}; }
265266
section { max-width: 1400px; margin: 0 auto; }
266267
section > h2:first-child { margin-top: 0; }
267268
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
268269
figure { margin: 0 0 32px; }
269270
figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
270-
figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #434343; }
271-
figcaption a { color: #7a7a7a; text-decoration: none; font-size: 12px; }
272-
figcaption a:hover { color: #1a1a1a; }
273-
iframe { width: 100%; height: 900px; border: 1px solid #d8d8d8; border-radius: 8px; background: #fff; display: block; }
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; }
274275
</style>
275276
</head>
276277
<body>
277278
<h1>Email Templates</h1>
278-
<p class="count">Every email Sim sends — ${Object.values(PREVIEW_CATEGORIES).flat().length} previews.</p>
279+
<p class="count">Every email Sim sends — ${Object.keys(emailTemplates).length} previews.</p>
279280
${categoryHtml}
280281
</body>
281282
</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)