TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import {4 ArrowUp,5 BarChart3,6 Boxes,7 Check,8 ChevronDown,9 ChevronRight,10 Copy,11 FolderOpen,12 GitBranch,13 Library,14 Menu,15 MessageSquare,16 Mic,17 PanelLeft,18 Plus,19 RotateCcw,20 Search,21 Sparkles,22 SquarePen,23 Star,24 Swords,25 Trophy,26 WandSparkles,27 Zap,28} from "lucide-react";29import { Logo } from "@/components/brand/logo";30import { ProviderIcon } from "@/components/brand/provider-icon";31import { Badge } from "@/components/ui/badge";32import { MOBILE_TABS } from "@/components/app/bottom-nav";33import { cn } from "@/lib/utils";34import { approxTokens } from "./fake-stream";35import { MOCK_MODELS, MOCK_PROJECTS, RECENT_CONVERSATIONS, SCOREBOARD, USAGE_BY_PROVIDER, USAGE_COST_SERIES, USAGE_DAY_LABELS, USAGE_KPIS, USAGE_SAVINGS, USAGE_SERIES, PROVIDER_LABEL, type MockModel } from "./mock-data";3637/* ------------------------------------------------------------------------------------------------38 * Ultra-light inline markdown for the mocks (bold, inline code, paragraphs, pipe tables).39 * Not the real renderer — just enough to look like it.40 * ---------------------------------------------------------------------------------------------- */41function renderInline(line: string, keyPrefix: string) {42 const parts = line.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean);43 return parts.map((p, i) => {44 if (p.startsWith("**") && p.endsWith("**")) return <strong key={`${keyPrefix}-${i}`}>{p.slice(2, -2)}</strong>;45 if (p.startsWith("`") && p.endsWith("`")) return <code key={`${keyPrefix}-${i}`}>{p.slice(1, -1)}</code>;46 return <React.Fragment key={`${keyPrefix}-${i}`}>{p}</React.Fragment>;47 });48}4950function MockTable({ block, streaming }: { block: string; streaming: boolean }) {51 const lines = block.split("\n").filter((l) => l.trim().startsWith("|"));52 const rows = lines.filter((l) => !/^\|\s*-{2,}/.test(l.trim())).map((l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));53 if (!rows.length) return null;54 const [head, ...body] = rows;55 return (56 <table className={cn(streaming && "opacity-90")}>57 <thead>58 <tr>59 {head.map((c, i) => (60 <th key={i}>{renderInline(c, `h${i}`)}</th>61 ))}62 </tr>63 </thead>64 <tbody>65 {body.map((r, ri) => (66 <tr key={ri}>67 {r.map((c, ci) => (68 <td key={ci}>{renderInline(c, `c${ri}-${ci}`)}</td>69 ))}70 </tr>71 ))}72 </tbody>73 </table>74 );75}7677export function MockMarkdown({ text, streaming, className }: { text: string; streaming: boolean; className?: string }) {78 const blocks = text.split(/\n\n/);79 return (80 <div className={cn("prose-chat text-[13px] leading-[1.6] sm:text-[13.5px]", className)}>81 {blocks.map((b, i) => {82 const last = i === blocks.length - 1;83 if (b.trim().startsWith("|")) {84 return (85 <React.Fragment key={i}>86 <MockTable block={b} streaming={streaming && last} />87 {streaming && last ? <span className="caret" aria-hidden /> : null}88 </React.Fragment>89 );90 }91 return (92 <p key={i}>93 {renderInline(b, String(i))}94 {streaming && last ? <span className="caret" aria-hidden /> : null}95 </p>96 );97 })}98 </div>99 );100}101102/* ------------------------------------------------------------------------------------------------103 * Atoms that mirror the real app104 * ---------------------------------------------------------------------------------------------- */105export function ModelPill({ model, className, size = "md" }: { model: MockModel; className?: string; size?: "sm" | "md" }) {106 return (107 <span className={cn("inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elevated font-medium", size === "sm" ? "h-6 px-1.5 text-[11.5px]" : "h-7 px-2 text-[12.5px]", className)}>108 <ProviderIcon provider={model.provider} size={size === "sm" ? 12 : 13} />109 <span className="truncate">{model.name}</span>110 <ChevronDown className={cn("shrink-0 text-fg-subtle", size === "sm" ? "size-3" : "size-3.5")} aria-hidden />111 </span>112 );113}114115export function ContextIndicator({ used, total, className }: { used: string; total: string; className?: string }) {116 return (117 <span className={cn("inline-flex items-center gap-1.5 font-mono text-[10.5px] tabular-nums text-fg-subtle", className)} title="Context used">118 <span className="h-1 w-10 overflow-hidden rounded-full bg-bg-muted">119 <span className="block h-full w-[9%] rounded-full bg-accent" />120 </span>121 {used} / {total}122 </span>123 );124}125126/** The metadata line shown under an assistant message in the real app. */127export function MessageMeta({ chars, elapsedMs, ttftMs, done, model, className }: { chars: number; elapsedMs: number; ttftMs: number; done: boolean; model: MockModel; className?: string }) {128 const out = approxTokens(chars);129 const inTok = 60 + Math.round(chars / 40);130 const secs = Math.max(0.1, elapsedMs / 1000);131 const tps = chars > 0 ? Math.round(out / secs) : 0;132 const cost = (out / 1_000_000) * parseFloat(model.output.slice(1)) + (inTok / 1_000_000) * parseFloat(model.input.slice(1));133 return (134 <div className={cn("flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-[10.5px] tabular-nums text-fg-subtle", className)} aria-label="Message metadata">135 <span className="inline-flex items-center gap-1 text-fg-muted">136 <ProviderIcon provider={model.provider} size={11} />137 {model.name}138 </span>139 <span>140 {inTok} in · {out} out141 </span>142 <span>{(secs + ttftMs / 1000).toFixed(1)} s</span>143 <span>TTFT {(ttftMs / 1000).toFixed(1)} s</span>144 <span>{tps} tok/s</span>145 <span className={cn(done && "text-fg-muted")}>≈ ${cost.toFixed(4)}</span>146 {done ? (147 <span className="ml-auto hidden items-center gap-2 text-fg-subtle sm:inline-flex" aria-hidden>148 <Copy className="size-3" />149 <RotateCcw className="size-3" />150 <GitBranch className="size-3" />151 </span>152 ) : null}153 </div>154 );155}156157/** Desktop composer as in the real app: auto-grow area, tools row, active model pill, send. */158export function ComposerMock({ text, model, streaming, compact }: { text?: string; model: MockModel; streaming?: boolean; compact?: boolean }) {159 return (160 <div className="rounded-2xl border border-border bg-bg-elevated shadow-xs" aria-hidden>161 <div className={cn("px-3.5 pt-3 text-[13px] leading-6", compact ? "min-h-[36px]" : "min-h-[44px]", text ? "text-fg" : "text-fg-subtle")}>162 {text ?? "Ask anything…"}163 {text && !streaming ? <span className="caret" aria-hidden /> : null}164 </div>165 <div className="flex items-center gap-1.5 px-2 pb-2 pt-1">166 <span className="inline-flex size-7 items-center justify-center rounded-md text-fg-muted">167 <Plus className="size-4" />168 </span>169 <ModelPill model={model} size="sm" />170 <span className="hidden h-6 items-center gap-1 rounded-md border border-dashed border-border px-1.5 text-[11px] text-fg-muted lg:inline-flex">Web search</span>171 <span className="ml-auto inline-flex size-7 items-center justify-center rounded-md text-fg-muted">172 <Mic className="size-4" />173 </span>174 <span className={cn("inline-flex size-7 items-center justify-center rounded-full", streaming ? "bg-bg-muted text-fg" : "bg-fg text-bg")}>{streaming ? <span className="size-2.5 rounded-[2px] bg-current" /> : <ArrowUp className="size-4" />}</span>175 </div>176 </div>177 );178}179180/** Phone composer: `[+] Ask anything… [mic] [send]` — one row, 16 px-equivalent text. */181export function MobileComposerMock({ text, streaming }: { text?: string; streaming?: boolean }) {182 return (183 <div className="flex items-end gap-1.5 px-2.5 pb-2 pt-1.5" aria-hidden>184 <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-bg-muted text-fg-muted">185 <Plus className="size-[18px]" />186 </span>187 <div className={cn("flex min-h-9 flex-1 items-center rounded-[18px] border border-border bg-bg-elevated px-3 text-[13px]", text ? "text-fg" : "text-fg-subtle")}>188 <span className="truncate">{text ?? "Ask anything…"}</span>189 </div>190 <span className="flex size-9 shrink-0 items-center justify-center rounded-full text-fg-muted">191 <Mic className="size-[18px]" />192 </span>193 <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-full", streaming ? "bg-bg-muted text-fg" : "bg-fg text-bg")}>{streaming ? <span className="size-3 rounded-[3px] bg-current" /> : <ArrowUp className="size-[18px]" />}</span>194 </div>195 );196}197198/** Mirrors `components/app/bottom-nav.tsx` (same tabs, same geometry) inside the phone frame. */199export function BottomNavMock({ active }: { active: "Chat" | "Arena" | "Models" | "Usage" | "Account" }) {200 return (201 <nav aria-hidden className="glass-strong shrink-0 border-t border-hairline pb-4">202 <ul className="grid h-[52px] grid-cols-5">203 {MOBILE_TABS.map((t) => {204 const on = t.label === active;205 return (206 <li key={t.href} className="flex flex-col items-center justify-center gap-0.5 text-[9.5px] font-medium tracking-tight">207 <span className={cn("flex h-6 w-10 items-center justify-center rounded-full", on ? "bg-accent-soft text-accent" : "text-fg-subtle")}>208 <t.icon className="size-[17px]" strokeWidth={on ? 2.2 : 1.8} />209 </span>210 <span className={on ? "text-fg" : "text-fg-subtle"}>{t.label}</span>211 </li>212 );213 })}214 </ul>215 </nav>216 );217}218219export function MobileHeaderMock({ center, right, title }: { center?: React.ReactNode; right?: React.ReactNode; title?: string }) {220 return (221 <div className="flex h-11 shrink-0 items-center justify-between px-2.5 hairline-b" aria-hidden>222 <span className="flex size-8 items-center justify-center rounded-md text-fg-muted">223 <Menu className="size-[18px]" />224 </span>225 {center ?? <span className="text-[13px] font-semibold tracking-tight">{title}</span>}226 {right ?? (227 <span className="flex size-8 items-center justify-center rounded-md text-fg-muted">228 <SquarePen className="size-[17px]" />229 </span>230 )}231 </div>232 );233}234235/* ------------------------------------------------------------------------------------------------236 * Desktop sidebar (mirrors components/app/sidebar.tsx)237 * ---------------------------------------------------------------------------------------------- */238const SIDEBAR_NAV = [239 { label: "Chat", icon: MessageSquare },240 { label: "Arena", icon: Swords },241 { label: "Models", icon: Boxes },242 { label: "Prompts", icon: WandSparkles },243 { label: "Presets", icon: Sparkles },244 { label: "Library", icon: Library },245 { label: "Usage", icon: BarChart3 },246] as const;247248export function SidebarMock({ active = "Chat", activeConversation = RECENT_CONVERSATIONS[0], className }: { active?: (typeof SIDEBAR_NAV)[number]["label"]; activeConversation?: string | null; className?: string }) {249 return (250 <aside className={cn("flex w-[13.5rem] shrink-0 flex-col border-r border-border bg-bg-subtle p-2.5 text-[12px]", className)} aria-hidden>251 <div className="flex items-center justify-between px-1">252 <Logo size={18} className="[&_span]:text-[13px]" />253 <PanelLeft className="size-3.5 text-fg-subtle" />254 </div>255 <div className="mt-2.5 flex h-7 items-center gap-2 rounded-md bg-fg px-2 text-[11.5px] font-medium text-bg">256 <SquarePen className="size-3.5" /> New chat <kbd className="kbd ml-auto border-bg/20 bg-bg/10 text-bg/80">⌘N</kbd>257 </div>258 <div className="mt-1.5 flex h-7 items-center gap-2 rounded-md border border-border bg-bg px-2 text-fg-subtle">259 <Search className="size-3.5" /> Search <kbd className="kbd ml-auto">⌘K</kbd>260 </div>261 <ul className="mt-2.5 space-y-px">262 {SIDEBAR_NAV.map((n) => (263 <li key={n.label} className={cn("flex h-7 items-center gap-2 rounded-md px-2", n.label === active ? "bg-bg-muted font-medium text-fg" : "text-fg-muted")}>264 <n.icon className="size-3.5" /> {n.label}265 </li>266 ))}267 </ul>268 <div className="mt-3 flex items-center justify-between px-2 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-fg-subtle">269 Projects <Plus className="size-3" />270 </div>271 <ul className="mt-1 space-y-px">272 {MOCK_PROJECTS.map((p) => (273 <li key={p} className="flex h-6.5 items-center gap-2 rounded-md px-2 text-fg-muted">274 <FolderOpen className="size-3.5 text-fg-subtle" /> {p}275 </li>276 ))}277 </ul>278 <div className="mt-3 px-2 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-fg-subtle">Recent</div>279 <ul className="mt-1 min-h-0 flex-1 space-y-px overflow-hidden">280 {RECENT_CONVERSATIONS.map((t) => (281 <li key={t} className={cn("flex h-6.5 items-center gap-2 truncate rounded-md px-2", t === activeConversation ? "bg-bg-muted font-medium text-fg" : "text-fg-muted")}>282 <MessageSquare className="size-3.5 shrink-0 text-fg-subtle" /> <span className="truncate">{t}</span>283 </li>284 ))}285 </ul>286 <div className="mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-fg-muted">287 <span className="flex size-5 items-center justify-center rounded-full bg-accent-soft text-[9px] font-semibold text-accent">SP</span>288 <span className="truncate">simon@…</span>289 <ChevronDown className="ml-auto size-3 text-fg-subtle" />290 </div>291 </aside>292 );293}294295/* ------------------------------------------------------------------------------------------------296 * Chat — desktop and phone297 * ---------------------------------------------------------------------------------------------- */298export interface ChatMockProps {299 prompt: string;300 reply: string;301 progress: number;302 elapsedMs: number;303 ttftMs: number;304 model: MockModel;305 className?: string;306}307308function AssistantTurn({ reply, progress, elapsedMs, ttftMs, model, dense }: Omit<ChatMockProps, "prompt" | "className"> & { dense?: boolean }) {309 const shown = reply.slice(0, progress);310 const done = progress >= reply.length;311 return (312 <div className="flex gap-2.5">313 <span className={cn("mt-0.5 flex shrink-0 items-center justify-center rounded-md border border-border bg-bg-elevated", dense ? "size-5" : "size-6")}>314 <ProviderIcon provider={model.provider} size={dense ? 11 : 13} />315 </span>316 <div className="min-w-0 flex-1">317 {progress === 0 ? (318 <p className="inline-flex items-center gap-1.5 text-[12.5px] text-fg-subtle">319 <span className="size-1.5 rounded-full bg-accent animate-pulse-soft" />320 Thinking…321 </p>322 ) : (323 <MockMarkdown text={shown} streaming={!done} className={dense ? "text-[12.5px] sm:text-[12.5px]" : undefined} />324 )}325 {progress > 0 ? <MessageMeta chars={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} done={done} model={model} className="mt-2.5" /> : null}326 </div>327 </div>328 );329}330331export function DesktopChatMock({ prompt, reply, progress, elapsedMs, ttftMs, model, className }: ChatMockProps) {332 const done = progress >= reply.length;333 return (334 <div className={cn("flex min-h-[26rem] bg-bg", className)}>335 <SidebarMock className="hidden md:flex" />336 <div className="flex min-w-0 flex-1 flex-col">337 <div className="flex h-11 items-center gap-2 px-3 hairline-b">338 <ModelPill model={model} />339 <Badge variant="accent" className="hidden sm:inline-flex">340 thinking · 16K341 </Badge>342 <ContextIndicator used="12K" total={model.context} className="ml-auto" />343 <Star className="size-3.5 text-border-strong" aria-hidden />344 </div>345 <div className="min-h-0 flex-1 space-y-5 overflow-hidden px-4 py-4 sm:px-6">346 <div className="flex justify-end">347 <p className="max-w-[85%] rounded-2xl rounded-br-md bg-bg-muted px-3.5 py-2 text-[13px] leading-6 sm:text-[13.5px]">{prompt}</p>348 </div>349 <AssistantTurn reply={reply} progress={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} model={model} />350 </div>351 <div className="p-3 sm:px-6 sm:pb-4">352 <ComposerMock model={model} streaming={!done && progress > 0} compact />353 </div>354 </div>355 </div>356 );357}358359/** Full-screen phone chat with header, messages, composer and bottom nav — the real mobile layout. */360export function MobileChatMock({ prompt, reply, progress, elapsedMs, ttftMs, model }: ChatMockProps) {361 const done = progress >= reply.length;362 return (363 <>364 <MobileHeaderMock center={<ModelPill model={model} size="sm" />} />365 <div className="min-h-0 flex-1 space-y-4 overflow-hidden px-3 py-3 mask-b">366 <div className="flex justify-end">367 <p className="max-w-[88%] rounded-2xl rounded-br-md bg-bg-muted px-3 py-1.5 text-[12.5px] leading-5">{prompt}</p>368 </div>369 <AssistantTurn reply={reply} progress={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} model={model} dense />370 </div>371 <MobileComposerMock streaming={!done && progress > 0} />372 <BottomNavMock active="Chat" />373 </>374 );375}376377/* ------------------------------------------------------------------------------------------------378 * Arena — desktop grid, phone swipeable single response, hero mini card379 * ---------------------------------------------------------------------------------------------- */380export interface ArenaMockProps {381 prompt: string;382 models: MockModel[];383 replies: string[];384 progress: number[];385 elapsedFor: (i: number) => number;386 ttftFor: (i: number) => number;387 fastest: number;388 allDone: boolean;389 /** Index the "user" votes for once everything is done (scripted). */390 winner?: number;391 className?: string;392}393394const VOTES = ["Best", "Most accurate", "Best writing", "Best value"] as const;395396function ArenaColumn({ m, text, progress, elapsedMs, ttftMs, isFastest, allDone, winner, dense }: { m: MockModel; text: string; progress: number; elapsedMs: number; ttftMs: number; isFastest: boolean; allDone: boolean; winner: boolean; dense?: boolean }) {397 const shown = text.slice(0, progress);398 const done = progress >= text.length;399 return (400 <div className="flex h-full min-w-0 flex-col p-3 sm:p-4">401 <div className="flex items-center gap-1.5 text-[12.5px] font-medium">402 <ProviderIcon provider={m.provider} size={13} />403 <span className="truncate">{m.name}</span>404 {done && isFastest ? (405 <Badge variant="success" className="ml-auto">406 <Zap /> fastest407 </Badge>408 ) : !done && progress > 0 ? (409 <span className="ml-auto size-1.5 rounded-full bg-accent animate-pulse-soft" aria-label="streaming" />410 ) : !done ? (411 <span className="ml-auto font-mono text-[10px] text-fg-subtle">queued</span>412 ) : null}413 </div>414 <div className="mt-2.5 min-h-0 flex-1">415 {progress === 0 ? <p className="text-[12px] text-fg-subtle animate-pulse-soft">Waiting for first token…</p> : <MockMarkdown text={shown} streaming={!done} className={dense ? "text-[12.5px] sm:text-[12.5px]" : undefined} />}416 </div>417 <div className="mt-3 border-t border-hairline pt-2.5">418 {progress > 0 ? <MessageMeta chars={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} done={done} model={m} /> : <span className="font-mono text-[10.5px] text-fg-subtle">—</span>}419 <div className={cn("mt-2.5 flex flex-wrap gap-1 transition-opacity duration-300", allDone ? "opacity-100" : "pointer-events-none opacity-0")} aria-hidden={!allDone}>420 {VOTES.map((v, i) => (421 <span key={v} className={cn("inline-flex h-6 items-center gap-1 rounded-full border px-2 text-[10.5px] font-medium", winner && i === 0 ? "border-fg bg-fg text-bg" : "border-border text-fg-muted")}>422 {winner && i === 0 ? <Check className="size-3" /> : null}423 {v}424 </span>425 ))}426 </div>427 </div>428 </div>429 );430}431432export function DesktopArenaMock({ prompt, models, replies, progress, elapsedFor, ttftFor, fastest, allDone, winner = 0, className }: ArenaMockProps) {433 return (434 <div className={cn("flex min-h-[26rem] bg-bg", className)}>435 <SidebarMock active="Arena" activeConversation={null} className="hidden lg:flex" />436 <div className="flex min-w-0 flex-1 flex-col">437 <div className="flex h-11 items-center gap-2 px-3 text-[12.5px] hairline-b">438 <span className="font-semibold">Arena</span>439 <Badge>{models.length} models</Badge>440 <Badge variant="outline" className="hidden sm:inline-flex">441 Blind: off442 </Badge>443 {allDone ? (444 <Badge variant="accent" className="ml-auto animate-fade-in">445 <Trophy /> Winner: {models[winner].name}446 </Badge>447 ) : (448 <span className="ml-auto font-mono text-[10.5px] text-fg-subtle">streaming…</span>449 )}450 </div>451 <div className="px-3 py-2.5 text-[12.5px] hairline-b sm:px-4">452 <span className="text-fg-subtle">Prompt · </span>453 {prompt}454 </div>455 <div className={cn("grid min-h-0 flex-1 divide-y divide-border md:divide-x md:divide-y-0", models.length === 2 ? "md:grid-cols-2" : models.length === 4 ? "md:grid-cols-2 lg:grid-cols-4" : "md:grid-cols-3")}>456 {models.map((m, i) => (457 <ArenaColumn key={m.key} m={m} text={replies[i]} progress={progress[i]} elapsedMs={elapsedFor(i)} ttftMs={ttftFor(i)} isFastest={i === fastest} allDone={allDone} winner={i === winner} />458 ))}459 </div>460 </div>461 </div>462 );463}464465/** Phone Arena: sticky model tabs + a `.snap-row` carousel, one response at a time (as in the real app). */466export function MobileArenaMock({ prompt, models, replies, progress, elapsedFor, ttftFor, fastest, allDone, winner = 0 }: ArenaMockProps) {467 const ref = React.useRef<HTMLDivElement>(null);468 const [index, setIndex] = React.useState(0);469 React.useEffect(() => {470 const el = ref.current;471 if (!el) return;472 let raf = 0;473 const onScroll = () => {474 cancelAnimationFrame(raf);475 raf = requestAnimationFrame(() => setIndex(Math.max(0, Math.min(models.length - 1, Math.round(el.scrollLeft / (el.clientWidth || 1))))));476 };477 el.addEventListener("scroll", onScroll, { passive: true });478 return () => {479 el.removeEventListener("scroll", onScroll);480 cancelAnimationFrame(raf);481 };482 }, [models.length]);483 const go = (i: number) => ref.current?.scrollTo({ left: i * (ref.current.clientWidth || 0), behavior: "smooth" });484485 return (486 <>487 <MobileHeaderMock title="Arena" right={<span className="px-2 text-[12px] font-medium text-accent">New</span>} />488 <div className="px-3 py-2 text-[12px] leading-5 hairline-b">489 <span className="text-fg-subtle">Prompt · </span>490 {prompt}491 </div>492 <div className="flex gap-1 overflow-x-auto px-2 py-1.5 scrollbar-none hairline-b" role="tablist" aria-label="Arena responses">493 {models.map((m, i) => (494 <button key={m.key} type="button" role="tab" aria-selected={index === i} onClick={() => go(i)} className={cn("inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] font-medium transition-colors", index === i ? "bg-fg text-bg" : "bg-bg-muted text-fg-muted")}>495 <ProviderIcon provider={m.provider} size={11} />496 {m.name}497 {progress[i] > 0 && progress[i] < replies[i].length ? <span className="size-1.5 rounded-full bg-accent animate-pulse-soft" /> : null}498 </button>499 ))}500 </div>501 <div ref={ref} className="snap-row min-h-0 flex-1 touch-pan-x" data-no-edge-swipe>502 {models.map((m, i) => (503 <div key={m.key} className="h-full overflow-hidden">504 <ArenaColumn m={m} text={replies[i]} progress={progress[i]} elapsedMs={elapsedFor(i)} ttftMs={ttftFor(i)} isFastest={i === fastest} allDone={allDone} winner={i === winner} dense />505 </div>506 ))}507 </div>508 <div className="flex justify-center gap-1 pb-1.5" aria-hidden>509 {models.map((m, i) => (510 <span key={m.key} className={cn("size-1.5 rounded-full transition-colors", i === index ? "bg-fg" : "bg-border-strong")} />511 ))}512 </div>513 <BottomNavMock active="Arena" />514 </>515 );516}517518/** Small "Arena · 3 models" card layered over the hero window. */519export function ArenaMiniCard({ models, replies, progress, elapsedFor, fastest, allDone, className }: Omit<ArenaMockProps, "prompt" | "ttftFor">) {520 return (521 <div className={cn("w-[17rem] rounded-xl border border-border bg-bg-elevated p-3 shadow-lg", className)} aria-hidden>522 <div className="flex items-center gap-1.5 text-[12px] font-semibold">523 <Swords className="size-3.5 text-accent" /> Arena524 <span className="ml-auto font-mono text-[10px] font-normal text-fg-subtle">{allDone ? "done" : "streaming"}</span>525 </div>526 <ul className="mt-2.5 space-y-2">527 {models.map((m, i) => {528 const pct = Math.min(100, Math.round((progress[i] / replies[i].length) * 100));529 const tps = progress[i] > 0 ? Math.round(approxTokens(progress[i]) / Math.max(0.1, elapsedFor(i) / 1000)) : 0;530 return (531 <li key={m.key} className="text-[11px]">532 <div className="flex items-center gap-1.5">533 <ProviderIcon provider={m.provider} size={11} />534 <span className="truncate font-medium">{m.name}</span>535 {pct >= 100 && i === fastest ? <Zap className="size-3 text-success" /> : null}536 <span className="ml-auto font-mono tabular-nums text-fg-subtle">{tps ? `${tps} tok/s` : "—"}</span>537 </div>538 <div className="mt-1 h-1 overflow-hidden rounded-full bg-bg-muted">539 <div className="h-full rounded-full bg-accent transition-[width] duration-150" style={{ width: `${pct}%` }} />540 </div>541 </li>542 );543 })}544 </ul>545 </div>546 );547}548549/* ------------------------------------------------------------------------------------------------550 * Models catalog — desktop table, phone stacked rows551 * ---------------------------------------------------------------------------------------------- */552const MODEL_CHIPS = ["All", "Favorites", "Reasoning", "Vision", "Cheap", "Long context", "New"] as const;553const FAVORITES = new Set(["anthropic/claude-opus-5", "openai/gpt-5.5", "gemini/gemini-3.8-flash"]);554const TAG_LABEL: Record<NonNullable<MockModel["tags"]>[number], { label: string; variant: "accent" | "success" | "info" | "warning" | "default" }> = {555 new: { label: "New", variant: "accent" },556 fast: { label: "Fast", variant: "success" },557 cheap: { label: "Cheap", variant: "success" },558 reasoning: { label: "Reasoning", variant: "info" },559 vision: { label: "Vision", variant: "default" },560 coding: { label: "Coding", variant: "default" },561 long: { label: "Long context", variant: "warning" },562};563564function ChipsMock({ active = "All", className }: { active?: (typeof MODEL_CHIPS)[number]; className?: string }) {565 return (566 <div className={cn("flex gap-1.5 overflow-x-auto scrollbar-none", className)} aria-hidden>567 {MODEL_CHIPS.map((c) => (568 <span key={c} className={cn("inline-flex h-6.5 shrink-0 items-center rounded-full border px-2.5 text-[11px] font-medium", c === active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted")}>569 {c}570 </span>571 ))}572 </div>573 );574}575576export function DesktopModelsMock({ className }: { className?: string }) {577 return (578 <div className={cn("flex min-h-[26rem] bg-bg", className)}>579 <SidebarMock active="Models" activeConversation={null} className="hidden lg:flex" />580 <div className="flex min-w-0 flex-1 flex-col">581 <div className="flex h-11 items-center gap-3 px-3 hairline-b">582 <span className="text-[12.5px] font-semibold">Models</span>583 <span className="font-mono text-[10.5px] text-fg-subtle">{MOCK_MODELS.length * 6 - 2} models · 9 providers</span>584 <span className="ml-auto inline-flex h-7 w-52 items-center gap-2 rounded-md border border-border bg-bg-elevated px-2 text-[12px] text-fg-subtle">585 <Search className="size-3.5" /> cheap vision model586 <kbd className="kbd ml-auto">⌘/</kbd>587 </span>588 </div>589 <div className="px-3 py-2 hairline-b">590 <ChipsMock />591 </div>592 <table className="w-full text-[12.5px]">593 <thead>594 <tr className="text-left font-mono text-[10.5px] font-normal uppercase tracking-[0.08em] text-fg-subtle">595 <th className="px-3 py-2 font-normal">Model</th>596 <th className="hidden px-3 py-2 font-normal sm:table-cell">Context</th>597 <th className="px-3 py-2 text-right font-normal">$ / M in · out</th>598 <th className="hidden px-3 py-2 font-normal md:table-cell">Badges</th>599 <th className="w-10 px-3 py-2" />600 </tr>601 </thead>602 <tbody>603 {MOCK_MODELS.slice(0, 7).map((m, i) => (604 <tr key={m.key} className={cn("hairline-t", i === 2 && "bg-accent-soft/40")}>605 <td className="px-3 py-2">606 <div className="flex items-center gap-2">607 <ProviderIcon provider={m.provider} size={14} />608 <div className="min-w-0">609 <p className="truncate font-medium">{m.name}</p>610 <p className="truncate font-mono text-[10.5px] text-fg-subtle">{m.key}</p>611 </div>612 </div>613 </td>614 <td className="hidden px-3 py-2 font-mono text-[11.5px] tabular-nums text-fg-muted sm:table-cell">{m.context}</td>615 <td className="px-3 py-2 text-right font-mono text-[11.5px] tabular-nums text-fg-muted">616 {m.input} · {m.output}617 </td>618 <td className="hidden px-3 py-2 md:table-cell">619 <div className="flex gap-1">620 {(m.tags ?? []).slice(0, 2).map((t) => (621 <Badge key={t} variant={TAG_LABEL[t].variant}>622 {TAG_LABEL[t].label}623 </Badge>624 ))}625 </div>626 </td>627 <td className="px-3 py-2 text-right">628 <Star className={cn("inline size-3.5", FAVORITES.has(m.key) ? "fill-warning text-warning" : "text-border-strong")} aria-hidden />629 </td>630 </tr>631 ))}632 </tbody>633 </table>634 <div className="mt-auto flex items-center justify-between px-3 py-2 font-mono text-[10.5px] text-fg-subtle hairline-t">635 <span>Synced 4 min ago</span>636 <span>2 selected → Compare</span>637 </div>638 </div>639 </div>640 );641}642643export function MobileModelsMock() {644 return (645 <>646 <MobileHeaderMock title="Models" right={<span className="flex size-8 items-center justify-center rounded-md text-fg-muted"><Search className="size-[17px]" /></span>} />647 <div className="px-2.5 pt-2">648 <ChipsMock active="Favorites" />649 </div>650 <ul className="min-h-0 flex-1 overflow-hidden px-2.5 pt-1 mask-b">651 {MOCK_MODELS.slice(0, 7).map((m) => (652 <li key={m.key} className="flex items-center gap-2.5 py-2.5 hairline-b">653 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-subtle">654 <ProviderIcon provider={m.provider} size={15} />655 </span>656 <div className="min-w-0 flex-1">657 <p className="truncate text-[13px] font-medium">{m.name}</p>658 <p className="truncate font-mono text-[10.5px] text-fg-subtle">659 {m.context} ctx · {m.input} / {m.output}660 </p>661 </div>662 <Star className={cn("size-4 shrink-0", FAVORITES.has(m.key) ? "fill-warning text-warning" : "text-border-strong")} aria-hidden />663 <ChevronRight className="size-4 shrink-0 text-fg-subtle" aria-hidden />664 </li>665 ))}666 </ul>667 <BottomNavMock active="Models" />668 </>669 );670}671672/* ------------------------------------------------------------------------------------------------673 * Usage analytics — desktop dashboard, phone stacked tiles674 * ---------------------------------------------------------------------------------------------- */675function Bars({ values, className, label }: { values: number[]; className?: string; label: string }) {676 const max = Math.max(...values);677 return (678 <div className={cn("flex items-end gap-[3px] border-b border-hairline", className)} role="img" aria-label={label}>679 {values.map((v, i) => (680 <div key={i} title={`${USAGE_DAY_LABELS[i]} · ${v}`} className="flex-1 rounded-t-[3px] bg-accent/80" style={{ height: `${Math.max(6, (v / max) * 100)}%` }} />681 ))}682 </div>683 );684}685686function ProviderCostRows({ dense }: { dense?: boolean }) {687 const total = USAGE_BY_PROVIDER.reduce((a, b) => a + b.cost, 0);688 return (689 <ul className={cn("space-y-2", dense && "space-y-1.5")}>690 {USAGE_BY_PROVIDER.map((row) => (691 <li key={row.provider} className={cn("grid items-center gap-2 text-[12px]", dense ? "grid-cols-[5.5rem_1fr_3rem]" : "grid-cols-[7rem_1fr_3.5rem]")}>692 <span className="inline-flex items-center gap-1.5 truncate">693 <ProviderIcon provider={row.provider} size={12} />694 {PROVIDER_LABEL[row.provider]}695 </span>696 <span className="h-1.5 overflow-hidden rounded-full bg-bg-muted">697 <span className="block h-full rounded-full" style={{ width: `${(row.cost / total) * 100}%`, background: `var(--p-${row.provider})` }} />698 </span>699 <span className="text-right font-mono tabular-nums text-fg-muted">${row.cost.toFixed(2)}</span>700 </li>701 ))}702 </ul>703 );704}705706function SavingsCard({ dense }: { dense?: boolean }) {707 return (708 <div className={cn("rounded-xl bg-accent-soft/60 p-3", dense && "p-2.5")}>709 <p className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-accent">710 <Sparkles className="size-3.5" /> Savings opportunity711 </p>712 <p className="mt-1 text-[12px] font-medium">{USAGE_SAVINGS.title}</p>713 <p className="mt-0.5 text-[11.5px] leading-5 text-fg-muted">{USAGE_SAVINGS.body}</p>714 </div>715 );716}717718export function DesktopUsageMock({ className }: { className?: string }) {719 return (720 <div className={cn("flex min-h-[26rem] bg-bg", className)}>721 <SidebarMock active="Usage" activeConversation={null} className="hidden lg:flex" />722 <div className="flex min-w-0 flex-1 flex-col">723 <div className="flex h-11 items-center gap-2 px-3 hairline-b">724 <span className="text-[12.5px] font-semibold">Usage</span>725 <div className="ml-auto inline-flex h-7 items-center gap-0.5 rounded-lg bg-bg-muted p-0.5 text-[11.5px]" aria-hidden>726 {["Today", "7d", "30d", "90d"].map((r) => (727 <span key={r} className={cn("rounded-md px-2 py-0.5", r === "30d" ? "bg-bg-elevated font-medium shadow-xs" : "text-fg-muted")}>728 {r}729 </span>730 ))}731 </div>732 </div>733 <div className="grid grid-cols-2 gap-px bg-hairline sm:grid-cols-4">734 {USAGE_KPIS.map((k) => (735 <div key={k.label} className="bg-bg px-3.5 py-3">736 <p className="text-[11px] font-medium text-fg-muted">{k.label}</p>737 <p className="mt-0.5 text-lg font-semibold tabular-nums tracking-tight">{k.value}</p>738 <p className="text-[10.5px] text-fg-subtle">{k.hint}</p>739 </div>740 ))}741 </div>742 <div className="grid gap-4 p-3.5 hairline-t md:grid-cols-[1.4fr_1fr]">743 <div>744 <div className="flex items-baseline justify-between">745 <p className="text-[12px] font-medium">Cost per day</p>746 <p className="font-mono text-[10.5px] text-fg-subtle">peak ${Math.max(...USAGE_COST_SERIES).toFixed(2)}</p>747 </div>748 <Bars values={USAGE_COST_SERIES} className="mt-3 h-24" label="Cost per day over the last 14 days" />749 <div className="mt-1.5 flex justify-between font-mono text-[10px] text-fg-subtle">750 <span>{USAGE_DAY_LABELS[0]}</span>751 <span>{USAGE_DAY_LABELS[USAGE_DAY_LABELS.length - 1]}</span>752 </div>753 </div>754 <div className="space-y-3">755 <p className="text-[12px] font-medium">Cost by provider</p>756 <ProviderCostRows />757 <SavingsCard dense />758 </div>759 </div>760 </div>761 </div>762 );763}764765export function MobileUsageMock() {766 return (767 <>768 <MobileHeaderMock title="Usage" right={<span className="px-2 font-mono text-[11px] text-fg-muted">30d</span>} />769 <div className="min-h-0 flex-1 space-y-3 overflow-hidden px-3 pt-3 mask-b">770 <div className="grid grid-cols-2 gap-2">771 {USAGE_KPIS.map((k) => (772 <div key={k.label} className="panel px-3 py-2.5">773 <p className="text-[10.5px] font-medium text-fg-muted">{k.label}</p>774 <p className="mt-0.5 text-[17px] font-semibold tabular-nums tracking-tight">{k.value}</p>775 </div>776 ))}777 </div>778 <div className="panel p-3">779 <div className="flex items-baseline justify-between">780 <p className="text-[12px] font-medium">Requests per day</p>781 <p className="font-mono text-[10px] text-fg-subtle">peak {Math.max(...USAGE_SERIES)}</p>782 </div>783 <Bars values={USAGE_SERIES} className="mt-2.5 h-16" label="Requests per day over the last 14 days" />784 </div>785 <div className="panel p-3">786 <p className="mb-2 text-[12px] font-medium">Cost by provider</p>787 <ProviderCostRows dense />788 </div>789 <SavingsCard dense />790 </div>791 <BottomNavMock active="Usage" />792 </>793 );794}795796/* ------------------------------------------------------------------------------------------------797 * Scoreboard (feature visual)798 * ---------------------------------------------------------------------------------------------- */799export function ScoreboardMock({ className }: { className?: string }) {800 return (801 <div className={cn("panel p-4", className)}>802 <div className="flex items-center gap-2 text-[12.5px] font-semibold">803 <Trophy className="size-4 text-warning" /> Your scoreboard804 <span className="ml-auto font-mono text-[10.5px] font-normal text-fg-subtle">Coding · last 30 d</span>805 </div>806 <ul className="mt-3 space-y-2.5">807 {SCOREBOARD.map((r, i) => {808 const pct = Math.round((r.wins / r.total) * 100);809 return (810 <li key={r.model.key} className="grid grid-cols-[1.25rem_1fr_3rem] items-center gap-2 text-[12.5px]">811 <span className="font-mono text-[11px] text-fg-subtle">{i + 1}</span>812 <div className="min-w-0">813 <div className="flex items-center gap-1.5">814 <ProviderIcon provider={r.model.provider} size={12} />815 <span className="truncate font-medium">{r.model.name}</span>816 <span className="ml-auto font-mono text-[10.5px] text-fg-subtle">817 {r.wins}/{r.total}818 </span>819 </div>820 <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-bg-muted">821 <div className="h-full rounded-full" style={{ width: `${pct}%`, background: `var(--p-${r.model.provider})` }} />822 </div>823 </div>824 <span className="text-right font-mono text-[11.5px] tabular-nums">{pct}%</span>825 </li>826 );827 })}828 </ul>829 </div>830 );831}832