Public catalog: compact client rows with per-provider expander (5 MB → <1 MB)
4 changed files +187 −98
added
qa/prod-scroll.mjs
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +import { chromium } from "@playwright/test"; | |
| 2 | +const b = await chromium.launch(); | |
| 3 | +const p = await b.newPage({ viewport: { width: 1440, height: 900 } }); | |
| 4 | +await p.goto("https://www.polyllm.io/", { waitUntil: "networkidle" }); | |
| 5 | +const h = await p.evaluate(() => document.documentElement.scrollHeight); | |
| 6 | +console.log("page height", h); | |
| 7 | +for (const y of [1400, 3200, 5200, 7200, 9200]) { | |
| 8 | + await p.evaluate((yy) => window.scrollTo(0, yy), y); | |
| 9 | + await p.waitForTimeout(900); | |
| 10 | + await p.screenshot({ path: `qa/out/prod-desktop-home-y${y}.png` }); | |
| 11 | +} | |
| 12 | +await b.close(); | |
added
qa/prod-shots.mjs
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +import { chromium } from "@playwright/test"; | |
| 2 | +const b = await chromium.launch(); | |
| 3 | +for (const [name, vp, mobile] of [["m390", { width: 390, height: 844 }, true], ["desktop", { width: 1440, height: 900 }, false]]) { | |
| 4 | + const p = await b.newPage({ viewport: vp, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: mobile ? 2 : 1 }); | |
| 5 | + for (const path of ["/", "/models", "/security", "/login"]) { | |
| 6 | + await p.goto("https://www.polyllm.io" + path, { waitUntil: "networkidle" }); | |
| 7 | + await p.waitForTimeout(800); | |
| 8 | + await p.screenshot({ path: `qa/out/prod-${name}-${path.replace(/\//g, "") || "home"}.png`, fullPage: path === "/" && !mobile }); | |
| 9 | + } | |
| 10 | + await p.close(); | |
| 11 | +} | |
| 12 | +await b.close(); | |
| 13 | +console.log("shots ok"); | |
modified
src/app/(marketing)/models/page.tsx
+20 −98
@@ -1,10 +1,10 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | −import { ArrowRight, Check, Minus } from "lucide-react"; | |
| 3 | +import { ArrowRight } from "lucide-react"; | |
| 4 | 4 | import { Container, Eyebrow } from "@/components/marketing/section"; |
| 5 | 5 | import { Button } from "@/components/ui/button"; |
| 6 | 6 | import { ProviderIcon } from "@/components/brand/provider-icon"; |
| 7 | −import { BadgeChips, LifecycleChip } from "@/components/models/badge-chips"; | |
| 7 | +import { PublicCatalogGroup, type CatalogRow } from "@/components/models/public-catalog"; | |
| 8 | 8 | import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers"; |
| 9 | 9 | import { getPublicModels, popularComparisons } from "@/lib/models/public-data"; |
| 10 | 10 | import { deriveBadges, lifecycleStatus, sortWeightOf } from "@/lib/models/badges"; |
@@ -21,8 +21,23 @@ export const metadata: Metadata = { | ||
| 21 | 21 | openGraph: { title: "AI model catalog — PolyLLM", description: "Prices, context windows and capabilities for every supported model.", url: "/models" }, |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | −function Mark({ on }: { on: boolean }) { | |
| 25 | − return on ? <Check className="mx-auto size-4 text-success" aria-label="yes" /> : <Minus className="mx-auto size-4 text-border-strong" aria-label="no" />; | |
| 24 | +function toRow(m: PolyModel, ctx: BadgeContext): CatalogRow { | |
| 25 | + return { | |
| 26 | + key: m.key, | |
| 27 | + provider: m.provider, | |
| 28 | + name: m.displayName, | |
| 29 | + id: m.id, | |
| 30 | + inPrice: formatPrice(m.pricing?.inputPerMillion), | |
| 31 | + outPrice: formatPrice(m.pricing?.outputPerMillion), | |
| 32 | + context: formatContext(m.limits?.contextTokens), | |
| 33 | + maxOut: formatContext(m.limits?.maxOutputTokens), | |
| 34 | + reasoning: m.capabilities.reasoning, | |
| 35 | + vision: m.capabilities.vision, | |
| 36 | + tools: m.capabilities.tools, | |
| 37 | + json: m.capabilities.structuredOutput, | |
| 38 | + lifecycle: lifecycleStatus(m, { ctx }), | |
| 39 | + badges: deriveBadges(m, ctx), | |
| 40 | + }; | |
| 26 | 41 | } |
| 27 | 42 | |
| 28 | 43 | export default async function PublicModelsPage() { |
@@ -81,60 +96,7 @@ export default async function PublicModelsPage() { | ||
| 81 | 96 | </div> |
| 82 | 97 | </div> |
| 83 | 98 | |
| 84 | − {/* Desktop table */} | |
| 85 | − <div className="mt-4 hidden overflow-hidden rounded-xl border border-border md:block"> | |
| 86 | − <table className="w-full text-[13px]"> | |
| 87 | − <thead> | |
| 88 | − <tr className="border-b border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle"> | |
| 89 | − <th className="px-3 py-2">Model</th> | |
| 90 | − <th className="px-3 py-2 text-right">Input $/1M</th> | |
| 91 | − <th className="px-3 py-2 text-right">Output $/1M</th> | |
| 92 | − <th className="px-3 py-2 text-right">Context</th> | |
| 93 | − <th className="px-3 py-2 text-right">Max out</th> | |
| 94 | − <th className="px-3 py-2 text-center">Reasoning</th> | |
| 95 | − <th className="px-3 py-2 text-center">Vision</th> | |
| 96 | − <th className="px-3 py-2 text-center">Tools</th> | |
| 97 | − <th className="px-3 py-2">Status</th> | |
| 98 | − </tr> | |
| 99 | − </thead> | |
| 100 | − <tbody> | |
| 101 | − {g.list.map((m) => ( | |
| 102 | − <tr key={m.key} className="border-b border-hairline last:border-b-0"> | |
| 103 | − <td className="px-3 py-2"> | |
| 104 | − <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 105 | − <span className="font-medium">{m.displayName}</span> | |
| 106 | − <BadgeChips badges={deriveBadges(m, ctx)} max={3} size="xs" /> | |
| 107 | − </div> | |
| 108 | − <div className="font-mono text-[11px] text-fg-subtle">{m.id}</div> | |
| 109 | − </td> | |
| 110 | − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatPrice(m.pricing?.inputPerMillion)}</td> | |
| 111 | − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatPrice(m.pricing?.outputPerMillion)}</td> | |
| 112 | − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatContext(m.limits?.contextTokens)}</td> | |
| 113 | − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatContext(m.limits?.maxOutputTokens)}</td> | |
| 114 | − <td className="px-3 py-2"> | |
| 115 | − <Mark on={m.capabilities.reasoning} /> | |
| 116 | − </td> | |
| 117 | − <td className="px-3 py-2"> | |
| 118 | − <Mark on={m.capabilities.vision} /> | |
| 119 | − </td> | |
| 120 | − <td className="px-3 py-2"> | |
| 121 | − <Mark on={m.capabilities.tools} /> | |
| 122 | − </td> | |
| 123 | − <td className="px-3 py-2"> | |
| 124 | − <LifecycleChip lifecycle={lifecycleStatus(m, { ctx })} /> | |
| 125 | − </td> | |
| 126 | − </tr> | |
| 127 | − ))} | |
| 128 | − </tbody> | |
| 129 | − </table> | |
| 130 | − </div> | |
| 131 | − | |
| 132 | − {/* Mobile rows */} | |
| 133 | − <ul className="mt-4 space-y-2 md:hidden"> | |
| 134 | − {g.list.map((m) => ( | |
| 135 | − <MobileRow key={m.key} m={m} ctx={ctx} /> | |
| 136 | − ))} | |
| 137 | − </ul> | |
| 99 | + <PublicCatalogGroup provider={g.provider} rows={g.list.map((m) => toRow(m, ctx))} /> | |
| 138 | 100 | </section> |
| 139 | 101 | ))} |
| 140 | 102 | </div> |
@@ -186,43 +148,3 @@ export default async function PublicModelsPage() { | ||
| 186 | 148 | </> |
| 187 | 149 | ); |
| 188 | 150 | } |
| 189 | − | |
| 190 | −function MobileRow({ m, ctx: c }: { m: PolyModel; ctx: BadgeContext }) { | |
| 191 | − return ( | |
| 192 | − <li className="panel p-3"> | |
| 193 | − <div className="flex items-start gap-2.5"> | |
| 194 | − <ProviderIcon provider={m.provider} size={18} className="mt-0.5 shrink-0" /> | |
| 195 | − <div className="min-w-0 flex-1"> | |
| 196 | − <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"> | |
| 197 | − <span className="text-[14px] font-medium">{m.displayName}</span> | |
| 198 | − <LifecycleChip lifecycle={lifecycleStatus(m, { ctx: c })} /> | |
| 199 | − </div> | |
| 200 | − <div className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</div> | |
| 201 | − <dl className="mt-2 grid grid-cols-3 gap-2 text-[12px]"> | |
| 202 | − <div> | |
| 203 | − <dt className="text-[10.5px] text-fg-subtle">In / Out $/1M</dt> | |
| 204 | − <dd className="font-mono tabular-nums"> | |
| 205 | − {formatPrice(m.pricing?.inputPerMillion)} / {formatPrice(m.pricing?.outputPerMillion)} | |
| 206 | − </dd> | |
| 207 | − </div> | |
| 208 | − <div> | |
| 209 | − <dt className="text-[10.5px] text-fg-subtle">Context</dt> | |
| 210 | − <dd className="font-mono tabular-nums">{formatContext(m.limits?.contextTokens)}</dd> | |
| 211 | − </div> | |
| 212 | − <div> | |
| 213 | − <dt className="text-[10.5px] text-fg-subtle">Max out</dt> | |
| 214 | − <dd className="font-mono tabular-nums">{formatContext(m.limits?.maxOutputTokens)}</dd> | |
| 215 | − </div> | |
| 216 | − </dl> | |
| 217 | − <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted"> | |
| 218 | − <span className={m.capabilities.reasoning ? "" : "line-through text-fg-subtle"}>Reasoning</span> | |
| 219 | − <span className={m.capabilities.vision ? "" : "line-through text-fg-subtle"}>Vision</span> | |
| 220 | − <span className={m.capabilities.tools ? "" : "line-through text-fg-subtle"}>Tools</span> | |
| 221 | − <span className={m.capabilities.structuredOutput ? "" : "line-through text-fg-subtle"}>JSON</span> | |
| 222 | − </div> | |
| 223 | − <BadgeChips badges={deriveBadges(m, c)} max={4} size="xs" className="mt-2" /> | |
| 224 | − </div> | |
| 225 | − </div> | |
| 226 | − </li> | |
| 227 | − ); | |
| 228 | −} | |
added
src/components/models/public-catalog.tsx
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +"use client"; | |
| 2 | +import * as React from "react"; | |
| 3 | +import { Check, ChevronDown, Minus } from "lucide-react"; | |
| 4 | +import { ProviderIcon } from "@/components/brand/provider-icon"; | |
| 5 | +import { Badge } from "@/components/ui/badge"; | |
| 6 | +import { BadgeChips, LifecycleChip } from "@/components/models/badge-chips"; | |
| 7 | +import type { Lifecycle, ModelBadge } from "@/lib/models/badges"; | |
| 8 | +import type { ProviderId } from "@/lib/ai/core/types"; | |
| 9 | +import { cn } from "@/lib/utils"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Compact, pre-formatted row for the public catalog. The server sends only these strings/booleans | |
| 13 | + * (≈ 200 B per model instead of the full PolyModel) and renders the first rows; the rest expands client-side. | |
| 14 | + */ | |
| 15 | +export interface CatalogRow { | |
| 16 | + key: string; | |
| 17 | + provider: ProviderId; | |
| 18 | + name: string; | |
| 19 | + id: string; | |
| 20 | + inPrice: string; | |
| 21 | + outPrice: string; | |
| 22 | + context: string; | |
| 23 | + maxOut: string; | |
| 24 | + reasoning: boolean; | |
| 25 | + vision: boolean; | |
| 26 | + tools: boolean; | |
| 27 | + json: boolean; | |
| 28 | + lifecycle: Lifecycle; | |
| 29 | + badges: ModelBadge[]; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const INITIAL = 8; | |
| 33 | + | |
| 34 | +function Mark({ on }: { on: boolean }) { | |
| 35 | + return on ? <Check className="mx-auto size-4 text-success" aria-label="yes" /> : <Minus className="mx-auto size-4 text-border-strong" aria-label="no" />; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function PublicCatalogGroup({ rows, provider }: { rows: CatalogRow[]; provider: ProviderId }) { | |
| 39 | + const [expanded, setExpanded] = React.useState(false); | |
| 40 | + const shown = expanded ? rows : rows.slice(0, INITIAL); | |
| 41 | + const hidden = rows.length - shown.length; | |
| 42 | + return ( | |
| 43 | + <> | |
| 44 | + {/* Desktop table */} | |
| 45 | + <div className="mt-4 hidden overflow-hidden rounded-xl border border-border md:block"> | |
| 46 | + <table className="w-full text-[13px]"> | |
| 47 | + <thead> | |
| 48 | + <tr className="border-b border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle"> | |
| 49 | + <th className="px-3 py-2">Model</th> | |
| 50 | + <th className="px-3 py-2 text-right">Input $/1M</th> | |
| 51 | + <th className="px-3 py-2 text-right">Output $/1M</th> | |
| 52 | + <th className="px-3 py-2 text-right">Context</th> | |
| 53 | + <th className="px-3 py-2 text-right">Max out</th> | |
| 54 | + <th className="px-3 py-2 text-center">Reasoning</th> | |
| 55 | + <th className="px-3 py-2 text-center">Vision</th> | |
| 56 | + <th className="px-3 py-2 text-center">Tools</th> | |
| 57 | + <th className="px-3 py-2">Status</th> | |
| 58 | + </tr> | |
| 59 | + </thead> | |
| 60 | + <tbody> | |
| 61 | + {shown.map((m) => ( | |
| 62 | + <tr key={m.key} className="border-b border-hairline last:border-b-0"> | |
| 63 | + <td className="px-3 py-2"> | |
| 64 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 65 | + <span className="font-medium">{m.name}</span> | |
| 66 | + <BadgeChips badges={m.badges} max={3} size="xs" /> | |
| 67 | + </div> | |
| 68 | + <div className="font-mono text-[11px] text-fg-subtle">{m.id}</div> | |
| 69 | + </td> | |
| 70 | + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{m.inPrice}</td> | |
| 71 | + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{m.outPrice}</td> | |
| 72 | + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{m.context}</td> | |
| 73 | + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{m.maxOut}</td> | |
| 74 | + <td className="px-3 py-2"> | |
| 75 | + <Mark on={m.reasoning} /> | |
| 76 | + </td> | |
| 77 | + <td className="px-3 py-2"> | |
| 78 | + <Mark on={m.vision} /> | |
| 79 | + </td> | |
| 80 | + <td className="px-3 py-2"> | |
| 81 | + <Mark on={m.tools} /> | |
| 82 | + </td> | |
| 83 | + <td className="px-3 py-2"> | |
| 84 | + <LifecycleChip lifecycle={m.lifecycle} /> | |
| 85 | + </td> | |
| 86 | + </tr> | |
| 87 | + ))} | |
| 88 | + </tbody> | |
| 89 | + </table> | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + {/* Mobile rows */} | |
| 93 | + <ul className="mt-4 space-y-2 md:hidden"> | |
| 94 | + {shown.map((m) => ( | |
| 95 | + <li key={m.key} className="panel p-3"> | |
| 96 | + <div className="flex items-start gap-2.5"> | |
| 97 | + <ProviderIcon provider={provider} size={18} className="mt-0.5 shrink-0" /> | |
| 98 | + <div className="min-w-0 flex-1"> | |
| 99 | + <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"> | |
| 100 | + <span className="text-[14px] font-medium">{m.name}</span> | |
| 101 | + <LifecycleChip lifecycle={m.lifecycle} /> | |
| 102 | + </div> | |
| 103 | + <div className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</div> | |
| 104 | + <dl className="mt-2 grid grid-cols-3 gap-2 text-[12px]"> | |
| 105 | + <div> | |
| 106 | + <dt className="text-[10.5px] text-fg-subtle">In / Out $/1M</dt> | |
| 107 | + <dd className="font-mono tabular-nums"> | |
| 108 | + {m.inPrice} / {m.outPrice} | |
| 109 | + </dd> | |
| 110 | + </div> | |
| 111 | + <div> | |
| 112 | + <dt className="text-[10.5px] text-fg-subtle">Context</dt> | |
| 113 | + <dd className="font-mono tabular-nums">{m.context}</dd> | |
| 114 | + </div> | |
| 115 | + <div> | |
| 116 | + <dt className="text-[10.5px] text-fg-subtle">Max out</dt> | |
| 117 | + <dd className="font-mono tabular-nums">{m.maxOut}</dd> | |
| 118 | + </div> | |
| 119 | + </dl> | |
| 120 | + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted"> | |
| 121 | + <span className={m.reasoning ? "" : "line-through text-fg-subtle"}>Reasoning</span> | |
| 122 | + <span className={m.vision ? "" : "line-through text-fg-subtle"}>Vision</span> | |
| 123 | + <span className={m.tools ? "" : "line-through text-fg-subtle"}>Tools</span> | |
| 124 | + <span className={m.json ? "" : "line-through text-fg-subtle"}>JSON</span> | |
| 125 | + </div> | |
| 126 | + <BadgeChips badges={m.badges} max={4} size="xs" className="mt-2" /> | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + </li> | |
| 130 | + ))} | |
| 131 | + </ul> | |
| 132 | + | |
| 133 | + {hidden > 0 || expanded ? ( | |
| 134 | + <button type="button" onClick={() => setExpanded((v) => !v)} className={cn("tap mt-3 inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated text-[13px] font-medium text-fg-muted transition-colors hover:border-border-strong hover:text-fg sm:w-auto sm:px-4")} aria-expanded={expanded}> | |
| 135 | + {expanded ? "Show fewer" : `Show all ${rows.length} models`} | |
| 136 | + <ChevronDown className={cn("size-4 transition-transform", expanded && "rotate-180")} /> | |
| 137 | + {!expanded ? <Badge variant="default" className="ml-1">+{hidden}</Badge> : null} | |
| 138 | + </button> | |
| 139 | + ) : null} | |
| 140 | + </> | |
| 141 | + ); | |
| 142 | +} | |
| 143 | ||