design catalog · transactional email · v1
Ten templates, rendered from the real code in
convex/email/templates.ts,
all on the branded shell today. They used to split into two visual worlds,
which is the reason this page exists:
two went through the warm-clay branded shell, and
seven fell through to an unbranded fallback that shared
no colour, type, or button with the brand.
The page runs in that order: what ships today, then the two directions
it was judged against, then the whole set
re-envisioned in the chosen one — Direction A, with the full logo and a
warm #FDFAF8 ground.
Branded shell · was 2 of 9
Purchase confirmation and renewal reminder called
renderBrandedEmailShell(). Warm ground, 600px card,
brand palette, serif display, pill CTA.
Unbranded fallback · was 7 of 9
Every invite/membership email plus the config test called
renderSimpleEmailHtml():
#f9fafb ground,
#e5e7eb borders, Inter/Arial, and a
#111827 button. No brand token appears anywhere in it.
Generated from convex/email/templates.ts by
tools/dump-email-previews.ts, re-run after any template change.
purchase confirmation · account
You purchased SearchSync on tangible.one
purchase confirmation · guest
You purchased SearchSync on tangible.one
renewal reminder · dormant — Stripe sends these today
Your SearchSync, Tangible Blocks license expires in 14 days
invite sent
You are invited to join Team Tangible on Tangible
invite reminder
Reminder: join Team Tangible on Tangible
invite accepted
ghen@teamtangible.com joined Team Tangible
invite revoked
Your invite to Team Tangible was revoked
member removed
Team Tangible access removed
role changed
Your role in Team Tangible has changed
config test · staff only
Tangible email configuration test
These are the good ones, and still the reason the shell reads as
generic: a white card on near-white, with a near-black CTA and
no brandmark unless a logoUrl is passed —
and no call site passes one.
purchase confirmation · account
Your SearchSync license & download
purchase confirmation · guest
Your SearchSync license & download
renewal reminder · dormant — Stripe sends these today
Your SearchSync, Tangible Blocks license expires in 14 days
Seven templates, one generic shell. These are the first emails a new teammate ever gets from us — the invite lands before any receipt does.
invite sent
You are invited to join Team Tangible on Tangible
invite reminder
Reminder: join Team Tangible on Tangible
invite accepted
ghen@teamtangible.com joined Team Tangible
invite revoked
Your invite to Team Tangible was revoked
member removed
Team Tangible access removed
role changed
Your role in Team Tangible has changed
config test · staff only
Tangible email configuration test
Both use the purchase confirmation as the specimen so they're
comparable with the panels above, and both are real email HTML —
tables and inline styles, no @font-face
(clients strip it), no SVG (Outlook drops it). The
brandmark is built from a 3×3 table of coloured cells,
which is the one way to get the actual mark into an inbox intact.
Content parity is deliberate. Both directions carry everything the shipped template carries — order line, license panel, download CTA, the 30-day link note, the claim block, and the footer. Only the treatment differs. Each is shown in both variants, because they diverge on the thing that matters most: a guest has no key yet (it's minted on claim), so the license panel states the condition and the claim block carries the sign-in path rather than a dashboard link.
Direction A · warm letter
Warm ground (#FDFAF8, the same surface colour the dashboard
uses — full cream read too intense at page scale). Letterhead carries the
full logo — wordmark and mark, per Julia — not a mark plus
"Tangible" set in a serif. Serif display at a size you'd actually
call display. Indigo pill replaces the near-black one. Key in a
cream-soft chip with a tobacco hairline.
Mechanism note. The full logo includes letterforms, and
letterforms can't be built from table cells — so this is a hosted
3× transparent PNG drawn at 26px, passed as the shell's
existing logoUrl (which no call site passes today). The
trade-off against the table-cell mark it replaces: a
blocked image degrades to styled alt text rather than to the
real mark. Production must serve it from a durable host — R2 or
api.tangible.one — not the design catalog it points at here.
A · account variant
Key present · claim block → dashboard
A · guest variant
No key yet · claim block → sign in with the purchase email
Direction B · ledger
Treats a receipt as a record, not a letter: ink masthead with the mark reversed out, cream body, label/value rows. Optimised for the person who comes back six months later hunting the key — which prose-first layouts bury. Note how the guest variant turns the License row into a condition rather than a blank.
B · account variant
Key present · claim block → dashboard
B · guest variant
No key yet · claim block → sign in with the purchase email
All nine templates through the chosen direction — the 7/2 split gone.
These are no longer a proposal: the shell now lives in
convex/email/brandedShell.ts with its parts in
convex/email/parts.ts, all seven stragglers are
repointed, renderSimpleEmailHtml is retired, and
these frames are dumped from that code. The earlier hand-built previews were
generated by
tools/design-email-previews.mjs from one
shell() function, on purpose: hand-authoring ten
pages is exactly how the shipped set drifted apart in the first place, and
the artifact would stop being evidence of anything. That generator is also
the reference for the real change in
convex/email/brandedShell.ts — its signature
deliberately matches renderBrandedEmailShell().
Copy is preserved, not rewritten. Every fact these carry is a fact the shipped template carries. Where wording changed it changed to fit the layout (facts moved into label/value rows), never to add a claim. Two judgement calls worth challenging: the bad-news emails (invite revoked, access removed) get no cheerful eyebrow and no pill CTA — inventing an action where the only real next step is a human felt worse than a dead end; and invite accepted goes to existing members, so its CTA is member management rather than anything the reader must do.
purchase confirmation · account
Key present · rows + key chip · claim → dashboard
purchase confirmation · guest
No key yet · dashed condition panel · claim → sign in
renewal reminder
Tobacco eyebrow — a deadline, not good news
invite sent
The first email a teammate ever gets · inviter message in a cream panel
invite reminder
Expiry is the point, so it is the only row
invite accepted
Goes to existing members · mint panel, no competing CTA
invite revoked
No pill CTA — the real next step is a person
member removed
Same restraint; never dress up losing access
role changed
Rows carry the account and the new role
config test · staff only
Branded anyway — its whole job is proving the pipe works
Fixing the split surfaced a bigger gap. The platform emits 35 outbox codes; ten of them have an email. Several of the silent ones are the moments where money or access changes — exactly where silence is most expensive. This section is an inventory, not a design: nothing below is built.
Missing emails · money or access changes, customer hears nothing
| Outbox code | What happened | Why silence hurts |
|---|---|---|
| SUBSCRIPTION_PAST_DUE | A payment failed | Built — see below. Dormant until dunningSender is flipped. |
| SUBSCRIPTION_UNPAID | Dunning exhausted | Built — the `final` severity of the same email. |
| LICENSE_KEY_EXPIRED | A key stopped working | Updates silently stop. First contact is a support ticket. |
| LICENSE_KEY_REVOKED | A key was pulled | Same, but deliberate — so it needs an explanation, not a discovery. |
| SUBSCRIPTION_CANCEL_SCHEDULED | Cancellation booked | No confirmation of when access actually ends — the sunsetting cohort's exact confusion. |
| SUBSCRIPTION_TRIAL_WILL_END | Trial ending | Emits a CRM fact only. Nothing reaches the person about to be charged. |
| ORDER_REFUNDED | Money returned | A refund with no email reads as a mistake or a fraud alert. |
| UPGRADE_APPLIED | Plan changed mid-term | Proration is the single most disputed line on any invoice; explain it once, cheaply. |
| ACTIVATION_ACTIVATED | A new site used the key | The licensing equivalent of "new sign-in from a new device" — the only way a customer notices key sharing. |
| ACCOUNT_PRIMARY_RECIPIENT_CHANGED | Billing mail redirected | Changing who receives billing email is a security-relevant act; it should notify both addresses. |
| CATALOG_PRICE_CHANGED | A price moved | Advance notice before a subscription renews at a new price is a legal requirement in several markets. |
Design language now present
#C22F32). The last one is new: email previously had no way to say "this went wrong", so revoked-access mail was styled like a receipt.danger/success ramps from tailwind.config.js, finally reachable from email.Design language still absent
prefers-color-scheme handling exists.<div>s, so screen readers get no document outline.The honest summary: the branding gap is now closed and the coverage gap is not. Of the two, coverage is worth more — a beautifully branded set of nine emails still leaves a customer whose payment just failed hearing nothing at all. Recommended order: payment failed → trial ending → licence expired/revoked → cancellation confirmed, then the rest.
The highest-value missing email, now built. Two severities, not
one — and they come straight from the fact stream rather than an
attempt counter: SUBSCRIPTION_PAST_DUE means
Stripe will retry the card, SUBSCRIPTION_UNPAID
means it has stopped. Day one must not look like the final notice, which is
the escalation gap this page listed as missing.
Dormant by default. Gated on a new
dunningSender setting that defaults to
stripe, exactly like renewal reminders. Stripe's
own dunning emails may already be switched on, and two "your payment failed"
messages for one event reads as a system in disarray at precisely the moment
the customer is deciding whether to re-enter a card. Confirm Stripe's are OFF
before flipping it.
past_due · attention
Nothing lost yet · keepsake panel reassures · retry date when known
unpaid · negative
Access-ends date stated · problem panel · sites keep working
Two deliberate differences from the revoked-access emails.
This one keeps its pill CTA in both severities — unlike bad news
whose only next step is a person, a declined card is something the customer
can fix alone, so withholding the button would be the unhelpful choice. And
it never states a date it wasn't given: no amount is available (neither the
fact nor the subscriptions row carries one) and
no retry date exists for past_due, so those rows
are omitted rather than guessed.
Cross-referenced against Tangible.One Transactional Emails (Julia, 22 Aug 2026). Her document carries worked copy for the two purchase variants; the other seven were pasted current output rather than copy decisions, so those remain mine and are still unreviewed.
Tangible ONE (purchases@tangible.one); we send as no-reply@tangible.one. Per-category sender addresses need a Postmark sender signature and DKIM for the new address before anything can send from it.undefined values, which came from a bad fixture in this artifact's first dump (mine, since fixed) rather than from the template.renderSimpleEmailHtml(args) calls with the same argument shape, so they were pointed at the branded shell without touching their copy.#FDFAF8, A's surface now matches the shipped shell — warm ground, white card. What still separates them is the full logo, the indigo pill, display-scale serif, and the cream-soft key chip. Cream survives as an accent (key chip #FEECD0, claim block #F0EEFD) rather than as a field. That's a restraint call, not an accident — but if the result reads too close to what we already send, the lever to pull is the card, not the ground.renderBrandedEmailShell must actually be passed a logoUrl, and the asset needs a durable home with a stable URL. Rasterised from public/tangible-logo.svg; keep the two in step or the email logo silently ages.renderBrandedEmailShell only draws a logo when passed a logoUrl, and no call site passes one — so every email falls back to the word "Tangible" set in a serif. Both directions above solve this without a hosted image, which also dodges image-blocking.#000000 on warm reads as a default; the brand's action colour is indigo #6359D6.brandedShell.ts cites .docs/specs/brand-design-system-rollout.md, which exists but contains zero mentions of email — it covers app surfaces only. The email hexes are their own de facto source, and they diverge from tailwind.config.js: email uses ink #1A1712 where the app uses #000000, and adds muted/hairline that don't exist app-side.renewal_sender_not_tangible unless the stored renewalReminderSender is tangible; the default is stripe. Included here because the design decision lands before the switch does.customer.subscription.trial_will_end emits an outbox/CRM fact for Bento, not a send; guest_purchase_recorded is a claim kind that needs purchase-domain preparation.emails/current/. After changing a template, run DESIGN_CATALOG_DIR=<catalog checkout> npx tsx tools/dump-email-previews.ts in tangible-one rather than hand-editing, or this page starts lying.border-radius pills degrade to squares on old Outlook, which is acceptable; verify before shipping.