Skip to content

ADR 36: Marketing Landing Page

Status: Accepted
Date: 2026-06-08
Last Updated: 2026-06-09

Terms (this ADR)

IDTermMeaning
RnFunctional requirementNumbered obligation the system must meet (R1, R2, …).
NRnNon-functional requirementQuality attribute: security, performance, operability (NR1, NR2, …).
CnConstraintNon-negotiable boundary; violating it invalidates the decision (C1, C2, …).
CCnCross-cutting challengeRisk or tension that spans components, with a documented mitigation (CC1, …).
LandingMarketing SPAPublic substratum.cloud site in apps/landing — families-first copy, not the SaaS dashboard.
PrerenderBuild-time HTMLNode script renders Mithril per locale into static index.html for crawlers and first paint.
SpindleTangled CIGit-push pipeline on Tangled that builds and deploys artifacts.
Midnight GalleryVisual themeCinematic 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.

Substratum marketing landing page — Google Stitch mockup (Midnight Gallery, hero through footer)

Stitch sectionADR v1 section
Hero + platform CTAsHero
Digital fragility cardsProblem
Five steps to sovereigntyHow it works
Resilience map (centerpiece)Centerpiece
A day in the life timelineWeek in life
Typical cloud vs SubstratumCompare
Pricing cardsPricing (two tiers; Coming soon badges — no dollar amounts in v1)
Footer CTA + AT Proto sign-inCTA + Footer

Requirements

Functional requirements (R1–R13)

IDRequirement
R1Ship 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.
R2Hero and body copy use family voice (homes/places, not mesh jargon); builder strip links to technical-specification.md.
R3Support six locales: en, es, fr, de, pt-BR, fil — same set as Lingui config and supportedLocales in apps/landing/src/i18n.ts.
R4LanguagePicker switches locale, persists substratum.locale in localStorage, and navigates to /<locale>/ for shareable URLs.
R5Build pipeline produces one prerendered index.html per locale with visible body copy, injected <title>, <meta description>, hreflang, and canonical.
R6Default locale (en) is served at /; other locales at /<locale>/ (e.g. /es/).
R7Ship robots.txt, sitemap.xml, and og:image (public/og-image.png) with prerender-injected Open Graph meta.
R8Client entry (main.tsx) hydrates Mithril on #app after load; interactivity (picker, scroll animations) runs in browser only.
R9Use @substratum/ui-kit components (DisplayHeader, SurfaceCard, Button, Chip, GradientProgressBar, Accordion, Icon, LanguagePicker) — not full app Layout.
R10Spindle deploy: pnpm nx build landing (includes prerender) then rsync dist/apps/landing/ to droplet /var/www/landing.
R11Caddy serves locale directories and SPA fallback via try_files {path} {path}/index.html /index.html.
R12Build-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/.
R13Pricing 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)

IDRequirement
NR1View-source on / and /es/ shows localized <h1> and meta without executing JavaScript.
NR2Non-technical reader understands value proposition in ~10 seconds.
NR3Visual consistency with file-explorer tokens and ui-kit (Midnight Gallery).
NR4Fast client bundle — static Mithril SPA, no heavy framework bloat (ADR 08).
NR5prefers-reduced-motion respected for scroll animations.
NR6Landing deploy artifact stays out of Tangled git (dist/ on server only).

Constraints (C1–C5)

IDConstraint
C1Docs remain on Tangled Sites /site (ADR 06); landing does not share that deploy directory.
C2No DigitalOcean App Platform or GitHub mirror for v1 — Pulumi + droplet + Spindle only.
C3ui-kit does not import Lingui; landing resolves strings and passes plain string props (ADR 14).
C4v1 routing is anchor links on a single scroll page — no client m.route.
C5Theme CSS reuses apps/file-explorer/src/styles/index.css until libs/theme exists.

Cross-cutting challenges (CC1–CC4)

IDChallengeMitigation
CC1Droplet serves static files only — no SSRBuild-time prerender per locale in phase 1 (not deferred).
CC2Tangled Sites allows one site config per repoDocs keep /site; landing on separate DO droplet with custom domain.
CC3Locale parity across Lingui catalogsSame workflow as ADR 14: explicit IDs, all messages.json updated per change.
CC4Prerendered markup vs client mountStandard Mithril client entry; mount replaces or matches prerendered #app content.

