AI
Analyzer tool landing — paste-in input, scored output panel and a results-led proof section.
# Viral Score
## What you're building
A single-screen analyzer tool for `{{brand}}`: the visitor pastes a social post on the left, gets a scored breakdown on the right, and never scrolls. The audience is creators and marketers who post daily and want a second opinion before they hit publish. The distinctive idea is that **the tool is the landing page** — there is no hero section selling the tool, because the tool sells itself in one interaction; the entire page is a two-panel workbench with a free-usage meter that converts by running out.
The workbench layout and its state machine are the product. Everything else on this page is chrome.
## Art direction
Mood: clean daylight utility — a pale sky wash behind two white cards, one confident blue accent, nothing decorative. It should read as a working instrument, not a marketing page.
| Token | Hex | Use |
|---|---|---|
| `--page` | `#F0F9FF` | Page ground; a soft top-to-bottom sky wash into white |
| `--card` | `#FFFFFF` | Both panel fills — the most-painted surface |
| `--tint` | `#E0F2FE` | Select fill, info chips, template dropdown |
| `--tint-2` | `#BAE6FD` | Hover on tinted controls, meter track |
| `--line` | `#E2E8F0` | Card borders, rules, disabled button fill, gauge track |
| `--accent` | `#0369A1` | Primary button, links, character counter, focus ring |
| `--accent-bright` | `#0EA5E9` | Banner gradient start, live states |
| `--ink` | `#0F172A` | H1 |
| `--ink-2` | `#1E293B` | Panel headings, empty-state instruction |
| `--ink-3` | `#334155` | Field labels, checkbox labels, body |
| `--fg` | `#475569` | Deck line |
| `--fg-muted` | `#64748B` | Helper text, footnotes, `/100` |
| `--fg-faint` | `#94A3B8` | Placeholders, provisional gauge stroke |
| `--on-accent` | `#FFFFFF` | Text on the accent button and the banner |
| `--good` / `--warn` / `--bad` | `#10B981` / `#F59E0B` / `#EF4444` | Score bands and signal verdicts only |
**One correction.** The disabled button's label must stay legible: use `--ink-3` on `--line` (**7.6:1**), not `--fg-faint` on `--line` (**2.3:1**, a failure). A disabled control still has to be read — that is how the user learns why it is disabled.
Type stack — one family, `system-ui, -apple-system, sans-serif`. This is deliberate: no webfont request, instant first paint, and the H1 is therefore the LCP element with nothing blocking it.
- **Display (H1):** 700, `clamp(2rem, 1.3rem + 2.2vw, 3rem)`, `line-height: 1.0`, `letter-spacing: normal`, colour `--ink`, centred.
- **Panel heading (H2):** 700, `1.5rem` / `2rem`, colour `--ink-2`.
- **Deck:** 400, `1.125rem` / `1.75rem`, colour `--fg`, centred.
- **Body / inputs:** 400, `0.9375rem` / `1.5`, colour `--ink-3`. Inputs never below `1rem`.
- **Label:** 600, `0.875rem`, colour `--ink-3`, sentence case, with the `(optional)` suffix inside the label element.
- **Micro:** 400, `0.8125rem`, colour `--fg-muted` — the button footnote and the two lines under the fold rule.
- **Score numeral:** 700, `3.5rem`, `line-height: 1`, `font-variant-numeric: tabular-nums` so the count-up cannot jitter the layout.
**H1 fit, checked against the supplied copy.** At 1440px the clamp resolves to `1.3rem + 2.2vw = 52.5px`, so the `3rem` cap applies: **48px**. `Will Your Post Go Viral?` is 24 characters; system-ui bold averages ≈0.55em per character, so the line measures ≈634px inside the 1180px container — one line, comfortably. At 480px the clamp gives 31.4px and the line measures ≈414px against 448px of available width — still one line. The H1 never wraps at any supported width.
Shape: every control and card at `border-radius: 8px`, cards with a 1px `--line` border and a barely-there shadow (`0 1px 2px rgb(15 23 42 / 0.04)`). No gradients except the top banner.
## Copy
Every string the page renders, verbatim. Placeholders name a real price, a real product, a real citation or a runtime value: `{{brand}}`, `{{algorithm-source}}`, `{{methodology-url}}`, `{{pack_price}}`, `{{pack_size}}`, `{{free_runs}}`, and the runtime counters written as `{{n}}`. **Never invent a price, a benchmark, or a claim about what a real ranking algorithm contains.**
### Chrome
| Slot | String |
|---|---|
| Skip link | `Skip to the post field` |
| Banner line | `{{n}} free analyses left — no account, no email.` |
| Banner link | `View packs ↗` |
| Banner dismiss (`aria-label`) | `Dismiss this banner` |
| Runs chip | `⚡ {{n}} left` |
| Runs chip (`aria-label`) | `{{n}} free analyses remaining` |
| Theme toggle (`aria-label`, light active) | `Switch to dark theme` |
| Theme toggle (`aria-label`, dark active) | `Switch to light theme` |
| `↗` glyph, visually-hidden companion | `(opens in a new tab)` |
### Header
| Slot | String |
|---|---|
| H1 | `Will Your Post Go Viral?` |
| Deck | `Score your post, then get AI rewrites that actually perform.` |
| Methodology micro line | `Signals derived from {{algorithm-source}}'s open-source ranking code.` |
### Left panel — input
| Slot | String |
|---|---|
| H2 | `Analyze Your Post` |
| Template dropdown, closed | `⚡ Viral Format Templates ▼` |
| Template dropdown (`aria-label`) | `Insert a post format template` |
| Template undo link | `Undo template` |
| Textarea label | `Your post` |
| Textarea placeholder | `Paste your post here…` |
| Character counter | `{{n}}/280` |
| Checkbox group legend | `What's attached?` |
| Checkbox labels | `Has image` · `Has video` · `Has link` |
| Optional input 1 label | `Topic (optional)` |
| Optional input 1 placeholder | `Product launch` |
| Optional input 2 label | `Goal (optional)` |
| Optional input 2 placeholder | `Replies` |
| Primary button | `🔍 Analyze Viral Potential` |
| Button micro line | `Uses 1 analysis. {{n}} left after this.` |
**Six templates**, all supplied. Selecting one replaces the textarea contents with the scaffold and places the caret at the first `[` token. `\n` is a literal newline.
| Template name | Scaffold |
|---|---|
| `Contrarian take` | `Everyone says [common advice].\n\nThey're wrong, and here's what I do instead:\n\n[your approach]` |
| `Before / after` | `[N] months ago: [old situation]\n\nToday: [new situation]\n\nWhat changed: [the one thing]` |
| `Numbered list` | `[N] things I wish I knew about [topic]:\n\n1. [point]\n2. [point]\n3. [point]` |
| `One hard lesson` | `I lost [what you lost] learning this:\n\n[the lesson, in one sentence]\n\nDon't repeat it.` |
| `Question opener` | `What would you do if [situation]?\n\nHere's what I did: [answer]` |
| `Build in public` | `Update on [project]:\n\n· [metric or milestone]\n· [what broke]\n· [what's next]` |
### Right panel — results
| Slot | String |
|---|---|
| H2 | `Results` |
| Empty — instruction line | `Paste a post and press Analyze.` |
| Empty — feature line | `Viral score · 15 signals · AI rewrites` |
| Empty — allowance chip | `{{free_runs}} free analyses, no account` |
| Empty — price micro line | `Then {{pack_price}} for {{pack_size}} more.` |
| Score suffix | `/100` |
| Verdict, 70–100 | `Strong` |
| Verdict, 40–69 | `Mixed` |
| Verdict, 0–39 | `Weak` |
| Breakdown sub-heading, left column | `Working for you` |
| Breakdown sub-heading, right column | `Working against you` |
| Signal verdict labels | `Strong` · `Fine` · `Weak` |
| Unknown signal id fallback label | `Unrecognised signal` |
**The fifteen signal labels**, in fixed display order. The UI owns these strings and keys them by `id`; the backend supplies only `id`, `weight`, `verdict` and a one-line `note`, which is rendered verbatim beneath the label.
| # | Label | # | Label | # | Label |
|---|---|---|---|---|---|
| 01 | `Hook strength` | 06 | `Line-break rhythm` | 11 | `Reply invitation` |
| 02 | `First-line length` | 07 | `Emoji density` | 12 | `Reading level` |
| 03 | `Post length fit` | 08 | `Hashtag load` | 13 | `Numeral use` |
| 04 | `Question present` | 09 | `External link` | 14 | `Sentiment swing` |
| 05 | `Concrete specifics` | 10 | `Media attached` | 15 | `Repost-friendly framing` |
These are the tool's own display names for what it inspects. They are **not** presented as the internal feature names of any third-party system, and no copy on this page may claim otherwise; the only claim made about `{{algorithm-source}}` is the one in the methodology line, which must link to the real source.
### Gated boundary
| Slot | String |
|---|---|
| Section heading | `How to fix it` |
| Free-row prefix | `Fix 1 of {{n}}` |
| Lock line | `{{n}} more fixes and {{n}} AI rewrites are in the paid pack.` |
| Pack CTA | `Unlock the fixes — {{pack_price}} ↗` |
| Blurred rows (`aria-hidden`, inert placeholder text) | `Locked fix. Purchase to reveal.` |
### State strings
| State | String |
|---|---|
| Button disabled — empty textarea | `Paste a post to analyze.` |
| Button disabled — over limit | `{{n}} characters over the limit. Trim to 280 to analyze.` |
| Button disabled — offline | `You're offline. Reconnect to analyze.` |
| Button disabled — no runs left | *(the button is not disabled; it becomes the purchase CTA, below)* |
| Submitting — button label | `Analyzing…` |
| Submitting — skeleton caption | `Scoring 15 signals…` |
| Success — live-region announcement | `Score {{n}} out of 100. Verdict: {{verdict}}.` |
| Partial — badge | `Provisional` |
| Partial — explanation | `Some signals didn't resolve. This score covers {{n}} of 15.` |
| Partial — retry button | `Retry the rest` |
| Error — heading | `Analysis didn't finish.` |
| Error — body | `Your post is still here. Nothing was lost.` |
| Error — cause, network | `The analyzer couldn't be reached.` |
| Error — cause, timeout | `That took too long — the analyzer timed out after 20 seconds.` |
| Error — cause, server | `The analyzer returned an error.` |
| Error — retry button | `Try again` |
| Error — run-safety line | `This did not use a run.` |
| Rate limited — heading | `Too many requests.` |
| Rate limited — body | `Try again in {{n}} minutes. This did not use a run.` |
| Depleted — banner line | `0 free analyses left.` |
| Depleted — chip | `⚡ 0 left` |
| Depleted — button label | `Get {{pack_size}} more — {{pack_price}} ↗` |
| Depleted — micro line under the button | `Your last result stays on screen.` |
| Offline — banner line | `You're offline. Results will resume when you reconnect.` |
### Footnotes
| Slot | String |
|---|---|
| Data-handling line | `Your post text is sent to the analyzer, scored, and discarded. It is not stored and it is not used to train anything.` |
| Methodology line | `Scoring derived from {{algorithm-source}}'s open-source ranking code.` + link `Read the source ↗` → `{{methodology-url}}` |
The data-handling line is a **commitment, not decoration**. If your implementation stores the submitted text, change the sentence — never the placement.
### Glyph and non-visible labels
The page renders **no raster images**. Every mark is an inline SVG, and each is described here.
| Glyph | Size | What it depicts | Treatment |
|---|---|---|---|
| Bolt | 16px | A filled lightning bolt, `--accent-bright` | `aria-hidden`; appears in the banner chip and the template dropdown |
| Chevron | 12px | A downward caret on the dropdown | `aria-hidden` |
| Magnifier | 16px | An outline circle with a 45° handle, on the primary button | `aria-hidden` |
| Spinner | 16px | A 270° arc in `--on-accent`, rotating | `aria-hidden`; state is carried by `aria-busy` and the label |
| Gauge | 160px | A 270° ring, 10px stroke, track `--line`, progress in the band colour, with a 3px tick at the arc end | `aria-hidden`; the numeral and verdict carry the value |
| Segmented bar | 56 × 8px | Four equal segments per signal, filled to the verdict level | `aria-hidden`; the verdict word carries the value |
| Verdict glyph | 12px | A `+` for Strong, a `·` for Fine, a `−` for Weak | `aria-hidden` |
| Lock | 16px | A closed padlock over the gated rows | `aria-hidden`; the lock line carries the meaning |
| North-east arrow | 12px | `↗` on every off-site link | `aria-hidden`, with the visually-hidden companion text |
| Sun / moon | 16px | A filled sun disc with rays / a crescent | `aria-hidden`; the toggle's `aria-label` carries the action |
| Dismiss | 14px | A 2px `×` | inside a button with the `aria-label` above |
| Empty-state mark | 48px | An outline circle with a dashed upper arc — an unfilled gauge | `aria-hidden` |
## Layout
**One screen, and the arithmetic that keeps it there.** Container `max-width: 1180px`. Vertical stack at 1440 × 1080: banner 40px, 24px gap, header block ≈150px, 24px gap, workbench panels at `min-height: 32rem` (512px), 24px gap, footnote block ≈80px, plus 32px of page padding — **≈886px**, comfortably inside a 1080px viewport with nothing below the fold but the footnote block. Treat one screen as the constraint, not the total pixel count.
1. **Usage banner — 40px, full-bleed.** A left-to-right gradient `--accent-bright` → `#6366F1`. Centred `--on-accent` line stating remaining runs and that no account is needed, then the underlined packs link with an `↗`. Dismiss `×` at the far right.
2. **Header — three stacked children, centred.** H1, then the deck, then the methodology micro line. Floated top-right of this band and absolutely positioned on desktop: the runs chip (`--tint` fill, bolt glyph, count) and the theme toggle pill.
3. **Workbench — two panels, `grid-template-columns: 1fr 1fr`, 32px gap, both cards `min-height: 32rem` so neither can change height as content swaps.**
- **Left — `Analyze Your Post`.** H2, then in order: the full-width template dropdown; the textarea at 6 rows with the live counter bottom-right; a row of three checkboxes 24px apart; a 2-column optional-input row; the primary button, full width, 48px tall; and the micro line naming the cost in runs. Content height with a 6-row textarea is ≈420px plus 48px of padding, so the 512px floor holds and the button is never pushed below the fold.
- **Right — `Results`.** H2, then the state machine below. The empty state is doing pre-sell work: treat its copy as ad copy, not filler.
4. **Footnote block.** A 1px `--line` rule across the container, then the two centred micro lines. These two lines carry the entire trust load of the page — do not delete them to save space.
Mobile stacks the two panels vertically, input first; the results panel scrolls itself into view on submit.
## Conversion architecture
- **Hook:** an H1 posed as the user's own pre-publish anxiety, answered by a free instrument sitting immediately below it. There is no "learn more" — the reader's first available action is the product.
- **Mechanism:** *value before ask.* Five free runs, no account, no email. The cost of trying is zero and the output is specific enough to be useful alone. Conversion happens when the meter empties on a user for whom the tool has already been right about something.
- **The tool's own funnel:**
1. **Free analysis** — paste, click, no signup. The runs counter is visible in three places (banner, header chip, button micro line) so depletion is always felt.
2. **Result** — the score plus the full fifteen-signal breakdown is given away. Diagnosis is free; this is what earns trust.
3. **Gated upgrade** — the *prescription* is paid. The fix list renders **one** fully-readable row and the rest behind the scrim, with the pack CTA inside the result at the moment of maximum interest, never in a nav bar.
4. **Depletion** — at zero runs the input panel stays fully interactive but the primary button becomes the purchase action, with the last result still on screen as the reason to buy.
- **Proof / credibility devices:** the methodology line under the deck, linked to a real published source; the fifteen-signal breakdown, which is self-evidently non-random; the data-handling footnote; and the "no account needed" claim honoured literally.
- **Objection handling, in order:** *"Is this just a vibe check?"* → named methodology plus fifteen discrete signals. *"Do I have to sign up?"* → banner, first six words. *"Will you keep my post?"* → footnote line one. *"Is it worth paying for?"* → the free result's quality is the argument; the locked fixes are the offer. *"How much?"* → one price, stated twice (banner link, empty state), never a table.
- **CTA cadence:** exactly two distinct actions — `🔍 Analyze Viral Potential` (free, and replaced by the purchase label at zero runs) and the packs link (banner, empty state, gated rows). Nothing else is clickable except the methodology citation and the theme toggle.
- **Single next action:** paste a post and press Analyze.
## Interaction & states
The Results panel is a strict state machine over `idle | submitting | success | partial | error | rateLimited | depleted`, plus an orthogonal `offline` flag. Every state fills the same `min-height: 32rem` card so the layout never jumps. All copy is in **Copy → State strings**.
- **Empty (default):** as described in Layout §3. The Analyze button is disabled with `--ink-3` on `--line`, `cursor: not-allowed`, `aria-disabled="true"`, and the reason exposed via `aria-describedby` — never a tooltip.
- **Focused input:** textarea border → `--accent`, 3px ring at 15% alpha. The counter is `--fg-muted` at 0, `--accent` while typing, `--warn` from 240 characters, `--bad` past 280 — and past 280 the button disables with the over-limit reason naming the exact overage.
- **Template selected:** the dropdown replaces the textarea contents with the scaffold, moves the caret to the first `[` token, and shows `Undo template` for six seconds. Undo restores the exact previous contents, including an empty field.
- **Submitting:** button label → `Analyzing…` with the 16px spinner placed **before** the label so the button width is stable; `aria-busy="true"`; the results panel swaps to a skeleton — a grey ring outline plus three shimmer bars and the `Scoring 15 signals…` caption — so the shape of the answer is visible before the answer is. Hard timeout at 20 seconds, which routes to the timeout error.
- **Success:** the score display renders, then signal rows stagger in. The runs counter decrements in all three places simultaneously. Focus moves to the results H2 and the announcement fires via `aria-live="polite"`.
- **Partial:** if some signals resolve and others fail, render the ring stroke in `--fg-faint` with the numeral shown and the `Provisional` badge beside it, list only the resolved signals, show the explanation naming how many of fifteen resolved, and offer `Retry the rest` as a ghost button. **Never interpolate a missing signal into the total** — the denominator is the count that resolved, and the badge says so.
- **Error:** the panel shows an inline card — heading, the matching cause line, the body, `Try again`, and the run-safety line. Input contents preserved. `aria-live="assertive"`.
- **Rate limited:** same card shape, rate-limit heading and body. If limiting is server-side per IP rather than per-run, show the reset in whole minutes, never a timestamp.
- **Depleted:** banner and chip switch to zero. The Analyze button becomes the purchase CTA with the changed label and stays enabled; the previous result stays rendered above it.
- **Offline:** `navigator.onLine === false` disables the button with the offline reason and shows the offline banner line. An `online` listener restores the previous state without losing the field contents. Submission is never attempted while offline.
**Score display.** A 160px circular gauge: 10px track in `--line`, progress arc in the band colour, 270° sweep starting at the 7-o'clock position. Centre: the numeral (`3.5rem`, tabular) over `/100` in `--fg-muted`, with the verdict word beneath in the band colour. Bands: 0–39 `--bad` / `Weak`, 40–69 `--warn` / `Mixed`, 70–100 `--good` / `Strong`. **Colour is never the only carrier** — the verdict word and a 3px tick at the arc end both encode the band. To the right of the gauge, a 2-column list under `Working for you` and `Working against you`, each row a signal label, its four-step segmented bar, and its verdict glyph.
**Gated boundary.** Under the breakdown sits the `How to fix it` section. Row one is fully rendered and readable. The remaining rows are inert placeholder strings at 4px blur behind a `--card` scrim at 70% with the lock glyph, the lock line, and the pack CTA. **The blurred rows are generated client-side from the fixed placeholder string** — real gated content is never sent to the browser, so it cannot appear in the network response, the DOM, or a screenshot with the blur removed.
## Motion
- Panel state transitions: cross-fade `220ms ease-out`; the card height is fixed, so nothing reflows.
- Skeleton shimmer: a 1.4s linear translate loop, 8% white sweep.
- Score count-up: 0 → value over `1100ms`, `cubic-bezier(0.16, 1, 0.3, 1)`, with the arc's `stroke-dashoffset` on the identical curve and duration so number and arc land together.
- Signal rows: stagger at 45ms intervals, `opacity` + `translateY(8px)`, `300ms ease-out`, capped at eight rows.
- Buttons: `background` and `transform: translateY(-1px)` in `120ms ease-out`; active resets instantly.
- Counter colour changes: `160ms linear` — no bounce, it is a warning not a delight.
- **`prefers-reduced-motion: reduce` resolves to one behaviour:** the numeral renders its **final value** and the arc its final `stroke-dashoffset` on first paint, with the count-up loop never started; the skeleton is a static `--tint` block with no shimmer; signal rows appear at once with no stagger and no transform; panel transitions become a one-frame opacity swap.
Subscribe to the preference, do not read it once:
```js
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const [reduced, setReduced] = useState(mq.matches);
useEffect(() => {
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
```
An empty-dependency `useEffect` that reads `mq.matches` once never re-runs when the user flips the OS setting mid-session — and a count-up that started before the flip must be cancelled and snapped to its final value, not left running.
## Build notes
- React + Tailwind, single route, no router. One reducer over `idle | submitting | success | partial | error | rateLimited | depleted` plus the `offline` flag — do not derive panel content from scattered booleans.
- No webfont. `system-ui` is the design, not a fallback.
- The analysis is a **clearly-labelled integration point**, not something this prompt implements: `POST /api/analyze { text, hasImage, hasVideo, hasLink, topic?, goal? }` → `{ score: 0..100, signals: [{ id, weight, verdict, note }], fixes?: string[], rewrites?: string[] }`. Ship it behind a typed client with mocked fixtures covering every state — success, partial, each error cause, rate-limited — so the whole UI is reachable with the backend switched off. **Do not specify or invent the model, the prompt, or the weighting here**, and do not render any signal label that did not come from the fixed table in **Copy**; an unknown `id` renders the fallback label.
- Rate limiting and the free-run counter are enforced **server-side** (IP + cookie); the client counter is a display of server truth, never the source of it. Clearing `localStorage` must not grant more runs.
- Validation: trim the input; block submit on empty or over-limit; strip nothing else — the user's formatting is part of what is scored. Send no PII beyond the pasted text, and say so in the footnote.
- **Assets** — the page renders **zero images**. Every mark is inline SVG from the glyph table in **Copy**, drawn in the current colour so both themes work without a second asset. There are no logos, no illustrations, no screenshots, and no `<img>` element anywhere. Because of that the LCP element is the H1 text and nothing can block it.
- **Deferred work: none.** There is nothing on this page large enough for a code split to be worth measuring. The template scaffolds are six short strings — inline them as a module constant, not a fetched JSON file. The gauge is one `<svg>` with a static `d` and one animated attribute; a dynamic import for it would cost a round trip to save well under a kilobyte. If you need to defer something, there is nothing here to defer.
- **Performance budget, with the strategy that meets it** — LCP < 1.2s (no fonts, no images, no blocking requests above the fold); CLS = 0 (banner is a fixed 40px, both panels carry `min-height: 32rem` from first paint, and the score numeral is tabular so digits cannot change width); JS < 90KB gzipped; TTI < 1.5s on mid-tier mobile. The gauge is where a naive build burns frames, so name what is static: the arc `d` string and its geometry are computed **once** at module load for the fixed 270° sweep and 160px radius, `pathLength="100"` is set on the path so the animated value is simply `100 − score` with no trigonometry at runtime, and a **single** `requestAnimationFrame` loop writes both the numeral's text and the path's `stroke-dashoffset` per frame. Nothing else recomputes during the count-up; the signal rows are laid out once from the response and never re-measured.
- **Accessibility must-haves**
- `<label for>` on the textarea and both optional inputs; the `(optional)` suffix lives inside the label element, not the placeholder.
- The character counter is `aria-live="polite"` and throttled to 500ms — never announced per keystroke.
- Checkboxes are real `<input type="checkbox">` inside a `<fieldset>` with a `<legend>`.
- The results panel is `role="status"`; on success focus moves to the results H2 so screen-reader users land on the answer.
- The disabled button keeps its label at ≥4.5:1 (`--ink-3` on `--line`) and exposes its reason via `aria-describedby`.
- Every score band is carried by the verdict word and the arc tick as well as by colour, and every signal verdict by its glyph as well as its bar.
- The blurred gated rows are `aria-hidden="true"` and `inert` so a screen-reader user is not read placeholder noise; the lock line states what is behind them.
- Dark-mode toggle persists via `localStorage` inside `try/catch`, falls back to `prefers-color-scheme`, and both themes are contrast-verified independently against the same minimums.
- **Breakpoints:** `1180px` (container caps), `1024px` (workbench 2→1 column), `768px` (optional-input row 2→1), `480px` (banner wraps to two lines, gauge 160→128px). Note that below 1024px the page is no longer one screen — that is expected and is why the one-screen check is specified at 1440 × 1080.
- **Mobile behaviour**
- Single column, input panel first, results below.
- On success the results panel scrolls itself into view (`scroll-margin-top: 16px`) — the user must never wonder whether anything happened.
- Textarea and both inputs at `font-size: 16px` to defeat iOS zoom.
- The optional-input row collapses to one column; the gauge shrinks to 128px and the signal list stacks beneath it.
- The banner wraps to two lines and keeps its dismiss target at 44 × 44px.
- The runs chip and theme toggle move from absolutely positioned to a centred row under the deck.
## Acceptance checks
- At 1440 × 1080 the banner, header, both panels and the footnote block all render without scrolling, and the Analyze button is fully visible on first paint.
- The Analyze button is disabled with visible reason text until the textarea has content, re-disables past 280 characters with the overage named, and disables while offline with the offline reason.
- A successful analysis renders a 0–100 gauge whose numeral counts up and whose arc reaches the same value on the same curve, with the band colour matching the verdict word and the arc tick present.
- The breakdown renders labels drawn only from the fifteen in **Copy**; an injected unknown `id` renders `Unrecognised signal` rather than a blank row.
- A partial response renders the `Provisional` badge, states how many of fifteen resolved, lists only resolved signals, and offers `Retry the rest`. The score is not adjusted to compensate for the missing ones.
- Exactly one fix-list row is readable; the remainder are the fixed placeholder string, and no gated content appears in the network response or anywhere in the DOM.
- Forcing an API error shows an announced error card with the matching cause line, preserves the pasted text, does not decrement the runs counter, and shows the run-safety line.
- Setting runs to 0 converts the primary button into the purchase CTA with its own label while leaving the last result on screen.
- Going offline mid-session disables submit with the offline reason and shows the offline banner; coming back online restores the previous state with the field contents intact.
- With `prefers-reduced-motion: reduce`, the numeral and the full arc are correct on first paint, no count-up runs, and no shimmer runs. Flipping the OS setting with the page open changes the behaviour without a reload.
- The page contains no `<img>` element and requests no font file; the LCP element is the H1.
- Every string the page renders comes from **Copy**, or is one of the listed placeholders, or is the API-supplied `note` on a signal row.