|
| 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