<!-- OpenSmartRoute: osr-product-experience. https://opensmartroute.ai/docs/skills/osr-product-experience -->

# Product experience: screens, flows and components of the web app

The web app (`platform/web`, Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui primitives, Recharts) is the
place where a person decides whether OpenSmartRoute is real, whether it is for them and whether they can trust
it with traffic. This skill is the playbook for changing what they see. Read `osr-platform` first for the build,
proxy and deployment mechanics; this skill is about what goes on the screen, where, and how to prove it works.
For onboarding, activation and growth *measurement* see `osr-customer-success`; for words and colours see
`docs/BRAND.md` (voice: precise, calm, evidence-led - "routes", "decides", "learns", never "magically").

## Who is on the other side of the screen

Design every page for one of these people first and name them in the PR. Each has a different question, a
different first screen and a different definition of "it works".

| Person | Arrives at | Wants to know in 30 s | First success | Where they hesitate |
|---|---|---|---|---|
| **Evaluator** (CTO, platform lead) | `/`, `/pricing`, `/compare`, `/rankings`, `/status` | Is this real, who runs it, what does it save, what does it cost, is it up | Sees live numbers (`LiveStats`, `/rankings`, `/models`), the readiness checks on `/status` and a trace, not adjectives | Marketing claims without evidence, a pricing page that hides limits, no status |
| **Developer** (wires the API) | `/platform/signup`, `/docs/QUICKSTART_PLATFORM`, `/platform/playground` | How do I send one request and see why it was routed | `POST /api/v1/route` answers and the `DecisionPanel` explains the choice | A key they cannot find again, an unclear error, `execute` failing because no provider is connected |
| **Workspace admin** | `/platform/dashboard` | Are we within quota and budget, who is using it, what did it cost | The overview `StatCard`s, `/dashboard/usage`, `/dashboard/savings`, `/dashboard/governance` | A form that needs the docs, a gated feature with no explanation, a number without a unit |
| **Operator** (runs the deployment) | `/platform/admin` | Is the deployment healthy, who signed up, who is stuck, what is the margin | `/platform/admin/analytics`, `/health`, `/finance`, `/activity` | Pages that need three clicks to answer one question, tables without links to the thing |

Empathy is procedural here, not a mood: before changing a page, write the one question that person brings to
it, then check the page answers it above the fold, in their words, with a real number or a real action.

## Where things live

```text
src/app/(marketing)/*            public site: /, /pricing, /compare, /models, /rankings, /marketplace, /estimate,
                                 /roi, /blog, /providers, /vendors, /support, /privacy, /terms
src/app/platform/(auth)/*        /platform/login, /signup, /forgot-password, /reset-password, /verify-email,
                                 /invite/[token], /cli/authorize, /email/preferences
src/app/platform/dashboard/*     workspace: overview, activity, handoffs, usage, savings, keys, providers,
                                 integrations, listings, governance, events, learning, health, notifications,
                                 tenants, audit, members, workspace, billing, support, account
src/app/platform/admin/(console) operator console (ADMIN_NAV in components/admin/admin-shell.tsx)
src/app/platform/playground      the route tracer (components/playground/{playground,decision-panel,compare-panel}.tsx)
src/components/ui/*              primitives: button, card (Card, CardHeader, CardBody, StatCard, Alert, Badge,
                                 Skeleton, EmptyState, Trend), field (Input, Textarea, SearchInput), table
                                 (DataTable), tabs (Tabs, SegmentedControl), dialog, sheet, drawer, toast (Sonner),
                                 tooltip (Hint), empty, code (CodeBlock, CopyButton), progress, switch (SwitchField)
src/components/charts/index.tsx  CHART_COLORS / SERIES / seriesColor (CSS vars), TimeBars, TimeArea, Bars,
                                 RankedBars, ShareBar, Sparkline, ChartEmpty, ChartTooltip
src/components/layout/*          SiteHeader, SiteFooter, Logo / BrandMarkImage, ThemeToggle, NavIcon, SearchCommand
src/components/dashboard/*       DashboardShell (SECTIONS, PageHeader), GettingStarted, UsageChart, ApiError
src/components/observability/*   TraceView / TraceLoader (the proof a decision is explainable), StatusPill,
                                 SystemStatusCard, AlertsPanel, series
src/lib/config/site.ts           site, seo, install one-liners, headerNav, dashboardNav, footerNav
src/lib/api/{client,use-api,types,proxy}.ts   api.get/post, ApiError (status, upgradeRequired, keyScope,
                                 edition, budget, walletBalanceCents, requestId, retryAfter), useApi, useAdminApi
src/lib/auth/{session,operator}.tsx           useSession (me, apiKey, signIn/out), useOperator
src/app/globals.css              tokens: --ink/--paper/--snow/--mist/--line, --brand-blue/-cyan/-violet,
                                 --status-*, --chart-1..9, text-display-{xl,lg,md,sm}, .dark, .theme-dark
```

