Guides

The Quiet example

A tour of examples/quiet, the worked example that doubles as lttr's conformance suite, and how its nine emails map to the design handoff.

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.

The example is there to copy from, not to import. Its brand, sender domain, entity addresses and copy belong to CaribHubs, and two of its three entity addresses are marked // PLACEHOLDER in config.go. Build your own theme and families the same way. Your own family and theme walks through it.

Tour the files

FileWhat it holds
theme.goTheme(): 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.goThe shared footer copy, a few helpers that build it, and Families, the registry of nine families.
config.goConfig(assetBase): domain caribhubs.com, the site URL, the theme, the families, three entity addresses and the default region.
assets.goAssets, 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.goEmbeds templates/*.md and compiles each one with lttr.MustCompile into a package-level variable (OrderShipped, PayoutSent, …).
samples.goOne data type per template (OrderShippedData, …) and a Sample…() function for each that returns the data and a recipient.
examples.goExample, Examples() and ByName(name): the nine emails behind one untyped interface, for tools that do not know the data types.
mapbox.goMapbox(token, style): a mapbox.Static builder with the pin and route colours read from the theme.
*_test.goThe 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):

examples/quiet/config.go
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.

FamilySenderStreamHeaderFooterAlso
buyerCaribHubs <orders@caribhubs.com>TransactionalH1F2About "your order"; tracking
sellerCaribHubs Sellers <sellers@…>TransactionalH1F2Its own note; tracking
driverCaribHubs Drivers <drivers@…>TransactionalH1F2About "your driver account"; tracking
securityCaribHubs Security <security@…>SecurityH1F2Empty About, so no service line; restricted
announcementCaribHubs <hello@…>MarketingH1F1No nav row; a Legal override to Transactional with F2; tracking
newsletterHub Journal <journal@…>MarketingH3F1Masthead Hub Journal · No. {issue}; nav row; tracking
promoCaribHubs <hello@…>MarketingH1F1Reason uses {region}; nav row; tracking
b2bfrontmatter from, on partners.{domain}OutreachH1F3Opt-out line; restricted
internalCaribHubs Ops <team@…>InternalH2F3InternalBanner Internal · Do not forward; restricted

Two of them show family options the others don't use:

examples/quiet/families.go
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 frameTemplateFamilyModulesData type
01Q1 Order shippedorder.out_for_deliverybuyerstatement, progress, receipt, metaOrderShippedData
02Q2 Payout sentpayout.sentsellerfigure, breakdown, actions, signoffPayoutSentData
03Q3 Password resetauth.resetsecuritystatement, code, noticePasswordResetData
04Q4 Hub Journaljournal.digestnewsletterstatement, image, stories, divider, quoteJournalDigestData
05Q5 Campaigncampaign.heritage_weekpromostatement, image, products, divider, offerCampaignData
06Q6 Driver job offerjob.offereddriverstatement, map, meta, breakdownJobOfferedData
07Q7 Fee changefees.changeannouncementstatement, compare, divider, letter, signoffFeesChangeData
08Q8 Partner outreachoutreach.partnerb2bletter, signaturePartnerOutreachData
09Q9 Internal updateinternal.ops_updateinternalstatement, table, notice, signoffOpsUpdateData

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.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

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.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

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:

examples/quiet/templates/02-payout-sent.md
---
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:

examples/quiet/samples.go
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:

examples/quiet/templates.go
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():

main.go
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)
    }
}
Output
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:

CommandUses
make servequiet.Examples(), recompiling templates/*.md from disk on every request
make previewquiet.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 namequiet.ByName(name) and its template's merge tags
lttr render -t namequiet.Config(quiet.AssetBase) and the example's sample data
make goldenRewrites testdata/golden/*.html and *.txt from the nine renders
make shootThe 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/*.md plus MustCompile into 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 serviceCopy or marketingCopy function 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. See golden_test.go and lint_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.

Copyright © 2026