-
Notifications
You must be signed in to change notification settings - Fork 10
feat(emails): welcome steps mirror the app's four checkpoints (chat#1889 item 1, part 4) #787
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7eee332
7ec6867
40df59d
e3fbd06
ab04ea5
59191a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { renderEmailLayout } from "@/lib/emails/renderEmailLayout"; | ||
|
|
||
| describe("renderEmailLayout", () => { | ||
| it("wraps the body HTML inside the layout", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Hello world</p>" }); | ||
| expect(html).toContain("<p>Hello world</p>"); | ||
| }); | ||
|
|
||
| it("includes the footer HTML when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| footerHtml: "<div>Footer bits</div>", | ||
| }); | ||
| expect(html).toContain("<div>Footer bits</div>"); | ||
| }); | ||
|
|
||
| it("omits the footer region when no footer is provided", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| expect(html).not.toContain("Footer bits"); | ||
| }); | ||
|
|
||
| it("renders a CTA button with the given label and url when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| cta: { label: "Open Recoup", url: "https://chat.recoupable.dev" }, | ||
| }); | ||
| expect(html).toContain("Open Recoup"); | ||
| expect(html).toContain('href="https://chat.recoupable.dev"'); | ||
| }); | ||
|
|
||
| it("does not render a CTA when none is provided", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| expect(html).not.toContain("<a "); | ||
| }); | ||
|
|
||
| it("carries the Recoup house style — wordmark, font stack, and shadow-as-border card", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| // Header wordmark. | ||
| expect(html).toContain("Recoup"); | ||
| // Achromatic near-black brand ink (DESIGN.md --foreground). | ||
| expect(html).toContain("#0a0a0a"); | ||
| // Shadow-as-border card outline (DESIGN.md), not a CSS `border` on the card. | ||
| expect(html).toContain("box-shadow"); | ||
| // Brand font stack (Plus Jakarta Sans for UI, per DESIGN.md). | ||
| expect(html).toContain("Plus Jakarta Sans"); | ||
| // Constrained, centered container. | ||
| expect(html).toContain("max-width"); | ||
| }); | ||
|
|
||
| it("never breaks a style attribute with quotes in the font stack", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
|
|
||
| // Regression: double-quoted font names inside a double-quoted style="…" | ||
| // attribute terminate it early, silently dropping the font (clients fall | ||
| // back to serif) and every declaration after it. Font names must be | ||
| // single-quoted so the attribute stays intact. | ||
| expect(html).not.toContain('"Plus Jakarta Sans"'); | ||
| expect(html).toContain("'Plus Jakarta Sans'"); | ||
| // No style attribute may contain a stray double quote before its close. | ||
| for (const style of html.match(/style="[^"]*"/g) ?? []) { | ||
| expect(style).not.toContain('font-family:"'); | ||
| } | ||
| }); | ||
|
|
||
| it("returns a single HTML string", () => { | ||
| expect(typeof renderEmailLayout({ bodyHtml: "<p>x</p>" })).toBe("string"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| export type EmailLayoutCta = { | ||
| /** Button label. */ | ||
| label: string; | ||
| /** Absolute URL the button links to. */ | ||
| url: string; | ||
| }; | ||
|
|
||
| export type RenderEmailLayoutParams = { | ||
| /** The email's main content, already rendered to HTML. */ | ||
| bodyHtml: string; | ||
| /** Optional footer HTML (reply note, chat link, artist attribution). */ | ||
| footerHtml?: string; | ||
| /** Optional primary call-to-action rendered as a button below the body. */ | ||
| cta?: EmailLayoutCta; | ||
| }; | ||
|
|
||
| // Brand tokens (DESIGN.md §3) — kept literal because email clients can't read | ||
| // CSS custom properties. Achromatic chrome; color comes from content. | ||
| const INK = "#0a0a0a"; // --foreground | ||
| const MUTED_INK = "#6b6b6b"; // --muted-foreground | ||
| const PAGE_BG = "#f7f7f7"; // --muted (page canvas) | ||
| const CARD_BG = "#ffffff"; // --card | ||
| const BORDER = "#e8e8e8"; // --border | ||
| const CTA_BG = "#0a0a0a"; // --primary | ||
| const CTA_FG = "#ffffff"; // --primary-foreground | ||
|
|
||
| // UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system | ||
| // fallbacks for clients that can't load a webfont. | ||
| // | ||
| // Font names are SINGLE-quoted on purpose: this stack is interpolated into | ||
| // double-quoted `style="…"` attributes, so double quotes here would terminate | ||
| // the attribute early — silently dropping the font (clients fall back to | ||
| // serif) and every declaration after it. | ||
| const FONT_STACK = | ||
| "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; | ||
|
|
||
| // Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via | ||
| // box-shadow instead of a CSS `border` on the card. | ||
| const CARD_SHADOW = `box-shadow: 0px 0px 0px 1px ${BORDER}, 0px 2px 4px rgba(0,0,0,0.04);`; | ||
|
|
||
| /** | ||
| * Shared house-style wrapper for all onboarding emails (welcome, valuation, | ||
| * weekly report — recoupable/chat#1885 consistency pass). Wraps a rendered | ||
| * body in one header + footer + CTA structure so every automated email reads | ||
| * as one family: achromatic chrome, shadow-as-border card, the Recoup wordmark | ||
| * header, and the DESIGN.md font stack. | ||
| * | ||
| * Email-client-safe: all styles inline, a centered fixed-max-width container, | ||
| * literal hex tokens (no CSS variables), and webfonts degrade to system fonts. | ||
| */ | ||
| export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayoutParams): string { | ||
| const header = ` | ||
| <div style="padding:0 0 20px;"> | ||
| <span style="font-family:${FONT_STACK};font-weight:700;font-size:18px;letter-spacing:-0.02em;color:${INK};">Recoup</span> | ||
| </div>`.trim(); | ||
|
|
||
| const ctaBlock = cta | ||
| ? ` | ||
| <div style="padding:24px 0 4px;"> | ||
| <a href="${cta.url}" target="_blank" rel="noopener noreferrer" | ||
| style="display:inline-block;background:${CTA_BG};color:${CTA_FG};font-family:${FONT_STACK};font-weight:600;font-size:14px;text-decoration:none;padding:12px 20px;border-radius:8px;"> | ||
| ${cta.label} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: CTA labels containing markup are emitted as HTML, so any non-static caller can alter the email content. Treat Prompt for AI agents
Comment on lines
+60
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Escape CTA URLs and labels at the layout boundary.
🤖 Prompt for AI Agents |
||
| </a> | ||
| </div>`.trim() | ||
| : ""; | ||
|
|
||
| const footerBlock = footerHtml | ||
| ? ` | ||
| <div style="padding-top:24px;margin-top:24px;"> | ||
| ${footerHtml} | ||
| </div>`.trim() | ||
| : ""; | ||
|
|
||
| return ` | ||
| <div style="background:${PAGE_BG};padding:32px 16px;font-family:${FONT_STACK};color:${INK};"> | ||
| <div style="max-width:560px;margin:0 auto;background:${CARD_BG};border-radius:12px;${CARD_SHADOW}padding:32px;"> | ||
| ${header} | ||
| <div style="font-family:${FONT_STACK};font-size:15px;line-height:1.6;color:${INK};"> | ||
| ${bodyHtml} | ||
| </div> | ||
| ${ctaBlock} | ||
| ${footerBlock} | ||
| </div> | ||
| <div style="max-width:560px;margin:16px auto 0;text-align:center;font-family:${FONT_STACK};font-size:11px;color:${MUTED_INK};"> | ||
| Recoup, the AI agent platform for the music industry | ||
| </div> | ||
| </div>`.trim(); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: Flag AI Slop and Fabricated Changes
The comments cite 'DESIGN.md §3' and 'DESIGN.md four-font system' as the source for brand tokens and the font stack, but a full-repo search confirms no DESIGN.md exists. Remove the fabricated document references from the comments so they describe the actual intent locally without pointing to a non-existent file.
Prompt for AI agents