ADR 36: Marketing Landing Page
Status: Accepted
Date: 2026-06-08
Last Updated: 2026-06-09
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation the system must meet (R1, R2, …). |
| NRn | Non-functional requirement | Quality attribute: security, performance, operability (NR1, NR2, …). |
| Cn | Constraint | Non-negotiable boundary; violating it invalidates the decision (C1, C2, …). |
| CCn | Cross-cutting challenge | Risk or tension that spans components, with a documented mitigation (CC1, …). |
| Landing | Marketing SPA | Public substratum.cloud site in apps/landing — families-first copy, not the SaaS dashboard. |
| Prerender | Build-time HTML | Node script renders Mithril per locale into static index.html for crawlers and first paint. |
| Spindle | Tangled CI | Git-push pipeline on Tangled that builds and deploys artifacts. |
| Midnight Gallery | Visual theme | Cinematic Noir / editorial asymmetry shared with file-explorer (DESIGN.md). |
Canonical product vocabulary: Glossary.
Context
Substratum needs a public marketing surface at substratum.cloud that explains the product to families first, household helpers second, and builders third. Copy should come from product-overview.md; technical depth links to technical-specification.md.
Documentation already ships on Tangled Sites (ADR 06). The SaaS dashboard uses Mithril.js on DigitalOcean (ADR 08) with Lingui i18n (ADR 14). The landing page must reuse the same frontend stack and visual language without coupling to gateway auth or drive APIs.
Marketing SEO requires real <h1>, <meta>, and hreflang on first response. The chosen host serves static files only (no SSR server). App Platform and a GitHub mirror were considered but deferred for v1.
North star sentence: Your memories live in more than one home you trust — and you can see that they're safe.
Visual design
Full-page mockup exported from Google Stitch (site landing page design). Use this as the layout and tonal reference when implementing apps/landing; copy still comes from product-overview.md.

