11/**
22 * 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.
3+ * hardcodes hex copies of the platform tokens. This suite fails when those
4+ * copies drift from `globals.css`, `tailwind.config.ts`, or the chip chrome.
75 *
86 * @vitest -environment node
97 */
108import { readFileSync } from 'node:fs'
119import { join } from 'node:path'
1210import { describe , expect , it } from 'vitest'
1311import { baseStyles , colors , typography } from '@/components/emails/_styles'
12+ import tailwindConfig from '@/tailwind.config'
1413
1514const APP_ROOT = join ( __dirname , '../../..' )
1615
1716const globalsCss = readFileSync ( join ( APP_ROOT , 'app/_styles/globals.css' ) , 'utf8' )
18- const tailwindConfig = readFileSync ( join ( APP_ROOT , 'tailwind.config.ts' ) , 'utf8' )
1917const chipChrome = readFileSync (
2018 join ( APP_ROOT , '../../packages/emcn/src/components/chip/chip-chrome.ts' ) ,
2119 'utf8'
2220)
2321
22+ const tailwindFontSize = tailwindConfig . theme ?. extend ?. fontSize as Record < string , string >
23+
2424/**
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.
25+ * Dark mode redefines the same names later in the file and emails are
26+ * light-only, so the FIRST definition is the one to read.
2727 */
2828function readCssVar ( name : string ) : string {
29- const match = globalsCss . match ( new RegExp ( `-- ${ name } :\\s*([^;]+);` ) )
29+ const match = globalsCss . match ( new RegExp ( `(?:^|[^-\\w])-- ${ name } :\\s*([^;]+);` , 'm' ) )
3030 if ( ! match ) throw new Error ( `--${ name } not found in globals.css` )
3131 return match [ 1 ] . trim ( )
3232}
3333
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-
4034/** Every email color token and the platform variable it copies. */
4135const COLOR_MIRROR : Record < string , string > = {
4236 bgOuter : 'surface-1' ,
4337 bgCard : 'surface-2' ,
4438 surfaceSubtle : 'surface-3' ,
4539 textPrimary : 'text-primary' ,
4640 textBody : 'text-body' ,
41+ textSecondary : 'text-secondary' ,
4742 textMuted : 'text-muted' ,
4843 textInverse : 'text-inverse' ,
4944 border : 'border' ,
@@ -52,10 +47,7 @@ const COLOR_MIRROR: Record<string, string> = {
5247 footerBg : 'surface-1' ,
5348}
5449
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- */
50+ /** Tokens with no single CSS variable behind them, and why. */
5951const UNMIRRORED_COLORS : Record < string , string > = {
6052 brandTertiary : 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.' ,
6153}
@@ -69,30 +61,25 @@ describe('email color tokens mirror globals.css', () => {
6961
7062 it ( 'every color token is either mirrored or has a written exemption' , ( ) => {
7163 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- }
64+ expect ( Object . keys ( colors ) . filter ( ( key ) => ! accounted . has ( key ) ) ) . toEqual ( [ ] )
8065 } )
8166} )
8267
8368describe ( '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 ) )
69+ it . each ( [ 'caption' , 'small' , 'base' , 'md' ] ) ( 'fontSize.%s matches the Tailwind token' , ( name ) => {
70+ expect ( typography . fontSize [ name as keyof typeof typography . fontSize ] ) . toBe (
71+ tailwindFontSize [ name ]
72+ )
8673 } )
8774
8875 it ( 'sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome' , ( ) => {
8976 expect ( typography . fontSize . sm ) . toBe ( '14px' )
77+ expect ( tailwindFontSize . sm ) . toBeUndefined ( )
9078 expect ( chipChrome ) . toContain ( 'text-sm' )
9179 } )
9280
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'" )
81+ it ( 'display is deliberately off-scale — the platform has no headline numeral' , ( ) => {
82+ expect ( Object . values ( tailwindFontSize ) ) . not . toContain ( typography . fontSize . display )
9683 } )
9784} )
9885
@@ -106,10 +93,9 @@ describe('email geometry mirrors the platform', () => {
10693 it ( 'the CTA transcribes chipGeometryClass' , ( ) => {
10794 const geometry = chipChrome . match ( / c h i p G e o m e t r y C l a s s = ` ( [ ^ ` ] + ) ` / ) ?. [ 1 ]
10895 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' )
96+ for ( const token of [ 'h-[30px]' , 'rounded-lg' , 'px-2' , 'text-sm' ] ) {
97+ expect ( geometry ) . toContain ( token )
98+ }
11399
114100 expect ( baseStyles . button . lineHeight ) . toBe ( '30px' )
115101 expect ( baseStyles . button . borderRadius ) . toBe ( '8px' )
0 commit comments