SPB Git

spb/polyllm

Public

Public catalog: compact client rows with per-provider expander (5 MB → <1 MB)

Simon-Pierre Boucher committed (Sep 11, 2026) parent 695baa8 Browse files

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 import type { Metadata } from "next"; 1 import type { Metadata } from "next";
2 import Link from "next/link"; 2 import Link from "next/link";
3−import { ArrowRight, Check, Minus } from "lucide-react"; 3+import { ArrowRight } from "lucide-react";
4 import { Container, Eyebrow } from "@/components/marketing/section"; 4 import { Container, Eyebrow } from "@/components/marketing/section";
5 import { Button } from "@/components/ui/button"; 5 import { Button } from "@/components/ui/button";
6 import { ProviderIcon } from "@/components/brand/provider-icon"; 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 import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers"; 8 import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
9 import { getPublicModels, popularComparisons } from "@/lib/models/public-data"; 9 import { getPublicModels, popularComparisons } from "@/lib/models/public-data";
10 import { deriveBadges, lifecycleStatus, sortWeightOf } from "@/lib/models/badges"; 10 import { deriveBadges, lifecycleStatus, sortWeightOf } from "@/lib/models/badges";
@@ -21,8 +21,23 @@ export const metadata: Metadata = {
21 openGraph: { title: "AI model catalog — PolyLLM", description: "Prices, context windows and capabilities for every supported model.", url: "/models" }, 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 }) { 24+function toRow(m: PolyModel, ctx: BadgeContext): CatalogRow {
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" />; 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 export default async function PublicModelsPage() { 43 export default async function PublicModelsPage() {
@@ -81,60 +96,7 @@ export default async function PublicModelsPage() {
81 </div> 96 </div>
82 </div> 97 </div>
83 98
84− {/* Desktop table */} 99+ <PublicCatalogGroup provider={g.provider} rows={g.list.map((m) => toRow(m, ctx))} />
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>
138 </section> 100 </section>
139 ))} 101 ))}
140 </div> 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
Menu

Theme