@qmusic/email-shell 0.0.0-stage → 1.0.0

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
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # @qmusic/email-shell
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Q Music transactional email shell: the warm light card that every client and
4
+ artist email renders in ("Family A" in Q Music App's `EMAIL_STYLE_GUIDE.md`).
5
+
6
+ This is the one copy. Three repos render through it:
7
+
8
+ | Repo | How it imports | Bumped by |
9
+ | --- | --- | --- |
10
+ | qmusic-events-portal | `src/lib/email-shell.js` re-exports the package | Renovate PR |
11
+ | Q Music App (edge functions) | `supabase/functions/_shared/email-shell.ts` is `export * from "npm:@qmusic/email-shell@<version>"` | Renovate PR (regex manager) |
12
+ | gig-bot | `src/lib/email-shell.ts` re-exports the package | Renovate PR |
13
+
14
+ Each repo pins an exact version, so a new version reaches a repo only when that
15
+ repo merges its bump.
16
+
17
+ ## Use
18
+
19
+ ```js
20
+ import {
21
+ renderEmailShell, statusBanner, introBlock, eventDetailCard, noteBlock,
22
+ ctaButton, scheduleCallLink, footerBlock,
23
+ } from '@qmusic/email-shell';
24
+
25
+ const html = renderEmailShell({
26
+ subject: 'Your bids are in',
27
+ preheader: 'Three artists bid on your event.',
28
+ eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
29
+ body: [
30
+ introBlock('Hi Jordan, three artists bid on your event.'),
31
+ eventDetailCard({ month: 'JUL', day: '18', weekday: 'SAT', title: 'Jordan Lee', position: 'The Ivy Room', time: '8:00 PM - 11:00 PM' }),
32
+ ctaButton({ href: 'https://example.com', label: 'View Bids →' }),
33
+ footerBlock(),
34
+ ].join(''),
35
+ });
36
+ ```
37
+
38
+ Exports: `renderEmailShell`, `statusBanner`, `introBlock`, `introBlockHtml`,
39
+ `detailInfoCell`, `lateBadgeRow`, `eventDetailCard`, `noteBlock`, `ctaButton`,
40
+ `secondaryLink`, `scheduleCallLink`, `centeredText`, `centeredStatCard`,
41
+ `footerBlock`, `esc`, `FONT`, `QM`, and the input types.
42
+
43
+ Date cards use the three-line form: pass `title` + `position` + `time` to
44
+ `eventDetailCard()`. Passing only `subtitle` gives the legacy single-line card
45
+ with no time on it.
46
+
47
+ ## How to make a style change
48
+
49
+ 1. Edit `src/index.ts`. Recipient-facing copy has no em dashes.
50
+ 2. `npm run golden:write`, then read the HTML diff under `test/golden/`. That diff
51
+ is the change every email will get; review it like one.
52
+ 3. The first style change after 1.0.0 also deletes `test/reference.test.mjs` and
53
+ `test/reference/`. They pin 1.0.0 to the hand-kept copies it replaced and fail
54
+ on any intentional change.
55
+ 4. Bump the version (`npm version minor --no-git-tag-version`), add a
56
+ `CHANGELOG.md` entry, commit, and tag the commit `v<version>`.
57
+ 5. `npm publish` from that clean, tagged commit. `prepublishOnly` rebuilds, runs
58
+ the tests, and refuses a dirty tree or an untagged HEAD. It needs an npm login
59
+ with publish rights on `@qmusic` and a 2FA code.
60
+ 6. Renovate opens a bump PR in each of the three repos. Merge each one and deploy
61
+ it the way that repo deploys:
62
+ - qmusic-events-portal: `npm run deploy` from main.
63
+ - Q Music App: redeploy every edge function that imports `_shared/email-shell.ts`
64
+ or any `_shared/*-emails.ts`, with the Supabase CLI.
65
+ - gig-bot: its normal deploy.
66
+
67
+ ## Test
68
+
69
+ ```
70
+ npm test
71
+ ```
72
+
73
+ Builds `dist/`, then checks that every case in `test/cases.mjs` renders exactly
74
+ its file in `test/golden/`, and that the pre-package copies in `test/reference/`
75
+ render the same goldens.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * @qmusic/email-shell: the Q Music transactional email shell, the warm light card
3
+ * ("Family A" in Q Music App's EMAIL_STYLE_GUIDE.md).
4
+ *
5
+ * The one copy. The events portal (Cloudflare Worker), the Supabase edge
6
+ * functions (Deno, via an npm: import) and gig-bot all render through this
7
+ * package, so a style change is made here once, published, and reaches each repo
8
+ * when that repo merges its Renovate bump. 1.0.0 renders byte-for-byte what the
9
+ * three hand-kept copies rendered (qmusic-events-portal 3177b08, plus the Deno
10
+ * copy's footerBlock `note`); test/golden pins that.
11
+ *
12
+ * One family for every gated notification: an off-white page (#f0eee9), a white
13
+ * card, and a near-black (#161310) header band. The header is inline HTML
14
+ * (eyebrow, two-tone title, glow logo) when `title` is given, or the legacy
15
+ * FLATTENED PNG (logo + title baked together as pixels) when it is not. Either
16
+ * way it rides a locked dark cell that Gmail's Android dark mode cannot recolor,
17
+ * and the CSS locks below hold the rest of the surfaces (card, insets, CTA) light.
18
+ *
19
+ * Usage:
20
+ * renderEmailShell({
21
+ * subject, preheader,
22
+ * eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
23
+ * body: [
24
+ * introBlock('Hi Jordan, ...'),
25
+ * eventDetailCard({ month:'JUL', day:'18', weekday:'SAT', title:'Jordan Lee', position:'The Ivy Room', time:'8:00 PM - 11:00 PM' }),
26
+ * noteBlock(`<strong>These bids expire in two weeks.</strong> Review them ...`),
27
+ * ctaButton({ href:'https://app.qmusic.co/?dltoken=...', label:'View Bids →' }),
28
+ * scheduleCallLink(),
29
+ * footerBlock(),
30
+ * ].join(''),
31
+ * })
32
+ */
33
+ /** HTML-escape for safe interpolation into the template. */
34
+ export declare function esc(s: unknown): string;
35
+ export declare const FONT = "'Poppins','Helvetica Neue',Helvetica,Arial,sans-serif";
36
+ export declare const QM: {
37
+ readonly page: "#f0eee9";
38
+ readonly card: "#ffffff";
39
+ readonly ink: "#161310";
40
+ readonly bodyInk: "#1a1a1a";
41
+ readonly body: "#5f5a52";
42
+ readonly muted: "#9a948a";
43
+ readonly inset: "#faf9f6";
44
+ readonly insetBorder: "#ebe8e2";
45
+ readonly cream: "#FFFEED";
46
+ readonly coralDark: "#c0392b";
47
+ readonly coralRule: "#e2503f";
48
+ readonly amber: "#FF9800";
49
+ readonly urgent: "#ba0101";
50
+ };
51
+ export interface ShellInput {
52
+ /** The document <title>. */
53
+ subject: string;
54
+ /** Hidden inbox-preview line. */
55
+ preheader: string;
56
+ /** Bulleted kicker on the dark band (e.g. "Artist Bids"). */
57
+ eyebrow?: string;
58
+ /** Cream lead of the two-tone title. Giving a title selects the inline HTML header. */
59
+ title?: string;
60
+ /** Coloured accent word(s) of the title. */
61
+ accent?: string;
62
+ /** Accent/eyebrow colour on the dark band. Default #FF933B. */
63
+ accentColor?: string;
64
+ /** Render the coloured accent BEFORE the plain title ("**We've got it** from
65
+ * here." rather than "Cover in **progress.**"). Default false. */
66
+ accentFirst?: boolean;
67
+ /** LEGACY flattened header PNG; used only when `title` is absent. */
68
+ headerImageUrl?: string;
69
+ /** Logo/img alt text. */
70
+ headerAlt?: string;
71
+ /** Pre-joined HTML from the component helpers below. */
72
+ body: string;
73
+ }
74
+ /** Render a complete email document. */
75
+ export declare function renderEmailShell({ subject, preheader, headerImageUrl, headerAlt, eyebrow, title, accent, accentColor, accentFirst, body }: ShellInput): string;
76
+ export interface StatusBannerInput {
77
+ /** e.g. 'These bids expire in 3 days'. */
78
+ text: string;
79
+ /** amber = deadline approaching, red = imminent. */
80
+ variant?: 'amber' | 'red';
81
+ /** Leading HTML entity (raw), default a stopwatch. */
82
+ glyph?: string;
83
+ }
84
+ /**
85
+ * Full-bleed time-pressure strip, placed directly under the header (pass it as
86
+ * the FIRST body component). Dark label on amber, white label on red.
87
+ */
88
+ export declare function statusBanner({ text, variant, glyph }: StatusBannerInput): string;
89
+ /** Lead paragraph, addressed to the recipient. Pass plain text (escaped here). */
90
+ export declare function introBlock(text: string): string;
91
+ /**
92
+ * Same as introBlock but `html` is interpolated raw, so callers can emphasize
93
+ * a word (<strong>…</strong>). Only pass trusted/escaped content.
94
+ */
95
+ export declare function introBlockHtml(html: string): string;
96
+ export interface DetailInfoInput {
97
+ /** Line 1, the identifier: client name for client/admin mail, venue for artist mail. */
98
+ title: string;
99
+ /** Line 2, the muted uppercase label (venue, or position/event type).
100
+ * Supplying this or a time selects the three-line house layout. */
101
+ position?: string;
102
+ /** Line 3, the times on their own larger line, e.g. '8:00 PM - 11:45 PM'. */
103
+ time?: string;
104
+ /** Pre-rendered time markup (raw, trusted). Wins over `time` when both are given. */
105
+ timeHtml?: string;
106
+ /** Legacy single second line, used only when there is no position or time. */
107
+ subtitle?: string;
108
+ /** Pre-built subtitle HTML (raw, trusted). Overrides `subtitle` when set. */
109
+ subtitleHtml?: string;
110
+ }
111
+ /**
112
+ * The text column of every date card: location, then position, then times.
113
+ *
114
+ * One builder so a card cannot read differently depending on which email drew
115
+ * it. The three-line form is the house style; the single-subtitle fallback
116
+ * exists for callers whose second line is not a position at all (an invoice
117
+ * number, a cancellation reason), which would read wrong in the position slot.
118
+ */
119
+ export declare function detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml }: DetailInfoInput): string;
120
+ /**
121
+ * The LATE strip across the bottom of a date badge (Saam, 2026-09-30). The
122
+ * badge names the service night (OCT 23 FRI); the strip says the set is the
123
+ * late end of it. A full-width row inside the badge's table rather than a tag
124
+ * laid over its border, because email clients do not position one element over
125
+ * another reliably. `radius` is the badge's inner corner (its radius minus its
126
+ * 2px border). White on coralDark is 5.4:1.
127
+ */
128
+ export declare function lateBadgeRow(late: boolean | undefined, radius: number, fontSize: number): string;
129
+ export interface DetailCardInput extends DetailInfoInput {
130
+ /** e.g. 'JUL'. */
131
+ month: string;
132
+ /** e.g. '18'. */
133
+ day: string;
134
+ /** e.g. 'SAT'. */
135
+ weekday: string;
136
+ /** e.g. '2027'. REPLACES `weekday` on the badge's bottom row; the badge stays
137
+ * three rows. Set only when the event is far enough out that a bare month/day
138
+ * would be ambiguous (showEventYear in each repo). */
139
+ year?: string;
140
+ /** e.g. '$1,200' (omit for no rate zone). For artist emails this MUST be the
141
+ * artist's own bid/payout, never the client-facing fee-inclusive price. */
142
+ rate?: string;
143
+ /** Rate zone styling. 'coral' matches the month accent; 'ink' is the default
144
+ * payout look. The portal's Balance Due card is the only caller that
145
+ * overrides any of these four. */
146
+ rateColor?: 'ink' | 'coral';
147
+ rateValign?: string;
148
+ rateSize?: string;
149
+ rateWidth?: string;
150
+ /** Translucent diagonal stamp PNG laid behind the card (e.g. CANCELLED).
151
+ * Degrades to no-op + struck date. */
152
+ stampUrl?: string;
153
+ /** Strike through the date number (pairs with a stamp). */
154
+ struck?: boolean;
155
+ /** Optional full-width caution line under the row, inside the card (raw HTML,
156
+ * trusted), e.g. the weather-dependent note. */
157
+ footnoteHtml?: string;
158
+ /** A late-night set (starts 12:00-4:59 AM, filed under the night before):
159
+ * draws the LATE strip across the bottom of the date badge. See lateBadgeRow. */
160
+ late?: boolean;
161
+ }
162
+ /**
163
+ * Event/shift detail inset: date badge + the detailInfoCell text column, with an
164
+ * optional rate pinned to the right (the gig-alert shift-card layout). On mobile
165
+ * the rate drops to its own right-aligned line under a hairline divider.
166
+ */
167
+ export declare function eventDetailCard({ month, day, weekday, year, title, subtitle, subtitleHtml, rate, rateColor, rateValign, rateSize, rateWidth, stampUrl, struck, footnoteHtml, position, time, timeHtml, late }: DetailCardInput): string;
168
+ /**
169
+ * Coral left-rule callout. `html` is interpolated raw so callers can pass
170
+ * <strong>…</strong> — only pass trusted/escaped content. Optional `signoff`
171
+ * adds a small right-aligned italic "- Name" line inside the same callout
172
+ * (hyphen, never an em dash).
173
+ */
174
+ export declare function noteBlock(html: string, signoff?: string): string;
175
+ export interface CtaInput {
176
+ href: string;
177
+ label: string;
178
+ /** Cell colour; default ink. Pass QM.urgent for within-24h, etc. */
179
+ bg?: string;
180
+ }
181
+ /** Single bulletproof CTA. The whole cell is the hit target. */
182
+ export declare function ctaButton({ href, label, bg }: CtaInput): string;
183
+ export interface SecondaryLinkInput {
184
+ /** Muted lead sentence. */
185
+ lead: string;
186
+ /** The link text. */
187
+ label: string;
188
+ href: string;
189
+ }
190
+ /**
191
+ * A subtle centered secondary action: lead text plus a coral text link, never a
192
+ * button. Use under the primary CTA for the "or do this instead" path
193
+ * (decline, talk to us, etc.).
194
+ */
195
+ export declare function secondaryLink({ lead, label, href }: SecondaryLinkInput): string;
196
+ /** The subtle "Schedule a call" secondary — a text link, never a button. */
197
+ export declare function scheduleCallLink({ href, lead, label, }?: Partial<SecondaryLinkInput>): string;
198
+ /** Centered body paragraph. `html` is raw (pass trusted/escaped content). */
199
+ export declare function centeredText(html: string): string;
200
+ /** Centered inset stat card: small uppercase label over a large value. */
201
+ export declare function centeredStatCard({ label, value }: {
202
+ label: string;
203
+ value: string;
204
+ }): string;
205
+ export interface FooterInput {
206
+ /** Support address for the mailto. Default support@qmusic.co. */
207
+ email?: string;
208
+ /** Renders a "📅 Subscribe to your Q Music Calendar" link line above the
209
+ * "Questions?" line. */
210
+ calendarUrl?: string | null;
211
+ /** One trailing sentence appended to the Questions line, for a renderer with a
212
+ * short caveat that belongs with the sign-off rather than floating as its own
213
+ * block (the payment receipt's ePayment timing, say). */
214
+ note?: string;
215
+ }
216
+ /** Standard sign-off: the support mailto, plus the optional calendar line and note. */
217
+ export declare function footerBlock({ email, calendarUrl, note }?: FooterInput): string;
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * @qmusic/email-shell: the Q Music transactional email shell, the warm light card
3
+ * ("Family A" in Q Music App's EMAIL_STYLE_GUIDE.md).
4
+ *
5
+ * The one copy. The events portal (Cloudflare Worker), the Supabase edge
6
+ * functions (Deno, via an npm: import) and gig-bot all render through this
7
+ * package, so a style change is made here once, published, and reaches each repo
8
+ * when that repo merges its Renovate bump. 1.0.0 renders byte-for-byte what the
9
+ * three hand-kept copies rendered (qmusic-events-portal 3177b08, plus the Deno
10
+ * copy's footerBlock `note`); test/golden pins that.
11
+ *
12
+ * One family for every gated notification: an off-white page (#f0eee9), a white
13
+ * card, and a near-black (#161310) header band. The header is inline HTML
14
+ * (eyebrow, two-tone title, glow logo) when `title` is given, or the legacy
15
+ * FLATTENED PNG (logo + title baked together as pixels) when it is not. Either
16
+ * way it rides a locked dark cell that Gmail's Android dark mode cannot recolor,
17
+ * and the CSS locks below hold the rest of the surfaces (card, insets, CTA) light.
18
+ *
19
+ * Usage:
20
+ * renderEmailShell({
21
+ * subject, preheader,
22
+ * eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
23
+ * body: [
24
+ * introBlock('Hi Jordan, ...'),
25
+ * eventDetailCard({ month:'JUL', day:'18', weekday:'SAT', title:'Jordan Lee', position:'The Ivy Room', time:'8:00 PM - 11:00 PM' }),
26
+ * noteBlock(`<strong>These bids expire in two weeks.</strong> Review them ...`),
27
+ * ctaButton({ href:'https://app.qmusic.co/?dltoken=...', label:'View Bids →' }),
28
+ * scheduleCallLink(),
29
+ * footerBlock(),
30
+ * ].join(''),
31
+ * })
32
+ */
33
+ /** HTML-escape for safe interpolation into the template. */
34
+ export function esc(s) {
35
+ if (s === null || s === undefined)
36
+ return '';
37
+ return String(s)
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;');
43
+ }
44
+ export const FONT = "'Poppins','Helvetica Neue',Helvetica,Arial,sans-serif";
45
+ // Header title/eyebrow stack: geometric, heavy fallbacks for the clients that
46
+ // strip the Poppins @font-face (Gmail). Apple Mail/Outlook.com get real Poppins;
47
+ // Gmail iOS -> Futura; Gmail Android -> the device sans (Roboto Black) @ wt 900.
48
+ const HEADER_FONT = "'Poppins',Futura,'Century Gothic','Trebuchet MS',Arial,sans-serif";
49
+ // White-with-dark-glow wordmark: reads on the dark band AND on Gmail's inverted
50
+ // light band (images aren't recolored). Hosted in the shared logos/email bucket.
51
+ const HEADER_LOGO = 'https://bptygbjvxxerlkhiputf.supabase.co/storage/v1/object/public/logos/email/qm-logo-glow.png';
52
+ // Palette — single source of truth, matches EMAIL_STYLE_GUIDE.md §1.
53
+ export const QM = {
54
+ page: '#f0eee9',
55
+ card: '#ffffff',
56
+ ink: '#161310', // header band, CTA, date-badge border
57
+ bodyInk: '#1a1a1a',
58
+ body: '#5f5a52',
59
+ muted: '#9a948a',
60
+ inset: '#faf9f6',
61
+ insetBorder: '#ebe8e2',
62
+ cream: '#FFFEED',
63
+ coralDark: '#c0392b', // links / accents on light
64
+ coralRule: '#e2503f', // left-rule
65
+ amber: '#FF9800', // deadline status banner (brand AppColors.warning) — bright, pairs w/ dark ink
66
+ urgent: '#ba0101', // within-24h status banner + CTA — dark red, pairs w/ white text
67
+ };
68
+ /** Render a complete email document. */
69
+ export function renderEmailShell({ subject, preheader, headerImageUrl, headerAlt, eyebrow, title, accent, accentColor, accentFirst, body }) {
70
+ const accentCol = accentColor || '#FF933B';
71
+ const accentSpan = `<span style="color:${accentCol};font-weight:900">${esc(accent ?? '')}</span>`;
72
+ // Title and accent share a line and wrap naturally when they must — the
73
+ // colour already separates the two halves, so no forced phone-only break.
74
+ const titleHtml = accent
75
+ ? (accentFirst
76
+ ? `${accentSpan} ${esc(title)}`
77
+ : `${esc(title)} ${accentSpan}`)
78
+ : esc(title);
79
+ // Inline HTML header (band + eyebrow + two-tone title + glow logo) when a title
80
+ // is given; otherwise the legacy baked-PNG header. The HTML header paints with
81
+ // the body (no proxy-fetch flash) and the glow logo survives Gmail's inversion.
82
+ const headerCell = title
83
+ ? `<td class="qm-head qm-headpad" bgcolor="${QM.ink}" style="background-color:${QM.ink};padding:30px 30px 28px">
84
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%">
85
+ <tr>
86
+ <td class="qm-heyebrow" align="left" valign="middle" style="font-family:${HEADER_FONT};font-weight:700;font-size:12px;line-height:1;letter-spacing:2px;color:${accentCol};text-transform:uppercase">&#8226;&nbsp;${esc(eyebrow)}</td>
87
+ <td align="right" valign="middle"><img class="qm-hlogo" src="${HEADER_LOGO}" width="80" alt="${esc(headerAlt || 'Q Music')}" style="display:block;border:0;width:80px;height:auto"></td>
88
+ </tr>
89
+ </table>
90
+ <div class="qm-htitle" style="margin-top:20px;font-family:${HEADER_FONT};font-weight:900;font-size:33px;line-height:1.14;letter-spacing:-0.6px;color:${QM.cream}">${titleHtml}</div>
91
+ </td>`
92
+ : `<td class="qm-head" bgcolor="${QM.ink}" style="background-color:${QM.ink};font-size:0;line-height:0">
93
+ <img src="${esc(headerImageUrl)}" width="600" alt="${esc(headerAlt)}" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none">
94
+ </td>`;
95
+ return `<!DOCTYPE html>
96
+ <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
97
+ <head>
98
+ <meta charset="UTF-8">
99
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
100
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
101
+ <meta name="color-scheme" content="light">
102
+ <meta name="supported-color-schemes" content="light">
103
+ <meta name="x-apple-disable-message-reformatting">
104
+ <meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
105
+ <title>${esc(subject)}</title>
106
+ <!--[if mso]>
107
+ <noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
108
+ <![endif]-->
109
+ <style>
110
+ @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800;900&display=swap');
111
+ :root { color-scheme: light; supported-color-schemes: light; }
112
+
113
+ a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
114
+ u + #body a { color: inherit; text-decoration: none; }
115
+
116
+ /* This email is LIGHT on purpose. The flattened header image rides a locked
117
+ #161310 cell; these rules hold the remaining surfaces against forced-dark. */
118
+ [data-ogsc] .qm-page { background-color:${QM.page} !important; }
119
+ [data-ogsc] .qm-card { background-color:${QM.card} !important; }
120
+ [data-ogsc] .qm-head { background-color:${QM.ink} !important; }
121
+ [data-ogsc] .qm-inset{ background-color:${QM.inset} !important; }
122
+ [data-ogsc] .qm-ink { color:${QM.bodyInk} !important; }
123
+ [data-ogsc] .qm-btn { background-color:${QM.ink} !important; }
124
+ [data-ogsc] .qm-btn a{ color:${QM.cream} !important; }
125
+ /* Banners pair bg + text so dark-mode inversion can't strand them:
126
+ bright amber -> dark ink; dark red -> white. Lock both bg AND text. */
127
+ [data-ogsc] .qm-bann-amber { background-color:${QM.amber} !important; }
128
+ [data-ogsc] .qm-bann-amber * { color:${QM.ink} !important; }
129
+ [data-ogsc] .qm-bann-red { background-color:${QM.urgent} !important; }
130
+ [data-ogsc] .qm-bann-red * { color:#ffffff !important; }
131
+ @media (prefers-color-scheme: dark) {
132
+ .qm-page { background-color:${QM.page} !important; }
133
+ .qm-card { background-color:${QM.card} !important; }
134
+ .qm-head { background-color:${QM.ink} !important; }
135
+ .qm-inset{ background-color:${QM.inset} !important; }
136
+ .qm-bann-amber { background-color:${QM.amber} !important; }
137
+ .qm-bann-amber * { color:${QM.ink} !important; }
138
+ .qm-bann-red { background-color:${QM.urgent} !important; }
139
+ .qm-bann-red * { color:#ffffff !important; }
140
+ }
141
+
142
+ /* Phone tightening. Every rule here is mobile-only, so desktop rendering is
143
+ byte-identical to before — the goal is purely to keep the CTA on the
144
+ first screen (the header band + page gutter alone cost ~200px). */
145
+ @media (max-width:600px) {
146
+ .qm-htitle { font-size:21px !important; letter-spacing:-0.3px !important; margin-top:12px !important; }
147
+ .qm-heyebrow { font-size:10.5px !important; letter-spacing:1.4px !important; }
148
+ .qm-headpad { padding:16px 20px 15px !important; }
149
+ .qm-hlogo { width:56px !important; }
150
+ .qm-shellpad { padding:16px 12px 28px !important; }
151
+ .qm-pad { padding-left:22px !important; padding-right:22px !important; }
152
+ .qm-ctapad{ padding-top:16px !important; }
153
+ .qm-datecell { width:64px !important; }
154
+ .qm-datenum { font-size:26px !important; }
155
+ .qm-detailcell { display:block !important; width:100% !important; }
156
+ .qm-ratecell { display:block !important; width:100% !important; text-align:right !important; padding:8px 0 0 0 !important; border-top:1px solid ${QM.insetBorder} !important; margin-top:9px !important; }
157
+ }
158
+ </style>
159
+ </head>
160
+ <body id="body" class="qm-page" style="margin:0;padding:0;width:100%;background-color:${QM.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:${FONT}">
161
+
162
+ <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${QM.page};opacity:0">
163
+ ${esc(preheader)}
164
+ &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
165
+ </div>
166
+
167
+ <table role="presentation" class="qm-page" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.page}" style="background-color:${QM.page}">
168
+ <tr>
169
+ <td align="center" class="qm-shellpad" style="padding:36px 14px 44px">
170
+
171
+ <!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
172
+ <table role="presentation" class="qm-card" width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.card}" style="background-color:${QM.card};border-radius:16px;overflow:hidden;max-width:600px;width:100%;border:1px solid rgba(20,16,12,0.08);box-shadow:0 16px 44px rgba(20,16,12,0.16)">
173
+
174
+ <!-- HEADER : inline HTML (eyebrow + two-tone title + glow logo) when title given; legacy flattened PNG otherwise -->
175
+ <tr>
176
+ ${headerCell}
177
+ </tr>
178
+
179
+ ${body}
180
+
181
+ </table>
182
+ <!--[if mso]></td></tr></table><![endif]-->
183
+
184
+ </td>
185
+ </tr>
186
+ </table>
187
+
188
+ </body>
189
+ </html>`;
190
+ }
191
+ /**
192
+ * Full-bleed time-pressure strip, placed directly under the header (pass it as
193
+ * the FIRST body component). Dark label on amber, white label on red.
194
+ */
195
+ export function statusBanner({ text, variant = 'amber', glyph = '&#9201;' }) {
196
+ const isRed = variant === 'red';
197
+ const bg = isRed ? QM.urgent : QM.amber;
198
+ const cls = isRed ? 'qm-bann-red' : 'qm-bann-amber';
199
+ // Bright amber wants dark ink; dark red wants white. Either way bg+text are
200
+ // a legible pair so dark-mode recolor can't produce dark-on-dark.
201
+ const fg = isRed ? '#ffffff' : QM.ink;
202
+ return `
203
+ <tr>
204
+ <td class="${cls}" bgcolor="${bg}" style="background-color:${bg};padding:11px 26px" align="center">
205
+ <p style="margin:0;font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:${fg};font-family:${FONT}">${glyph}&nbsp; ${esc(text)}</p>
206
+ </td>
207
+ </tr>`;
208
+ }
209
+ /** Lead paragraph, addressed to the recipient. Pass plain text (escaped here). */
210
+ export function introBlock(text) {
211
+ return introBlockHtml(esc(text));
212
+ }
213
+ /**
214
+ * Same as introBlock but `html` is interpolated raw, so callers can emphasize
215
+ * a word (<strong>…</strong>). Only pass trusted/escaped content.
216
+ */
217
+ export function introBlockHtml(html) {
218
+ return `
219
+ <tr>
220
+ <td class="qm-pad" style="padding:14px 26px 6px">
221
+ <p style="margin:0;font-size:14.5px;line-height:1.6;color:${QM.body};font-family:${FONT}">${html}</p>
222
+ </td>
223
+ </tr>`;
224
+ }
225
+ /**
226
+ * The text column of every date card: location, then position, then times.
227
+ *
228
+ * One builder so a card cannot read differently depending on which email drew
229
+ * it. The three-line form is the house style; the single-subtitle fallback
230
+ * exists for callers whose second line is not a position at all (an invoice
231
+ * number, a cancellation reason), which would read wrong in the position slot.
232
+ */
233
+ export function detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml }) {
234
+ const location = `
235
+ <p class="qm-ink" style="margin:0 0 4px;font-size:19px;font-weight:700;letter-spacing:-0.3px;line-height:1.2;color:${QM.bodyInk};font-family:${FONT}">${esc(title)}</p>`;
236
+ const pos = (position ?? '').trim();
237
+ const t = (time ?? '').trim();
238
+ // Pre-rendered time markup, for callers that need part of the range emphasised
239
+ // — the amendment emails colour the CHANGED token ("12:00 AM - <hl>3:00 AM</hl>"),
240
+ // which is the entire point of those emails and cannot survive esc(). Trusted
241
+ // HTML, and it wins over `time` when both are supplied.
242
+ const tHtml = (timeHtml ?? '').trim();
243
+ if (!pos && !t && !tHtml) {
244
+ const sub = subtitleHtml ?? esc(subtitle ?? '');
245
+ return `
246
+ <td valign="middle" style="padding-left:18px">${location}` +
247
+ (sub
248
+ ? `
249
+ <p style="margin:0;line-height:1.3;font-size:11px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${sub}</p>`
250
+ : '') + `
251
+ </td>`;
252
+ }
253
+ return `
254
+ <td valign="middle" style="padding-left:18px">${location}` +
255
+ (pos
256
+ ? `
257
+ <p style="margin:0 0 5px;line-height:1.25;font-size:13px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${esc(pos)}</p>`
258
+ : '') +
259
+ (t || tHtml
260
+ ? `
261
+ <p class="qm-ink" style="margin:0;font-size:16.5px;font-weight:700;line-height:1.15;color:${QM.bodyInk};font-family:${FONT}">${tHtml || esc(t)}</p>`
262
+ : '') + `
263
+ </td>`;
264
+ }
265
+ /**
266
+ * The LATE strip across the bottom of a date badge (Saam, 2026-09-30). The
267
+ * badge names the service night (OCT 23 FRI); the strip says the set is the
268
+ * late end of it. A full-width row inside the badge's table rather than a tag
269
+ * laid over its border, because email clients do not position one element over
270
+ * another reliably. `radius` is the badge's inner corner (its radius minus its
271
+ * 2px border). White on coralDark is 5.4:1.
272
+ */
273
+ export function lateBadgeRow(late, radius, fontSize) {
274
+ if (!late)
275
+ return '';
276
+ return `
277
+ <tr><td align="center" bgcolor="${QM.coralDark}" style="background-color:${QM.coralDark};padding:3px 4px 4px;border-radius:0 0 ${radius}px ${radius}px">
278
+ <p style="margin:0;font-size:${fontSize}px;font-weight:800;letter-spacing:1.6px;line-height:1.2;color:#ffffff;font-family:${FONT}">LATE</p>
279
+ </td></tr>`;
280
+ }
281
+ /**
282
+ * Event/shift detail inset: date badge + the detailInfoCell text column, with an
283
+ * optional rate pinned to the right (the gig-alert shift-card layout). On mobile
284
+ * the rate drops to its own right-aligned line under a hairline divider.
285
+ */
286
+ export function eventDetailCard({ month, day, weekday, year, title, subtitle, subtitleHtml, rate, rateColor = 'ink', rateValign = 'middle', rateSize = '24px', rateWidth = '100', stampUrl, struck, footnoteHtml, position, time, timeHtml, late }) {
287
+ const dayStyle = `margin:0;font-size:34px;font-weight:800;line-height:1;color:${QM.ink};font-family:${FONT}` +
288
+ (struck ? `;text-decoration:line-through;text-decoration-color:${QM.coralDark};text-decoration-thickness:3px` : '');
289
+ // Rate zone styling. 'coral' matches the month accent (drop qm-ink so Outlook
290
+ // dark-mode doesn't force it back to ink); 'ink' is the default payout look.
291
+ const rateHex = rateColor === 'coral' ? QM.coralDark : QM.bodyInk;
292
+ const rateClass = rateColor === 'coral' ? '' : 'qm-ink';
293
+ // Bottom badge row: the weekday normally, the YEAR in its place for events far
294
+ // enough out that a bare "FEB 14" would be read as the coming February (see
295
+ // showEventYear in lib/dates.js for the horizon).
296
+ //
297
+ // A swap rather than a 4th row: past the horizon the year is the more
298
+ // load-bearing of the two -- an artist checking a date 14 months out needs to
299
+ // know which February before they need to know it is a Saturday -- and the
300
+ // badge keeps its exact three-row geometry, which matters because
301
+ // .qm-datecell narrows to 64px on mobile. Coral rather than the weekday's
302
+ // muted grey so it brackets the day with the month line and actually gets
303
+ // read; that grey is exactly where a year would be missed.
304
+ const tail = year || weekday;
305
+ const tailStyle = year
306
+ ? `margin:0;font-size:10px;font-weight:800;letter-spacing:1px;color:${QM.coralDark};font-family:${FONT}`
307
+ : `margin:0;font-size:10px;font-weight:700;letter-spacing:1px;color:${QM.muted};font-family:${FONT}`;
308
+ const badge = `
309
+ <td class="qm-datecell" width="84" valign="top" style="width:84px">
310
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:2px solid ${QM.ink};border-radius:11px">
311
+ <tr><td align="center" style="padding:9px 6px 1px">
312
+ <p style="margin:0;font-size:11px;font-weight:800;letter-spacing:1.5px;color:${QM.coralDark};font-family:${FONT}">${esc(month)}</p>
313
+ </td></tr>
314
+ <tr><td align="center" style="padding:0 6px">
315
+ <p class="qm-datenum qm-ink" style="${dayStyle}">${esc(day)}</p>
316
+ </td></tr>
317
+ <tr><td align="center" style="padding:2px 6px 9px">
318
+ <p style="${tailStyle}">${esc(tail)}</p>
319
+ </td></tr>${lateBadgeRow(late, 9, 9.5)}
320
+ </table>
321
+ </td>`;
322
+ const venue = detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml });
323
+ // No rate -> simple two-zone row. Rate -> wrap date+venue in qm-detailcell and
324
+ // pin the amount in qm-ratecell (stacks on mobile via the responsive rules).
325
+ const row = rate
326
+ ? `
327
+ <tr>
328
+ <td class="qm-detailcell" valign="middle">
329
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>${badge}${venue}</tr></table>
330
+ </td>
331
+ <td class="qm-ratecell" valign="${rateValign}" align="right" width="${rateWidth}" style="width:${rateWidth}px;padding-left:10px">
332
+ <p class="${rateClass}" style="margin:0;font-size:${rateSize};font-weight:800;letter-spacing:-0.4px;line-height:1;white-space:nowrap;color:${rateHex};font-family:${FONT}">${esc(rate)}</p>
333
+ </td>
334
+ </tr>`
335
+ : `
336
+ <tr>${badge}${venue}</tr>`;
337
+ // Stamp rides as a no-repeat centered background on the inset cell so the
338
+ // date/venue text sits on top. Clients that drop background-images (older
339
+ // Outlook) still get the struck-through date — the meaning survives.
340
+ const stampBg = stampUrl
341
+ ? `;background-image:url(${esc(stampUrl)});background-repeat:no-repeat;background-position:center center;background-size:contain`
342
+ : '';
343
+ // Optional caution footnote row (weather note) spanning both card columns.
344
+ const footnote = footnoteHtml
345
+ ? `
346
+ <tr>
347
+ <td colspan="2" style="padding:14px 0 0">
348
+ <p style="margin:0;font-size:12.5px;font-weight:600;line-height:1.45;color:${QM.coralDark};font-family:${FONT}">${footnoteHtml}</p>
349
+ </td>
350
+ </tr>`
351
+ : '';
352
+ return `
353
+ <tr>
354
+ <td class="qm-pad" style="padding:20px 26px 0">
355
+ <table role="presentation" class="qm-inset" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.inset}" style="background-color:${QM.inset};border:1px solid ${QM.insetBorder};border-radius:14px">
356
+ <tr>
357
+ <td style="padding:18px 20px${stampBg}">
358
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${row}${footnote}
359
+ </table>
360
+ </td>
361
+ </tr>
362
+ </table>
363
+ </td>
364
+ </tr>`;
365
+ }
366
+ /**
367
+ * Coral left-rule callout. `html` is interpolated raw so callers can pass
368
+ * <strong>…</strong> — only pass trusted/escaped content. Optional `signoff`
369
+ * adds a small right-aligned italic "- Name" line inside the same callout
370
+ * (hyphen, never an em dash).
371
+ */
372
+ export function noteBlock(html, signoff) {
373
+ const sign = signoff
374
+ ? `
375
+ <p style="margin:9px 0 0;font-size:12.5px;font-style:italic;text-align:right;color:${QM.muted};font-family:${FONT}">- ${esc(signoff)}</p>`
376
+ : '';
377
+ return `
378
+ <tr>
379
+ <td class="qm-pad" style="padding:16px 26px 0">
380
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
381
+ <tr>
382
+ <td width="3" bgcolor="${QM.coralRule}" style="width:3px;background-color:${QM.coralRule};border-radius:3px"></td>
383
+ <td style="padding-left:14px">
384
+ <p class="qm-ink" style="margin:0;font-size:14px;font-weight:500;line-height:1.55;color:#3f3a33;font-family:${FONT}">${html}</p>${sign}
385
+ </td>
386
+ </tr>
387
+ </table>
388
+ </td>
389
+ </tr>`;
390
+ }
391
+ /** Single bulletproof CTA. The whole cell is the hit target. */
392
+ export function ctaButton({ href, label, bg = QM.ink }) {
393
+ return `
394
+ <tr>
395
+ <td class="qm-pad qm-ctapad" style="padding:24px 26px 4px">
396
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:100%">
397
+ <tr>
398
+ <td class="qm-btn" bgcolor="${bg}" style="background-color:${bg};border-radius:12px" align="center">
399
+ <a href="${esc(href)}" style="display:block;padding:15px 24px;font-size:15px;font-weight:700;letter-spacing:0.2px;color:${QM.cream};text-decoration:none;font-family:${FONT}">${esc(label)}</a>
400
+ </td>
401
+ </tr>
402
+ </table>
403
+ </td>
404
+ </tr>`;
405
+ }
406
+ /**
407
+ * A subtle centered secondary action: lead text plus a coral text link, never a
408
+ * button. Use under the primary CTA for the "or do this instead" path
409
+ * (decline, talk to us, etc.).
410
+ */
411
+ export function secondaryLink({ lead, label, href }) {
412
+ return `
413
+ <tr>
414
+ <td class="qm-pad" style="padding:14px 26px 0;text-align:center">
415
+ <p style="margin:0;font-size:13px;line-height:1.5;color:${QM.muted};font-family:${FONT}">${esc(lead)} <a href="${esc(href)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">${esc(label)}</a></p>
416
+ </td>
417
+ </tr>`;
418
+ }
419
+ /** The subtle "Schedule a call" secondary — a text link, never a button. */
420
+ export function scheduleCallLink({ href = 'https://calendly.com/qmusic/30min', lead = 'Prefer to talk it through?', label = 'Schedule a call →', } = {}) {
421
+ return secondaryLink({ lead, label, href });
422
+ }
423
+ /* ----------------------------------------------------------------------------
424
+ * Result-page components — for browser confirmation pages (e.g. the bid
425
+ * extension accept/decline landing). renderEmailShell doubles as the page
426
+ * shell; these are the centered body pieces.
427
+ * -------------------------------------------------------------------------- */
428
+ /** Centered body paragraph. `html` is raw (pass trusted/escaped content). */
429
+ export function centeredText(html) {
430
+ return `
431
+ <tr>
432
+ <td class="qm-pad" style="padding:22px 26px 6px;text-align:center">
433
+ <p style="margin:0;font-size:14.5px;line-height:1.6;color:${QM.body};font-family:${FONT}">${html}</p>
434
+ </td>
435
+ </tr>`;
436
+ }
437
+ /** Centered inset stat card: small uppercase label over a large value. */
438
+ export function centeredStatCard({ label, value }) {
439
+ return `
440
+ <tr>
441
+ <td class="qm-pad" style="padding:22px 26px 0">
442
+ <table role="presentation" class="qm-inset" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.inset}" style="background-color:${QM.inset};border:1px solid ${QM.insetBorder};border-radius:14px">
443
+ <tr>
444
+ <td align="center" style="padding:26px 24px">
445
+ <p style="margin:0 0 5px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${esc(label)}</p>
446
+ <p class="qm-ink" style="margin:0;font-size:23px;font-weight:800;color:${QM.bodyInk};font-family:${FONT}">${esc(value)}</p>
447
+ </td>
448
+ </tr>
449
+ </table>
450
+ </td>
451
+ </tr>`;
452
+ }
453
+ /** Standard sign-off: the support mailto, plus the optional calendar line and note. */
454
+ export function footerBlock({ email = 'support@qmusic.co', calendarUrl, note } = {}) {
455
+ const cal = (calendarUrl ?? '').trim()
456
+ ? `<p style="margin:0 0 8px;font-size:12px;line-height:1.55;color:${QM.muted};font-family:${FONT}">📅 <a href="${esc(calendarUrl)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">Subscribe to your Q Music Calendar</a></p>
457
+ `
458
+ : '';
459
+ return `
460
+ <tr>
461
+ <td class="qm-pad" style="padding:18px 26px 28px">
462
+ ${cal}<p style="margin:0;font-size:12px;line-height:1.55;color:${QM.muted};font-family:${FONT}">Questions? Reach the team at <a href="mailto:${esc(email)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">${esc(email)}</a>.${(note ?? '').trim() ? ` ${esc(note.trim())}` : ''}</p>
463
+ </td>
464
+ </tr>`;
465
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@qmusic/email-shell",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Q Music transactional email shell: the warm light card every client and artist email renders in.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/qmusic-ai/email-shell.git"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "scripts": {
24
+ "build": "rm -rf dist && tsc -p tsconfig.json",
25
+ "test": "npm run build && node --test test/*.test.mjs",
26
+ "golden:write": "npm run build && node scripts/write-golden.mjs",
27
+ "prepublishOnly": "npm test && node scripts/assert-release.mjs"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "6.0.3"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }