EMAIL_DESIGN_SYSTEM.md β h.work
The shared brand + template system for every transactional email h.work sends. Brand lives in one file; templates compose partials; a designer changes a token once and all emails reflect it.
Issue: #1252 (part of epic #1258). Sibling issues: #1253 audience IA β TRANSACTIONAL_EMAIL_IA.md Β· #1254 copy/voice Β· #1255 the MJML+Handlebars engine (ADR-032) and the per-email migration.
Cross-reference: branding.md /
/BRANDING.md, DESIGN_PRINCIPLES.md,api/src/email/.Last updated: 2026-06-26 (#1252 β canonical brand tokens replace the placeholder slate palette).
0. What this isβ
Before this work every transactional email in email.service.ts was hand-rolled HTML: a dark navy header (#0f172a), a muted-gray wordmark, a generic black CTA β duplicated across 11+ (now 22) methods. "The header looks bland" had to be fixed in 11 places.
The system splits that into three layers, each with one home:
| Layer | Home | Owner |
|---|---|---|
| Brand tokens | api/src/email/templates/brand.ts | #1252 (this doc) |
| Base template + partials | api/src/email/templates/ (partials/{header,footer}.mjml.hbs, per-template template.mjml.hbs) | #1255 (engine) β consumes the tokens |
| Rendering engine | api/src/email/template-engine.service.ts | #1255 (ADR-032) |
A brand change is now a one-file edit to brand.ts: the engine injects brand into every render and the partials/templates reference it as {{brand.*}}, so the change propagates to every email with no per-template sweep.
1. Brand tokensβ
brand.ts is the single source of truth. The canonical palette (from BRANDING.md / TRANSACTIONAL_EMAIL_IA.md Β§6.6):
| Name | Hex | Role in email |
|---|---|---|
| H red (claret) | #7E1C1E | Primary accent / CTA, warm client header band |
| Dark Job (ink) | #1A1611 | Primary body text + dark header bands |
| Grey day | #87837C | Secondary / muted copy, footer, neutral eyebrow |
| Bright work | #F4F0E6 | Page canvas |
Why hex, not the frontend's oklch?
frontend/src/app/globals.cssexpresses the brand asoklch()CSS custom properties consumed by Tailwind. Email MUAs (notably Outlook) need literal sRGB hex with web-safe fallbacks β no CSS variables, nooklch(). So the brand is re-expressed as hex inbrand.ts, kept in sync by value with the Visual Identity Guidelines the oklch tokens were derived from. The frontend remains the source of truth for the web app;brand.tsis the source of truth for email.
Type stackβ
Manrope,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif
Manrope is the brand body face. MUAs can't load web fonts reliably, so Manrope leads a web-safe fallback chain β recipients with it installed get brand, everyone else gets a metrics-compatible neutral sans. (Display/serif β Source Serif 4 β is intentionally not used in transactional email; system mail reads cleaner in the body sans. See the BRANDING.md "brand spec vs in-code" note.)
BrandTokens shape (the stable contract)β
The keys are the contract the #1255 partials reference by name β keep them stable; adding a token means the partials can opt in, removing/renaming one breaks every template.
interface BrandTokens {
headerBg; headerFg; eyebrowFg; // header band
canvas; cardBg; cardBorder; // surfaces
textPrimary; textMuted; // copy
ctaBg; ctaFg; // primary button
fontStack; // body type
}
Surface tokens (canvas, cardBg, cardBorder, textPrimary, textMuted, fontStack) are shared across treatments via a single BASE so the system reads as one brand; treatments override only the header band, eyebrow accent, and CTA emphasis.
2. The five treatmentsβ
Each email maps to one of the five brand treatments from IA Β§7. The treatment is declared per-template in registry.ts (treatment: "client" | "expert" | ...); the engine looks up BRAND_TREATMENTS[treatment] and injects it.
| Treatment | Header band | Eyebrow | CTA | Used by (IA Β§7) |
|---|---|---|---|---|
| CLIENT | Claret #7E1C1E (warm, calm) | Muted rose #E6C9C9 | Claret | 4.1β4.7 β onboarding, billing, check-ins |
| EXPERT | Dark ink #1A1611 | Lightened claret #D9A0A1 | Claret | recruiting + onboarding mail |
| EXPERT-urgency | Claretβamber warning #8A3324 (not raw red) | Warm amber #F4C79A | Claret | expiry / final-reminder only |
| AM-Ops | Denser charcoal #241F1A, denser canvas #EAE5D8 | Ops orange #C2410C | Ink (links into /ops/*) | internal ops mail |
| SYSTEM | Minimal ink #1A1611 | Neutral grey #87837C | none (code is the payload) | OTP, password reset |
Design intent behind the differentiation:
- CLIENT vs EXPERT β clients get the warm claret header; experts get a dark-ink header with a claret eyebrow accent. The two are separable at a glance, which the IA flagged as a failure of the old "everything looks 90% identical" state.
- EXPERT-urgency keeps the CTA claret so the action reads as brand, not alarm β only the band signals urgency, and it's a claret/amber blend, never raw
#dc2626. - AM-Ops is "unmistakably internal": denser charcoal header, ops-orange eyebrow, and an ink CTA (operational, not the warm claret clients see). This generalizes the one good ops identity that already existed (
sendKycFailedToOps, IA Β§6.8). - SYSTEM is security-neutral: minimal header, neutral grey eyebrow (no accent colour), no marketing CTA.
3. Base template & partialsβ
Templates are MJML + Handlebars (template.mjml.hbs), compiled by the engine. The canonical wrapper is mj-body (canvas) β mj-wrapper (card) β {{> header}} β body section β {{> footer}}.
partials/header.mjml.hbsβ the audience-aware header band: wordmark eyebrow ({{brand.eyebrowFg}}) +{{heading}}on{{brand.headerBg}}.partials/footer.mjml.hbsβ wordmark, optional CAN-SPAM postal address ({{postalAddress}}, #1257), and an unsubscribe line rendered only when{{unsubscribeUrl}}is passed (nurture mail only β IA Β§6.9).- CTA β MJML's
mj-buttonstyled from{{brand.ctaBg/ctaFg}}; href hardened by thesafeUrlhelper (http(s) allow-list, XML-encoded). Primary today; secondary/link-only variants compose from the same tokens as templates migrate. - Info-card / Specialist card / OTP code box / progress bar β structural patterns the IA flagged as keepers (Β§6.6); they live in the per-template
template.mjml.hbsand reference{{brand.*}}for colour, so a token change restyles them too.
Scope boundary. This issue (#1252) ships the brand tokens the partials and templates consume. The partial/template structure and the per-email migration of all 22 methods is #1255 β its comment on #1252 confirms the split, and the
BrandTokensinterface is the contract between the two. Two templates (invite,otp) are migrated today as the structural proof; the rest land via #1255 and inherit these tokens automatically.
4. Plain-text & client compatibilityβ
- Plain-text parity (IA Β§6.3) β every email ships a
text/plainpart. The engine uses a hand-authoredtext.txt.hbswhen present, else auto-derives it from the rendered HTML (html-to-text). Not optional. - Email-client safety β MJML compiles to table-based, inline-CSS HTML (no flexbox/grid), which is what the acceptance targets (Gmail web/iOS, Apple Mail macOS/iOS, Outlook desktop/web) require. The token hex are literal sRGB with web-safe font fallbacks for the same reason.
- Link underline suppression (#2782) β a head
<style>a{text-decoration:none !important}</style>plus aformat-detectionmeta stop MUAs auto-linkifying/underlining the.workwordmark; pinned byemail-link-underline.spec.ts.
5. Previewing & testsβ
- Preview server β
cd api && npm run email:preview(EMAIL_PREVIEW_PORT, default 4500) lists every registered template and renders live HTML + derived plain-text + computed subject, with editable sample data. Non-prod hot-reloads on.hbs/brand.tsedits, so a designer can change a token and refresh to see every template update. This is the "storybook for email" the scope calls for β no separate Storybook runtime/dep. - Tests β
api/test/email-brand-tokens.spec.ts(#1252) β pins the canonical palette, retires the slate, asserts the five treatments stay differentiated, and verifies tokens reach rendered HTML.api/test/email-template-engine.spec.ts(#1255) β pins the engine contract (subject + email-safe HTML + plain-text, auto-escape,safeUrl).
6. Acceptance mapping (#1252)β
| Acceptance criterion | Status |
|---|---|
| Brand tokens are a single source of truth, mirror the frontend theme by value | β
brand.ts |
| Type stack (system-font fallback chain) | β
FONT_STACK (Manrope-led) |
| Five audience-aware header/CTA treatments (IA Β§7) | β
BRAND_TREATMENTS |
| Designer changes one token β every email reflects it | β
engine injects brand; partials/templates reference {{brand.*}}; preview hot-reloads |
| Plain-text auto-derivation for every email | β
engine (text.txt.hbs or html-to-text) |
| Table-based / inline-CSS / no-flexbox output | β MJML compilation |
| Documented design system | β this file |
Spacing/radius scale as named tokens; prefers-color-scheme: dark palette | β»οΈ deferred β both require the partial/engine changes owned by #1255; tracked there. Spacing/radius are currently literals in the templates; dark-mode-aware palette is the next token addition to BrandTokens. |