Fitness
Adaptive-training app landing: single-screen value prop, three-step mechanic and an OAuth sign-in.
# Margin — Adaptive Training Sign-In Screen
## What you're building
The entire front door to **Margin**, an adaptive training app: one screen, one card, one button. A near-black page with a lime accent, a tight 48px display headline, a three-step mechanic (Train / Track / Adapt) rendered as numbered rows, and a single OAuth sign-in. Audience: someone who already lifts and is tired of static programmes. Because this is the whole shop window, the card has to look like a finished product on first paint — there is no second section to recover in.
Margin is an invented brand and every string below ships exactly as written. The **only** placeholder is `{{oauthProvider}}` — the name of a real identity vendor, which we never write down. Its glyph is supplied by the buyer; until then the screen renders the neutral placeholder mark described under *Assets*.
## Art direction
Mood: a gym at 5am. Charcoal-green dark, one high-visibility lime, nothing else lit.
| Token | Hex | Use |
|---|---|---|
| `--bg` | `#0A0D0A` | Page background (near-black with a green cast) |
| `--bg-bloom` | `#101610` | Centre of the radial wash behind the card |
| `--card` | `#192019` | The sign-in card |
| `--row` | `rgba(215,255,99,0.08)` | Step-row fill and the numeral chip |
| `--row-hover` | `rgba(215,255,99,0.13)` | Step-row hover fill |
| `--line` | `rgba(243,245,239,0.07)` | Card border, row hairlines |
| `--text` | `#F3F5EF` | Headline, step titles, wordmark |
| `--text-muted` | `#A4ADA4` | Deck, step subtitles, footnote |
| `--accent` | `#C8FF4D` | CTA fill, step numerals, focus ring |
| `--accent-dim` | `#9FCC3E` | CTA active/pressed state |
| `--on-accent` | `#0E1309` | Label on the lime button |
| `--danger` | `#FF6B5A` | Auth failure text and border |
Card: `--card` fill, `1px solid var(--line)`, radius `24px`, padding `40px 36px`, width `470px` (content column **398px**). Its shadow is the only one on the page: `0 40px 120px rgba(0,0,0,.55)`. Behind it, one radial wash — `radial-gradient(70% 55% at 50% 42%, var(--bg-bloom), var(--bg) 70%)` — plus the diagonal texture described under *Assets* to stop the black from banding on cheap panels. Radii elsewhere: `12px` on step rows and the CTA, `999px` on the numeral chips.
**Type stack.** Two families, split by job.
```css
.wordmark { font-family:"Space Grotesk",sans-serif; font-weight:700; font-size:.6875rem; /* 11px */
line-height:1.4; letter-spacing:.16em; text-transform:uppercase; color:var(--text); }
.h1 { font-family:"Space Grotesk",sans-serif; font-weight:700; font-size:3rem; /* 48px */
line-height:.98; letter-spacing:-0.055em; color:var(--text); }
.deck { font-family:Manrope,sans-serif; font-weight:400; font-size:.875rem; /* 14px */
line-height:1.55; color:var(--text-muted); max-width:46ch; }
.steptitle{ font-family:"Space Grotesk",sans-serif; font-weight:700; font-size:.875rem;
line-height:1.2; letter-spacing:-0.01em; color:var(--text); }
.stepsub { font-family:Manrope,sans-serif; font-weight:400; font-size:.75rem; line-height:1.4;
color:var(--text-muted); }
.cta { font-family:"Space Grotesk",sans-serif; font-weight:700; font-size:1rem;
letter-spacing:-0.01em; color:var(--on-accent); }
.numeral { font-family:"Space Grotesk",sans-serif; font-weight:700; font-size:.6875rem;
color:var(--accent); font-variant-numeric: tabular-nums; }
.footnote { font-family:Manrope,sans-serif; font-weight:400; font-size:.75rem; line-height:1.4;
color:var(--text-muted); font-variant-numeric: tabular-nums; }
```
The `-0.055em` tracking on the H1 is the signature — at 48px that is `-2.64px`, tight enough that "results." nearly closes up. Do not soften it.
`font-variant-numeric: tabular-nums` goes on `.numeral` and `.footnote`. `.footnote` needs it because the rate-limit state renders a per-second countdown in that slot; without tabular figures the centred footnote shifts sideways once a second.
**Copy against the type scale.** In the 398px content column the H1 sets to **three lines**, breaking `Your plan should` / `learn from your` / `results.` — roughly 353px, 329px and 190px. Do not insert a `<br>`; the break has to move when the type clamps down. Three lines at `line-height:.98` is 141px. The deck, capped at `46ch` (≈354px at Manrope 14px), sets to **three lines**: `Train, track enough fuel and body-weight data,` / `and use what actually happens to improve what` / `you do next.` Every step subtitle is under 40 characters and sets to one line in the row's 356px right column.
## Copy
Every string the screen renders, verbatim. `{{oauthProvider}}` is the only substitution; everything else ships as written. Where a string contains a runtime value it is given as a template with its formatting rule — no string on this page is left for the builder to compose.
### Document
| Slot | String |
|---|---|
| `<title>` | `Margin — Adaptive Training` |
| `<meta name="description">` | `A training plan that learns from your workouts, fuel, and body-weight data.` |
| `<html lang>` | `en` |
### Card
| Slot | String |
|---|---|
| Wordmark | `MARGIN` |
| H1 | `Your plan should learn from your results.` |
| Deck | `Train, track enough fuel and body-weight data, and use what actually happens to improve what you do next.` |
| `<ol>` accessible name | `How Margin works` (via `aria-label`) |
| Step 1 title | `Train` |
| Step 1 subtitle | `Know exactly what to do today.` |
| Step 2 title | `Track` |
| Step 2 subtitle | `Log performance, fuel and weight.` |
| Step 3 title | `Adapt` |
| Step 3 subtitle | `Next week is built from last week.` |
| CTA label | `Continue with {{oauthProvider}}` |
| Footnote (idle) | `Your training and nutrition history stays on your account.` |
There are exactly three steps and exactly three numeral chips. The numerals `1` `2` `3` are rendered as visible text inside the chips **and** the rows are real `<li>` elements, so a screen reader gets the ordinal from list semantics; the chips are `aria-hidden="true"` so the number is not announced twice.
The em dash in `Margin — Adaptive Training` is a real `—` (U+2014).
### Every state's text, in full
| State | Where it renders | String |
|---|---|---|
| `redirecting` | live region | `Signing in…` |
| `redirecting` | footnote slot | `Signing in…` |
| `exchanging` | footnote slot and live region | `Finishing sign-in…` |
| `authenticated` | confirmation line 1, with a name | `Welcome back, ${displayName}.` |
| `authenticated` | confirmation line 1, `displayName` null | `Welcome back.` |
| `authenticated` | confirmation line 2 | `Opening your training week…` |
| `error: popup-blocked` | footnote slot, `--danger` | `Your browser blocked the sign-in window. Allow pop-ups for this site, or continue in this tab.` |
| `error: popup-blocked` | fallback control, a real `<button>` under the message | `Continue in this tab` |
| `error: cancelled` | footnote slot, `--danger` | `Sign-in was cancelled. Nothing was saved.` |
| `error: network` | footnote slot, `--danger` | `We could not reach {{oauthProvider}}. Check your connection and try again.` |
| `error: provider-error` | footnote slot, `--danger` | `{{oauthProvider}} could not complete sign-in. Try again in a moment.` |
| `error: rate-limited` | footnote slot, `--danger`, updates once per second | `Too many attempts. Try again in ${seconds}s.` |
| `error: rate-limited`, at zero | footnote slot | `Too many attempts. Try again now.` |
| offline at mount | footnote slot, `--text-muted` | `You are offline. Sign-in will re-enable when your connection returns.` |
| back online | live region, announced once | `Connection restored.` |
`${seconds}` is an integer with no padding and no thousands separator, produced by `String(Math.max(0, Math.ceil(msRemaining / 1000)))` — not `toLocaleString`. `${displayName}` is whatever the provider returned, inserted as text, never as HTML.
### Accessible names for everything that is not text
| Element | Value |
|---|---|
| CTA `<button>` | accessible name is its visible label; add `aria-busy="true"` while `redirecting` or `exchanging`, and never change the label mid-flight |
| Provider glyph inside the CTA | `aria-hidden="true"`, `focusable="false"` |
| Spinner | `aria-hidden="true"`, `focusable="false"` — the live region carries the words |
| Numeral chips | `aria-hidden="true"` |
| Radial bloom and diagonal texture | `aria-hidden="true"`, `pointer-events: none` |
| Live region | a single always-present `<div aria-live="polite" role="status">`, empty in `idle` |
There is no nav, no footer, no secondary link, no "already have an account". The scarcity is the design; do not add a string that is not in this section.
## Assets and illustrations
**No raster images, no video, no icon font, no external requests beyond the two webfonts.** Everything visible is a CSS gradient or an inline SVG with explicit `width` and `height`, so nothing loads late and cumulative layout shift is structurally zero.
1. **Provider glyph — 18 × 18, `viewBox="0 0 18 18"`, inline SVG, sits 10px left of the CTA label.** The buyer drops in the real `{{oauthProvider}}` mark. Ship the placeholder: a rounded square `1,1,16,16 rx 4`, `fill: none`, `stroke: var(--on-accent)`, `stroke-width: 1.6`, with a 6px-diameter solid `--on-accent` circle centred at `9,7` and a `stroke-width: 1.6` arc from `4.5,14` to `13.5,14` bowed 3px upward — a generic person mark that reads as an account, not as any vendor. Reserve the 18px box whatever mark replaces it, so swapping the glyph never moves the label.
2. **Numeral chips — 28px CSS circles**, `--row` fill, `border-radius: 999px`, containing the text `1`, `2`, `3` in `.numeral`. Not SVG.
3. **Spinner — 18px, `viewBox="0 0 18 18"`, inline SVG**, one circle `cx 9 cy 9 r 7`, `fill: none`, `stroke: var(--on-accent)`, `stroke-width: 2`, `stroke-linecap: round`, `stroke-dasharray: 34 10`, rotated by CSS. Under reduced motion it is replaced by the static three-dot glyph in *Motion*.
4. **Radial bloom** — one CSS gradient, no element of its own beyond a `position: fixed; inset: 0` div at `z-index: 0`.
5. **Diagonal texture** — `repeating-linear-gradient(115deg, rgba(243,245,239,.02) 0 1px, rgba(243,245,239,0) 1px 7px)`, `position: fixed; inset: 0`, `opacity: 1` (the 2% is in the colour, not a second opacity), `z-index: 0`, `pointer-events: none`. Its only job is to break up banding in the bloom on 6-bit panels.
Do not substitute a photograph, an illustration, a logo lockup, or a product screenshot. There is no image on this page.
## Layout
The document **does not scroll** on a laptop: `document.documentElement.scrollHeight` equals the viewport height at 1440×1080.
**The card's height is arithmetic, and it is what makes that true.** Top padding 40 + wordmark 15 + 24 gap + H1 141 + 12 gap + deck 65 + 36 gap + step list 194 (3 × 58 + 2 × 10) + 36 gap + CTA 52 + 14 gap + footnote 17 + bottom padding 40 = **686px**. At a 1080px-tall viewport that leaves 197px of clearance above and below. Do not chase a pixel total — chase this stack.
1. **Page shell.** Full-viewport flex centre, `min-height: 100dvh`, `--bg` plus the radial wash. No nav, no footer, no logo lockup outside the card.
2. **Card, block, 4 children.**
1. **Wordmark** — uppercase, letterspaced, top-left of the card. 24px below it, the headline starts.
2. **Headline + deck.** H1 across three lines at the 398px content column — allow natural wrapping, do not hard-break. Deck 12px below at `max-width: 46ch`, three lines.
3. **Step list** — a `<ol>` of 3 rows, 10px gap, 36px above and below. Each row: `--row` fill, radius 12px, height 58px, padding `0 16px`, `display: grid; grid-template-columns: 28px 1fr; gap: 14px; align-items: center`. Left: the 28px numeral chip. Right: title over subtitle. The text column measures 398 − 32 (row padding) − 28 (chip) − 14 (gap) = **324px**, which is about 49 characters of Manrope 12px; every supplied subtitle is 34 characters or fewer and sets to one line.
4. **CTA block** — full-width lime button, 52px tall, radius 12px, glyph + label, centred. 14px below it, the footnote, centred, `min-height: 17px` reserved so an error message that wraps to two lines is the only thing that ever changes the card's height (and it does — see below).
3. **Nothing else.**
**Height under error.** `popup-blocked` and `network` wrap the footnote to two lines and add the 40px `Continue in this tab` button in the popup-blocked case, taking the card to 743px. Still 168px of clearance at 1080. Reserve nothing extra; let the card grow and stay centred.
**Widths, verified.**
| Viewport | Card | Gutter each side | Notes |
|---|---|---|---|
| 1440 | 470 centred | 485 | reference |
| 1280 | 470 centred | 405 | identical |
| 1080 | 470 centred | 305 | identical |
| 768 | 470 centred | 149 | identical |
| 520 | 470 centred | 25 | last width before the switch |
| < 520 | full-bleed, 20px gutters | 20 | border and shadow removed, `border-radius: 0` |
Below 520px the shell switches from centred to top-aligned with 56px of top padding so the CTA stays above the on-screen keyboard. H1 clamps to `clamp(2.125rem, 9vw, 3rem)` — 34px at 375px wide, where the 335px content column takes the headline to four lines at most. The deck and step subtitles hold their sizes; shrinking 12px text is how this design fails.
**Short viewports.** Below **760px of viewport height** the centred layout cannot hold 686px plus breathing room, so the shell switches to `align-items: flex-start` with 40px of top padding and the document is allowed to scroll. Say so rather than pretending; a 1366×768 laptop with browser chrome lands here.
## Data & states
Auth is the only stateful thing here, but it is the whole product surface, so specify it fully:
```ts
type AuthSession = {
status: 'idle' | 'redirecting' | 'exchanging' | 'authenticated' | 'error';
provider: 'oauth'; // single provider; the label comes from {{oauthProvider}}
user: { id: string; displayName: string | null; avatarUrl: string | null } | null;
returnTo: string; // path to resume after sign-in
error: { code: 'popup-blocked' | 'cancelled' | 'network' | 'provider-error' | 'rate-limited';
message: string; retryable: boolean; retryAfterMs: number | null } | null;
attemptedAt: string | null; // ISO 8601
};
type Step = { order: 1 | 2 | 3; title: string; subtitle: string };
```
Legal transitions, and nothing else: `idle → redirecting → exchanging → authenticated`; `redirecting → error`; `exchanging → error`; `error → redirecting` (retry); `error(rate-limited) → idle` when the countdown hits zero. A transition not on that list is a bug, not a state.
- **idle** — as drawn. The button is enabled, the card is at full opacity.
- **redirecting** — the moment the button is pressed: the label is replaced by the 18px spinner, the button keeps its resting width via a `min-width` locked from `getBoundingClientRect().width` **before** the swap, `aria-busy="true"`, and the whole card drops to `pointer-events: none`. Do not dim the card — dimming reads as failure.
- **exchanging** — after the provider returns, while the code is traded for a session. Same visual as `redirecting`; the footnote swaps to `Finishing sign-in…`. Cap this at **12s**, then fall to `error: 'network'`.
- **authenticated** — the card cross-fades to the two-line confirmation for 600ms, then navigates to `returnTo`. Never leave the user on this screen after success.
- **error** — the button re-enables, its border goes `1px solid var(--danger)`, and the footnote is replaced by the `--danger` line for that code. `popup-blocked` additionally renders the `Continue in this tab` button, which re-enters `redirecting` on the same-tab path. `cancelled` is quiet and non-alarming. `rate-limited` disables the button and runs the countdown from `retryAfterMs`; when it reaches zero the button re-enables and the status returns to `idle`.
- **offline** — `navigator.onLine === false` at mount, or an `offline` event at any time: the button is disabled with the offline note, and it re-enables on the `online` event without a page reload. Register both listeners on `window` and remove them on unmount.
- **loading (content)** — if the three steps come from a config endpoint, render the card frame, wordmark, headline, deck, CTA and footnote immediately from static content and skeleton only the three rows: 58px `--row` blocks with a shimmer. Never skeleton the headline; it is the one thing that must be on screen at first paint.
- **empty / partial** — fewer than three steps renders only what exists, keeping the 10px gap. Zero steps removes the `<ol>` and collapses both 36px gaps to a single 36px gap, so the card shortens to 528px and stays centred; there is no empty-state message, because the CTA still works. A step with a missing subtitle collapses to a single centred title line rather than leaving an empty second line.
There is no live-updating data on this screen and no polling. Two timers exist and no more: the 12s exchange ceiling, and the 1s rate-limit countdown — the latter only while `error.code === 'rate-limited'`.
**Never store tokens in `localStorage` or any JS-reachable store.** The code exchange happens server-side; the browser receives an httpOnly cookie. This screen holds no secrets in JS.
## Motion
Eight movements total. Anything more on a single-screen page looks like a demo reel.
1. Card entrance on load: `opacity 0→1` + `translateY(18px→0)` + `scale(.985→1)` over **560ms** `cubic-bezier(.16,1,.3,1)`. Once.
2. Inner stagger: wordmark, headline, deck, each step row, CTA, footnote at **55ms** intervals, `opacity` + `translateY(10px→0)` over **420ms**, same easing, starting 120ms into the card entrance. Eight targets, so the last one begins at 120 + 7 × 55 = 505ms and the screen is settled by 925ms.
3. Radial bloom: `opacity 0→1` over **900ms** `ease-out`, no loop.
4. Step-row hover: background `--row` → `--row-hover` over **160ms** `ease-out`. No lift. Rows are not focusable, so there is no focus variant.
5. CTA: hover raises the fill to `#D2FF6B` over **140ms**; `:active` is `scale(.985)` + `--accent-dim` over **90ms**.
6. Spinner: **720ms** `linear` infinite rotation.
7. Skeleton shimmer: **1.5s** `ease-in-out` infinite, a 12% white gradient across `--row`.
8. Success cross-fade: outgoing content **180ms** out, incoming **240ms** in, then navigate.
**`prefers-reduced-motion: reduce` resolves to exactly one behaviour.** The card and all eight stagger targets render at final position and opacity on first paint — no entrance, no stagger, no transition property on them at all. The bloom paints immediately. Hover background changes are instant. The shimmer is a flat `--row` block. The spinner is replaced by a static `···` glyph in `--on-accent` and the pending state is carried by the live region's words. The success state swaps content instantly with no cross-fade and navigates immediately. The rate-limit countdown still updates once per second — it is information, not decoration.
**Honour a mid-session change.** Put the whole motion setup in one function, call it once, and subscribe to `matchMedia('(prefers-reduced-motion: reduce)')`'s `change` event: on change, run the cleanup (clear the stagger timeouts, drop the transition classes) and re-run the function. A `useEffect` with an empty dependency array will not re-run on its own. Remove the `change` listener on unmount.
## Build notes
React + Tailwind, delivered as a Next.js route — a real project directory, not a single file. Space Grotesk (700) and Manrope (400) via `next/font/google` with `display: 'swap'` and `adjustFontFallback: true`; a font swap that reflows a 48px headline on a single-screen page is very visible. Those two families are the page's only network requests.
- Keep the whole screen one client component driven by a single `status` union; the auth flow is a state machine, not a set of flags.
- Use the provider's **redirect** flow as the default path. Popups are blocked often enough that popup-first costs sign-ups; keep the popup path only as the `Continue in this tab` fallback's counterpart.
- Format the rate-limit countdown with `String(Math.ceil(ms / 1000))`. Do not use `toLocaleString()` anywhere on this page: called without an explicit locale it resolves against the runtime's default, which can differ between the Node render and the browser, and a server/client mismatch on a rendered string produces a hydration error.
- No charting library, no animation library. Everything here is CSS transitions and one `setTimeout` chain; adding a motion library for eight movements is a 40KB tax on the only screen you have.
- Set `min-height: 100dvh`, not `100vh`, so mobile browser chrome doesn't push the CTA off-screen.
- There is no `IntersectionObserver` on this page — every reveal is time-based on mount. If you add one later (for example to defer the texture layer), give it a root margin with real height rather than a zero-height root, and resolve the target from `getBoundingClientRect()` against the viewport midpoint inside the callback rather than trusting the order entries arrive in.
**Accessibility.** The three step rows are content, not controls: mark them as an `<ol>` of `<li>`, and do not make them focusable — they do nothing. The CTA is a real `<button type="button">` whose accessible name includes the provider. Focus ring: `2px solid var(--accent)` at `3px` offset on the card's controls; on the lime button itself the ring switches to `2px solid var(--text)` so it never disappears into the fill. Every error names its cause in words — the red border alone is not the message. Announce status transitions through the single `aria-live="polite"` region; never `assertive`; set `aria-busy` on the button rather than swapping its accessible name mid-flight.
**Contrast, measured against the surface each string actually sits on** — the card at `#192019` and the step-row fill, which composites `rgba(215,255,99,.08)` over the card to `#28321F`:
| Foreground | Surface | Ratio |
|---|---|---|
| `--text` `#F3F5EF` | card `#192019` | 15.1:1 |
| `--text-muted` `#A4ADA4` | card `#192019` | 7.2:1 |
| `--danger` `#FF6B5A` | card `#192019` | 6.0:1 |
| `--text` `#F3F5EF` | row `#28321F` | 12.2:1 |
| `--text-muted` `#A4ADA4` | row `#28321F` | 5.8:1 |
| `--accent` `#C8FF4D` | row `#28321F` | 11.4:1 |
| `--on-accent` `#0E1309` | button `#C8FF4D` | 16.0:1 |
| `--accent` focus ring | card `#192019` | 14.2:1 |
Every one clears 4.5:1. The lime must only ever appear on a dark surface or carry `--on-accent` text; lime on white anywhere is a failure.
**Mobile.** The card fills the viewport width, the CTA is the last element above a 24px bottom safe-area inset, and tap targets stay at 48px minimum. Test with the on-screen keyboard open — the button must remain reachable without scrolling the headline off.
## Acceptance checks
- At 1440×1080 the page does not scroll: `document.documentElement.scrollHeight === window.innerHeight`. The card measures 686px tall in `idle` and sits centred with ~197px of clearance above and below.
- The headline renders in Space Grotesk 700 at 48px with `letter-spacing: -0.055em` and wraps to exactly three lines — `Your plan should` / `learn from your` / `results.` — with no `<br>` in the markup.
- The deck wraps to exactly three lines at its `46ch` measure, and no step subtitle wraps to a second line.
- Pressing the CTA locks the button width from its pre-swap measured width, shows the spinner, and sets `aria-busy="true"` — the card does not dim and no element outside the button moves.
- Blocking the auth request in devtools shows `We could not reach {{oauthProvider}}. Check your connection and try again.` in `--danger` under the button, re-enables the CTA, and announces the same sentence once through the polite live region.
- Forcing `error: 'popup-blocked'` renders both the message and a working `Continue in this tab` button; forcing `error: 'rate-limited'` disables the CTA and counts down once per second in tabular figures with no horizontal shift in the centred footnote.
- The three steps are exposed as an ordered list of three items to a screen reader, and the numeral chips are not announced (they are `aria-hidden`).
- With `prefers-reduced-motion: reduce`, the card is fully visible on first paint with no entrance, stagger, shimmer or spinner rotation, and the pending state reads as `Signing in…` in text. Toggling the OS setting mid-session flips the page to the other mode without a reload.
- In a greyscale screenshot the CTA is still the clear primary action and the error state is still identifiable from its words alone.
- With the network offline at load, the button is disabled with the offline note, and it re-enables automatically on the `online` event with `Connection restored.` announced once.
- Exactly two timers can ever be running: the 12s exchange ceiling and the 1s rate-limit countdown. Instrument `setInterval`/`setTimeout` to confirm nothing else is scheduled in `idle`.
- The only network requests the page makes are the two font files. There is no image, no icon font, and no third-party script.