Skip to content

Commit 7e3ac86

Browse files
committed
improvement(emails): size the header wordmark to the landing navbar's rule
The email header rendered the wordmark at 34px of ink against 16px body copy, and inked it #1a1a1a while every other line of the email uses #434343. Size it by the rule the landing navbar states: the mark stands 2px above and below its neighbouring text (18px against 14px chip labels), so 20px against the email's 16px body copy. Rasterize it from the same brand outlines the navbar renders, filled with the email's own textBody token, so the two surfaces cannot drift.
1 parent aeb77dd commit 7e3ac86

7 files changed

Lines changed: 166 additions & 12 deletions

File tree

apps/sim/app/(landing)/components/navbar/components/sim-wordmark/sim-wordmark.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from '@/lib/branding/wordmark'
2+
13
/**
24
* Inline "sim" brand logotype (wordmark, no separate icon mark) - the paths
35
* from the v1.0 brand guide's `simLogotype--dark.svg`, inlined so the logo
4-
* ships as zero-request server-rendered HTML.
6+
* ships as zero-request server-rendered HTML. They live in
7+
* `@/lib/branding/wordmark` because the email header rasterizes the same
8+
* outlines.
59
*
610
* Filled with a single solid `var(--text-body)` - the navbar's own text color
711
* (the same token its nav-link chips use) - so the wordmark reads as one solid
@@ -14,18 +18,17 @@
1418
export function SimWordmark() {
1519
return (
1620
<svg
17-
viewBox='0 0 441 212'
21+
viewBox={`0 0 ${WORDMARK_VIEW_BOX.width} ${WORDMARK_VIEW_BOX.height}`}
1822
width={37}
1923
height={18}
2024
fill='none'
2125
aria-hidden='true'
2226
className='-translate-y-[1.5px] h-[18px] w-auto'
2327
>
2428
<g fill='var(--text-body)'>
25-
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
26-
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
27-
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
28-
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
29+
{WORDMARK_PATHS.map((d) => (
30+
<path key={d} d={d} />
31+
))}
2932
</g>
3033
</svg>
3134
)

apps/sim/components/emails/components/email-layout.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import { Body, Container, Font, Head, Html, Img, Preview, Section } from '@react-email/components'
22
import { baseStyles } from '@/components/emails/_styles'
33
import { EmailFooter } from '@/components/emails/components/email-footer'
4+
import { EMAIL_WORDMARK_SIZE } from '@/lib/branding/wordmark'
45
import { getBaseUrl } from '@/lib/core/utils/urls'
56
import { getBrandConfig } from '@/ee/whitelabeling'
67

78
/**
8-
* Wordmark display size — exactly 1/4 of `wordmark.png`'s intrinsic 272×164.
9-
* The asset is a 4x source and must stay that way, since email clients do no
10-
* responsive image selection; changing one dimension alone distorts the mark.
9+
* Sim's own wordmark, rasterized from the shared brand outlines by
10+
* `scripts/generate-email-wordmark.ts`. Both dimensions are pinned because
11+
* email clients do no responsive image selection.
1112
*/
12-
const WORDMARK_SIZE = { height: '41', width: '68' } as const
13+
const WORDMARK_SIZE = {
14+
width: String(EMAIL_WORDMARK_SIZE.width),
15+
height: String(EMAIL_WORDMARK_SIZE.height),
16+
} as const
1317

14-
/** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */
15-
const CUSTOM_LOGO_SIZE = { height: '34' } as const
18+
/**
19+
* Whitelabeled logos are arbitrary aspect ratios, so only height is pinned —
20+
* matched to the wordmark so both marks read at the same weight.
21+
*/
22+
const CUSTOM_LOGO_SIZE = { height: String(EMAIL_WORDMARK_SIZE.height) } as const
1623