`page.tsx` files may only export `default`; shared UI goes into `components/`. Navigation is declared in one
place each: `dashboardNav` + `SECTIONS` (dashboard), `ADMIN_NAV` (console), `headerNav` / `footerNav` (site).
A new dashboard or console page also needs its row in `docs/PLATFORM.md` section 13 (the docs-claims test
checks every `/platform/dashboard/<page>` and `/platform/admin/<page>` documented there exists, and vice versa).

## Rules for a clean screen

1. **One question per page, answered above the fold.** The `PageHeader` title says what the page is, the
   description says what the numbers mean and over which window ("Usage covers the last seven days"). The
   first row is `StatCard`s with a unit and a hint (`hint="Only executed requests incur provider cost"`), never a
   bare number.
2. **Right action in the right place.** The primary action sits in `PageHeader action=` (top right); the
   destructive one at the bottom of its card, `variant="destructive"`, behind a `Dialog` confirmation. Row
   actions live in the row. Never two primary buttons on one screen.
3. **Progressive disclosure, not a wall.** Defaults first, the advanced block in a `Collapsible` / `Tabs`.
   The playground shows `text` + *Route*; objective weights, constraints, `plan` and `execute` sit behind it.
   Gate by plan with the same `Alert` pattern everywhere (`ApiError.upgradeRequired` -> "not in your plan" with
   the link to `/platform/dashboard/billing`), never by hiding the feature silently.