Decision

1. Split hosting surfaces

SurfaceHostBuildArtifact in Tangled git?
DocsTangled Sites (/site)Husky + VitePressYes (site/)
LandingDO Droplet + CaddySpindle: nx build landing + rsyncNo (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)

text
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 up

Spindle 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 name marketing)
  • Resources: Droplet, Reserved IPv4, Firewall, DNS A record (when domain is set), SpacesBucketCorsConfiguration on the installer bucket, ProjectResources
  • Cloud-init: Ubuntu 24.04 + Caddy, /var/www/landing, try_files for locale paths + SPA fallback; Caddy auto-TLS when domain is 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.

ComponentRole
Reserved IPv4Stable target for DNS A, SSH deploy, and Caddy TLS; reassigned when the droplet is replaced
Droplet + CaddyServes locale HTML and hashed assets from /var/www/landing; terminates HTTPS on :443
Spaces bucketHosts macOS installer .dmg files and latest.json; CDN origin for browser downloads
Spaces CORSAllows fetch() of the installer manifest from https://substratum.cloud at build/runtime
FirewallInbound TCP 22, 80, 443 only; outbound open for apt, Let's Encrypt, and Spaces uploads from operators

4. Build-time prerender (phase 1)

text
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 droplet

Nx build script:

json
"build": "pnpm compile-locales && vite build && node scripts/prerender.mjs"

5. URL layout (Caddy)

URLFile
//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

text
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.png

7. Tech stack

LayerChoice
FrameworkMithril.js + TSX
UI@substratum/ui-kit
Themefile-explorer index.css (until libs/theme)
i18nLingui — explicit IDs, 6 locales
BuildNx + Vite → dist/apps/landing + prerender

8. Implementation phases

PhaseDeliverable
1apps/landing scaffold + config/ module + Vite build + prerender per locale + en copy for all sections (including Pricing with Coming soon badges)
2infra/marketing Pulumi + Caddy cloud-init
3Spindle landing.yml (pulumi up + build + rsync)
4All locales + centerpiece animation
5Custom 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:

FileRole
types.tsLandingConfig, LandingLinks, LandingMeta
load.tsloadLandingConfig() — sole import.meta.env reader
accessors.tslandingLinks, landingMeta, landingAvailability, isLinkLive()
index.tsBarrel 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.

VariableRequiredPurpose
VITE_SITE_ORIGINyesCanonical origin for prerender canonical, og:url, sitemap (no trailing slash)
VITE_DOCS_URLyesGetting started + technical spec links
VITE_INSTALLER_URLnoHero + tier-1 (home copies) CTA — omit for Coming soon UX
VITE_APP_URLnoSign-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:

TierFramingv1 UX
Home copiesSelf-hosted path: home helper, up to three places, you control diskPlanCard + Coming soon on price; installer CTA when VITE_INSTALLER_URL is set
Optional safety copiesSubstratum-operated secure storage in more than one part of the world; home copies remain yoursPlanCard + 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

AlternativeWhy rejected
DigitalOcean App PlatformRequires GitHub/GitLab mirror; learning path is Pulumi + small server + Spindle for v1.
Tangled Sites for landingOne 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

#QuestionOptions
1Hero headlineTrust/places vs photos vs anti-single-device
2Primary CTA at launchDefault: hero → installer when VITE_INSTALLER_URL set; sign-in Coming soon until VITE_APP_URL configured
3Default locale URL/ = en only vs auto-redirect from Accept-Language
4CenterpieceMesh animation vs illustration
5Domain phase 1IP + HTTP first vs substratum.cloud immediately

Verification

ScenarioExpected
View-source /Localized <h1>, <title>, <meta description> without JS
View-source /es/Spanish body copy and hreflang alternates
LanguagePickerSwitches locale and navigates to /<locale>/
Spindle deployRsynced prerendered tree; Caddy serves correct locale HTML
Non-technical readerUnderstands value in ~10 seconds