1724
interface EmailLayoutProps {
1825
/** Preview text shown in email client list view */

apps/sim/lib/branding/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
export { defaultBrandConfig } from './defaults'
22
export type { BrandConfig, ThemeColors } from './types'
33
export { HEX_COLOR_REGEX } from './types'
4+
export {
5+
EMAIL_WORDMARK_SCALE,
6+
EMAIL_WORDMARK_SIZE,
7+
WORDMARK_PATHS,
8+
WORDMARK_VIEW_BOX,
9+
} from './wordmark'
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { readFileSync } from 'node:fs'
5+
import path from 'node:path'
6+
import { describe, expect, it } from 'vitest'
7+
import {
8+
EMAIL_WORDMARK_SCALE,
9+
EMAIL_WORDMARK_SIZE,
10+
WORDMARK_PATHS,
11+
WORDMARK_VIEW_BOX,
12+
} from '@/lib/branding/wordmark'
13+
14+
const WORDMARK_PNG = path.join(
15+
import.meta.dirname,
16+
'..',
17+
'..',
18+
'public',
19+
'brand',
20+
'color',
21+
'email',
22+
'wordmark.png'
23+
)
24+
25+
/** Reads width/height out of a PNG's IHDR, which always follows the 8-byte signature. */
26+
function readPngSize(file: string): { width: number; height: number } {
27+
const buffer = readFileSync(file)
28+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
29+
}
30+
31+
describe('email wordmark asset', () => {
32+
it('is committed at the scale the email header renders it', () => {
33+
expect(readPngSize(WORDMARK_PNG)).toEqual({
34+
width: EMAIL_WORDMARK_SIZE.width * EMAIL_WORDMARK_SCALE,
35+
height: EMAIL_WORDMARK_SIZE.height * EMAIL_WORDMARK_SCALE,
36+
})
37+
})
38+
39+
it('renders the mark at the brand outlines aspect ratio, within a rounding pixel', () => {
40+
const boxAspect = EMAIL_WORDMARK_SIZE.width / EMAIL_WORDMARK_SIZE.height
41+
const viewBoxAspect = WORDMARK_VIEW_BOX.width / WORDMARK_VIEW_BOX.height
42+
expect(Math.abs(boxAspect - viewBoxAspect) / viewBoxAspect).toBeLessThan(0.01)
43+
})
44+
45+
it('carries every glyph of the logotype', () => {
46+
expect(WORDMARK_PATHS).toHaveLength(4)
47+
for (const d of WORDMARK_PATHS) expect(d.startsWith('M')).toBe(true)
48+
})
49+
})

apps/sim/lib/branding/wordmark.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* The "sim" logotype paths from the v1.0 brand guide (`simLogotype--dark.svg`).
3+
*
4+
* Shared so the two surfaces that draw the mark cannot drift. The landing
5+
* navbar inlines them as server-rendered SVG; `scripts/generate-email-wordmark.ts`
6+
* rasterizes the same paths into the PNG the email header uses, because email
7+
* clients (Gmail chief among them) strip inline SVG.
8+
*/
9+
export const WORDMARK_VIEW_BOX = { width: 441, height: 212 } as const
10+
11+
/** Glyph outlines, drawn in `WORDMARK_VIEW_BOX` coordinates. */
12+
export const WORDMARK_PATHS: readonly string[] = [
13+
'M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z',
14+
'M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z',
15+
'M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z',
16+
'M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z',
17+
]
18+
19+
/**
20+
* Display box for the email header wordmark.
21+
*
22+
* The landing navbar sizes the mark at its neighbouring text plus 2px above and
23+
* below (18px ink against 14px chip labels). Email body copy is 16px, so the
24+
* mark stands 20px tall; the width follows the view box. Both dimensions are
25+
* pinned because email clients do no responsive image selection.
26+
*/
27+
export const EMAIL_WORDMARK_SIZE = { width: 42, height: 20 } as const
28+
29+
/** Retina factor the email raster is generated at. */
30+
export const EMAIL_WORDMARK_SCALE = 4
-7.56 KB
Loading
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Regenerates the email header wordmark raster from the shared brand outlines.
3+
*
4+
* Email clients strip inline SVG, so the header has to ship a PNG - but that PNG
5+
* is derived from the same `WORDMARK_PATHS` the landing navbar renders, and
6+
* filled with the same `textBody` ink the email's own body copy uses, so the two
7+
* surfaces cannot drift apart.
8+
*
9+
* Run with `bun run apps/sim/scripts/generate-email-wordmark.ts` and commit the
10+
* result; nothing regenerates it at build time.
11+
*/
12+
13+
import { writeFile } from 'node:fs/promises'
14+
import path from 'node:path'
15+
import { createLogger } from '@sim/logger'
16+
import sharp from 'sharp'
17+
import { colors } from '@/components/emails/_styles'
18+
import {
19+
EMAIL_WORDMARK_SCALE,
20+
EMAIL_WORDMARK_SIZE,
21+
WORDMARK_PATHS,
22+
WORDMARK_VIEW_BOX,
23+
} from '@/lib/branding/wordmark'
24+
25+
const logger = createLogger('GenerateEmailWordmark')
26+
27+
const OUTPUT_PATH = path.join(
28+
import.meta.dirname,
29+
'..',
30+
'public',
31+
'brand',
32+
'color',
33+
'email',
34+
'wordmark.png'
35+
)
36+
37+
function buildSvg(width: number, height: number, fill: string): string {
38+
const paths = WORDMARK_PATHS.map((d) => `<path d="${d}" />`).join('')
39+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${WORDMARK_VIEW_BOX.width} ${WORDMARK_VIEW_BOX.height}"><g fill="${fill}">${paths}</g></svg>`
40+
}
41+
42+
async function main(): Promise<void> {
43+
const width = EMAIL_WORDMARK_SIZE.width * EMAIL_WORDMARK_SCALE
44+
const height = EMAIL_WORDMARK_SIZE.height * EMAIL_WORDMARK_SCALE
45+
const svg = buildSvg(width, height, colors.textBody)
46+
47+
const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer()
48+
await writeFile(OUTPUT_PATH, png)
49+
50+
logger.info('Generated email wordmark', {
51+
output: OUTPUT_PATH,
52+
pixels: `${width}x${height}`,
53+
displayedAt: `${EMAIL_WORDMARK_SIZE.width}x${EMAIL_WORDMARK_SIZE.height}`,
54+
fill: colors.textBody,
55+
bytes: png.byteLength,
56+
})
57+
}
58+
59+
await main()

0 commit comments

Comments
 (0)