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:
{ "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 = falseandstatus ∈ {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
modelstable directly (@/db) — no change tolib/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:
- Create account — already done → green check with name/e-mail.
- Choose providers — grid of tiles (
PROVIDER_ORDER,PROVIDERSmeta), multi-select witharia-pressed; connected ones show "Connected". - 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. - Validate key — the dialog result (
ok,modelsAvailable,error) is recorded as the validation; "Test connection" calls the existingPOST /api/providers {action:"validate"}; status Valid / Rejected / Not validated with hint, models count, latency, relative time; "Replace key" on failure. - Select favorite models — connected, non-deprecated models grouped per provider (6 per provider, filter input), star toggles
useApp().toggleFavorite(44 px rows). - Send first prompt — four suggested prompts + "Open the chat".
finish()=updatePreferences({ onboardingCompleted: true }), localStorage markerpolyllm:onboarding-done:<userId>,setSelectedModelKey(firstFavorite),router.replace('/app/chat?model=<key>&q=<prompt>'),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.<iv>.<tag>.<ct> 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.tsxis reusable (<InstallHint compact />); it stores dismissal inlocalStorage["polyllm:install-hint-dismissed"]. - B (models): the footer and header link to
/models; the hero strip links "Browse all" to/models.formatContext/formatPricePairinsrc/lib/marketing/public-models.tsare generic if useful. - A (chat):
?q=prefill (see above). TheOnboardingcomponent is still mounted fromcomponents/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/modelsreturns 503 (stop the DB to test). -
/api/public/modelsreturns ≤ 40 models,Cache-Controlheader 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,#faqanchors 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
-
/securityflow renders as a vertical stack with down arrows on phones and a 3-column row at ≥ lg; headerdlrows wrap long CSP values without overflow; rate-limit tables are two columns at ≥ md. -
/contactmailto works; external MacLustr link opens in a new tab. -
/sitemap.xmllists/models,/security,/contact.
Onboarding
- New user (no providers,
onboardingCompletedAtnull) opening/app/chatis 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/modelsfavorites) 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.webmanifestlinked from the layout (already); Lighthouse "installable" passes; "Install PolyLLM" hint on the homepage footer (Chromium) triggers the native prompt.