Landing page and themes
The landing page as a list of blocks in one config file, the block shapes you can use, the four built-in themes, and product screenshots.
The landing page is data. src/config/landing.ts exports an array of blocks,
and src/routes/_marketing/index.tsx does nothing but render it — the route
holds no copy and no layout. The look of the whole app, landing page
included, comes from a theme: a set of CSS custom properties, with four built
in.
Editing the landing page
Everything in landing.ts is the template's own pitch and is meant to be
replaced. The common edits are one-line changes:
| To | Do |
|---|---|
| Reorder sections | Move an entry in the array |
| Remove a section | Delete its entry |
| Add a section | Add an entry with one of the block shapes below |
| Change copy | Edit the message in messages/en.json and messages/zh.json |
A block is an object with a type and that shape's fields:
import { m } from '@/paraglide/messages'
export const landing: readonly LandingBlock[] = [
{
type: 'hero',
title: () => m.marketing_hero_title(),
subtitle: () => m.marketing_hero_subtitle(),
},
{
type: 'faq',
kicker: () => m.marketing_faq_kicker(),
title: () => m.marketing_faq_title(),
items: [
{ q: () => m.marketing_faq_cost_q(), a: () => m.marketing_faq_cost_a() },
],
},
{
type: 'cta',
title: () => m.marketing_cta_band(),
docs: () => m.marketing_cta_band_docs(),
},
]
Every string is a function
Copy is always written as () => m.key(), never m.key(). The config
module is evaluated once per Worker isolate, but the locale belongs to each
request. A bare call would freeze whichever language loaded the module first
and serve it to everyone. The Text type in
src/components/marketing/blocks/types.ts enforces this, so a bare string or
call fails typecheck. See Internationalization.
Tone
Most blocks take an optional tone: 'plain' (the page ground, the default)
or 'sheet', which lays a rounded grey sheet under the section. Alternating
the two gives a long page rhythm without images. One rule: blocks whose own
cards use the surface colour — modules, bento, chips, pricing, faq,
compare, gallery, testimonials — must stay plain, or the cards
disappear into the sheet.
Block shapes
All shapes are one union type, LandingBlock, in
src/components/marketing/blocks/types.ts, and each is implemented in its own
file beside it.
| Type | What it renders |
|---|---|
hero | Title, subtitle, the main call to action and a docs link |
logos | Rows of brand marks, such as your stack |
logos-grouped | Brand marks in labelled groups |
chips | Named chips in groups, for things with no brand mark |
stats | Big numbers that count up when scrolled into view |
terminal | A shell session, line by line |
steps | Numbered steps, each with its own shell block |
surfaces | Full-width product screenshots that swap as the reader scrolls |
modules | A ledger of what is included: a name and short chips per row |
bento | An uneven grid of cards |
compare | A comparison table |
gallery | Things people built with your product; renders nothing when empty |
testimonials | Customer quotes on moving cards |
pricing | Your plans, read from src/config/plans.ts |
faq | Folding questions and answers |
cta | The closing call to action |
The pricing block has no prices of its own: it reads the same
src/config/plans.ts that checkout uses, so the page cannot advertise a price
you do not charge (see Payments). The main call-to-action
target in hero and cta is /dashboard, or /waitlist when the waitlist
is switched on in src/config/app-config.ts.
The block palette at /blocks
The default page uses only some of the shapes. Run bun run dev and open
/blocks to see every shape rendered with sample data. It is a workbench, so
its copy is plain English strings. Copy the object you want into
landing.ts and replace its strings with message thunks. The page renders
nothing in a production build.
Adding a shape
Add a member to the LandingBlock union, a component in a new file under
src/components/marketing/blocks/, an export in blocks.tsx, and a case in
the switch in render.tsx. The switch is exhaustive, so a missing case is a
type error rather than a section that silently renders nothing.
Before launch
The default page ships a testimonials block with invented people and
placeholder avatars. Replace every item with a real customer's words, used
with their permission, or delete the block. Leaving it in publishes invented
reviews.
Screenshots and media
The surfaces block shows real screenshots of the app. They are committed
files in public/marketing/, one per theme mode: admin-light.webp and
admin-dark.webp, and so on. Only the current mode's image is downloaded.
To refresh them after the UI changes:
bun run dev # in one terminal
bun run capture:marketing # all shots; `bun run capture:marketing admin` for one
The script seeds the local database, signs in with test sessions, drives the
real app with Playwright, and writes the files. It refuses to save a capture
that shows an email address outside the seed data's fake domains, because the
admin screens show whatever is in your local database. It converts PNGs to
WebP with cwebp if that is installed. Adding a surface means adding an
entry to SHOTS in scripts/capture-marketing.ts as well as to landing.ts.
For heavier assets you do not want in git, such as video, the <Frame> and
<Clip> components in src/components/marketing/media.tsx read from
appConfig.mediaUrl, under a marketing/ prefix. With mediaUrl empty (the
default) they render a quiet placeholder rather than a broken image.
bun run upload:marketing <dir> resizes the images in a folder to WebP and
uploads them with wrangler. Note that it uploads to the first bucket in
wrangler.jsonc, the same bucket that holds private user files; if you give
that bucket a public domain to serve as mediaUrl, consider a separate bucket
for marketing media instead (see File storage).
Themes
Four visual styles are built in, registered in src/config/themes.ts:
| Theme | Look |
|---|---|
shipkit (default) | Neutral and blue, soft corners |
editorial | Serif, square corners, printed paper |
soft | Lilac, round corners, gentle shadows |
brutal | Monospace, hard edges, loud yellow |
Each has a light and a dark face. Two settings combine independently:
- The style is the
data-themeattribute on<html>, set bysrc/components/theme-provider.tsx. The reader's choice is kept inlocalStorage, and a small inline script applies it before first paint so the page never flashes the default. - The mode, light or dark, is the
darkclass, handled by next-themes.
Readers pick a style from the menu in the marketing header or the Style row in the account settings; mode has its own toggle.
A theme is only tokens
The default theme lives in :root and .dark in src/styles.css; the other
three are blocks in src/styles/themes.css under :root[data-theme="…"] and
:root[data-theme="…"].dark. No component reads data-theme. Everything a
theme changes is a CSS custom property: colours such as --background,
--primary and --brand, and shape tokens:
| Token | Controls |
|---|---|
--radius-btn | Button corners |
--radius-card | Card corners |
--radius-sheet | Large sheets and bands |
--shadow-surface | Shadow on cards, dialogs and menus |
--shadow-raised | Shadow on buttons |
--font-body | Body typeface |
--font-display | Marketing headline typeface |
That is why a hand-written button uses rounded-[var(--radius-btn)] rather
than rounded-full. If a new look needs a component to change shape, add a
token for that shape and set it in each theme, instead of branching in the
component.
Changing the default or adding a theme
To ship in a different look, change defaultTheme in src/config/themes.ts.
If you want one look with no choice at all, also remove ThemePicker from
src/routes/_marketing.tsx and the Style row (ThemeRow) from
src/features/auth/components/account-section.tsx.
To add a theme, add an entry to themes (id, label and hint messages, and
three swatch colours for the picker) and write a light block and a dark block
in src/styles/themes.css. The no-flash script is generated from the
registry, so it needs no edit. The design rules for the default look — colour,
radius, buttons — are in src/CLAUDE.md.