@broberg/mail-core 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ const html = renderShell({
30
30
 
31
31
  ## Why `accentColor` is required, not defaulted
32
32
 
33
- Every other color has a sensible neutral default (`cardBg` white, `textColor` auto-derived for contrast against `cardBg`, `backdropColor` light grey) — but `accentColor` doesn't, on purpose. A default accent color IS a brand choice; shipping one would silently brand every consumer that forgets to set it. Pass your product's own accent explicitly.
33
+ Every other color has a sensible neutral default (`cardBg` #fffffe — see below, `textColor` auto-derived for contrast against `cardBg`, `backdropColor` light grey) — but `accentColor` doesn't, on purpose. A default accent color IS a brand choice; shipping one would silently brand every consumer that forgets to set it. Pass your product's own accent explicitly.
34
34
 
35
35
  ## Dark cards work
36
36
 
@@ -50,3 +50,60 @@ if (attachment) {
50
50
  await resend.emails.send({ ..., attachments: [attachment] });
51
51
  }
52
52
  ```
53
+
54
+ ## Signatures — three tiers, one axis each
55
+
56
+ ```ts
57
+ signOff([
58
+ { text: "Med venlig hilsen" }, // lead
59
+ { text: "Christian Broberg", tier: "name" }, // + bold
60
+ { text: "CEO & Founding Partner · WebHouse ApS", tier: "meta" }, // + muted colour
61
+ ]);
62
+ ```
63
+
64
+ **Each tier changes exactly one axis against `lead`** — `name` adds weight,
65
+ `meta` adds a muted colour, and neither touches size. That is an invariant with a
66
+ test behind it, not a matter of taste, and it is why there is no fourth tier
67
+ coming: there is no fourth axis left to spend.
68
+
69
+ Two things it deliberately does **not** do:
70
+
71
+ - **`name` is bold, not bold-and-darker.** On a real palette `#1a1c2b` is 16.86:1
72
+ on white and `#0b0e15` is 19.29:1 — both so far past every threshold that the
73
+ step cannot be seen. The weight does the work; the colour shift was decoration.
74
+ - **`meta` has no size of its own.** A tier carrying a *relative* size step turns
75
+ a 17/17-bold/15 signature into 15/15-bold/13 in a palette with a smaller base,
76
+ and 13px secondary text is the exact thing one consumer measured their way out
77
+ of (13.5px `#8486a6` at 3.5:1, failing WCAG in **light** mode before anyone
78
+ mentioned dark). 15px is a measured floor for secondary text in mail.
79
+
80
+ `meta`'s colour is a real value (`#4a4d63`, 8.29:1 on white, 7.54:1 on the
81
+ default backdrop) and never an `opacity`. **An opacity is not a low contrast
82
+ value — it is a contrast value for ONE background.** `opacity:0.65` of `#1a1c2b`
83
+ measures 5.29:1 while the ground stays white and lands somewhere nobody measured
84
+ the moment a client tints or inverts; no contrast tool can read it, because there
85
+ is no colour there to read.
86
+
87
+ ### The legacy form still works, byte for byte
88
+
89
+ `signOff(line1, line2, sign)` renders identically to how it always has — asserted
90
+ against a snapshot captured from the shipped build, because repos are calling it
91
+ in production mail. It has one fixed axis (size) and its big slot is the **last**
92
+ argument, which is why the array form exists: a name-then-title signature had to
93
+ be forced into it, and rendered the job title larger than the person.
94
+
95
+ The one correction: an empty `sign` no longer emits a blank line and an empty
96
+ `<span style="font-size:20px;"></span>`.
97
+
98
+ ## The centred 180px logo is a deliberate shared choice
99
+
100
+ `renderShell` centres the logo at `max-width:180px`, and that is on purpose
101
+ rather than pending. A consumer arrived with their own 40px mark beside the
102
+ sender name and dropped it for this one — *"one shared expression is worth more
103
+ than our variant."* Recorded so the next consumer does not have to ask.
104
+
105
+ **But the brand hooks themselves stay caller-supplied.** Do not wrap
106
+ `logoUrl` / `accentColor` / `fontSerif` in a constant inside a consuming repo:
107
+ **the mail carries the SENDER's identity, and the sender is not always the repo
108
+ the template lives in.** A shared shell with per-send branding is the point; a
109
+ shell with the branding baked in is a different, worse product.
package/dist/index.cjs CHANGED
@@ -17,7 +17,7 @@ function isDark(hex) {
17
17
  return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
18
18
  }
19
19
  function resolveColors(b) {
20
- const cardBg = b.cardBg ?? "#ffffff";
20
+ const cardBg = b.cardBg ?? "#fffffe";
21
21
  const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
22
22
  const backdropColor = b.backdropColor ?? "#f4f4f5";
23
23
  const fontSans = b.fontSans ?? "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
@@ -44,9 +44,10 @@ function renderShell(opts) {
44
44
  <img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
45
45
  </td></tr>
46
46
  </table>` : "";
47
+ const footerText = isDark(backdropColor) ? "#c1c2d1" : "#4a4d63";
47
48
  const footerBlock = showFooter ? `<tr>
48
- <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);">
49
- ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;opacity:0.65;">${escapeHtml(l)}</p>`).join("")}
49
+ <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
50
+ ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
50
51
  ${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${accentColor};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
51
52
  </td>
52
53
  </tr>` : "";
@@ -145,12 +146,21 @@ function paragraph(text) {
145
146
  function paragraphHtml(html) {
146
147
  return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
147
148
  }
148
- function signOff(line1, line2, sign) {
149
+ var SIGNOFF_META_LIGHT = "#4a4d63";
150
+ var SIGNOFF_META_DARK = "#c1c2d1";
151
+ function signOffLine(line, metaColor) {
152
+ const text = escapeHtml(line.text);
153
+ if (line.tier === "name") return `<strong style="font-weight:700;">${text}</strong>`;
154
+ if (line.tier === "meta") return `<span style="color:${metaColor};">${text}</span>`;
155
+ return text;
156
+ }
157
+ function signOff(a, b, sign) {
158
+ const br = "<br>\n ";
159
+ const metaColor = Array.isArray(a) && typeof b === "object" && b?.cardBg && isDark(b.cardBg) ? SIGNOFF_META_DARK : SIGNOFF_META_LIGHT;
160
+ const body = Array.isArray(a) ? a.map((l) => signOffLine(l, metaColor)).join(br) : [escapeHtml(a), escapeHtml(typeof b === "string" ? b : "")].join(br) + (sign ? `${br}<span style="font-size:20px;">${escapeHtml(sign)}</span>` : "");
149
161
  return `<div style="margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;">
150
162
  <p style="margin:0;font-size:15px;line-height:1.8;">
151
- ${escapeHtml(line1)}<br>
152
- ${escapeHtml(line2)}<br>
153
- <span style="font-size:20px;">${escapeHtml(sign)}</span>
163
+ ${body}
154
164
  </p>
155
165
  </div>`;
156
166
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? \"#c1c2d1\" : \"#4a4d63\";\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = \"#4a4d63\";\nconst SIGNOFF_META_DARK = \"#c1c2d1\";\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -15,7 +15,9 @@ interface BrandColors {
15
15
  /** Top-of-card accent + CTA button color. Required — no fleet-wide default,
16
16
  * so nothing is silently branded as some other product's identity. */
17
17
  accentColor: string;
18
- /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')
18
+ /** Card background. Default '#fffffe' — one byte off white on purpose, so a
19
+ * client looking for EXACTLY #ffffff does not decide the mail wants
20
+ * inverting. Pass a dark value (e.g. '#1a1a1a')
19
21
  * for a dark-card brand; textColor's default adapts automatically. */
20
22
  cardBg?: string;
21
23
  /** Body text color. Default derived from cardBg (light card → dark text,
@@ -131,6 +133,56 @@ declare function paragraph(text: string): string;
131
133
  /** Like paragraph(), but the string is injected as raw HTML (not escaped) —
132
134
  * the caller must escapeHtml() any dynamic values themselves. */
133
135
  declare function paragraphHtml(html: string): string;
136
+ /** One line of a signature, and the tier that styles it.
137
+ *
138
+ * THE INVARIANT, and it is testable rather than a matter of taste: **each tier
139
+ * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,
140
+ * because there is no fourth axis left to spend.
141
+ *
142
+ * lead the base — the size and colour of the surrounding text
143
+ * name + bold (same size, same colour)
144
+ * meta + muted colour (same size, same weight)
145
+ *
146
+ * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:
147
+ * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15
148
+ * is 19.29:1. Both are so far past every threshold that the step cannot be
149
+ * seen. The weight does all the work; the colour shift was decoration. Their
150
+ * finding, on their own design.
151
+ *
152
+ * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier
153
+ * carrying a *relative* size step turns a 17/17-bold/15 signature into
154
+ * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is
155
+ * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at
156
+ * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went
157
+ * UP in size as part of what doubled legibility. A relative step would quietly
158
+ * roll that back, and the fault would live in a tier definition nobody reads
159
+ * while choosing `meta`. 15px is a measured floor for secondary text in mail.
160
+ */
161
+ interface SignOffLine {
162
+ text: string;
163
+ tier?: "lead" | "name" | "meta";
164
+ }
165
+ /** A signature block.
166
+ *
167
+ * TWO FORMS, and the old one is load-bearing: three repos call
168
+ * `signOff(line1, line2, sign)` in production mail, so it renders
169
+ * byte-identically and always will.
170
+ *
171
+ * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is
172
+ * the LAST argument and its only axis is size. A name-then-title signature had
173
+ * to be forced into it, and rendered the job title larger than the person —
174
+ * in a mail Christian opened. The API could not express the signature, so the
175
+ * mapping was wrong before anyone wrote a line of calling code.
176
+ *
177
+ * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it
178
+ * would place the name and still leave the title nowhere to go, i.e. the same
179
+ * defect in a new shape. It also defaults to index 0 — "Med venlig hilsen" —
180
+ * inverting the old form's last-line emphasis for everyone who did not pass
181
+ * the option. vn-leker caught that; it was worse than the bug it fixed.
182
+ */
183
+ declare function signOff(lines: SignOffLine[], opts?: {
184
+ cardBg?: string;
185
+ }): string;
134
186
  declare function signOff(line1: string, line2: string, sign: string): string;
135
187
  /** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action
136
188
  * button — the pattern every surveyed template hand-rolled per-brand. */
@@ -162,4 +214,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
162
214
  contentType?: string;
163
215
  }): MailAttachment | null;
164
216
 
165
- export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
217
+ export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
package/dist/index.d.ts CHANGED
@@ -15,7 +15,9 @@ interface BrandColors {
15
15
  /** Top-of-card accent + CTA button color. Required — no fleet-wide default,
16
16
  * so nothing is silently branded as some other product's identity. */
17
17
  accentColor: string;
18
- /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')
18
+ /** Card background. Default '#fffffe' — one byte off white on purpose, so a
19
+ * client looking for EXACTLY #ffffff does not decide the mail wants
20
+ * inverting. Pass a dark value (e.g. '#1a1a1a')
19
21
  * for a dark-card brand; textColor's default adapts automatically. */
20
22
  cardBg?: string;
21
23
  /** Body text color. Default derived from cardBg (light card → dark text,
@@ -131,6 +133,56 @@ declare function paragraph(text: string): string;
131
133
  /** Like paragraph(), but the string is injected as raw HTML (not escaped) —
132
134
  * the caller must escapeHtml() any dynamic values themselves. */
133
135
  declare function paragraphHtml(html: string): string;
136
+ /** One line of a signature, and the tier that styles it.
137
+ *
138
+ * THE INVARIANT, and it is testable rather than a matter of taste: **each tier
139
+ * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,
140
+ * because there is no fourth axis left to spend.
141
+ *
142
+ * lead the base — the size and colour of the surrounding text
143
+ * name + bold (same size, same colour)
144
+ * meta + muted colour (same size, same weight)
145
+ *
146
+ * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:
147
+ * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15
148
+ * is 19.29:1. Both are so far past every threshold that the step cannot be
149
+ * seen. The weight does all the work; the colour shift was decoration. Their
150
+ * finding, on their own design.
151
+ *
152
+ * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier
153
+ * carrying a *relative* size step turns a 17/17-bold/15 signature into
154
+ * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is
155
+ * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at
156
+ * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went
157
+ * UP in size as part of what doubled legibility. A relative step would quietly
158
+ * roll that back, and the fault would live in a tier definition nobody reads
159
+ * while choosing `meta`. 15px is a measured floor for secondary text in mail.
160
+ */
161
+ interface SignOffLine {
162
+ text: string;
163
+ tier?: "lead" | "name" | "meta";
164
+ }
165
+ /** A signature block.
166
+ *
167
+ * TWO FORMS, and the old one is load-bearing: three repos call
168
+ * `signOff(line1, line2, sign)` in production mail, so it renders
169
+ * byte-identically and always will.
170
+ *
171
+ * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is
172
+ * the LAST argument and its only axis is size. A name-then-title signature had
173
+ * to be forced into it, and rendered the job title larger than the person —
174
+ * in a mail Christian opened. The API could not express the signature, so the
175
+ * mapping was wrong before anyone wrote a line of calling code.
176
+ *
177
+ * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it
178
+ * would place the name and still leave the title nowhere to go, i.e. the same
179
+ * defect in a new shape. It also defaults to index 0 — "Med venlig hilsen" —
180
+ * inverting the old form's last-line emphasis for everyone who did not pass
181
+ * the option. vn-leker caught that; it was worse than the bug it fixed.
182
+ */
183
+ declare function signOff(lines: SignOffLine[], opts?: {
184
+ cardBg?: string;
185
+ }): string;
134
186
  declare function signOff(line1: string, line2: string, sign: string): string;
135
187
  /** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action
136
188
  * button — the pattern every surveyed template hand-rolled per-brand. */
@@ -162,4 +214,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
162
214
  contentType?: string;
163
215
  }): MailAttachment | null;
164
216
 
165
- export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
217
+ export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ function isDark(hex) {
15
15
  return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
16
16
  }
17
17
  function resolveColors(b) {
18
- const cardBg = b.cardBg ?? "#ffffff";
18
+ const cardBg = b.cardBg ?? "#fffffe";
19
19
  const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
20
20
  const backdropColor = b.backdropColor ?? "#f4f4f5";
21
21
  const fontSans = b.fontSans ?? "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
@@ -42,9 +42,10 @@ function renderShell(opts) {
42
42
  <img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
43
43
  </td></tr>
44
44
  </table>` : "";
45
+ const footerText = isDark(backdropColor) ? "#c1c2d1" : "#4a4d63";
45
46
  const footerBlock = showFooter ? `<tr>
46
- <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);">
47
- ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;opacity:0.65;">${escapeHtml(l)}</p>`).join("")}
47
+ <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
48
+ ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
48
49
  ${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${accentColor};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
49
50
  </td>
50
51
  </tr>` : "";
@@ -143,12 +144,21 @@ function paragraph(text) {
143
144
  function paragraphHtml(html) {
144
145
  return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
145
146
  }
146
- function signOff(line1, line2, sign) {
147
+ var SIGNOFF_META_LIGHT = "#4a4d63";
148
+ var SIGNOFF_META_DARK = "#c1c2d1";
149
+ function signOffLine(line, metaColor) {
150
+ const text = escapeHtml(line.text);
151
+ if (line.tier === "name") return `<strong style="font-weight:700;">${text}</strong>`;
152
+ if (line.tier === "meta") return `<span style="color:${metaColor};">${text}</span>`;
153
+ return text;
154
+ }
155
+ function signOff(a, b, sign) {
156
+ const br = "<br>\n ";
157
+ const metaColor = Array.isArray(a) && typeof b === "object" && b?.cardBg && isDark(b.cardBg) ? SIGNOFF_META_DARK : SIGNOFF_META_LIGHT;
158
+ const body = Array.isArray(a) ? a.map((l) => signOffLine(l, metaColor)).join(br) : [escapeHtml(a), escapeHtml(typeof b === "string" ? b : "")].join(br) + (sign ? `${br}<span style="font-size:20px;">${escapeHtml(sign)}</span>` : "");
147
159
  return `<div style="margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;">
148
160
  <p style="margin:0;font-size:15px;line-height:1.8;">
149
- ${escapeHtml(line1)}<br>
150
- ${escapeHtml(line2)}<br>
151
- <span style="font-size:20px;">${escapeHtml(sign)}</span>
161
+ ${body}
152
162
  </p>
153
163
  </div>`;
154
164
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? \"#c1c2d1\" : \"#4a4d63\";\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = \"#4a4d63\";\nconst SIGNOFF_META_DARK = \"#c1c2d1\";\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@broberg/mail-core",
3
- "version": "0.2.1",
4
- "description": "Branded HTML email shell + primitives for the broberg.ai fleet — renderShell, heading/paragraph/cta/factBox/signOff, and a CID logo-attachment helper. No sending (@broberg/mail) and no template-content/override-resolution (@broberg/mail-templates) — layer 1 (visual structure) only. Every brand value is a caller-supplied param.",
3
+ "version": "0.3.1",
4
+ "description": "Branded HTML email shell + primitives for the broberg.ai fleet — renderShell, heading/paragraph/cta/factBox/signOff, eyebrow/noteBox, a three-tier signOff, and a CID logo-attachment helper. No sending (@broberg/mail) and no template storage (that lives in cardmem) — layer 1 (visual structure) only. Every brand value is a caller-supplied param.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "sideEffects": false,