| Stitch section | ADR v1 section |
|---|---|
| Hero + platform CTAs | Hero |
| Digital fragility cards | Problem |
| Five steps to sovereignty | How it works |
| Resilience map (centerpiece) | Centerpiece |
| A day in the life timeline | Week in life |
| Typical cloud vs Substratum | Compare |
| Pricing cards | Pricing (two tiers; Coming soon badges — no dollar amounts in v1) |
| Footer CTA + AT Proto sign-in | CTA + Footer |
Requirements
Functional requirements (R1–R13)
| ID | Requirement |
|---|---|
| R1 | Ship apps/landing as a single-scroll marketing page per locale: Header → Hero → Problem → How it works → Centerpiece → Week in life → Compare → Pricing → Roles → Privacy → FAQ → CTA → Footer. |
| R2 | Hero and body copy use family voice (homes/places, not mesh jargon); builder strip links to technical-specification.md. |
| R3 | Support six locales: en, es, fr, de, pt-BR, fil — same set as Lingui config and supportedLocales in apps/landing/src/i18n.ts. |
| R4 | LanguagePicker switches locale, persists substratum.locale in localStorage, and navigates to /<locale>/ for shareable URLs. |
| R5 | Build pipeline produces one prerendered index.html per locale with visible body copy, injected <title>, <meta description>, hreflang, and canonical. |
| R6 | Default locale (en) is served at /; other locales at /<locale>/ (e.g. /es/). |
| R7 | Ship robots.txt, sitemap.xml, and og:image (public/og-image.png) with prerender-injected Open Graph meta. |
| R8 | Client entry (main.tsx) hydrates Mithril on #app after load; interactivity (picker, scroll animations) runs in browser only. |
| R9 | Use @substratum/ui-kit components (DisplayHeader, SurfaceCard, Button, Chip, GradientProgressBar, Accordion, Icon, LanguagePicker) — not full app Layout. |
| R10 | Spindle deploy: pnpm nx build landing (includes prerender) then rsync dist/apps/landing/ to droplet /var/www/landing. |
| R11 | Caddy serves locale directories and SPA fallback via try_files {path} {path}/index.html /index.html. |
| R12 | Build-time env exposes VITE_SITE_ORIGIN, VITE_DOCS_URL (required), and optional VITE_INSTALLER_URL, VITE_APP_URL for CTAs. Sections and prerender read these only through the encapsulated config/ module — never import.meta.env outside src/config/. |
| R13 | Pricing section ships in v1 with two tiers framed per ADR 32 (home copies vs optional safety copies). All price points and paid sign-up CTAs show Coming soon Chip badges — no dollar amounts, Stripe, or Stitch naming (Field-Vault, Sovereign Vault). |
Non-functional requirements (NR1–NR6)
| ID | Requirement |
|---|---|
| NR1 | View-source on / and /es/ shows localized <h1> and meta without executing JavaScript. |
| NR2 | Non-technical reader understands value proposition in ~10 seconds. |
| NR3 | Visual consistency with file-explorer tokens and ui-kit (Midnight Gallery). |
| NR4 | Fast client bundle — static Mithril SPA, no heavy framework bloat (ADR 08). |
| NR5 | prefers-reduced-motion respected for scroll animations. |
| NR6 | Landing deploy artifact stays out of Tangled git (dist/ on server only). |
Constraints (C1–C5)
| ID | Constraint |
|---|---|
| C1 | Docs remain on Tangled Sites /site (ADR 06); landing does not share that deploy directory. |
| C2 | No DigitalOcean App Platform or GitHub mirror for v1 — Pulumi + droplet + Spindle only. |
| C3 | ui-kit does not import Lingui; landing resolves strings and passes plain string props (ADR 14). |
| C4 | v1 routing is anchor links on a single scroll page — no client m.route. |
| C5 | Theme CSS reuses apps/file-explorer/src/styles/index.css until libs/theme exists. |
Cross-cutting challenges (CC1–CC4)
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Droplet serves static files only — no SSR | Build-time prerender per locale in phase 1 (not deferred). |
| CC2 | Tangled Sites allows one site config per repo | Docs keep /site; landing on separate DO droplet with custom domain. |
| CC3 | Locale parity across Lingui catalogs | Same workflow as ADR 14: explicit IDs, all messages.json updated per change. |
| CC4 | Prerendered markup vs client mount | Standard Mithril client entry; mount replaces or matches prerendered #app content. |
Decision
1. Split hosting surfaces
| Surface | Host | Build | Artifact in Tangled git? |
|---|---|---|---|
| Docs | Tangled Sites (/site) | Husky + VitePress | Yes (site/) |
| Landing | DO Droplet + Caddy | Spindle: nx build landing + rsync | No (dist/ on server) |
Landing targets substratum.cloud on a small DigitalOcean Droplet. wickedbased (App Platform spec library) is deferred until App Platform is needed.
2. Deploy pipeline (Spindle + Pulumi)
Tangled push main
→ Spindle: pnpm install && pnpm nx build landing
(set VITE_SITE_ORIGIN, VITE_DOCS_URL, optional VITE_INSTALLER_URL / VITE_APP_URL)
(Vite client bundle + Node prerender → dist/apps/landing/<locale>/index.html)
→ Spindle: rsync dist/apps/landing → droplet:/var/www/landing
→ (on infra/ changes) Spindle: pulumi upSpindle secrets: DIGITALOCEAN_TOKEN, PULUMI_ACCESS_TOKEN, VITE_SITE_ORIGIN, VITE_DOCS_URL (deploy SSH key: Pulumi stack output landingSshPrivateKey)
3. Infrastructure (Pulumi)
- Path:
infra/marketing/(stack namemarketing) - Resources:
Droplet, Reserved IPv4,Firewall, DNSArecord (whendomainis set),SpacesBucketCorsConfigurationon the installer bucket,ProjectResources - Cloud-init: Ubuntu 24.04 + Caddy,
/var/www/landing,try_filesfor locale paths + SPA fallback; Caddy auto-TLS whendomainis configured - Exports:
dropletIp(reserved IP),reservedIpAddress,dropletId,landingSshPrivateKey(secret),landingDeployKeyFingerprint,installerCdnBaseUrl,downloadsBucketName
The Reserved IPv4 is the stable public address for DNS, SSH, and HTTPS. The DNS A record and Let's Encrypt validation target the reserved IP — not the droplet's ephemeral address — so replacing the droplet does not churn DNS or hit certificate rate limits.
| Component | Role |
|---|---|
| Reserved IPv4 | Stable target for DNS A, SSH deploy, and Caddy TLS; reassigned when the droplet is replaced |
| Droplet + Caddy | Serves locale HTML and hashed assets from /var/www/landing; terminates HTTPS on :443 |
| Spaces bucket | Hosts macOS installer .dmg files and latest.json; CDN origin for browser downloads |
| Spaces CORS | Allows fetch() of the installer manifest from https://substratum.cloud at build/runtime |
| Firewall | Inbound TCP 22, 80, 443 only; outbound open for apt, Let's Encrypt, and Spaces uploads from operators |
4. Build-time prerender (phase 1)
1. compile-locales (Lingui → messages.mjs per locale)
2. Vite production client bundle (JS/CSS/assets)
3. prerender-landing (Node: apps/landing/scripts/prerender.mjs)
for each locale in supportedLocales:
dynamicActivate(locale)
html = m.render(LandingPage, { locale })
write dist/apps/landing/<locale>/index.html
inject <title>, <meta>, link rel="alternate" hreflang
4. dist/apps/landing/index.html → default locale (en)
5. rsync entire dist/apps/landing/ to dropletNx build script:
"build": "pnpm compile-locales && vite build && node scripts/prerender.mjs"5. URL layout (Caddy)
| URL | File |
|---|---|
/ | /var/www/landing/index.html (default en) |
/es/ | /var/www/landing/es/index.html |
/fr/, /de/, /pt-BR/, /fil/ | same pattern |
/assets/* | shared Vite hashed assets at deploy root |
6. App structure
apps/landing/src/
main.tsx, App.tsx
config/ # load.ts, accessors.ts, types.ts — barrel index.ts only
sections/ # Hero, Problem, HowItWorks, Pricing, …
components/ # MarketingHeader, MarketingFooter, PlanCard
styles/landing.css
i18n/ # supported-locales.ts, activate.ts, message-ids.ts, locales/
apps/landing/scripts/
prerender.mjs
apps/landing/public/
robots.txt, sitemap.xml, og-image.png7. Tech stack
| Layer | Choice |
|---|---|
| Framework | Mithril.js + TSX |
| UI | @substratum/ui-kit |
| Theme | file-explorer index.css (until libs/theme) |
| i18n | Lingui — explicit IDs, 6 locales |
| Build | Nx + Vite → dist/apps/landing + prerender |
8. Implementation phases
| Phase | Deliverable |
|---|---|
| 1 | apps/landing scaffold + config/ module + Vite build + prerender per locale + en copy for all sections (including Pricing with Coming soon badges) |
| 2 | infra/marketing Pulumi + Caddy cloud-init |
| 3 | Spindle landing.yml (pulumi up + build + rsync) |
| 4 | All locales + centerpiece animation |
| 5 | Custom domain + TLS; sitemap.xml / Search Console |
| 6 | /privacy, /terms as separate prerendered pages (v2) |
9. Encapsulated configuration
Follow the global encapsulated-configuration rule (see root AGENTS.md and gateway Config pattern). One apps/landing/src/config/ module tree owns all environment parsing:
| File | Role |
|---|---|
types.ts | LandingConfig, LandingLinks, LandingMeta |
load.ts | loadLandingConfig() — sole import.meta.env reader |
accessors.ts | landingLinks, landingMeta, landingAvailability, isLinkLive() |
index.ts | Barrel only — re-exports public API; no runtime logic |
main.tsx, sections, components, and prerender.mjs import from config/ only. Vite dev defaults live in vite.config.ts env:; production values in Spindle build env and .env.example.
| Variable | Required | Purpose |
|---|---|---|
VITE_SITE_ORIGIN | yes | Canonical origin for prerender canonical, og:url, sitemap (no trailing slash) |
VITE_DOCS_URL | yes | Getting started + technical spec links |
VITE_INSTALLER_URL | no | Hero + tier-1 (home copies) CTA — omit for Coming soon UX |
VITE_APP_URL | no | Sign-in / SaaS CTA — omit for Coming soon UX |
10. Pricing (v1 — Coming soon)
The Pricing section is in the v1 scroll (between Compare and Roles), not deferred. Copy uses product-overview.md framing — two tiers families already understand:
| Tier | Framing | v1 UX |
|---|---|---|
| Home copies | Self-hosted path: home helper, up to three places, you control disk | PlanCard + Coming soon on price; installer CTA when VITE_INSTALLER_URL is set |
| Optional safety copies | Substratum-operated secure storage in more than one part of the world; home copies remain yours | PlanCard + Coming soon on price and sign-up; app CTA when VITE_APP_URL is set |
PlanCard is a landing-local composed component (SurfaceCard + Chip + Button) — not a ui-kit primitive.
Why no dollar amounts yet: Dollar pricing waits until Substratum operates its own PDS and can cover storage costs for optional safety copies (ADR 32 C8 — no external billing provider in v1; entitlements in Postgres until webhooks ship). Proposed targets and free vs paid identity paths are published in Business model. Until checkout ships, Chip badges read Coming soon and tier copy explains the two-layer model without Stitch dollar figures or vault naming.
Header nav includes a Pricing anchor (#pricing) alongside How it works and FAQ.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
| DigitalOcean App Platform | Requires GitHub/GitLab mirror; learning path is Pulumi + small server + Spindle for v1. |
| Tangled Sites for landing | One site config per repo (docs use /site); custom domains on Tangled Sites not available yet. |
| Client-only SEO (no prerender) | Marketing domain needs semantic HTML and meta on first response; droplet has no SSR. |
Merging landing into site/ | Conflicts with docs deploy path and separate domain strategy. |
Consequences
Positive
- Families-first message on a dedicated domain without touching docs or SaaS deploy paths.
- SEO and first paint from static prerender; interactivity from lightweight Mithril SPA.
- Reuses ui-kit, Lingui, and Midnight Gallery — consistent brand with file-explorer.
- Predictable ops: rsync static tree to droplet; no App Platform coupling.
Negative
- Separate infra (droplet, Pulumi, Spindle secrets) to maintain alongside Tangled docs.
- Prerender script must stay in sync with locale list and page components.
- Open product decisions (hero headline, primary CTA, centerpiece) block final copy polish.
Neutral
- wickedbased, live gateway status footer, and App Platform migration remain deferred.
- v2 adds privacy/terms as additional prerendered routes.
Open decisions
| # | Question | Options |
|---|---|---|
| 1 | Hero headline | Trust/places vs photos vs anti-single-device |
| 2 | Primary CTA at launch | Default: hero → installer when VITE_INSTALLER_URL set; sign-in Coming soon until VITE_APP_URL configured |
| 3 | Default locale URL | / = en only vs auto-redirect from Accept-Language |
| 4 | Centerpiece | Mesh animation vs illustration |
| 5 | Domain phase 1 | IP + HTTP first vs substratum.cloud immediately |
Verification
| Scenario | Expected |
|---|---|
View-source / | Localized <h1>, <title>, <meta description> without JS |
View-source /es/ | Spanish body copy and hreflang alternates |
LanguagePicker | Switches locale and navigates to /<locale>/ |
| Spindle deploy | Rsynced prerendered tree; Caddy serves correct locale HTML |
| Non-technical reader | Understands value in ~10 seconds |
Related
- Glossary
- Product overview — primary copy source
- Getting started — household helper setup path
- Technical specification — builders and operators
- File explorer DESIGN.md — Midnight Gallery
- Landing design mockup — Google Stitch full-page reference (also embedded above)
- ADR 06: Documentation Hosting — docs on Tangled Sites
- ADR 08: Hosting and Frontend Stack — DO, Mithril, Global Triangle
- ADR 14: Frontend Internationalization — Lingui, ui-kit boundaries
- ADR 32: Account Entitlements and Hosting Policy — two-tier pricing framing; PDS-cost timing for dollar amounts
- wickedbased — DO App Platform spec library (deferred)