Stepo Web — Design Authority
Scope: everything served by stepo-web — the apex landing (stepo.app), the public share shells (/@username, journey, step), legal pages, and the admin dashboard (admin.stepo.app). This doc rules web-specific design. On shared identity questions (palette, type voice, accent semantics, meter rule, copy honesty) the app ledger wins: ../app/DESIGN.md. Mocks live in ./mocks/.
Stack rulings
- SvelteKit on Cloudflare Pages (already deployed;
hooks.server.tsis host-aware). - Tailwind v4 (PO ruling 2026-07-13). Tokens live ONCE in the
@themeblock ofstepo-web/src/app.css(v4 declares its theme in CSS, not a JS config); styling is utility classes at the call site — no component CSS files, no@applylayers, matching the mobile "inline styles, tokens global" convention. - Fonts self-hosted via Fontsource: Archivo (500–800), Inter (400–700), Fraunces italic (500/600). No Google Fonts CDN at runtime (privacy + CSP).
- Mocks use the Tailwind Play CDN with the same inline config so mock classes copy straight into production Svelte files.
Tokens (BRIGHT system → Tailwind theme)
Mirrors the app's 2026-07-12 temperature ruling ("neutral app, warm human moments"). If the app tokens change, this table changes in the same commit.
| Tailwind name | Hex | Use |
|---|---|---|
paper | #FAFAF9 | Canvas everywhere |
paper2 | #F2F2F0 | Chips, secondary fills |
card | #FFFFFF | Cards — lift via border-line + soft gray shadow |
ink | #191817 | Primary text; solid buttons; carbon surfaces |
ink2 | #403E3B | Secondary text |
mutedink | #666360 | Tertiary text |
faintink | #A6A4A1 | Whisper text, placeholders |
line / line2 | #E7E6E3 / #DBDAD7 | Borders (solid; they carry card hierarchy) |
live / livetext | #FF6B3C / #C4441C | 24h window; fills/rings vs text/glyphs |
starter / startertext | #EF9400 / #9A6100 | First steps, Starter |
celeb / celebtext | #F0457E / #C22860 | Finales, Celebrator |
danger | #B3322E | Destructive only — never rose |
Rules carried over verbatim: accents never carry text (use the -text twins); one accent per card; a count is a memory, a ratio is a meter (no meters); Fraunces italic = authored voice only — journey titles, human quotes, brand taglines. Never UI labels, never admin chrome.
About (stepo.app/about) — the one first-person page (ruled 2026-07-30)
Mock: about.html (en + vi side by side). Everywhere else Stepo is chrome around other people's content; this is the one page where the maker speaks. Read once, by someone curious enough to look, so it is a page, not a pitch: no hero, no install CTA, no metrics, no team grid.
- Four beats, in order. Who (one line that answers why in the same breath: "made by one person who loves to build"), why (the origin, small and specific), what it is for (in the product's own language), then proof as a live link to Making Stepo, the app's oldest journey. The claim is rendered as evidence, never as a screenshot.
- Voice is the PO's own, drafted from his first step and his portfolio: short sentences, admits the mess, claims nothing the app has not earned. No em-dashes.
- Special thanks is a named list, in the order he wrote it, Vietnamese honorifics intact ("chị Tiên Lê"). Never alphabetised, never a credits grid, never linked to profiles without consent. One sentence does the thanking; the names carry themselves.
- The close is an invitation with a real address, not a contact form. A form would put a database between a stranger and a person, which is the opposite of the point. Under it sits one quiet muted line of the maker's other addresses (chauapps.com · LinkedIn · GitHub). The email keeps the accent because it is the invitation; those are only places he is. Never buttons, never icon tiles, never a follow-me grid.
- vi is authored, not translated (
/about?lang=vi, the hreflang pair of the clean URL — see Site localization). It reuses sentences from his first step verbatim and keepsobsessionin English because that is how he wrote it. Names, the email, and the journey title are identical across locales. The Vietnamese is locked indialect/translations/vi.arb, so a laterdialect translatecannot rewrite his voice.
BUILT 2026-07-30. stepo.app/about, linked from the landing footer, and hosted rather than rebuilt in Flutter: the app's Settings hub opens the same page in a webview, first in the About group above Terms. One copy of a person's words is easier to keep true than two. The page reads nothing from the API, so it renders on a day the backend is down; the only moving part is which journey "the build itself" links to, which is the same editorial config the landing already reads. The app asks for the page in the language it is currently speaking, which is the one way this link differs from the legal ones. Offline it falls back to a bundled copy per language, regenerated from the live page by stepo-mobile/scripts/refresh-about-asset.sh.
Public share shells — "a quiet window into the app"
One shell for profile / journey / step (mock: share-step.html, share-profile.html):
- Content first. Wordmark small top-left; the shared thing is the page. No marketing sections on share pages.
- Open-or-get CTA (one, sticky on mobile): installed → the app; not installed →
/get, which resolves to the visitor's own store. The handoff is platform-shaped (2026-08-09): iOS takes thestepo://custom scheme with a ~1.5s timed fallback, Android takesintent://…;S.browser_fallback_url=…;end. Chrome on Android refuses a custom scheme navigated to from a page and renders ERR_UNKNOWN_URL_SCHEME on a full error page, installed or not — so one scheme for both platforms is not a simplification, it is a broken button on one of them. The intent form also needs no timer: Chrome opens the app or follows the fallback itself, and cannot strand the visitor on an error. Apple Smart App Banner meta tag on every share page, carryingapp-argumentso a tap that opens Stepo lands on the shared thing. Never a same-domain universal-link<a>(iOS ignores it by design). - Any link from our own pages into app content behaves the same way (2026-08-08). The About page names the founder's journey as its proof, and a bare
<a>to/@averagechau/…lands every phone on the web shell — same-domain universal links are ignored, so a site can never reach its own app through an href. Those links attemptstepo://first and keep the web page as their fallback, and only on a coarse pointer: a desktop visitor wants the page, and a custom-scheme attempt there buys a browser error dialog. One helper,$lib/open-or-get, serves both the CTA and inline links. - Step page: journey line (serif title) → author row → note → media → step-with presence as a memory line ("Maya and 11 others stepped with her") — faces, never meters, never live counts ticking. If the step is live, the live pill may say so; interaction happens in the app, never on web.
- Journey page: cover (or first effectively-public step's media), serif title, author, honest step count, first/latest step teasers.
- Profile page: name, avatar, bio, journeys first (cards, serif titles); follower/following demoted to one quiet
mutedinkline — same ruling as the app profile. Never lead with counts. - Privacy boundary: private, deleted, and never-existed are ONE indistinguishable state — the normal shell with "This isn't available." + a quiet "Get Stepo" (mock:
share-unavailable.html). HTTP 404 status in all three cases (no status-code oracle), calm copy, zero whimsy at the boundary. - OG images: step → its media; journey → cover/first public step; profile → avatar-on-paper card. An OG image never shows content the visitor couldn't open.
Store links (ruled 2026-07-28, both stores approved)
Stepo is live on the App Store (6790456635) and Google Play (com.chauapps.stepo). Three rules govern every place the web offers the app:
- Both stores are always visible. The visitor's platform, read from the user-agent server-side, decides which badge comes first; it never decides which badge exists. Someone on a laptop mailing the link to a friend, or an Android user opening a link an iPhone shared, has to be able to find their own store. Desktop and anything unrecognised lead with the App Store.
/getis the one-link form. For a bio, a QR code, a message — anywhere there is only room for one URL —stepo.app/get302s by user-agent. It is a redirect, never a page, and never cached as permanent because the destination depends on who is asking.- Official badge artwork in both cases (Apple's and Google's own lockups), at
cardonline2, never a generic "Download" button.
Site localization (ruled 2026-07-13, settled 2026-07-29 when it shipped)
Every public page answers in the reader's language: the landing, the share shells, the legal documents, About. Copy comes from per-locale dictionary files generated from the app's own Dialect source, so the site says what the app says; the one dynamic slot (the latest/live step from the public API) renders as authored on both locales — real content never gets translated, only chrome (captions, explainers, kickers, CTA) localizes.
Language rides the request, not the path. An explicit ?lang= → a remembered cookie → Accept-Language → English, resolved once per request. The parameter wins so a shared ?lang=vi link reads Vietnamese to someone whose cookie says otherwise: the link is the more specific intent. A corollary for anything linking in — the app's Settings row, an email, a QR — a clean URL guesses, so a link that means one language names it. There is no /vi/ prefix: the share grammar is locked, so one shared thing stays one indexed URL in every language. The canonical address of any page is always its clean URL, and the ?lang= addresses exist as real hreflang alternates that render directly (an alternate that redirects is a lie). A page that answers in two languages carries Vary: Accept-Language, Cookie. The language toggle in the footer is plain links.
Legal documents are the exception to the dictionary: a policy is a document, not a string catalog, so each ships as a whole authored body per language.
Landing (stepo.app) — the journey is the pitch (ruled 2026-07-13)
Mock: landing-v4-vision.html — the ruled direction (PO vision, codex structure pass, design-authority ratification). The page teaches Stepo entirely through real content; no slogan/claims sections (the "Never streaks…" band and pitch paragraphs are retired from the landing — that voice lives in the store kit and the explanatory captions).
Sections, in order: 1. The journey (split hero) — left: eyebrow, "Making Stepo" serif title, author row, journey description (per-locale; see Site localization), quiet stat line, supporters as ONE memory sentence (not a stats card); one Get Stepo CTA. Right: the latest step as a full card with media, marked live (marking the latest step "live" is acceptable even when the window has lapsed). 2. How a journey works — a numbered 01→02→03 rail; three step cards, media-forward: the real first step (Starter chip), the real latest step (live), and an example finale of Making Stepo. The connectors between the numbers are labelled "many more steps", because the rail is teaching that a journey is a run of steps and these are three of them. Short sans captions under each teach Starter / live / Achievement+Celebrator. This is the one section that illustrates rather than records — see "The rail illustrates" below. 3. Recognition — founder profile card (bio, journey chip, earned badge chips) beside a "People who showed up" list (role lines as memories). 4. Final CTA strip (Get Stepo + both store badges, live per Store links) and footer (legal links, the language toggle, About, wordmark).
Rules: media honesty — every photo ships real. The mock's Unsplash/randomuser faces are placeholders standing in for people who do not exist; no fake testimonials, no feature carousel bloat. The one invented number on the page is the thousand in the rail's example finale, and it is inside a card tagged Example — anywhere the page speaks in its own voice, a number is a real one or it is absent.
BUILT 2026-07-28. The landing is now the mock's structure rendered from the live public API at request time, so it tells the truth by construction — there is no copy of the journey to go stale, and the photos are whatever the founder actually posted. The journey is resolved from config (FOUNDER_HANDLE / FOUNDER_JOURNEY_CODE, defaulting to the identifiers in ../../StepoBackend/docs/stepo/founder_journey_playbook.md → "Live identity"), never hard-coded in a component — the pair already changed once.
Honesty falls out of the data rather than out of care, which is the point:
- Recognition language appears only where recognition was earned. With no Starters yet, the hero says the smaller true thing ("… have stepped along the way") instead of borrowing the word.
- "Live now" renders only while the latest step is inside its own window.
- An empty bio, an absent step, a journey with nobody in it: the element is absent, not empty. "0 people" would be a meter, and the ledger has no meters.
- The rail is the one section that invents, and every invented part is dressed as one. See "The rail illustrates" below; the dressing is load-bearing.
- If the API read fails the page still renders, minus the journey — a front door that 502s because a content read timed out is the worse trade.
- Faces come from hosts we don't own and intermittently fail, so avatars fall back to the person's initial on
line2(the tile the share pages already used for someone with no photo). A broken-image glyph is the loudest thing on a quiet page.
The rail illustrates (ruled 2026-08-07)
Section 2 has a second job the rest of the page does not: it teaches Starter, live and Celebrator. Making Stepo has nobody on its first step and no finale at all, so holding the rail to "records only" taught nothing — the finale sat as an empty dashed frame reading "not written yet", and card 01 said "where this journey begins" where a presence row belongs. The rail is therefore allowed to illustrate, and every illustrated part is dressed as one. The dressing is the ruling; the illustration is just what it protects.
- Card 03 is a written example finale. Dashed border and translucent card as before, the eyebrow reads "the finale, one day", and an Example tag sits on the media frame. The media is the app mark on a dashed tile, not a photograph — the step a journey ends on is the thing it made, and a stock photo of a day nobody has lived is the lie the mark avoids. Its note is invented, the thousand people in it included, which is what the tag on the frame covers.
- The presence rows on 01 and 03 are illustrated too, and carry the Example tag (card 01 beside the count; on card 03 the frame's tag covers the whole card). They borrow this journey's real supporter avatars rather than shipping stock portraits.
- An illustrated row counts people, it never names one. That is the line between borrowing a face for an illustration and putting words in a real person's mouth: "12 people were there for the finish" is a diagram, "Linh was there" is a claim about Linh. Pinned by
stepo-web/src/routes/landing-rail.test.ts. - The counts differ per card (2 at the start, the real count in the middle, 12 at the finish) because a beginning and a finish draw different crowds, and three identical numbers read as placeholder.
- Sections 1 and 3 are unchanged: they record, and they still degrade to the truth.
The exploration mocks landing-v1-journey/-v2-split/-v3-showcase.html are kept as the decision record in ../archive/explorations/landing-concepts/; landing.html is the pre-launch page this replaced.
Admin dashboard (admin.stepo.app)
Mocks: admin-reports.html, admin-config.html. Utilitarian, on-token, zero serif — the dashboard is chrome, and the interface is speaking. Desktop-first, fully usable at 390px (the founder acts from a phone via report-alert email links).
- Layout: left nav (Reports · Users · Config · Roles), collapses to top bar on mobile. Content = tables/cards on
paper, white cards,lineborders. - Reports queue: self-harm/urgent pinned first with a
danger-tinted row edge (the one colored thing); each row = content preview in context, category, reporter count, age. Actions: Dismiss / Remove content / Ban user — destructive actions get a typed-confirmation sheet,dangerfill, and show who/when (audit line) after. - Users: lookup by handle/email → profile summary, status, moderation history; ban/unban/shadow-ban; role grant (SuperAdmin only) for future delegation.
- Config: the
AppConfigtable rendered as key → editor (JSON with validation), save shows diff + who/when. No secrets ever appear here (secrets stay in Render). - Every mutation shows its audit trail (actor, timestamp) — delegation-ready means reviewable, not just role-gated.
- Sign-in: Firebase (same accounts as the app); the page is a thin client — the backend role check is the security boundary, the UI only reflects it.
States & accessibility
Empty states follow the app's quiet doctrine (an empty queue says "Nothing waiting." — no illustration, no cheer). Focus rings visible (ink 2px offset), all actions keyboard-reachable, text ≥14px on public pages, WCAG AA against paper/card (the -text twins exist for exactly this).