SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%

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

Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 695baa8

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