The Quiet example
examples/quiet is the Quiet email design from handoff 11, built with lttr for CaribHubs, a Caribbean marketplace. It has everything a consumer writes: a theme, a family registry with footer copy, a config, nine templates and typed sample data for each one.
It is also lttr's conformance suite. The golden files, the structural HTML lint, lttr preview, lttr serve, make shoot and the release workflow's consume check all run against these nine emails. When a renderer change moves a pixel, it shows up here first.
// PLACEHOLDER in config.go. Build your own theme and families the same way. Your own family and theme walks through it.Tour the files
| File | What it holds |
|---|---|
theme.go | Theme(): the Quiet light and dark palettes, the three font stacks (Georgia, Arial, Courier New) and the brand mark (CaribHubs, logo.png and logo-dark.png, shown at 36px). |
families.go | The shared footer copy, a few helpers that build it, and Families, the registry of nine families. |
config.go | Config(assetBase): domain caribhubs.com, the site URL, the theme, the families, three entity addresses and the default region. |
assets.go | Assets, the two logo PNGs embedded with //go:embed, and AssetBase, the production URL they are served from. |
templates/ | The nine templates, 01-order-shipped.md to 09-internal-update.md. |
templates.go | Embeds templates/*.md and compiles each one with lttr.MustCompile into a package-level variable (OrderShipped, PayoutSent, …). |
samples.go | One data type per template (OrderShippedData, …) and a Sample…() function for each that returns the data and a recipient. |
examples.go | Example, Examples() and ByName(name): the nine emails behind one untyped interface, for tools that do not know the data types. |
mapbox.go | Mapbox(token, style): a mapbox.Static builder with the pin and route colours read from the theme. |
*_test.go | The compile checks, the goldens, the structural lint and the Mapbox colour test. |
Read the theme and config
Theme() returns a lttr.Theme literal. Every colour is a #RRGGBB string and every font stack is a plain CSS list. The library holds no brand values of its own, so these are the only place the Quiet colours live. See Themes for what each token colours.
Config(assetBase) takes the asset base as an argument because the same theme is served from different places. The CLI's preview passes a relative path, and real sends use quiet.AssetBase (https://caribhubs.com/assets/email):
func Config(assetBase string) lttr.Config {
return lttr.Config{
Domain: "caribhubs.com",
SiteURL: "https://caribhubs.com",
AssetBase: assetBase,
Theme: Theme(),
Families: Families,
Entities: map[string]lttr.Entity{
"St Kitts & Nevis": {Address: "Basseterre, St Kitts"},
"Antigua & Barbuda": {Address: "St John's, Antigua"}, // PLACEHOLDER
"Anguilla": {Address: "The Valley, Anguilla"}, // PLACEHOLDER
},
DefaultRegion: "St Kitts & Nevis",
}
}
Entities are keyed by region. The footer's {address} placeholder is the recipient's EntityAddress if one is set, and otherwise the address of their region, or of DefaultRegion when they have no region.
Read the families
families.go keeps its footer copy in four constants and builds each family's FooterCopy with two helpers: serviceCopy(note, about) for the F2 service footer and marketingCopy(reason, nav) for the F1 marketing footer. restricted() lists the modules that the security, b2b and internal families may not use: image, stories, products, offer and quote.
| Family | Sender | Stream | Header | Footer | Also |
|---|---|---|---|---|---|
buyer | CaribHubs <orders@caribhubs.com> | Transactional | H1 | F2 | About "your order"; tracking |
seller | CaribHubs Sellers <sellers@…> | Transactional | H1 | F2 | Its own note; tracking |
driver | CaribHubs Drivers <drivers@…> | Transactional | H1 | F2 | About "your driver account"; tracking |
security | CaribHubs Security <security@…> | Security | H1 | F2 | Empty About, so no service line; restricted |
announcement | CaribHubs <hello@…> | Marketing | H1 | F1 | No nav row; a Legal override to Transactional with F2; tracking |
newsletter | Hub Journal <journal@…> | Marketing | H3 | F1 | Masthead Hub Journal · No. {issue}; nav row; tracking |
promo | CaribHubs <hello@…> | Marketing | H1 | F1 | Reason uses {region}; nav row; tracking |
b2b | frontmatter from, on partners.{domain} | Outreach | H1 | F3 | Opt-out line; restricted |
internal | CaribHubs Ops <team@…> | Internal | H2 | F3Internal | Banner Internal · Do not forward; restricted |
Two of them show family options the others don't use:
lttr.Family{
Name: "b2b", FromFrontmatter: true, SenderDomain: "partners.{domain}", Stream: lttr.Outreach,
Header: lttr.H1, Footer: lttr.F3,
Copy: lttr.FooterCopy{OptOut: `Not the right contact? Reply "no thanks" and we won't follow up.`, Address: address},
Forbidden: restricted(),
},
lttr.Family{
Name: "internal", From: from("CaribHubs Ops", "team"), Stream: lttr.Internal,
Header: lttr.H2, Banner: "Internal · Do not forward", Footer: lttr.F3Internal,
Copy: lttr.FooterCopy{Internal: "Internal. Do not forward outside CaribHubs.", Address: address},
Forbidden: restricted(),
},
b2b has no fixed sender. Each template must set from: in its frontmatter, and at render time that address must be on partners.caribhubs.com. internal uses the H2 header, which requires a Banner, and sends on the Internal stream, which a Router delivers only to addresses its InternalAllowed function accepts. See Families and streams.
Map the nine emails to the handoff
The handoff's design/handoff-11/design/CaribHubs · Email Quiet.html has one frame per email, labelled Q1 Order shipped to Q9 Internal update, plus mobile frames for Q1, Q3, Q5 and Q6. The file numbers match the frame numbers.
| # | Handoff frame | Template | Family | Modules | Data type |
|---|---|---|---|---|---|
| 01 | Q1 Order shipped | order.out_for_delivery | buyer | statement, progress, receipt, meta | OrderShippedData |
| 02 | Q2 Payout sent | payout.sent | seller | figure, breakdown, actions, signoff | PayoutSentData |
| 03 | Q3 Password reset | auth.reset | security | statement, code, notice | PasswordResetData |
| 04 | Q4 Hub Journal | journal.digest | newsletter | statement, image, stories, divider, quote | JournalDigestData |
| 05 | Q5 Campaign | campaign.heritage_week | promo | statement, image, products, divider, offer | CampaignData |
| 06 | Q6 Driver job offer | job.offered | driver | statement, map, meta, breakdown | JobOfferedData |
| 07 | Q7 Fee change | fees.change | announcement | statement, compare, divider, letter, signoff | FeesChangeData |
| 08 | Q8 Partner outreach | outreach.partner | b2b | letter, signature | PartnerOutreachData |
| 09 | Q9 Internal update | internal.ops_update | internal | statement, table, notice, signoff | OpsUpdateData |
compile_test.go pins this table: each example must compile with no warnings, to exactly this family, the family's stream and this module list. Where the rendered spacing differs from SPEC §8, the handoff frame wins, and the layout rules name the frame they come from (Source: "Q2 (D10)"). make shoot puts each render next to its frame, pairing them by the Q number in the frame label.
Q1 is the most complete transactional email: a hero with an action, a progress tracker, a receipt bound to a data.Receipt, and a two-column meta block.
Q4 shows the newsletter chrome: the H3 masthead fills {issue} from the frontmatter, and the F1 footer carries the nav row and the unsubscribe links.
Every email is in the Gallery.
Follow one template from source to message
Each template is a Markdown file whose frontmatter names the template and its family:
---
template: payout.sent
family: seller
subject: "Payout sent: {{amount}}"
preheader: "{{order_count}} orders, {{period}}. In your bank within 48 hours."
footer:
note: Questions about this payout? Reply to this email.
---
::figure{eyebrow="Weekly payout · {{period}}" amount="{{amount}}" usd="≈ {{amount_usd}}"}
It's on its way to your bank account ending {{account_last4}} and should arrive within 48 hours.
::
::breakdown
| Sales · {{order_count}} orders | {{sales}} |
| --- | ---: |
| Commission · {{commission_rate}} | {{commission}} |
| [Reference]{.small .muted} | [`{{reference}}`]{.small .muted} |
::
::actions
:button[View payout]{href="{{payout_url}}"} :link[See orders]{href="{{orders_url}}"}
::
::signoff{closing="Thanks for selling with us,"}
::
Its data type sits in samples.go. Every merge tag above resolves to one of these fields through its JSON name. sales is a data.Exact, so it always shows two decimals (EC$1,085.00), as the handoff's breakdown does:
type PayoutSentData struct {
Amount data.Money `json:"amount"`
AmountUSD data.Money `json:"amount_usd"`
Period string `json:"period"`
OrderCount int `json:"order_count"`
AccountLast4 string `json:"account_last4"`
Sales data.Exact `json:"sales"`
CommissionRate string `json:"commission_rate"`
Commission data.Money `json:"commission"`
Reference string `json:"reference"`
PayoutURL string `json:"payout_url"`
OrdersURL string `json:"orders_url"`
}
templates.go compiles it once, when the package initialises. A broken template therefore fails every go test and every program that imports the package:
PayoutSent = lttr.MustCompile[PayoutSentData](Families, "payout.sent", mustSource("02-payout-sent.md"))
To render it, build a Renderer from the config and pass a recipient and the data. This program renders Q2 through its typed variable, then all nine through Examples():
package main
import (
"fmt"
"log"
"github.com/sulv-io/lttr"
"github.com/sulv-io/lttr/examples/quiet"
)
func main() {
r, err := lttr.NewRenderer(quiet.Config(quiet.AssetBase))
if err != nil {
log.Fatal(err)
}
// One template, typed: the data and recipient come from its sample.
v, to := quiet.SamplePayoutSent()
m, err := quiet.PayoutSent.Render(r, to, v)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s → %s: %s\n\n", m.From.Address, m.To.Address, m.Subject)
// All nine, untyped: the view the CLI, goldens and lint use.
for _, ex := range quiet.Examples() {
m, err := ex.Render(r, nil)
if err != nil {
log.Fatalf("%s: %v", ex.Name, err)
}
fmt.Printf("%02d %-24s %-13s %s\n", ex.Number, ex.Name, m.Stream, m.Subject)
}
}
sellers@caribhubs.com → dwayne@example.com: Payout sent: EC$1,019.90
01 order.out_for_delivery transactional Your order is on its way
02 payout.sent transactional Payout sent: EC$1,019.90
03 auth.reset security Your CaribHubs reset code
04 journal.digest marketing The woodworkers of Old Road
05 campaign.heritage_week marketing Heritage Week: 48 hours of island makers
06 job.offered transactional New job: Basseterre to Frigate Bay · EC$15
07 fees.change marketing Lower commission on services from 1 November
08 outreach.partner outreach Delivery partnership on Nevis routes
09 internal.ops_update internal Q4 driver onboarding schedule
Example.Render(r, nil) renders the precompiled template. Pass a source instead of nil and it compiles that source first against the same data type, which is how lttr serve previews your edits without a rebuild.
SampleJobOffered leaves MapURL empty on purpose. The map module's src is optional, so Q6 renders its placeholder ground instead of calling Mapbox. In production you would fetch the image with quiet.Mapbox(token, style), upload it to your CDN and put that URL in MapURL. See Maps.Run the tools against it
The lttr CLI and the make targets are wired to this package. They are how you look at the design and check a library change:
| Command | Uses |
|---|---|
make serve | quiet.Examples(), recompiling templates/*.md from disk on every request |
make preview | quiet.Config with relative URLs, rendering every example to preview/ |
lttr lint [files…] | The example whose template: name the file declares, compiled against its data type |
lttr tags -t name | quiet.ByName(name) and its template's merge tags |
lttr render -t name | quiet.Config(quiet.AssetBase) and the example's sample data |
make golden | Rewrites testdata/golden/*.html and *.txt from the nine renders |
make shoot | The preview, screenshotted next to the handoff frames |
See CLI, Live preview, Screenshots and Goldens and testing.
Copy the patterns, not the brand
What carries over to your own service:
- Keep templates as files and compile them at init.
//go:embed templates/*.mdplusMustCompileinto package-level variables means a broken template stops the build's tests, never a send. - Build footer copy with helpers. Most families share their copy. A
serviceCopyormarketingCopyfunction keeps the wording in one place. - Give each template a sample. A
Sample…()function per template returns realistic data and a recipient, so one test can render everything. Seegolden_test.goandlint_test.go. - Restrict modules per family. A
restricted()list for the security-sensitive and internal families turns "no promotional blocks in a password reset" into a compile error.
What doesn't carry over: the CaribHubs name, colours, senders, addresses and copy. They are example data.
Goldens and testing
make golden and reviewing its diffs, the structural lint, make check, fuzzing, the coverage floor, and testing your own templates.
Your own family and theme
Set lttr up for your own brand, step by step, from a theme and a family registry to a first template and a test that renders it.