# F — Marketing site, onboarding, auth pages, PWA polish (2026-09-11) Workstream F of `docs/UPGRADE-PLAN.md`. Everything below compiles (`pnpm typecheck` green, `eslint` clean on every file listed here, `pnpm test` 125/125). No dev server was run; visual QA belongs to the integration phase. ## Files ### Created | File | Purpose | | --- | --- | | `src/app/api/public/models/route.ts` | Public, unauthenticated `GET /api/public/models` (see API below). | | `src/lib/marketing/public-models.ts` | Pure curation (`curatePublicModels`), `formatContext`, `formatPricePair`, `PublicModelSummary` type. | | `tests/unit/public-models.test.ts` | Vitest: filtering, ordering (new → flagship, provider round-robin), cap, field shape, formatters. | | `src/components/marketing/fake-stream.ts` | Shared scripted-streaming engine (`useFakeStreams`, `fastestIndex`, `approxTokens`). rAF-driven, instant under reduced motion. | | `src/components/marketing/frames.tsx` | `PhoneFrame` (390×844, status bar, home indicator) and `DesktopFrame` (window chrome with URL bar). | | `src/components/marketing/product-mock.tsx` | Faithful mocks of the real app: `SidebarMock`, `ModelPill`, `ContextIndicator`, `MessageMeta`, `ComposerMock`, `MobileComposerMock` (`[+] Ask anything… [mic] [send]`), `BottomNavMock` (reads the real `MOBILE_TABS`), `MobileHeaderMock`, `DesktopChatMock`/`MobileChatMock`, `DesktopArenaMock`/`MobileArenaMock` (`.snap-row`, sticky model tabs, dots), `ArenaMiniCard`, `DesktopModelsMock`/`MobileModelsMock`, `DesktopUsageMock`/`MobileUsageMock`, `ScoreboardMock`, `MockMarkdown` (bold, code, paragraphs, pipe tables). | | `src/components/marketing/hero-visual.tsx` | Animated hero: desktop window (sidebar, model pill, streaming answer + caret, metadata line, mini Arena card that fades in) / phone frame with the real mobile layout below `md`. One shared stream. | | `src/components/marketing/model-strip.tsx` | Live marquee (`.marquee` + `.mask-x`, duplicated list, hover-pause) fed by SWR from `/api/public/models`; skeleton first; static wrapped grid under reduced motion; hidden on error/empty. | | `src/components/marketing/security-flow.tsx` | `SecurityFlow` (Browser → PolyLLM encrypted layer → Provider) and `KeyLifecycle` (5 steps) for `/security`. | | `src/components/marketing/security-teaser.tsx` | Homepage `#security` section linking to `/security`. | | `src/components/marketing/install-hint.tsx` | "Install PolyLLM" — `beforeinstallprompt` (Chromium/Android) or iOS Share → Add to Home Screen tip; hidden when standalone/dismissed/installed. | | `src/app/(marketing)/security/page.tsx` | Security page (see verified claims). | | `src/app/(marketing)/contact/page.tsx` | Contact page: e-mail, who built it, hosting, security reports. | | `src/app/app/onboarding/page.tsx` + `onboarding-flow.tsx` | Six-step onboarding (details below). | ### Changed | File | Change | | --- | --- | | `src/app/(marketing)/page.tsx` | New section order: Hero → ModelStrip → Router → Arena → Catalog → Config → Workspace → Analytics → Endpoints → Demo → Also → SecurityTeaser → FAQ → FinalCta. Metadata description updated. | | `src/components/marketing/hero.tsx` | Headline "One interface. / Every model." (`text-5xl` token, `text-balance`), sub-line, CTAs "Start using PolyLLM" → `/signup`, "Try Arena" → `/app/arena` (proxy → `/login?next=/app/arena` → Arena), provider row, `HeroVisual`. | | `src/components/marketing/demo.tsx` | Rewritten: `Segmented` tabs Chat / Arena / Models / Usage, Replay, desktop frame (≥ md) + phone frame (< md and ≥ xl side by side), per-tab note. Desktop UI is never squeezed into the phone. | | `src/components/marketing/features.tsx` | Rewritten: `FeatureRouter` (AUTO recommendation card), `FeatureArena` (scoreboard + Blind Arena), `FeatureCatalog`, `FeatureWorkspace` (projects / prompt `{{variables}}` / context library), `FeatureAnalytics`, `FeatureEndpoints` (Ollama, LM Studio, vLLM), `FeatureAlso` (PWA, keyboard, exports, compare-in-chat, presets, temporary chats). Spatial grouping (`.panel`, hairlines) instead of boxed cards. | | `src/components/marketing/mock-data.ts` | 9 mock models (one per provider, with tags), hero/chat/arena scripts, usage series/KPIs/savings, scoreboard, FAQ rewritten (router, blind arena, custom endpoints, temporary chats). `PROVIDER_LABEL` is exhaustive over `ProviderId` (includes `custom`). | | `src/components/marketing/footer.tsx` | Columns Product / Models / Account / Legal; "Made by" block: **contact@spboucher.ai** (mailto), **Simon-Pierre Boucher**, **Hosted on MacLustr — www.maclustr.io** (external); providers trademark note; `InstallHint` (compact). | | `src/components/marketing/header.tsx` | Nav: Features, Demo, Models (`/models`), Security (`/security`), FAQ. | | `src/components/marketing/legal.tsx` | Contact e-mail aligned to contact@spboucher.ai. | | `src/components/marketing/providers-row.tsx` | Removed (superseded by the provider row in the hero and the live strip). | | `src/app/sitemap.ts` | Added `/models` (daily, 0.9), `/security` (0.7), `/contact` (0.4). | | `src/components/app/onboarding.tsx` | `Onboarding` export kept. Now a first-run gate: redirects once per browser session to `/app/onboarding` when `onboardingCompletedAt === null` and the (loaded) connections list is empty; afterwards renders a small "Finish setting up PolyLLM" link. Uses the same SWR key as the store (`/api/providers`) to wait for `isLoading`. | | `src/app/(auth)/layout.tsx` | Mobile-first shell: brand mark + wordmark above the form on phones, safe-area padding (`pt-safe`, `pb-[max(20px,var(--sab))]`), footer links (Privacy, Terms, Security, Help). Desktop ≥ lg: two columns, quiet brand panel (headline, 3 points, provider marks). | | `src/app/(auth)/_components/auth-card.tsx` | Borderless full-width on phones (26 px title, 15 px copy, 44 px inputs, 48 px submit), elevated card from `sm`. Form logic untouched. | ## API `GET /api/public/models` — no auth. Headers `Cache-Control: public, max-age=600, s-maxage=600, stale-while-revalidate=300`. Response `200`: ```json { "models": [ { "key": "openai/gpt-5.5", "displayName": "GPT-5.5", "provider": "openai", "contextTokens": 400000, "inputPerMillion": 1.25, "outputPerMillion": 10, "status": "active", "firstSeenAt": "2026-08-30T…Z" } ], "total": 132, "generatedAt": "…" } ``` - ≤ 40 entries, only `hidden = false` and `status ∈ {active, preview}`. - Ordering: models first seen in the last 30 days first, then by registry `sortWeight` (flagships), then newest; the ranked list is interleaved per provider so the first screen shows all providers. - `503 { models: [] }` (cached 60 s) if the database is unavailable; the strip hides itself. - Reads the `models` table directly (`@/db`) — no change to `lib/ai/registry` (B's area). ## Onboarding (`/app/onboarding`) Client component, inside the app shell (the shell already hides the bottom nav on this route). Six steps: 1. **Create account** — already done → green check with name/e-mail. 2. **Choose providers** — grid of tiles (`PROVIDER_ORDER`, `PROVIDERS` meta), multi-select with `aria-pressed`; connected ones show "Connected". 3. **Add first API key** — rows for chosen + connected providers; "Get a key" (provider console) and "Add key" → `useAddKeyDialog` / `AddKeyDialog` (E's component, unchanged). Continue requires ≥ 1 valid connection; Skip otherwise. 4. **Validate key** — the dialog result (`ok`, `modelsAvailable`, `error`) is recorded as the validation; "Test connection" calls the existing `POST /api/providers {action:"validate"}`; status Valid / Rejected / Not validated with hint, models count, latency, relative time; "Replace key" on failure. 5. **Select favorite models** — connected, non-deprecated models grouped per provider (6 per provider, filter input), star toggles `useApp().toggleFavorite` (44 px rows). 6. **Send first prompt** — four suggested prompts + "Open the chat". `finish()` = `updatePreferences({ onboardingCompleted: true })`, localStorage marker `polyllm:onboarding-done:`, `setSelectedModelKey(firstFavorite)`, `router.replace('/app/chat?model=&q=')`, `router.refresh()`. Layout: phones = progress bar + swipeable `.snap-row` panels (`useSnapCarousel`, each panel scrolls on its own) + fixed footer with 44 px buttons (Back / Skip / Continue) above the safe area; desktop = centered two-column (sticky step list with check marks, content column). "Skip setup" in the header marks onboarding complete and returns to chat. **TODO(integration: chat)** — `ChatView` currently applies `?preset=`, `?prompt=` (prompt preset id) and `?model=`. The onboarding passes the suggested text as `?q=`; add one line in `components/chat/chat-view.tsx` to prefill the composer draft from `search.get("q")` (e.g. `if (q && !conversationId) setDraft(q)` in the existing search-params effect). Until then the user lands on a new chat with the favorite model selected and an empty composer. ## Verified security claims (source references) Every statement on `/security`, the homepage security teaser and the FAQ was checked against the code on 2026-09-11: | Claim | Where verified | | --- | --- | | AES-256-GCM, data key derived with HKDF-SHA256 from `API_KEY_ENCRYPTION_SECRET` (info `polyllm:provider-key:v1`), random 12-byte IV, 16-byte auth tag, AAD `userId\|provider`, envelope `v1...` base64url | `src/lib/crypto/keys.ts` (`dataKey`, `encryptSecret`, `decryptSecret`) | | Key hint = recognizable prefix (≤ 8 chars) + `••••••••` + last 4; SHA-256 fingerprint for same-key detection | `keyHint`, `fingerprintSecret` in `src/lib/crypto/keys.ts` | | Key validated against the provider on save; shape check 8–512 chars, no whitespace | `upsertConnection` in `src/lib/providers/keys.ts`; `PUT /api/providers` passes `validate: true` | | Decrypted only right before a provider call (`getDecryptedKey`, `validateConnection`) | `src/lib/providers/keys.ts`; callers `api/providers`, `api/models/sync`, chat/arena services | | `PublicConnection` has no key material | `src/lib/providers/keys.ts` interface | | Audit events `provider.key_added` / `provider.key_replaced` / `provider.key_deleted` with IP | `src/lib/providers/keys.ts` → `writeAudit` (`src/lib/audit.ts`: action, ip, UA truncated to 300, meta) | | Other audit actions: `account.created`, `login` (ip + UA), `email.verified`, `email.change_requested`, `password.reset_requested`, `password.reset`, `account.deleted`; conversation actions `export`, `share`, `branch`, `duplicate` | `src/lib/auth.ts` hooks; `src/app/api/conversations/[id]/actions/route.ts` | | Audit log shown to the user (Settings → Account, `GET /api/account`) — **not** included in the JSON export | `src/app/app/settings/account/page.tsx`, `src/app/api/account/route.ts` | | Logs: structured JSON, `redactSecrets` on every string, keys matching api key/password/secret/token/authorization/cookie/prompt/content/messages → `[redacted]` | `src/lib/log.ts` | | Passwords: Argon2id, memoryCost 19 456 KiB (19 MiB), timeCost 2, parallelism 1; min 10 / max 128 chars | `ARGON2_OPTS` and `emailAndPassword` in `src/lib/auth.ts` | | E-mail verification required, no auto sign-in on sign-up, verification link 24 h, reset link 60 min, sessions revoked on password reset, change e-mail / delete account confirmed by e-mail | `src/lib/auth.ts` (`requireEmailVerification`, `autoSignIn: false`, `expiresIn`, `resetPasswordTokenExpiresIn`, `revokeSessionsOnPasswordReset`, `changeEmail`, `deleteUser`) | | Cookies: prefix `polyllm`, Secure in production, HttpOnly, SameSite=Lax; session 30 d, refreshed daily, 5-min cookie cache | `advanced.cookiePrefix`, `useSecureCookies: IS_PROD`, `session` in `src/lib/auth.ts`; defaults `httpOnly: true`, `sameSite: "lax"` in `node_modules/better-auth/dist/cookies/index.mjs` | | Auth accepted only from the app origin | `trustedOrigins: [APP_URL, "http://localhost:3000"]` | | Auth rate limits: 100/min default; sign-in 8, sign-up 4, forget/request-reset 4, send-verification 3, change-password 5, change-email 3, delete-user 3 (per minute) | `rateLimit.customRules` in `src/lib/auth.ts` | | App rate limits (per user id, in-memory sliding window, 429 + `Retry-After`): chat 60, arena 20, arena stream 80, key save 12, key validate 10, model sync 6, upload 40, search 120, share/conversation actions 20 per minute | `LIMITS` in `src/lib/rate-limit.ts`; `withUser` in `src/lib/api.ts`; route files under `src/app/api/*` | | CSP `default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; img-src 'self' data: blob: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' (prod); connect-src 'self'; worker-src 'self' blob:; upgrade-insecure-requests` | `next.config.ts` | | HSTS `max-age=31536000; includeSubDomains` (prod), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`, `poweredByHeader: false` | `next.config.ts` | | Unauthenticated `/app` and `/admin` redirected to `/login?next=` | `src/proxy.ts` | | Owner keys only for registry sync / admin / tests | `CLAUDE.md` non-negotiables; `syncAllWithEnvKeys` in `src/lib/ai/registry/index.ts` | Stated as a trade-off on the page: `'unsafe-inline'` in `script-src`/`style-src` (Next hydration + Radix). Not claimed anywhere: encrypted backups, nonce-based CSP, Redis-backed limits. ## Coming soon / not implemented Nothing is labelled "Coming soon". All buttons work. The only pending hook is the `?q=` draft prefill in ChatView (above). ## Notes for other areas - **E (settings/appearance):** `components/marketing/install-hint.tsx` is reusable (``); it stores dismissal in `localStorage["polyllm:install-hint-dismissed"]`. - **B (models):** the footer and header link to `/models`; the hero strip links "Browse all" to `/models`. `formatContext`/`formatPricePair` in `src/lib/marketing/public-models.ts` are generic if useful. - **A (chat):** `?q=` prefill (see above). The `Onboarding` component is still mounted from `components/chat/empty-state.tsx`; it now renders either nothing or a small link, so the empty state's own "Connect your first provider" card remains the primary CTA there. - Lint errors remaining in the tree at the time of writing are in G's files (`components/search/search-sheet.tsx`, `components/app/command-palette.tsx`), not in this area. ## QA checklist (integration phase, 375 / 390 / 393 / 430 / 1440) Homepage - [ ] Hero headline wraps as two lines on phones without orphan; CTAs full-width on phones, inline from `sm`. - [ ] Phone frame (< md) shows header with model pill, user bubble, streaming answer with caret, metadata line, composer `[+] Ask anything… [mic] [send]`, bottom nav with Chat active; no horizontal overflow at 375. - [ ] Desktop (≥ md) shows the window with sidebar, model pill, context indicator, streaming answer; mini Arena card appears bottom-right at ≥ lg after ~3 s and its bars finish; at 1440 the card does not overflow the container (`xl:-right-10`). - [ ] Live strip: skeleton → marquee; pauses on hover; "New"/"Preview" badges; `prefers-reduced-motion` → static wrapped grid; strip hidden when `/api/public/models` returns 503 (stop the DB to test). - [ ] `/api/public/models` returns ≤ 40 models, `Cache-Control` header present, no auth needed, no user fields. - [ ] Features: no boxed-card overload; visuals fit at 375 (Router card, Scoreboard + Blind Arena stack, Models/Usage windows without the sidebar, Workspace panel, Endpoints list). - [ ] Demo: Segmented fills the width on phones; tabs switch instantly; Replay restarts streams; phone Arena swipes between the three answers and the tab pills/dots follow; desktop Arena shows three columns, "fastest" badge, vote pills, Winner badge when done; Models/Usage tabs render both frames at ≥ xl. - [ ] `#features`, `#arena`, `#demo`, `#security`, `#faq` anchors land correctly with the sticky header (`scroll-mt-16`). - [ ] Footer: four columns collapse to 2 columns on phones; Made-by block shows mailto, name, MacLustr external link (new tab); Install hint appears only on Chromium (`beforeinstallprompt`) or iOS Safari, disappears when dismissed or in standalone mode. Security / Contact - [ ] `/security` flow renders as a vertical stack with down arrows on phones and a 3-column row at ≥ lg; header `dl` rows wrap long CSP values without overflow; rate-limit tables are two columns at ≥ md. - [ ] `/contact` mailto works; external MacLustr link opens in a new tab. - [ ] `/sitemap.xml` lists `/models`, `/security`, `/contact`. Onboarding - [ ] New user (no providers, `onboardingCompletedAt` null) opening `/app/chat` is redirected once to `/app/onboarding`; navigating back to chat in the same session shows the "Finish setting up PolyLLM" link instead of redirecting again. - [ ] Phone: progress bar, swipe between steps (`.snap-row`), footer buttons 44 px above the safe area, bottom nav hidden, each step scrolls independently, no horizontal overflow at 375. - [ ] Desktop: sticky step list with check marks; clicking a step jumps to it. - [ ] Step 3 opens `AddKeyDialog`; after a successful save, step 4 shows "Valid · N models"; "Test connection" updates status and shows latency; a rejected key shows the provider error and "Replace key". - [ ] Step 5 stars persist (`/api/models` favorites) and the count updates; filter input works. - [ ] Step 6 / "Open the chat" marks onboarding completed (`users.onboarding_completed_at`), selects the first favorite model and opens `/app/chat?model=…&q=…`; "Skip setup" does the same without a prompt. Auth - [ ] Login/signup/forgot/reset/verify at 375: brand mark + wordmark above the form, no card border, 44 px inputs (16 px text on touch), 48 px submit, password strength meter, footer above the home indicator. - [ ] ≥ lg: brand panel left, form right; the header logo is invisible on the right column (panel has its own). - [ ] All existing behaviours unchanged: unverified → resend flow, `?next=` redirect after login, notices (`reset`, `verified`, `signed-out`). PWA - [ ] `/manifest.webmanifest` linked from the layout (already); Lighthouse "installable" passes; "Install PolyLLM" hint on the homepage footer (Chromium) triggers the native prompt.