4. **Every state has a design.** `loading && !data` -> `Skeleton` with the final shape; `error` -> `Alert
   tone="danger"` with `error.message` and, for 5xx, the `requestId` so support can find it; `!data.length` ->
   `EmptyState` / `ChartEmpty` that says what will appear here and offers the action that creates it ("No
   requests yet - open the playground"). A page must never render a blank card or `undefined`.
5. **Show the evidence.** A routing decision is never a label alone: link to the trace (`TraceLoader` in the
   activity drawer, `DecisionPanel` in the playground), show confidence, cost and the alternatives. Savings show
   `baseline_cost_usd` next to `routed_cost_usd` and how the baseline was chosen. Status is a live `StatusPill`,
   not a green dot drawn in CSS.
6. **Copy is plain and specific.** Sentence case, no exclamation marks, numbers with units (`formatUsd`,
   `formatPercent`, `formatMs`, `formatCompact` from `lib/utils/format.ts`), dates through `formatDate`. Prefer
   "Route a first request" to "Get started!". Name the real API call next to UI actions where a developer is the
   audience (the getting-started list shows `POST /api/v1/route`).
7. **Forms.** `Field`-labelled inputs (a label a screen reader can name), inline validation on blur,
   `aria-invalid`, the submit button `loading` while pending, success through `toast.success`, the created secret
   (API key) shown once in a `CodeBlock` with `CopyButton` and a sentence saying it will not be shown again.
   Escape `-` inside `pattern` character classes (`[A-Za-z0-9._\-]`, the `v` flag). Never `Date.now()` in render
   (React Compiler lint) - `useState(() => Date.now())`.
8. **Tokens only.** Colours come from the CSS variables (`text-ink`, `bg-snow`, `border-line`,
   `text-brand-blue`, `CHART_COLORS.*`), never hex in a component; both themes must work (`.dark` on `<html>`,
   always-dark panes use `.theme-dark`; the brand mark through `BrandMarkImage`, vendor logos inside `.logo-tile`).
   Display type is `text-display-*` (registered in `tailwind-merge` `extendTailwindMerge` - keep it there).
9. **Responsive by construction.** Grid tracks are `minmax(0,1fr)` (a table or `<pre>` otherwise widens the
   track; `.grid > * { min-width: 0 }` in `globals.css` is the safety net); action rows wrap; tables and code
   panes scroll inside their card (`overflow-x-auto`), never the page. Two-column detail layouts collapse below
   `lg`. Check 375 px and 768 px.
10. **Accessible by default.** Every icon-only button has `aria-label`; every image `alt`; focus is visible
    (`focus-visible:ring-[3px]`); dialogs trap focus (Radix); colour is never the only signal (the `Badge` tone
    carries a word too); contrast >= 4.5:1 on both themes (`--brand-cyan-text` exists because cyan on white does
    not pass). Motion respects `prefers-reduced-motion`.

## Understanding behaviour before redesigning

Do not guess where people struggle - the platform records it, without prompts:

- **Funnel and time to value** - `GET /api/v1/admin/analytics/activation?days=` (console *Analytics*, card
  *Activation funnel*): the share of new workspaces that confirmed the e-mail, routed, got an answer, reported
  success, returned; the median hours to each. A step with a steep drop is a screen to redesign.
- **Stuck before the first request** - the same payload's `stuck` list: who signed up more than a day ago and
  never routed. Read their console page (`/platform/admin/workspaces/{id}`): did they create a key, open the
  playground, hit a 4xx?
- **Where errors happen** - `GET /api/v1/admin/activity?failed=true` and the events page; a 400 on
  `POST /api/v1/route` right after signup is a form or copy problem, not a user problem.
- **What people click on the public site** - GA4 events through `lib/analytics.ts` `track()` (`sign_up`, `login`,
  `select_content`), consent-gated (`ConsentBanner`), never on `/platform/dashboard/*` or `/platform/admin/*`
  (`isTrackedPath`). First-touch attribution (`lib/attribution.ts`) lands on the signup and in the funnel's
  `by_source`.
- **Support tickets and sales inquiries** (`/platform/admin/support`, `/leads`) are the qualitative signal;
  quote them in the PR when they motivate a change.
- **Watch someone.** For anything on the first-value path (signup -> key -> playground -> activity) sit with one
  person who has never seen it, say nothing, note every hesitation. Five people find most of what matters.

Write the finding as *observation -> hypothesis -> change -> metric that will move*, and put the metric in the
CHANGELOG entry ("activation funnel: first_route share").

## Building a page: the recipe

1. Data first: find or add the endpoint (`osr-platform`), then the TypeScript type in `lib/api/types.ts` with a
   one-line doc comment naming the route.
2. `useApi<T>("/api/v1/...")` (workspace) or `useAdminApi<T>` (console); design the four states before the
   happy path.
3. Compose from primitives: `PageHeader` -> `StatCard` row -> `Card` grid (`lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]`)
   -> `DataTable` / `RankedBars` / `TimeBars`. Rows link to the thing they describe (`Link` to
   `/platform/admin/workspaces/{id}`, `/models/{id}`).
4. Register: nav (`site.ts` / `SECTIONS` / `ADMIN_NAV`) + `NavIcon` name, the `docs/PLATFORM.md` row, the
   CHANGELOG bullet.
5. Prove it: unit test for any pure helper (`tests/*.test.ts`), e2e coverage (below), then the visual pass in
   both themes at 375 / 768 / 1280.

## Validation - what "working" means here

```bash
cd platform/web
npm run check                 # eslint + tsc + vitest (tests/*.test.ts, .tsx)
npm run build                 # the standalone build the e2e harness serves
$env:OSR_E2E_DATABASE_URL="postgresql://osr:osr@127.0.0.1:55432/osr"   # docker start osr-test-pg
npx playwright test e2e/customer-journey.spec.ts      # sign up -> dashboard -> playground -> keys -> activity
npx playwright test e2e/accessibility.spec.ts         # axe WCAG 2.1 A/AA on the first-value pages (serious+ fails)
npx playwright test e2e/responsive.spec.ts            # no sideways scroll at 375 / 768 on every page
npx playwright test e2e/dashboard-pages.spec.ts e2e/console-pages.spec.ts   # every page renders real data, no crash
npx playwright test e2e/page-quality.spec.ts          # failed requests, console errors, leaked "undefined"/NaN, one h1,
                                                      # dead internal links, unknown slugs -> not-found page (not a skeleton)
```

- `e2e/global-setup.ts` spawns a throw-away API on 8111 and the standalone build on 3111 (`OSR_E2E_WEB` /
  `OSR_E2E_API` point at servers you already run - clear them afterwards, they linger in the shell).
- Helpers in `e2e/helpers.ts`: `signUp(tag)` (unique e-mail per run - the DB is shared across runs),
  `adoptSession(page, token)`, `operatorToken()`, `setPlan`, `field(page, /^Label/)`, `expectNoCrash(page)`.
  Locator lessons: `Field` labels with hints -> `getByRole("textbox", { name: /^Code/ })`; `getByRole("alert")`
  also matches Next's route announcer; Card titles are `h3`; a hidden streamed copy of a form exists in
  `div#S:0` -> use `:visible`.
- Add a new page to `PUBLIC` / `DASHBOARD` / `ADMIN` in `accessibility.spec.ts` and `responsive.spec.ts`, and
  to the page-walk in `dashboard-pages.spec.ts` / `console-pages.spec.ts`. A new journey gets its own spec
  named after what the person does ("open a ticket, get its reference and e-mail, operator answers...").
- Theme and contrast audit: run against `next dev` on 3001 (`OSR_API_URL=http://localhost:8080`), sign in by
  fetch (`POST /api/v1/signup` -> `localStorage osr.platform.apiKey`), parse colours through a 1x1 canvas
  (`getImageData`; Tailwind 4 emits `lab()` / `oklab()`). Screenshots go to `%TEMP%`, never under `platform/web`.
- Against the dev stack (`docker compose up -d --build`, containers run the image, no watch):
  `python -X utf8 platform/api/scripts/smoke.py --api http://localhost:3000 --web http://localhost:3000
  --operator admin:admin-dev-only` walks every page and API surface.
- Live: `platform/api/scripts/live_use_cases.py --key <k>` proves routing on production; open the deployed
  page in both themes on a phone before closing the task.

## Anti-patterns seen in this codebase (do not bring them back)

- A `1fr` grid track that grew to a `<pre>` / install command and made the page scroll sideways on phones.
- `tailwind-merge` dropping `text-display-*` as a colour class; `shrink-0` action rows; fixed `w-64` inputs.
- A cache-only `useApi` page that showed stale numbers after a mutation without a `reload()`.
- A gated feature that disappeared on the free plan instead of explaining the gate.
- Provider "healthy" badges that were five days old; health must show *when* it was checked.
- Clicks that "worked" in the integrated browser but not on the deployed site - verify on the real origin.
- `notFound()` thrown by a detail page under a route-group `loading.tsx` left visitors on the skeleton with a 200:
  every route group that streams needs its own `not-found.tsx` (the public one is `app/(marketing)/not-found.tsx`).
- A checklist step that was "done" for the wrong reason (`user is None` on a key session read as verified): a step
  must be computed from the event it names, for every kind of principal.
- "100% succeeded" over zero requests; raw proxy errors (`platform API unavailable: fetch failed`) shown to a person.
