From cdf041362b418a4047b995b971dc3cd2ca6bc05c Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:05:45 +0100 Subject: [PATCH 01/18] feat(web): add @tanstack/table-core and @tanstack/virtual-core --- bun.lock | 18 ++++++++---------- packages/web/package.json | 2 ++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 57d0877f3..0eb3e8e28 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,8 @@ "packages/web": { "name": "@models.dev/web", "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*", }, @@ -56,9 +58,13 @@ "@models.dev/web": ["@models.dev/web@workspace:packages/web"], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="], + "@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="], - "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], @@ -78,7 +84,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -322,8 +328,6 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "models.dev/@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], - "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], @@ -331,11 +335,5 @@ "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - - "models.dev/@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], - - "models.dev/@types/bun/bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], - - "models.dev/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], } } diff --git a/packages/web/package.json b/packages/web/package.json index 852f51abe..691046d69 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,6 +6,8 @@ "build": "./script/build.ts" }, "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*" }, From aa07bd8e61b4abbb6285d2affce0e5c0e22ab307 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:06:14 +0100 Subject: [PATCH 02/18] feat(web): add Row type and flattenProviders utility --- packages/web/src/data.test.ts | 67 +++++++++++++++++++++++++++++++++++ packages/web/src/data.ts | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 packages/web/src/data.test.ts create mode 100644 packages/web/src/data.ts diff --git a/packages/web/src/data.test.ts b/packages/web/src/data.test.ts new file mode 100644 index 000000000..730a52ebd --- /dev/null +++ b/packages/web/src/data.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "bun:test"; +import { flattenProviders } from "./data"; + +const mockApi = { + anthropic: { + name: "Anthropic", + models: { + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + family: "claude-haiku", + reasoning: false, + tool_call: true, + attachment: true, + temperature: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.8, output: 4.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-11-04", + last_updated: "2024-11-04", + }, + }, + }, +}; + +describe("flattenProviders", () => { + it("creates one row per model", () => { + const rows = flattenProviders(mockApi as any); + expect(rows).toHaveLength(1); + }); + + it("adds providerId and providerName to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.providerId).toBe("anthropic"); + expect(row.providerName).toBe("Anthropic"); + }); + + it("adds modelId to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.modelId).toBe("claude-3-5-haiku-20241022"); + }); + + it("preserves model fields", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.name).toBe("Claude 3.5 Haiku"); + expect(row.cost?.input).toBe(0.8); + expect(row.limit.context).toBe(200_000); + }); + + it("filters out alpha models", () => { + const apiWithAlpha = { + ...mockApi, + test: { + name: "Test", + models: { + "alpha-model": { + ...mockApi.anthropic.models["claude-3-5-haiku-20241022"], + status: "alpha", + }, + }, + }, + }; + const rows = flattenProviders(apiWithAlpha as any); + expect(rows).toHaveLength(1); // alpha filtered out + }); +}); diff --git a/packages/web/src/data.ts b/packages/web/src/data.ts new file mode 100644 index 000000000..f21822aa0 --- /dev/null +++ b/packages/web/src/data.ts @@ -0,0 +1,64 @@ +export type Row = { + providerId: string; + providerName: string; + modelId: string; + name: string; + family?: string; + reasoning: boolean; + tool_call: boolean; + attachment: boolean; + temperature?: boolean; + structured_output?: boolean; + open_weights: boolean; + modalities: { input: string[]; output: string[] }; + cost?: { + input?: number; + output?: number; + reasoning?: number; + cache_read?: number; + cache_write?: number; + input_audio?: number; + output_audio?: number; + }; + limit: { context: number; input?: number; output: number }; + knowledge?: string; + release_date?: string; + last_updated?: string; +}; + +type ApiJson = Record< + string, + { + name: string; + models: Record; + } +>; + +export function flattenProviders(api: ApiJson): Row[] { + const rows: Row[] = []; + for (const [providerId, provider] of Object.entries(api)) { + for (const [modelId, model] of Object.entries(provider.models)) { + if (model.status === "alpha") continue; + rows.push({ + providerId, + providerName: provider.name, + modelId, + name: model.name, + family: model.family, + reasoning: model.reasoning, + tool_call: model.tool_call, + attachment: model.attachment, + temperature: model.temperature, + structured_output: model.structured_output, + open_weights: model.open_weights, + modalities: model.modalities, + cost: model.cost, + limit: model.limit, + knowledge: model.knowledge, + release_date: model.release_date, + last_updated: model.last_updated, + }); + } + } + return rows; +} From 229bdbfc7bff53e3092d3c0ea3b44a3706a3c84c Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:06:44 +0100 Subject: [PATCH 03/18] feat(web): add URL state serialization utilities --- packages/web/src/url-state.test.ts | 60 ++++++++++++++++++++++++++++++ packages/web/src/url-state.ts | 48 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/web/src/url-state.test.ts create mode 100644 packages/web/src/url-state.ts diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts new file mode 100644 index 000000000..a6ac2e3e8 --- /dev/null +++ b/packages/web/src/url-state.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "bun:test"; +import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; + +describe("parseUrlState", () => { + it("returns empty defaults when no params", () => { + const state = parseUrlState(new URLSearchParams("")); + expect(state.search).toBe(""); + expect(state.sort).toBeNull(); + expect(state.order).toBe("asc"); + expect(state.cols).toEqual([...ALL_COLUMN_IDS]); + }); + + it("parses search param", () => { + const state = parseUrlState(new URLSearchParams("search=gpt")); + expect(state.search).toBe("gpt"); + }); + + it("parses sort and order", () => { + const state = parseUrlState(new URLSearchParams("sort=input-cost&order=desc")); + expect(state.sort).toBe("input-cost"); + expect(state.order).toBe("desc"); + }); + + it("parses cols param as array", () => { + const state = parseUrlState(new URLSearchParams("cols=provider,model,input-cost")); + expect(state.cols).toEqual(["provider", "model", "input-cost"]); + }); +}); + +describe("serializeUrlState", () => { + it("omits cols when all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("cols")).toBeNull(); + }); + + it("includes cols when not all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: ["provider", "model"], + }); + expect(params.get("cols")).toBe("provider,model"); + }); + + it("omits search when empty", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("search")).toBeNull(); + }); +}); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts new file mode 100644 index 000000000..93cff089a --- /dev/null +++ b/packages/web/src/url-state.ts @@ -0,0 +1,48 @@ +export const ALL_COLUMN_IDS = [ + "provider", "model", "family", "provider-id", "model-id", + "tool-call", "reasoning", "input-modalities", "output-modalities", + "input-cost", "output-cost", "reasoning-cost", "cache-read-cost", + "cache-write-cost", "audio-input-cost", "audio-output-cost", + "context-limit", "input-limit", "output-limit", + "structured-output", "temperature", "weights", + "knowledge", "release-date", "last-updated", +] as const; + +export type ColumnId = (typeof ALL_COLUMN_IDS)[number]; + +export type UrlState = { + search: string; + sort: string | null; + order: "asc" | "desc"; + cols: string[]; +}; + +export function parseUrlState(params: URLSearchParams): UrlState { + const colsParam = params.get("cols"); + const cols = colsParam + ? colsParam + .split(",") + .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)) + : [...ALL_COLUMN_IDS]; + + return { + search: params.get("search") ?? "", + sort: params.get("sort"), + order: params.get("order") === "desc" ? "desc" : "asc", + cols: cols.length > 0 ? cols : [...ALL_COLUMN_IDS], + }; +} + +export function serializeUrlState(state: UrlState): URLSearchParams { + const params = new URLSearchParams(); + if (state.search) params.set("search", state.search); + if (state.sort) { + params.set("sort", state.sort); + if (state.order !== "asc") params.set("order", state.order); + } + const allVisible = + state.cols.length === ALL_COLUMN_IDS.length && + ALL_COLUMN_IDS.every((id) => state.cols.includes(id)); + if (!allVisible) params.set("cols", state.cols.join(",")); + return params; +} From 1918e49f38606460bffd8b1353dad32107d0f3e4 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:10:09 +0100 Subject: [PATCH 04/18] =?UTF-8?q?feat(web):=20strip=20render.tsx=20to=20sh?= =?UTF-8?q?ell=20=E2=80=94=20table=20rows=20moved=20to=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/src/render.tsx | 257 ++---------------------------------- 1 file changed, 13 insertions(+), 244 deletions(-) diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a1bdfcc2f..a5b60b064 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -210,252 +210,20 @@ export const Rendered = renderToString( ⌘K +
+ + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {Object.entries(Providers) - .sort(([, providerA], [, providerB]) => - providerA.name.localeCompare(providerB.name) - ) - .flatMap(([providerId, provider]) => - Object.entries(provider.models) - .filter(([, model]) => model.status !== "alpha") - .sort(([, modelA], [, modelB]) => - modelA.name.localeCompare(modelB.name) - ) - .map(([modelId, model]) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - )) - )} - -
- Provider - - Model - - Family - - Provider ID - - Model ID - - Tool Call - - Reasoning - - Input - - Output - -
- - Input Cost -
- per 1M tokens -
- -
-
-
- - Output Cost -
- per 1M tokens -
- -
-
-
- - Reasoning Cost -
- per 1M tokens -
- -
-
-
- - Cache Read Cost -
- per 1M tokens -
- -
-
-
- - Cache Write Cost -
- per 1M tokens -
- -
-
-
- - Audio Input Cost -
- per 1M tokens -
- -
-
-
- - Audio Output Cost -
- per 1M tokens -
- -
-
- Context Limit - - Input Limit - - Output Limit - - Structured Output - - Temperature - - Weights - - Knowledge - - Release Date - - Last Updated -
-
- {renderProviderLogo(providerId)} - {provider.name} -
-
{model.name}{model.family ?? "-"}{providerId} -
- {modelId} - -
-
{model.tool_call ? "Yes" : "No"}{model.reasoning ? "Yes" : "No"} -
- {model.modalities.input.map((modality) => - getModalityIcon(modality) - )} -
-
-
- {model.modalities.output.map((modality) => - getModalityIcon(modality) - )} -
-
{renderCost(model.cost?.input)}{renderCost(model.cost?.output)}{renderCost(model.cost?.reasoning)}{renderCost(model.cost?.cache_read)}{renderCost(model.cost?.cache_write)}{renderCost(model.cost?.input_audio)}{renderCost(model.cost?.output_audio)}{model.limit.context.toLocaleString()}{model.limit.input?.toLocaleString() ?? "-"}{model.limit.output.toLocaleString()} - {model.structured_output === undefined - ? "-" - : model.structured_output - ? "Yes" - : "No"} - {model.temperature ? "Yes" : "No"}{model.open_weights ? "Open" : "Closed"} - {model.knowledge ? model.knowledge.substring(0, 7) : "-"} - {model.release_date}{model.last_updated}
+
+ + + +
+
Loading models…
+

How to use

@@ -533,7 +301,8 @@ export const Rendered = renderToString(

- If we don't have a provider's logo, a default logo is served instead. + If we don't have a provider's logo, a default logo is served + instead.

Contribute

From 21a7b50846c82e5ef88330b25b6a4dd0f9fa7b04 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:11:16 +0100 Subject: [PATCH 05/18] feat(web): add Tanstack Table column definitions (safe DOM construction) --- packages/web/src/columns.ts | 496 ++++++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 packages/web/src/columns.ts diff --git a/packages/web/src/columns.ts b/packages/web/src/columns.ts new file mode 100644 index 000000000..a0d41f950 --- /dev/null +++ b/packages/web/src/columns.ts @@ -0,0 +1,496 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import type { Row } from "./data"; + +export type ColumnMeta = { + dataType: "text" | "number" | "boolean" | "modalities" | "cost"; + headerLabel: string; + headerSubLabel?: string; +}; + +// ── Cell helpers (safe DOM construction, no innerHTML) ──────────────────────── + +function makeBooleanCell(value: boolean | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : value ? "Yes" : "No"; + return span; +} + +function makeCostCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : `$${value.toFixed(2)}`; + return span; +} + +// SVG paths for each modality — built via createElementNS so no innerHTML needed +type SvgPathSpec = { + tag: "polyline" | "line" | "rect" | "circle" | "path" | "polygon"; + attrs: Record; +}; + +const MODALITY_PATHS: Record = { + text: [ + { tag: "polyline", attrs: { points: "4,7 4,4 20,4 20,7" } }, + { tag: "line", attrs: { x1: "9", y1: "20", x2: "15", y2: "20" } }, + { tag: "line", attrs: { x1: "12", y1: "4", x2: "12", y2: "20" } }, + ], + image: [ + { + tag: "rect", + attrs: { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }, + }, + { tag: "circle", attrs: { cx: "9", cy: "9", r: "2" } }, + { + tag: "path", + attrs: { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }, + }, + ], + audio: [ + { + tag: "polygon", + attrs: { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }, + }, + { + tag: "path", + attrs: { + d: "m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07", + }, + }, + ], + video: [ + { tag: "path", attrs: { d: "m22 8-6 4 6 4V8Z" } }, + { + tag: "rect", + attrs: { width: "14", height: "12", x: "2", y: "6", rx: "2", ry: "2" }, + }, + ], + pdf: [ + { + tag: "path", + attrs: { + d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + }, + { tag: "polyline", attrs: { points: "14,2 14,8 20,8" } }, + { tag: "line", attrs: { x1: "16", y1: "13", x2: "8", y2: "13" } }, + { tag: "line", attrs: { x1: "16", y1: "17", x2: "8", y2: "17" } }, + ], +}; + +function buildModalityIcon(modality: string): SVGSVGElement | null { + const paths = MODALITY_PATHS[modality]; + if (!paths) return null; + + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + + for (const { tag, attrs } of paths) { + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + svg.append(el); + } + return svg; +} + +function makeModalitiesCell(modalities: string[]): HTMLElement { + const div = document.createElement("div"); + div.className = "modalities"; + for (const m of modalities) { + const svg = buildModalityIcon(m); + if (!svg) continue; + const span = document.createElement("span"); + span.className = "modality-icon"; + span.setAttribute("data-tooltip", m.charAt(0).toUpperCase() + m.slice(1)); + span.append(svg); + div.append(span); + } + return div; +} + +function makeCopyIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("copy-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const rect = document.createElementNS(NS, "rect"); + rect.setAttribute("width", "14"); + rect.setAttribute("height", "14"); + rect.setAttribute("x", "8"); + rect.setAttribute("y", "8"); + rect.setAttribute("rx", "2"); + rect.setAttribute("ry", "2"); + const path = document.createElementNS(NS, "path"); + path.setAttribute( + "d", + "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" + ); + svg.append(rect, path); + return svg; +} + +function makeCheckIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("check-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.style.display = "none"; + const polyline = document.createElementNS(NS, "polyline"); + polyline.setAttribute("points", "20,6 9,17 4,12"); + svg.append(polyline); + return svg; +} + +function makeModelIdCell(modelId: string): HTMLElement { + const div = document.createElement("div"); + div.className = "model-id-cell"; + + const span = document.createElement("span"); + span.className = "model-id-text"; + span.textContent = modelId; + + const button = document.createElement("button"); + button.className = "copy-button"; + const copyIcon = makeCopyIcon(); + const checkIcon = makeCheckIcon(); + button.append(copyIcon, checkIcon); + + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(modelId); + copyIcon.style.display = "none"; + checkIcon.style.display = "block"; + setTimeout(() => { + copyIcon.style.display = "block"; + checkIcon.style.display = "none"; + }, 1000); + } catch { + // clipboard unavailable + } + }); + + div.append(span, button); + return div; +} + +function makeProviderCell( + providerId: string, + providerName: string +): HTMLElement { + const div = document.createElement("div"); + div.className = "provider-cell"; + + const img = document.createElement("img"); + img.src = `/logos/${providerId}.svg`; + img.alt = providerName; + img.width = 16; + img.height = 16; + img.className = "provider-logo"; + + const span = document.createElement("span"); + span.textContent = providerName; + + div.append(img, span); + return div; +} + +function makeNumberCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value == null ? "-" : value.toLocaleString(); + return span; +} + +// ── Column definitions ──────────────────────────────────────────────────────── + +export const columnDefs: ColumnDef[] = [ + { + id: "provider", + header: "Provider", + accessorFn: (row) => row.providerName, + size: 150, + meta: { dataType: "text", headerLabel: "Provider" } satisfies ColumnMeta, + cell: (info) => + makeProviderCell(info.row.original.providerId, info.getValue()), + }, + { + id: "model", + header: "Model", + accessorFn: (row) => row.name, + size: 200, + meta: { dataType: "text", headerLabel: "Model" } satisfies ColumnMeta, + }, + { + id: "family", + header: "Family", + accessorFn: (row) => row.family ?? "-", + size: 120, + meta: { dataType: "text", headerLabel: "Family" } satisfies ColumnMeta, + }, + { + id: "provider-id", + header: "Provider ID", + accessorFn: (row) => row.providerId, + size: 120, + meta: { dataType: "text", headerLabel: "Provider ID" } satisfies ColumnMeta, + }, + { + id: "model-id", + header: "Model ID", + accessorFn: (row) => row.modelId, + size: 220, + meta: { dataType: "text", headerLabel: "Model ID" } satisfies ColumnMeta, + cell: (info) => makeModelIdCell(info.getValue()), + }, + { + id: "tool-call", + header: "Tool Call", + accessorFn: (row) => row.tool_call, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Tool Call", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "reasoning", + header: "Reasoning", + accessorFn: (row) => row.reasoning, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Reasoning", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "input-modalities", + header: "Input", + accessorFn: (row) => row.modalities.input.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Input", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.input), + }, + { + id: "output-modalities", + header: "Output", + accessorFn: (row) => row.modalities.output.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Output", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.output), + }, + { + id: "input-cost", + header: "Input Cost", + accessorFn: (row) => row.cost?.input, + size: 110, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "output-cost", + header: "Output Cost", + accessorFn: (row) => row.cost?.output, + size: 115, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "reasoning-cost", + header: "Reasoning Cost", + accessorFn: (row) => row.cost?.reasoning, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Reasoning Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-read-cost", + header: "Cache Read Cost", + accessorFn: (row) => row.cost?.cache_read, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Read Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-write-cost", + header: "Cache Write Cost", + accessorFn: (row) => row.cost?.cache_write, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Write Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-input-cost", + header: "Audio Input Cost", + accessorFn: (row) => row.cost?.input_audio, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-output-cost", + header: "Audio Output Cost", + accessorFn: (row) => row.cost?.output_audio, + size: 140, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "context-limit", + header: "Context Limit", + accessorFn: (row) => row.limit.context, + size: 115, + meta: { + dataType: "number", + headerLabel: "Context Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "input-limit", + header: "Input Limit", + accessorFn: (row) => row.limit.input, + size: 100, + sortUndefined: "last", + meta: { + dataType: "number", + headerLabel: "Input Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "output-limit", + header: "Output Limit", + accessorFn: (row) => row.limit.output, + size: 110, + meta: { + dataType: "number", + headerLabel: "Output Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "structured-output", + header: "Structured Output", + accessorFn: (row) => row.structured_output, + size: 145, + meta: { + dataType: "boolean", + headerLabel: "Structured Output", + } satisfies ColumnMeta, + cell: (info) => { + const v = info.getValue(); + const span = document.createElement("span"); + span.textContent = v === undefined ? "-" : v ? "Yes" : "No"; + return span; + }, + }, + { + id: "temperature", + header: "Temperature", + accessorFn: (row) => row.temperature, + size: 110, + meta: { + dataType: "boolean", + headerLabel: "Temperature", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "weights", + header: "Weights", + accessorFn: (row) => (row.open_weights ? "Open" : "Closed"), + size: 90, + meta: { dataType: "text", headerLabel: "Weights" } satisfies ColumnMeta, + }, + { + id: "knowledge", + header: "Knowledge", + accessorFn: (row) => (row.knowledge ? row.knowledge.substring(0, 7) : "-"), + size: 105, + meta: { dataType: "text", headerLabel: "Knowledge" } satisfies ColumnMeta, + }, + { + id: "release-date", + header: "Release Date", + accessorFn: (row) => row.release_date ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Release Date", + } satisfies ColumnMeta, + }, + { + id: "last-updated", + header: "Last Updated", + accessorFn: (row) => row.last_updated ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Last Updated", + } satisfies ColumnMeta, + }, +]; From fdfe83c06e4a8781ade4c55081804e51d31fa58d Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:14:31 +0100 Subject: [PATCH 06/18] feat: rewrite index.ts with Tanstack Table + Virtual Replaces imperative sort/filter/render with declarative Tanstack Table (createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel) and virtual row rendering via Virtualizer class. Adds column picker dropdown, URL state sync (search/sort/order/cols), and keyboard shortcut Cmd+K to focus search. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.ts | 612 +++++++++++++++++++++++++------------- 1 file changed, 411 insertions(+), 201 deletions(-) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb4342..caf818010 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,240 +1,450 @@ +import { + createTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + type SortingState, + type VisibilityState, +} from "@tanstack/table-core"; +import { + Virtualizer, + observeElementRect, + observeElementOffset, + elementScroll, + measureElement, +} from "@tanstack/virtual-core"; +import { flattenProviders, type Row } from "./data"; +import { columnDefs, type ColumnMeta } from "./columns"; +import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; + +// ─── DOM refs ────────────────────────────────────────────────────────────────── +const scrollContainer = document.getElementById( + "table-scroll-container" +) as HTMLDivElement; +const tableHead = document.getElementById( + "table-head" +) as HTMLTableSectionElement; +const tableBody = document.getElementById( + "table-body" +) as HTMLTableSectionElement; +const tableLoading = document.getElementById("table-loading") as HTMLDivElement; +const searchInput = document.getElementById("search") as HTMLInputElement; +const columnsToggle = document.getElementById( + "columns-toggle" +) as HTMLButtonElement; +const columnsPicker = document.getElementById( + "columns-picker" +) as HTMLDivElement; const modal = document.getElementById("modal") as HTMLDialogElement; -const modalClose = document.getElementById("close")!; -const help = document.getElementById("help")!; -const search = document.getElementById("search")! as HTMLInputElement; - -///////////////////////// -// URL State Management -///////////////////////// -function getQueryParams() { - return new URLSearchParams(window.location.search); +const modalClose = document.getElementById("close") as HTMLButtonElement; +const helpButton = document.getElementById("help") as HTMLButtonElement; + +// ─── State ──────────────────────────────────────────────────────────────────── +let rows: Row[] = []; +let sorting: SortingState = []; +let globalFilter = ""; +let columnVisibility: VisibilityState = {}; + +// ─── Tanstack Table ─────────────────────────────────────────────────────────── +const table = createTable({ + data: rows, + columns: columnDefs, + state: { sorting, globalFilter, columnVisibility }, + onStateChange: () => {}, + renderFallbackValue: null, + onSortingChange: (updater) => { + sorting = typeof updater === "function" ? updater(sorting) : updater; + afterStateChange(); + }, + onGlobalFilterChange: (updater) => { + globalFilter = + typeof updater === "function" ? updater(globalFilter) : updater; + afterStateChange(); + }, + onColumnVisibilityChange: (updater) => { + columnVisibility = + typeof updater === "function" ? updater(columnVisibility) : updater; + afterStateChange(); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: (row, _columnId, filterValue: string) => { + const terms = filterValue + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + if (terms.length === 0) return true; + const orig = row.original as Record; + const text = Object.values(orig) + .map((v) => + typeof v === "string" || typeof v === "number" ? String(v) : "" + ) + .join(" ") + .toLowerCase(); + return terms.some((term) => text.includes(term)); + }, +}); + +function afterStateChange() { + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { sorting, globalFilter, columnVisibility }, + })); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ + ...virtualizer.options, + count: rowCount, + }); + virtualizer.scrollToOffset(0); + renderHead(); + renderRows(); + updateUrl(); + updateColumnPickerCheckboxes(); } -function updateQueryParams(updates: Record) { - const params = getQueryParams(); - for (const [key, value] of Object.entries(updates)) { - if (value) { - params.set(key, value); - } else { - params.delete(key); +// ─── Tanstack Virtual ───────────────────────────────────────────────────────── +const virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => scrollContainer, + estimateSize: () => 45, + overscan: 5, + scrollToFn: elementScroll, + observeElementRect, + observeElementOffset, + measureElement, + onChange: () => renderRows(), +}); + +// ─── Render: thead ──────────────────────────────────────────────────────────── +function renderHead() { + tableHead.textContent = ""; + const tr = document.createElement("tr"); + tr.style.cssText = "display: flex; width: 100%;"; + + for (const headerGroup of table.getHeaderGroups()) { + for (const header of headerGroup.headers) { + if (!header.column.getIsVisible()) continue; + + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + const colSize = header.column.getSize(); + const isSorted = header.column.getIsSorted(); + + const th = document.createElement("th"); + th.className = "sortable"; + th.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + th.setAttribute("data-column-id", header.column.id); + + if (meta?.headerSubLabel) { + const container = document.createElement("div"); + container.className = "header-container"; + + const textSpan = document.createElement("span"); + textSpan.className = "header-text"; + textSpan.textContent = meta.headerLabel; + + const descSpan = document.createElement("span"); + descSpan.className = "desc"; + descSpan.textContent = meta.headerSubLabel; + textSpan.append(document.createElement("br"), descSpan); + + const sortSpan = document.createElement("span"); + sortSpan.className = "sort-indicator"; + sortSpan.textContent = + isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : ""; + + container.append(textSpan, sortSpan); + th.append(container); + } else { + const label = meta?.headerLabel ?? header.column.id; + const sortIndicator = + isSorted === "asc" ? " ↑" : isSorted === "desc" ? " ↓" : ""; + th.textContent = label + sortIndicator; + } + + th.addEventListener("click", () => { + header.column.toggleSorting(header.column.getIsSorted() === "asc"); + }); + + tr.append(th); } } - const newPath = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname; - window.history.pushState({}, "", newPath); -} -function getColumnNameForURL(headerEl: Element): string { - const text = headerEl.textContent?.trim().toLowerCase() || ""; - return text.replace(/↑|↓/g, "").trim().split(/\s+/).slice(0, 2).join("-"); + tableHead.append(tr); } -function getColumnIndexByUrlName(name: string): number { - const headers = document.querySelectorAll("th.sortable"); - return Array.from(headers).findIndex( - (header) => getColumnNameForURL(header) === name - ); +// ─── Render: tbody (virtual) ────────────────────────────────────────────────── +function renderRows() { + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const tableRows = table.getRowModel().rows; + + tableBody.style.cssText = `height: ${totalSize}px; position: relative;`; + tableBody.textContent = ""; + + for (const virtualRow of virtualItems) { + const row = tableRows[virtualRow.index]; + if (!row) continue; + + const tr = document.createElement("tr"); + tr.style.cssText = [ + "position: absolute", + "top: 0", + `transform: translateY(${virtualRow.start}px)`, + "display: flex", + "width: 100%", + ].join("; "); + tr.dataset.index = String(virtualRow.index); + + for (const cell of row.getVisibleCells()) { + const td = document.createElement("td"); + const colSize = cell.column.getSize(); + td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + + const colDef = cell.column.columnDef; + if (typeof colDef.cell === "function") { + const rendered = colDef.cell(cell.getContext()); + if (rendered instanceof HTMLElement) { + td.append(rendered); + } else if (rendered != null) { + td.textContent = String(rendered); + } + } else { + const v = cell.getValue(); + td.textContent = v != null ? String(v) : "-"; + } + + tr.append(td); + } + + tableBody.append(tr); + } } -///////////////////////// -// Handle "How to use" -///////////////////////// -let y = 0; +// ─── Column picker ──────────────────────────────────────────────────────────── +const COLUMN_GROUPS: { label: string; ids: string[] }[] = [ + { + label: "Identity", + ids: ["provider", "model", "family", "provider-id", "model-id"], + }, + { + label: "Capabilities", + ids: [ + "tool-call", + "reasoning", + "structured-output", + "temperature", + "weights", + ], + }, + { label: "Modalities", ids: ["input-modalities", "output-modalities"] }, + { + label: "Cost", + ids: [ + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + ], + }, + { label: "Limits", ids: ["context-limit", "input-limit", "output-limit"] }, + { label: "Metadata", ids: ["knowledge", "release-date", "last-updated"] }, +]; + +function buildColumnPicker() { + columnsPicker.textContent = ""; + + const actions = document.createElement("div"); + actions.className = "picker-actions"; + const showAll = document.createElement("button"); + showAll.textContent = "Show all"; + showAll.addEventListener("click", () => { + table.toggleAllColumnsVisible(true); + }); + actions.append(showAll); + columnsPicker.append(actions); + + for (const group of COLUMN_GROUPS) { + const groupEl = document.createElement("div"); + groupEl.className = "picker-group"; + + const groupLabel = document.createElement("div"); + groupLabel.className = "picker-group-label"; + groupLabel.textContent = group.label; + groupEl.append(groupLabel); + + for (const colId of group.ids) { + const col = table.getColumn(colId); + if (!col) continue; + const meta = col.columnDef.meta as ColumnMeta | undefined; + + const label = document.createElement("label"); + label.className = "picker-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = col.getIsVisible(); + checkbox.dataset.colId = colId; + checkbox.addEventListener("change", () => { + col.toggleVisibility(checkbox.checked); + }); + + const labelText = document.createTextNode(meta?.headerLabel ?? colId); + label.append(checkbox, labelText); + groupEl.append(label); + } -help.addEventListener("click", () => { - y = window.scrollY; - document.body.style.position = "fixed"; - document.body.style.top = `-${y}px`; - modal.showModal(); -}); + columnsPicker.append(groupEl); + } +} -function closeDialog() { - modal.close(); - document.body.style.position = ""; - document.body.style.top = ""; - window.scrollTo(0, y); +function updateColumnPickerCheckboxes() { + columnsPicker + .querySelectorAll("[data-col-id]") + .forEach((cb) => { + const col = table.getColumn(cb.dataset.colId ?? ""); + if (col) cb.checked = col.getIsVisible(); + }); } -modalClose.addEventListener("click", closeDialog); -modal.addEventListener("cancel", closeDialog); -modal.addEventListener("click", (e) => { - if (e.target === modal) closeDialog(); +columnsToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !columnsPicker.hidden; + columnsPicker.hidden = isOpen; + if (!isOpen) buildColumnPicker(); }); -//////////////////// -// Handle Sorting -//////////////////// -let currentSort = { column: -1, direction: "asc" }; - -function sortTable(column: number, direction: "asc" | "desc") { - const header = document.querySelectorAll("th.sortable")[column]; - const columnType = header.getAttribute("data-type"); - if (!columnType) return; - - // update state - currentSort = { column, direction }; - updateQueryParams({ - sort: getColumnNameForURL(header), - order: direction, - }); +document.addEventListener("click", (e) => { + if ( + !columnsPicker.hidden && + !columnsPicker.contains(e.target as Node) && + e.target !== columnsToggle + ) { + columnsPicker.hidden = true; + } +}); - // sort rows - const tbody = document.querySelector("table tbody")!; - const rows = Array.from( - tbody.querySelectorAll("tr") - ) as HTMLTableRowElement[]; - rows.sort((a, b) => { - const aValue = getCellValue(a.cells[column], columnType); - const bValue = getCellValue(b.cells[column], columnType); - - // Handle undefined values - always sort to bottom - if (aValue === undefined && bValue === undefined) return 0; - if (aValue === undefined) return 1; - if (bValue === undefined) return -1; - - let comparison = 0; - if (columnType === "number" || columnType === "modalities") { - comparison = (aValue as number) - (bValue as number); - } else if (columnType === "boolean") { - comparison = (aValue as string).localeCompare(bValue as string); - } else { - comparison = (aValue as string).localeCompare(bValue as string); - } +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !columnsPicker.hidden) { + columnsPicker.hidden = true; + } +}); - return direction === "asc" ? comparison : -comparison; +// ─── URL state ──────────────────────────────────────────────────────────────── +function updateUrl() { + const visibleCols = table + .getAllColumns() + .filter((c) => c.getIsVisible()) + .map((c) => c.id); + + const params = serializeUrlState({ + search: globalFilter, + sort: sorting[0]?.id ?? null, + order: sorting[0]?.desc ? "desc" : "asc", + cols: visibleCols, }); - rows.forEach((row) => tbody.appendChild(row)); - - // update sort indicators - const headers = document.querySelectorAll("th.sortable"); - headers.forEach((header, i) => { - const indicator = header.querySelector(".sort-indicator")!; - if (i === column) { - indicator.textContent = direction === "asc" ? "↑" : "↓"; - } else { - indicator.textContent = ""; - } - }); + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({}, "", newPath); } -function getCellValue( - cell: HTMLTableCellElement, - type: string -): string | number | undefined { - if (type === "modalities") - return cell.querySelectorAll(".modality-icon").length; - - const text = cell.textContent?.trim() || ""; - if (text === "-") return; - if (type === "number") return parseFloat(text.replace(/[$,]/g, "")) || 0; - return text; -} +function applyUrlState() { + const state = parseUrlState(new URLSearchParams(window.location.search)); -document.querySelectorAll("th.sortable").forEach((header) => { - header.addEventListener("click", () => { - const column = Array.from(header.parentElement!.children).indexOf(header); - const direction = - currentSort.column === column && currentSort.direction === "asc" - ? "desc" - : "asc"; - sortTable(column, direction); - }); -}); + globalFilter = state.search; + searchInput.value = state.search; + sorting = state.sort + ? [{ id: state.sort, desc: state.order === "desc" }] + : []; -/////////////////// -// Handle Search -/////////////////// -function filterTable(value: string) { - const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); - const rows = document.querySelectorAll( - "table tbody tr" - ) as NodeListOf; - - rows.forEach((row) => { - const cellTexts = Array.from(row.cells).map((cell) => - cell.textContent!.toLowerCase() - ); - const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); - row.style.display = isVisible ? "" : "none"; - }); + const newVisibility: VisibilityState = {}; + for (const id of ALL_COLUMN_IDS) { + newVisibility[id] = state.cols.includes(id); + } + columnVisibility = newVisibility; + + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { sorting, globalFilter, columnVisibility }, + })); - updateQueryParams({ search: value || null }); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer.scrollToOffset(0); + + renderHead(); + renderRows(); + buildColumnPicker(); } -search.addEventListener("input", () => { - filterTable(search.value); +// ─── Search ─────────────────────────────────────────────────────────────────── +searchInput.addEventListener("input", () => { + table.setGlobalFilter(searchInput.value); +}); + +searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + searchInput.value = ""; + table.setGlobalFilter(""); + } }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - search.focus(); + searchInput.focus(); } }); -search.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - search.value = ""; - search.dispatchEvent(new Event("input")); - } +// ─── Help modal ─────────────────────────────────────────────────────────────── +let savedScrollY = 0; + +helpButton.addEventListener("click", () => { + savedScrollY = window.scrollY; + document.body.style.position = "fixed"; + document.body.style.top = `-${savedScrollY}px`; + modal.showModal(); }); -/////////////////////////////////// -// Handle Copy model ID function -/////////////////////////////////// -(window as any).copyModelId = async ( - button: HTMLButtonElement, - modelId: string -) => { - try { - if (navigator.clipboard) { - await navigator.clipboard.writeText(modelId); - - // Switch to check icon - const copyIcon = button.querySelector(".copy-icon") as HTMLElement; - const checkIcon = button.querySelector(".check-icon") as HTMLElement; - - copyIcon.style.display = "none"; - checkIcon.style.display = "block"; - - // Switch back after 1 second - setTimeout(() => { - copyIcon.style.display = "block"; - checkIcon.style.display = "none"; - }, 1000); - } - } catch (err) { - console.error("Failed to copy text: ", err); - } -}; - -/////////////////////////////////// -// Initialize State from URL -/////////////////////////////////// -function initializeFromURL() { - const params = getQueryParams(); - - (() => { - const searchQuery = params.get("search"); - if (!searchQuery) return; - search.value = searchQuery; - filterTable(searchQuery); - })(); - - (() => { - const columnName = params.get("sort"); - if (!columnName) return; - - const columnIndex = getColumnIndexByUrlName(columnName); - if (columnIndex === -1) return; - - const direction = (params.get("order") as "asc" | "desc") || "asc"; - sortTable(columnIndex, direction); - })(); +function closeDialog() { + modal.close(); + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, savedScrollY); } -document.addEventListener("DOMContentLoaded", initializeFromURL); -window.addEventListener("popstate", initializeFromURL); +modalClose.addEventListener("click", closeDialog); +modal.addEventListener("cancel", closeDialog); +modal.addEventListener("click", (e) => { + if (e.target === modal) closeDialog(); +}); + +// ─── Init ───────────────────────────────────────────────────────────────────── +async function init() { + const res = await fetch("/api.json"); + const api: Record = await res.json(); + rows = flattenProviders(api as any); + + // Default sort: provider name, then model name + rows.sort((a, b) => { + const p = a.providerName.localeCompare(b.providerName); + return p !== 0 ? p : a.name.localeCompare(b.name); + }); + + table.setOptions((prev) => ({ ...prev, data: rows })); + tableLoading.remove(); + applyUrlState(); +} + +window.addEventListener("popstate", applyUrlState); +document.addEventListener("DOMContentLoaded", () => { + init().catch(console.error); +}); From 51b481d42187a93d41cab883949ed181cdc7bdd3 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:17:05 +0100 Subject: [PATCH 07/18] feat: update CSS for virtual scroll and column picker; add data-column-id to tds - Add #table-scroll-container with full-viewport scroll height - Make thead th sticky at top:0 within scroll container - Replace nth-child column selectors with data-column-id attribute selectors - Add column picker dropdown styles (.picker-group, .picker-item, etc.) - Add .provider-logo img style; remove stale .provider-cell svg rule - Add #table-loading style - Add data-column-id to each td in renderRows() Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.css | 172 +++++++++++++++++++++++++++++-------- packages/web/src/index.ts | 1 + 2 files changed, 137 insertions(+), 36 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4e..58ec17a93 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -143,6 +143,11 @@ header { min-width: 12.5rem; } + .columns-container { + position: relative; + flex: 0 0 auto; + } + input { width: 100%; font-size: 0.8125rem; @@ -205,12 +210,101 @@ header { } } +/* ─── Column picker dropdown ────────────────────────────────────────────────── */ +#columns-picker { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.08), + 0 8px 24px rgba(0, 0, 0, 0.1); + padding: 0.375rem; + z-index: 100; + min-width: 11rem; + max-height: 32rem; + overflow-y: auto; +} + +.picker-actions { + padding: 0.25rem 0.5rem 0.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 0.375rem; + + button { + background: none; + color: var(--color-brand); + border: none; + font-size: 0.75rem; + cursor: pointer; + padding: 0; + height: auto; + line-height: 1; + + &:hover { + text-decoration: underline; + } + } +} + +.picker-group { + margin-bottom: 0.375rem; + + &:last-child { + margin-bottom: 0; + } +} + +.picker-group-label { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-tertiary); + padding: 0.25rem 0.5rem; + font-weight: 500; +} + +.picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.8125rem; + + &:hover { + background-color: var(--color-surface); + } + + input[type="checkbox"] { + cursor: pointer; + accent-color: var(--color-brand); + } +} + +/* ─── Virtual scroll container ──────────────────────────────────────────────── */ +#table-scroll-container { + height: calc(100svh - var(--header-height)); + overflow-y: auto; + overflow-x: auto; + margin-top: var(--header-height); +} + +#table-loading { + padding: 2rem; + text-align: center; + color: var(--color-text-tertiary); + font-size: 0.875rem; +} + table { border-collapse: separate; border-spacing: 0; font-size: 0.875rem; width: 100%; - margin-top: var(--header-height); } thead, @@ -218,7 +312,7 @@ tbody {} table thead th { position: sticky; - top: var(--header-height); + top: 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); font-size: 0.75rem; @@ -271,44 +365,53 @@ tbody { color: var(--color-text-tertiary); } - td:nth-child(1) { + /* Provider column — bold name */ + td[data-column-id="provider"] { font-weight: 500; + color: var(--color-text); } - td:nth-child(1), - td:nth-child(2), - td:nth-child(5), - td:nth-child(6), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16) { + /* Main identity columns — full text color */ + td[data-column-id="model"], + td[data-column-id="family"], + td[data-column-id="weights"] { color: var(--color-text); } - td:nth-child(5), - td:nth-child(6), - td:nth-child(18) { + /* ID columns — mono, uppercase */ + td[data-column-id="provider-id"], + td[data-column-id="model-id"] { font-size: 0.8125rem; font-family: var(--font-mono); - text-transform: uppercase; + color: var(--color-text); + } + + /* Cost columns — mono */ + td[data-column-id="input-cost"], + td[data-column-id="output-cost"], + td[data-column-id="reasoning-cost"], + td[data-column-id="cache-read-cost"], + td[data-column-id="cache-write-cost"], + td[data-column-id="audio-input-cost"], + td[data-column-id="audio-output-cost"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); + } + + /* Limit columns — mono */ + td[data-column-id="context-limit"], + td[data-column-id="input-limit"], + td[data-column-id="output-limit"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); } - td:nth-child(3), - td:nth-child(4), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16), - td:nth-child(17) { + /* Date/metadata columns — mono */ + td[data-column-id="knowledge"], + td[data-column-id="release-date"], + td[data-column-id="last-updated"] { font-size: 0.8125rem; font-family: var(--font-mono); } @@ -319,15 +422,12 @@ tbody { gap: 0.375rem; } - .provider-cell span:first-child { - flex: 0 0 auto; - } - - .provider-cell svg { + .provider-logo { display: block; + flex: 0 0 auto; width: 1rem; height: 1rem; - color: var(--color-text-secondary); + object-fit: contain; } .model-id-cell { diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index caf818010..134e4faed 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -202,6 +202,7 @@ function renderRows() { const td = document.createElement("td"); const colSize = cell.column.getSize(); td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + td.dataset.columnId = cell.column.id; const colDef = cell.column.columnDef; if (typeof colDef.cell === "function") { From 8dc67e40f2592bebf045a06ecb50185d010555c6 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 08:04:42 +0100 Subject: [PATCH 08/18] fix: call virtualizer._willUpdate() and add /api.json dev route In vanilla JS, Virtualizer._willUpdate() must be called manually to initialize scroll element observation (frameworks call it automatically via lifecycle hooks like useLayoutEffect). Without it, scrollElement stays null, observers are never set up, and getVirtualItems() always returns an empty array. Also added /api.json route to the dev server so the client-side fetch succeeds in development mode. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.ts | 16 ++++++++++++++-- packages/web/src/server.ts | 6 +++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 134e4faed..2c825e8e8 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -87,17 +87,25 @@ const table = createTable({ }, }); +// Prime state with all feature-provided defaults (e.g. columnPinning: {left:[],right:[]}) +// so that getHeaderGroups() never reads undefined.left +table.setOptions((prev) => ({ + ...prev, + state: { ...table.initialState, ...prev.state }, +})); + function afterStateChange() { table.setOptions((prev) => ({ ...prev, data: rows, - state: { sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; virtualizer.setOptions({ ...virtualizer.options, count: rowCount, }); + virtualizer._willUpdate(); virtualizer.scrollToOffset(0); renderHead(); renderRows(); @@ -117,6 +125,9 @@ const virtualizer = new Virtualizer({ measureElement, onChange: () => renderRows(), }); +// In vanilla JS, _willUpdate() must be called manually to start observing +// the scroll element (frameworks call it automatically via lifecycle hooks). +virtualizer._willUpdate(); // ─── Render: thead ──────────────────────────────────────────────────────────── function renderHead() { @@ -374,11 +385,12 @@ function applyUrlState() { table.setOptions((prev) => ({ ...prev, data: rows, - state: { sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer._willUpdate(); virtualizer.scrollToOffset(0); renderHead(); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 5302f92d5..c460353ee 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,11 +1,15 @@ import Index from "../index.html"; -import { Rendered } from "./render"; +import { Rendered, Providers } from "./render"; import path from "path"; Bun.serve({ port: 16_000, routes: { "/": Index, + "/api.json": () => + new Response(JSON.stringify(Providers), { + headers: { "Content-Type": "application/json" }, + }), "/assets/*": (req) => { const file = Bun.file( path.join(import.meta.dir, new URL(req.url).pathname) From c2c05609ae5fe080db17ae7c3de96d2b1d9af8d4 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 09:03:46 +0100 Subject: [PATCH 09/18] fix column container and auto formatting with biome --- packages/web/src/columns.ts | 4 +- packages/web/src/data.test.ts | 2 +- packages/web/src/index.css | 63 +++++++++++++++++------------- packages/web/src/index.ts | 24 ++++++------ packages/web/src/render.tsx | 22 ++++++----- packages/web/src/server.ts | 10 ++--- packages/web/src/url-state.test.ts | 12 ++++-- packages/web/src/url-state.ts | 32 +++++++++++---- 8 files changed, 102 insertions(+), 67 deletions(-) diff --git a/packages/web/src/columns.ts b/packages/web/src/columns.ts index a0d41f950..9b56a214b 100644 --- a/packages/web/src/columns.ts +++ b/packages/web/src/columns.ts @@ -136,7 +136,7 @@ function makeCopyIcon(): SVGSVGElement { const path = document.createElementNS(NS, "path"); path.setAttribute( "d", - "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" + "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", ); svg.append(rect, path); return svg; @@ -195,7 +195,7 @@ function makeModelIdCell(modelId: string): HTMLElement { function makeProviderCell( providerId: string, - providerName: string + providerName: string, ): HTMLElement { const div = document.createElement("div"); div.className = "provider-cell"; diff --git a/packages/web/src/data.test.ts b/packages/web/src/data.test.ts index 730a52ebd..dec870a75 100644 --- a/packages/web/src/data.test.ts +++ b/packages/web/src/data.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { flattenProviders } from "./data"; const mockApi = { diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58ec17a93..e87f6afaa 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -8,40 +8,40 @@ :root { --icon-opacity: 0.85; --header-height: 56px; - --font-mono: 'IBM Plex Mono', monospace; + --font-mono: "IBM Plex Mono", monospace; } :root { - --color-brand: #FD9527; - --color-background: #FFF; - --color-border: #DDD; - --color-surface: #EEE; + --color-brand: #fd9527; + --color-background: #fff; + --color-border: #ddd; + --color-surface: #eee; --color-alpha-background: rgba(255, 255, 255, 0.75); --color-text: #333; - --color-text-invert: #FFF; + --color-text-invert: #fff; --color-text-secondary: #666; --color-text-tertiary: #999; } @media (prefers-color-scheme: dark) { :root { - --color-brand: #FD9527; - --color-background: #1E1E1E; + --color-brand: #fd9527; + --color-background: #1e1e1e; --color-border: #333; --color-surface: #111; --color-alpha-background: rgba(30, 30, 30, 0.75); - --color-text: #FFF; + --color-text: #fff; --color-text-invert: #333; - --color-text-secondary: #AAA; + --color-text-secondary: #aaa; --color-text-tertiary: #666; } } html, body { - font-family: 'Rubik', sans-serif; + font-family: "Rubik", sans-serif; line-height: 1.6; color: var(--color-text); background-color: var(--color-background); @@ -81,7 +81,7 @@ header { width: 100%; z-index: 10; - &>div { + & > div { display: flex; align-items: center; @@ -173,7 +173,8 @@ header { font-size: 0.75rem; color: var(--color-text-tertiary); pointer-events: none; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } button { @@ -191,7 +192,6 @@ header { @media (max-width: 32rem) { div.left { - p, span.slash { display: none; @@ -201,7 +201,6 @@ header { @media (max-width: 45rem) { div.right { - .github, .search-container { display: none; @@ -222,12 +221,17 @@ header { 0 4px 8px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.1); padding: 0.375rem; - z-index: 100; + z-index: 10; min-width: 11rem; max-height: 32rem; overflow-y: auto; } +#table-head { + z-index: 1; + position: relative; +} + .picker-actions { padding: 0.25rem 0.5rem 0.5rem; border-bottom: 1px solid var(--color-border); @@ -282,6 +286,8 @@ header { input[type="checkbox"] { cursor: pointer; accent-color: var(--color-brand); + width: 1rem; + height: 1rem; } } @@ -308,7 +314,8 @@ table { } thead, -tbody {} +tbody { +} table thead th { position: sticky; @@ -437,7 +444,8 @@ tbody { gap: 0.375rem; } - .model-id-text {} + .model-id-text { + } .copy-button { flex: 0 0 auto; @@ -448,7 +456,9 @@ tbody { border-radius: 0.25rem; color: var(--color-text-tertiary); opacity: 0; - transition: opacity 0.2s ease, color 0.2s ease; + transition: + opacity 0.2s ease, + color 0.2s ease; } .model-id-cell:hover .copy-button { @@ -533,12 +543,12 @@ dialog { max-width: 40rem; max-height: calc(100svh - 2rem); box-shadow: - 0 2px 4px rgba(0, 0, 0, .05), - 0 4px 8px rgba(0, 0, 0, .05), - 0 8px 16px rgba(0, 0, 0, .07), - 0 16px 32px rgba(0, 0, 0, .07), - 0 32px 64px rgba(0, 0, 0, .07), - 0 48px 96px rgba(0, 0, 0, .07); + 0 2px 4px rgba(0, 0, 0, 0.05), + 0 4px 8px rgba(0, 0, 0, 0.05), + 0 8px 16px rgba(0, 0, 0, 0.07), + 0 16px 32px rgba(0, 0, 0, 0.07), + 0 32px 64px rgba(0, 0, 0, 0.07), + 0 48px 96px rgba(0, 0, 0, 0.07); flex-direction: column; overflow: hidden; @@ -645,5 +655,4 @@ dialog { } } } - -} \ No newline at end of file +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 2c825e8e8..9b2161dfb 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,39 +1,39 @@ import { createTable, getCoreRowModel, - getSortedRowModel, getFilteredRowModel, + getSortedRowModel, type SortingState, type VisibilityState, } from "@tanstack/table-core"; import { - Virtualizer, - observeElementRect, - observeElementOffset, elementScroll, measureElement, + observeElementOffset, + observeElementRect, + Virtualizer, } from "@tanstack/virtual-core"; +import { type ColumnMeta, columnDefs } from "./columns"; import { flattenProviders, type Row } from "./data"; -import { columnDefs, type ColumnMeta } from "./columns"; -import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; // ─── DOM refs ────────────────────────────────────────────────────────────────── const scrollContainer = document.getElementById( - "table-scroll-container" + "table-scroll-container", ) as HTMLDivElement; const tableHead = document.getElementById( - "table-head" + "table-head", ) as HTMLTableSectionElement; const tableBody = document.getElementById( - "table-body" + "table-body", ) as HTMLTableSectionElement; const tableLoading = document.getElementById("table-loading") as HTMLDivElement; const searchInput = document.getElementById("search") as HTMLInputElement; const columnsToggle = document.getElementById( - "columns-toggle" + "columns-toggle", ) as HTMLButtonElement; const columnsPicker = document.getElementById( - "columns-picker" + "columns-picker", ) as HTMLDivElement; const modal = document.getElementById("modal") as HTMLDialogElement; const modalClose = document.getElementById("close") as HTMLButtonElement; @@ -79,7 +79,7 @@ const table = createTable({ const orig = row.original as Record; const text = Object.values(orig) .map((v) => - typeof v === "string" || typeof v === "number" ? String(v) : "" + typeof v === "string" || typeof v === "number" ? String(v) : "", ) .join(" ") .toLowerCase(); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a5b60b064..3650f3307 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -1,14 +1,14 @@ /** @jsx jsx */ /** @jsxImportSource hono/jsx */ -import { generate } from "models.dev"; +import { existsSync } from "fs"; import { Fragment } from "hono/jsx"; import { renderToString } from "hono/jsx/dom/server"; -import { existsSync } from "fs"; +import { generate } from "models.dev"; import path from "path"; export const Providers = await generate( - path.join(import.meta.dir, "..", "..", "..", "providers") + path.join(import.meta.dir, "..", "..", "..", "providers"), ); // Function to load SVG content @@ -20,7 +20,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "providers", providerId, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( @@ -29,7 +29,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "..", "providers", - "logo.svg" + "logo.svg", ); try { @@ -211,10 +211,14 @@ export const Rendered = renderToString( ⌘K

- +
- +
@@ -227,7 +231,7 @@ export const Rendered = renderToString(

How to use

-
- + , ); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index c460353ee..5bb81787b 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,6 +1,6 @@ -import Index from "../index.html"; -import { Rendered, Providers } from "./render"; import path from "path"; +import Index from "../index.html"; +import { Providers, Rendered } from "./render"; Bun.serve({ port: 16_000, @@ -12,7 +12,7 @@ Bun.serve({ }), "/assets/*": (req) => { const file = Bun.file( - path.join(import.meta.dir, new URL(req.url).pathname) + path.join(import.meta.dir, new URL(req.url).pathname), ); return new Response(file); }, @@ -26,7 +26,7 @@ Bun.serve({ "..", "providers", provider, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( import.meta.dir, @@ -34,7 +34,7 @@ Bun.serve({ "..", "..", "providers", - "logo.svg" + "logo.svg", ); let file = Bun.file(logoPath); diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts index a6ac2e3e8..40d373038 100644 --- a/packages/web/src/url-state.test.ts +++ b/packages/web/src/url-state.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "bun:test"; -import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; +import { describe, expect, it } from "bun:test"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; describe("parseUrlState", () => { it("returns empty defaults when no params", () => { @@ -16,13 +16,17 @@ describe("parseUrlState", () => { }); it("parses sort and order", () => { - const state = parseUrlState(new URLSearchParams("sort=input-cost&order=desc")); + const state = parseUrlState( + new URLSearchParams("sort=input-cost&order=desc"), + ); expect(state.sort).toBe("input-cost"); expect(state.order).toBe("desc"); }); it("parses cols param as array", () => { - const state = parseUrlState(new URLSearchParams("cols=provider,model,input-cost")); + const state = parseUrlState( + new URLSearchParams("cols=provider,model,input-cost"), + ); expect(state.cols).toEqual(["provider", "model", "input-cost"]); }); }); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts index 93cff089a..ba0de1e22 100644 --- a/packages/web/src/url-state.ts +++ b/packages/web/src/url-state.ts @@ -1,11 +1,29 @@ export const ALL_COLUMN_IDS = [ - "provider", "model", "family", "provider-id", "model-id", - "tool-call", "reasoning", "input-modalities", "output-modalities", - "input-cost", "output-cost", "reasoning-cost", "cache-read-cost", - "cache-write-cost", "audio-input-cost", "audio-output-cost", - "context-limit", "input-limit", "output-limit", - "structured-output", "temperature", "weights", - "knowledge", "release-date", "last-updated", + "provider", + "model", + "family", + "provider-id", + "model-id", + "tool-call", + "reasoning", + "input-modalities", + "output-modalities", + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + "context-limit", + "input-limit", + "output-limit", + "structured-output", + "temperature", + "weights", + "knowledge", + "release-date", + "last-updated", ] as const; export type ColumnId = (typeof ALL_COLUMN_IDS)[number]; From 27da1d74a3f4cddbcef79ea9cdcd491b35228876 Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:00:26 -0800 Subject: [PATCH 10/18] feat: inline JSON data at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed api.json directly into HTML as diff --git a/packages/web/script/build.ts b/packages/web/script/build.ts index daad95cc7..4f0140d4f 100755 --- a/packages/web/script/build.ts +++ b/packages/web/script/build.ts @@ -43,6 +43,10 @@ for (const entry of entries) { let html = await Bun.file("./dist/index.html").text(); html = html.replace("", Rendered); +html = html.replace( + '', + '' +); await Bun.write("./dist/index.html", html); await Bun.write("./dist/api.json", JSON.stringify(Providers)); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index 3650f3307..66c139b69 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -226,7 +226,7 @@ export const Rendered = renderToString( -
Loading models…
+
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 5bb81787b..36db4f944 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -74,6 +74,10 @@ const server = Bun.serve({ let html = await result.then((r) => r.text()); html = html.replace("", Rendered); + html = html.replace( + '', + '' + ); return new Response(html, { headers: { "Content-Type": "text/html", From 2e1c2e1b936b3da2b024e3da95c0c2b8c773fd3c Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:01:01 -0800 Subject: [PATCH 11/18] feat: add default column visibility with localStorage persistence Show 9 curated columns by default instead of all 25: Provider, Model, Family, Model ID, Tool Call, Reasoning, Input Cost, Output Cost, Context Limit. - Export DEFAULT_COLUMN_IDS from url-state.ts - parseUrlState() returns defaults when no cols param in URL - serializeUrlState() omits cols when matching defaults - Update existing tests and add new ones for column defaults --- packages/web/src/url-state.test.ts | 39 ++++++++++++++++++++++++++---- packages/web/src/url-state.ts | 24 +++++++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts index 40d373038..ddbbb8917 100644 --- a/packages/web/src/url-state.test.ts +++ b/packages/web/src/url-state.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; +import { + ALL_COLUMN_IDS, + DEFAULT_COLUMN_IDS, + parseUrlState, + serializeUrlState, +} from "./url-state"; describe("parseUrlState", () => { it("returns empty defaults when no params", () => { @@ -7,7 +12,7 @@ describe("parseUrlState", () => { expect(state.search).toBe(""); expect(state.sort).toBeNull(); expect(state.order).toBe("asc"); - expect(state.cols).toEqual([...ALL_COLUMN_IDS]); + expect(state.cols).toEqual([...DEFAULT_COLUMN_IDS]); }); it("parses search param", () => { @@ -29,19 +34,43 @@ describe("parseUrlState", () => { ); expect(state.cols).toEqual(["provider", "model", "input-cost"]); }); + + it("explicit cols= in URL overrides defaults", () => { + const state = parseUrlState( + new URLSearchParams("cols=provider,model,reasoning-cost,weights"), + ); + expect(state.cols).toEqual([ + "provider", + "model", + "reasoning-cost", + "weights", + ]); + // Should NOT equal the defaults + expect(state.cols).not.toEqual(DEFAULT_COLUMN_IDS); + }); }); describe("serializeUrlState", () => { - it("omits cols when all visible", () => { + it("omits cols when matching defaults", () => { const params = serializeUrlState({ search: "", sort: null, order: "asc", - cols: [...ALL_COLUMN_IDS], + cols: [...DEFAULT_COLUMN_IDS], }); expect(params.get("cols")).toBeNull(); }); + it("includes cols when all columns visible (not default)", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("cols")).toBe(ALL_COLUMN_IDS.join(",")); + }); + it("includes cols when not all visible", () => { const params = serializeUrlState({ search: "", @@ -57,7 +86,7 @@ describe("serializeUrlState", () => { search: "", sort: null, order: "asc", - cols: [...ALL_COLUMN_IDS], + cols: [...DEFAULT_COLUMN_IDS], }); expect(params.get("search")).toBeNull(); }); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts index ba0de1e22..3480ce495 100644 --- a/packages/web/src/url-state.ts +++ b/packages/web/src/url-state.ts @@ -28,6 +28,18 @@ export const ALL_COLUMN_IDS = [ export type ColumnId = (typeof ALL_COLUMN_IDS)[number]; +export const DEFAULT_COLUMN_IDS: string[] = [ + "provider", + "model", + "family", + "model-id", + "tool-call", + "reasoning", + "input-cost", + "output-cost", + "context-limit", +]; + export type UrlState = { search: string; sort: string | null; @@ -41,13 +53,13 @@ export function parseUrlState(params: URLSearchParams): UrlState { ? colsParam .split(",") .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)) - : [...ALL_COLUMN_IDS]; + : [...DEFAULT_COLUMN_IDS]; return { search: params.get("search") ?? "", sort: params.get("sort"), order: params.get("order") === "desc" ? "desc" : "asc", - cols: cols.length > 0 ? cols : [...ALL_COLUMN_IDS], + cols: cols.length > 0 ? cols : [...DEFAULT_COLUMN_IDS], }; } @@ -58,9 +70,9 @@ export function serializeUrlState(state: UrlState): URLSearchParams { params.set("sort", state.sort); if (state.order !== "asc") params.set("order", state.order); } - const allVisible = - state.cols.length === ALL_COLUMN_IDS.length && - ALL_COLUMN_IDS.every((id) => state.cols.includes(id)); - if (!allVisible) params.set("cols", state.cols.join(",")); + const isDefault = + state.cols.length === DEFAULT_COLUMN_IDS.length && + DEFAULT_COLUMN_IDS.every((id) => state.cols.includes(id)); + if (!isDefault) params.set("cols", state.cols.join(",")); return params; } From f8fc2dea78a96cba4b44efea15d9cce1e6949c8e Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:01:10 -0800 Subject: [PATCH 12/18] feat: add MiniSearch fuzzy search module with debounce Replace brute-force String.includes() search with MiniSearch for fuzzy matching, prefix search, and relevance scoring. Fixes issue #977 where searching matched cost values. - New search.ts module with buildSearchIndex() and searchRows() - Index only text fields: provider name, model name, model ID, family - Does NOT index cost values, booleans, or limits - Fuzzy matching: "claud" finds "claude", "gpt4" finds "gpt-4" - Comma-separated multi-term OR logic preserved - Add debounce utility with cancel() for Escape key handling - Add minisearch ^7 dependency --- bun.lock | 3 ++ packages/web/package.json | 1 + packages/web/src/search.ts | 71 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 packages/web/src/search.ts diff --git a/bun.lock b/bun.lock index 0eb3e8e28..135b4cb17 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@tanstack/table-core": "^8", "@tanstack/virtual-core": "^3", "hono": "^4.8.0", + "minisearch": "^7.2.0", "models.dev": "workspace:*", }, "devDependencies": { @@ -204,6 +205,8 @@ "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], + "models.dev": ["models.dev@workspace:packages/core"], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/packages/web/package.json b/packages/web/package.json index 691046d69..a463f252c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,6 +9,7 @@ "@tanstack/table-core": "^8", "@tanstack/virtual-core": "^3", "hono": "^4.8.0", + "minisearch": "^7.2.0", "models.dev": "workspace:*" }, "devDependencies": { diff --git a/packages/web/src/search.ts b/packages/web/src/search.ts new file mode 100644 index 000000000..0e558bada --- /dev/null +++ b/packages/web/src/search.ts @@ -0,0 +1,71 @@ +import MiniSearch from 'minisearch'; +import type { Row } from './data.js'; + +// Fields to index — deliberately excludes cost values, booleans, limits, dates +const SEARCH_FIELDS = ['providerName', 'name', 'modelId', 'providerId', 'family'] as const; + +export const SEARCH_FIELD_NAMES = [...SEARCH_FIELDS]; // exported for testing + +let searchIndex: MiniSearch | null = null; + +export function buildSearchIndex(rows: Row[]): void { + // Create MiniSearch instance + // IMPORTANT: modelId is NOT unique across providers (same model appears under multiple providers) + // Use array index as the unique ID field + searchIndex = new MiniSearch({ + fields: [...SEARCH_FIELDS], + storeFields: [], + idField: '_searchId', + searchOptions: { + fuzzy: 0.2, // typo tolerance + prefix: true, // prefix matching ("claud" matches "claude") + boost: { name: 2, providerName: 1.5 }, // relevance weighting + }, + }); + + // Add rows with composite IDs (array index) + const docs = rows.map((row, i) => ({ + _searchId: i, + providerName: row.providerName, + name: row.name, + modelId: row.modelId, + providerId: row.providerId, + family: row.family ?? '', + })); + searchIndex.addAll(docs); +} + +export function searchRows(query: string, rows: Row[]): Row[] | null { + if (!searchIndex || !query.trim()) return null; // null = show all (no filter) + + // Support comma-separated OR terms + const terms = query.split(',').map(t => t.trim()).filter(Boolean); + if (terms.length === 0) return null; + + // Union of results for each term (OR logic) + const matchedIndices = new Set(); + for (const term of terms) { + const results = searchIndex.search(term, { fuzzy: 0.2, prefix: true }); + for (const r of results) { + matchedIndices.add(r.id as number); + } + } + + return Array.from(matchedIndices).map(i => rows[i]); +} + +export function debounce void>( + fn: T, + delay: number, +): T & { cancel(): void } { + let timer: ReturnType | null = null; + const debounced = (...args: any[]) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + debounced.cancel = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + return debounced as T & { cancel(): void }; +} From cf03a2b8b6c34f4badc166b4f31020006275df62 Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:01:17 -0800 Subject: [PATCH 13/18] feat: integrate search, debounce, and column persistence into client Wire together MiniSearch, debounce, localStorage column persistence, and inline JSON data loading in the main client entry point. - Replace TanStack globalFilterFn with MiniSearch pre-filtering - Remove getFilteredRowModel() (pre-filter rows before passing to TanStack) - Add 150ms debounced search input handler - Escape key cancels debounce and clears immediately - localStorage persistence for column visibility (models.dev:cols) - Priority: URL cols param > localStorage > DEFAULT_COLUMN_IDS - init() now synchronous (reads inline JSON, no fetch) - Build search index once on data load --- packages/web/src/index.ts | 120 +++++++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 9b2161dfb..f01c9d631 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,7 +1,6 @@ import { createTable, getCoreRowModel, - getFilteredRowModel, getSortedRowModel, type SortingState, type VisibilityState, @@ -15,7 +14,13 @@ import { } from "@tanstack/virtual-core"; import { type ColumnMeta, columnDefs } from "./columns"; import { flattenProviders, type Row } from "./data"; -import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; +import { buildSearchIndex, searchRows, debounce } from "./search"; +import { + ALL_COLUMN_IDS, + DEFAULT_COLUMN_IDS, + parseUrlState, + serializeUrlState, +} from "./url-state"; // ─── DOM refs ────────────────────────────────────────────────────────────────── const scrollContainer = document.getElementById( @@ -27,7 +32,6 @@ const tableHead = document.getElementById( const tableBody = document.getElementById( "table-body", ) as HTMLTableSectionElement; -const tableLoading = document.getElementById("table-loading") as HTMLDivElement; const searchInput = document.getElementById("search") as HTMLInputElement; const columnsToggle = document.getElementById( "columns-toggle", @@ -40,6 +44,7 @@ const modalClose = document.getElementById("close") as HTMLButtonElement; const helpButton = document.getElementById("help") as HTMLButtonElement; // ─── State ──────────────────────────────────────────────────────────────────── +let allRows: Row[] = []; let rows: Row[] = []; let sorting: SortingState = []; let globalFilter = ""; @@ -49,18 +54,13 @@ let columnVisibility: VisibilityState = {}; const table = createTable({ data: rows, columns: columnDefs, - state: { sorting, globalFilter, columnVisibility }, + state: { sorting, columnVisibility }, onStateChange: () => {}, renderFallbackValue: null, onSortingChange: (updater) => { sorting = typeof updater === "function" ? updater(sorting) : updater; afterStateChange(); }, - onGlobalFilterChange: (updater) => { - globalFilter = - typeof updater === "function" ? updater(globalFilter) : updater; - afterStateChange(); - }, onColumnVisibilityChange: (updater) => { columnVisibility = typeof updater === "function" ? updater(columnVisibility) : updater; @@ -68,23 +68,6 @@ const table = createTable({ }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - globalFilterFn: (row, _columnId, filterValue: string) => { - const terms = filterValue - .toLowerCase() - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - if (terms.length === 0) return true; - const orig = row.original as Record; - const text = Object.values(orig) - .map((v) => - typeof v === "string" || typeof v === "number" ? String(v) : "", - ) - .join(" ") - .toLowerCase(); - return terms.some((term) => text.includes(term)); - }, }); // Prime state with all feature-provided defaults (e.g. columnPinning: {left:[],right:[]}) @@ -95,10 +78,14 @@ table.setOptions((prev) => ({ })); function afterStateChange() { + const filtered = globalFilter + ? searchRows(globalFilter, allRows) ?? allRows + : allRows; + rows = filtered; table.setOptions((prev) => ({ ...prev, data: rows, - state: { ...prev.state, sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; virtualizer.setOptions({ @@ -111,6 +98,13 @@ function afterStateChange() { renderRows(); updateUrl(); updateColumnPickerCheckboxes(); + + // Persist column visibility changes to localStorage + const visibleCols = table + .getAllColumns() + .filter((c) => c.getIsVisible()) + .map((c) => c.id); + saveColsToStorage(visibleCols); } // ─── Tanstack Virtual ───────────────────────────────────────────────────────── @@ -277,6 +271,7 @@ function buildColumnPicker() { showAll.textContent = "Show all"; showAll.addEventListener("click", () => { table.toggleAllColumnsVisible(true); + saveColsToStorage([...ALL_COLUMN_IDS]); }); actions.append(showAll); columnsPicker.append(actions); @@ -347,6 +342,30 @@ document.addEventListener("keydown", (e) => { } }); +// ─── localStorage persistence ───────────────────────────────────────────────── +const LS_KEY = "models.dev:cols"; + +function saveColsToStorage(cols: string[]): void { + try { + localStorage.setItem(LS_KEY, cols.join(",")); + } catch { + // localStorage unavailable (e.g. private browsing with strict settings) + } +} + +function loadColsFromStorage(): string[] | null { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const cols = raw + .split(",") + .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)); + return cols.length > 0 ? cols : null; + } catch { + return null; + } +} + // ─── URL state ──────────────────────────────────────────────────────────────── function updateUrl() { const visibleCols = table @@ -368,7 +387,8 @@ function updateUrl() { } function applyUrlState() { - const state = parseUrlState(new URLSearchParams(window.location.search)); + const urlParams = new URLSearchParams(window.location.search); + const state = parseUrlState(urlParams); globalFilter = state.search; searchInput.value = state.search; @@ -376,16 +396,30 @@ function applyUrlState() { ? [{ id: state.sort, desc: state.order === "desc" }] : []; + // Priority: URL cols= param > localStorage > DEFAULT_COLUMN_IDS + let activeCols: string[]; + if (urlParams.has("cols")) { + // Explicit URL param — use it (already parsed and validated in state.cols) + activeCols = state.cols; + } else { + // No URL param — check localStorage, fall back to defaults + activeCols = loadColsFromStorage() ?? [...DEFAULT_COLUMN_IDS]; + } + const newVisibility: VisibilityState = {}; for (const id of ALL_COLUMN_IDS) { - newVisibility[id] = state.cols.includes(id); + newVisibility[id] = activeCols.includes(id); } columnVisibility = newVisibility; + const filtered = globalFilter + ? searchRows(globalFilter, allRows) ?? allRows + : allRows; + rows = filtered; table.setOptions((prev) => ({ ...prev, data: rows, - state: { ...prev.state, sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; @@ -399,14 +433,21 @@ function applyUrlState() { } // ─── Search ─────────────────────────────────────────────────────────────────── +const debouncedSearch = debounce((value: string) => { + globalFilter = value; + afterStateChange(); +}, 150); + searchInput.addEventListener("input", () => { - table.setGlobalFilter(searchInput.value); + debouncedSearch(searchInput.value); }); searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") { + debouncedSearch.cancel(); searchInput.value = ""; - table.setGlobalFilter(""); + globalFilter = ""; + afterStateChange(); } }); @@ -441,23 +482,24 @@ modal.addEventListener("click", (e) => { }); // ─── Init ───────────────────────────────────────────────────────────────────── -async function init() { - const res = await fetch("/api.json"); - const api: Record = await res.json(); - rows = flattenProviders(api as any); +function init() { + const dataEl = document.getElementById("model-data"); + const api = JSON.parse(dataEl!.textContent!); + allRows = flattenProviders(api as any); // Default sort: provider name, then model name - rows.sort((a, b) => { + allRows.sort((a, b) => { const p = a.providerName.localeCompare(b.providerName); return p !== 0 ? p : a.name.localeCompare(b.name); }); + buildSearchIndex(allRows); + rows = allRows; table.setOptions((prev) => ({ ...prev, data: rows })); - tableLoading.remove(); applyUrlState(); } window.addEventListener("popstate", applyUrlState); document.addEventListener("DOMContentLoaded", () => { - init().catch(console.error); + init(); }); From 6fb9754a259ae486b8a8b1816e1e97054064240e Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:01:23 -0800 Subject: [PATCH 14/18] test: add tests for search, debounce, and column visibility Add 27 new tests (41 total, up from 14): - search.test.ts (14): index building, fuzzy matching, prefix search, field exclusion (costs/booleans not indexed), comma-separated OR - debounce.test.ts (4): delay timing, call coalescing, cancel behavior - columns.test.ts (9): DEFAULT_COLUMN_IDS validation, parseUrlState column override, invalid ID filtering --- packages/web/src/columns.test.ts | 70 +++++++++++ packages/web/src/debounce.test.ts | 59 +++++++++ packages/web/src/search.test.ts | 194 ++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 packages/web/src/columns.test.ts create mode 100644 packages/web/src/debounce.test.ts create mode 100644 packages/web/src/search.test.ts diff --git a/packages/web/src/columns.test.ts b/packages/web/src/columns.test.ts new file mode 100644 index 000000000..b7225d982 --- /dev/null +++ b/packages/web/src/columns.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { + ALL_COLUMN_IDS, + DEFAULT_COLUMN_IDS, + parseUrlState, +} from "./url-state.js"; + +describe("DEFAULT_COLUMN_IDS", () => { + it("has exactly 9 items", () => { + expect(DEFAULT_COLUMN_IDS).toHaveLength(9); + }); + + it("contains the expected default columns", () => { + expect(DEFAULT_COLUMN_IDS).toContain("provider"); + expect(DEFAULT_COLUMN_IDS).toContain("model"); + expect(DEFAULT_COLUMN_IDS).toContain("family"); + expect(DEFAULT_COLUMN_IDS).toContain("model-id"); + expect(DEFAULT_COLUMN_IDS).toContain("tool-call"); + expect(DEFAULT_COLUMN_IDS).toContain("reasoning"); + expect(DEFAULT_COLUMN_IDS).toContain("input-cost"); + expect(DEFAULT_COLUMN_IDS).toContain("output-cost"); + expect(DEFAULT_COLUMN_IDS).toContain("context-limit"); + }); + + it("does NOT contain non-default columns", () => { + expect(DEFAULT_COLUMN_IDS).not.toContain("provider-id"); + expect(DEFAULT_COLUMN_IDS).not.toContain("cache-read-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("audio-input-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("audio-output-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("input-limit"); + expect(DEFAULT_COLUMN_IDS).not.toContain("output-limit"); + expect(DEFAULT_COLUMN_IDS).not.toContain("knowledge"); + expect(DEFAULT_COLUMN_IDS).not.toContain("release-date"); + expect(DEFAULT_COLUMN_IDS).not.toContain("last-updated"); + }); + + it("all entries are valid column IDs (present in ALL_COLUMN_IDS)", () => { + const allIds = [...ALL_COLUMN_IDS] as string[]; + for (const id of DEFAULT_COLUMN_IDS) { + expect(allIds).toContain(id); + } + }); +}); + +describe("column visibility via parseUrlState", () => { + it("cols=provider,model returns exactly those 2 columns", () => { + const state = parseUrlState(new URLSearchParams("cols=provider,model")); + expect(state.cols).toEqual(["provider", "model"]); + expect(state.cols).toHaveLength(2); + }); + + it("invalid column IDs in cols param are filtered out", () => { + const state = parseUrlState( + new URLSearchParams("cols=provider,not-a-real-column,model"), + ); + expect(state.cols).toEqual(["provider", "model"]); + }); + + it("cols param with all invalid IDs falls back to defaults", () => { + const state = parseUrlState( + new URLSearchParams("cols=fake-col,another-fake"), + ); + expect(state.cols).toEqual([...DEFAULT_COLUMN_IDS]); + }); + + it("no cols param returns DEFAULT_COLUMN_IDS", () => { + const state = parseUrlState(new URLSearchParams("")); + expect(state.cols).toEqual([...DEFAULT_COLUMN_IDS]); + }); +}); diff --git a/packages/web/src/debounce.test.ts b/packages/web/src/debounce.test.ts new file mode 100644 index 000000000..ab0c5d463 --- /dev/null +++ b/packages/web/src/debounce.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, mock } from "bun:test"; +import { debounce } from "./search.js"; + +describe("debounce", () => { + it("calls the function after the specified delay", async () => { + const fn = mock(() => {}); + const debounced = debounce(fn, 50); + + debounced(); + expect(fn).not.toHaveBeenCalled(); // not yet + + await Bun.sleep(80); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("rapid calls only trigger the function once (last call wins)", async () => { + const fn = mock((_val: string) => {}); + const debounced = debounce(fn, 50); + + debounced("a"); + debounced("b"); + debounced("c"); + + await Bun.sleep(80); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("c"); + }); + + it("cancel() prevents the pending execution", async () => { + const fn = mock(() => {}); + const debounced = debounce(fn, 50); + + debounced(); + debounced.cancel(); + + await Bun.sleep(80); + expect(fn).not.toHaveBeenCalled(); + }); + + it("works correctly with different delay values", async () => { + const fast = mock(() => {}); + const slow = mock(() => {}); + + const debouncedFast = debounce(fast, 30); + const debouncedSlow = debounce(slow, 120); + + debouncedFast(); + debouncedSlow(); + + await Bun.sleep(60); + // fast should have fired, slow should not yet + expect(fast).toHaveBeenCalledTimes(1); + expect(slow).not.toHaveBeenCalled(); + + await Bun.sleep(100); + // now slow should have fired too + expect(slow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/search.test.ts b/packages/web/src/search.test.ts new file mode 100644 index 000000000..3b12ea3ff --- /dev/null +++ b/packages/web/src/search.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { SEARCH_FIELD_NAMES, buildSearchIndex, searchRows } from "./search.js"; +import type { Row } from "./data.js"; + +// --------------------------------------------------------------------------- +// Mock rows +// --------------------------------------------------------------------------- + +const anthropicSonnet: Row = { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + family: "claude-sonnet", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 3.0, output: 15.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-10-22", + last_updated: "2024-10-22", +}; + +const anthropicHaiku: Row = { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + family: "claude-haiku", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.8, output: 4.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-11-04", + last_updated: "2024-11-04", +}; + +const openaiGpt4o: Row = { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-4o", + name: "GPT-4o", + family: "gpt-4o", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 2.5, output: 10.0 }, + limit: { context: 128_000, output: 16_384 }, + release_date: "2024-05-13", + last_updated: "2024-05-13", +}; + +const googleGemini: Row = { + providerId: "google", + providerName: "Google", + modelId: "gemini-1.5-pro", + name: "Gemini 1.5 Pro", + family: "gemini-1.5", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + cost: { input: 1.25, output: 5.0 }, + limit: { context: 1_000_000, output: 8_192 }, + release_date: "2024-02-15", + last_updated: "2024-02-15", +}; + +const MOCK_ROWS: Row[] = [anthropicSonnet, anthropicHaiku, openaiGpt4o, googleGemini]; + +// --------------------------------------------------------------------------- +// SEARCH_FIELD_NAMES +// --------------------------------------------------------------------------- + +describe("SEARCH_FIELD_NAMES", () => { + it("contains exactly the expected text fields", () => { + expect(SEARCH_FIELD_NAMES).toEqual([ + "providerName", + "name", + "modelId", + "providerId", + "family", + ]); + }); + + it("does NOT contain cost fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("cost"); + expect(SEARCH_FIELD_NAMES).not.toContain("input"); + expect(SEARCH_FIELD_NAMES).not.toContain("output"); + }); + + it("does NOT contain boolean capability fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("reasoning"); + expect(SEARCH_FIELD_NAMES).not.toContain("tool_call"); + expect(SEARCH_FIELD_NAMES).not.toContain("attachment"); + expect(SEARCH_FIELD_NAMES).not.toContain("open_weights"); + }); + + it("does NOT contain limit fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("limit"); + expect(SEARCH_FIELD_NAMES).not.toContain("context"); + }); +}); + +// --------------------------------------------------------------------------- +// searchRows +// --------------------------------------------------------------------------- + +describe("searchRows", () => { + beforeAll(() => { + buildSearchIndex(MOCK_ROWS); + }); + + it("returns null for empty query (show all)", () => { + expect(searchRows("", MOCK_ROWS)).toBeNull(); + }); + + it("returns null for whitespace-only query", () => { + expect(searchRows(" ", MOCK_ROWS)).toBeNull(); + }); + + it("exact match: 'claude' finds Claude models", () => { + const results = searchRows("claude", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.every((r) => r.name.toLowerCase().includes("claude"))).toBe(true); + }); + + it("fuzzy match: 'claud' (typo) still finds Claude models", () => { + const results = searchRows("claud", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.name.toLowerCase().includes("claude"))).toBe(true); + }); + + it("prefix match: 'gem' finds Gemini models", () => { + const results = searchRows("gem", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.name.toLowerCase().includes("gemini"))).toBe(true); + }); + + it("provider search: 'anthropic' finds Anthropic models", () => { + const results = searchRows("anthropic", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.every((r) => r.providerId === "anthropic")).toBe(true); + }); + + it("family search: 'gpt' finds GPT family models", () => { + const results = searchRows("gpt", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.family?.includes("gpt"))).toBe(true); + }); + + it("cost value NOT matched: '$15.00' returns empty array (cost not indexed)", () => { + // Dollar-sign prefix can't appear in any indexed text field (providerName, name, modelId, providerId, family) + const results = searchRows("$15.00", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!).toHaveLength(0); + }); + + it("boolean value NOT matched: 'true' returns empty array", () => { + // 'true' does not appear in any indexed text field + const results = searchRows("true", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!).toHaveLength(0); + }); + + it("comma-separated OR: 'claude, gpt' finds both Claude and GPT models", () => { + const results = searchRows("claude, gpt", MOCK_ROWS); + expect(results).not.toBeNull(); + const names = results!.map((r) => r.name); + expect(names.some((n) => n.toLowerCase().includes("claude"))).toBe(true); + expect(names.some((n) => n.toLowerCase().includes("gpt"))).toBe(true); + }); + + it("comma-separated OR: 'anthropic, google' finds models from both providers", () => { + const results = searchRows("anthropic, google", MOCK_ROWS); + expect(results).not.toBeNull(); + const providerIds = results!.map((r) => r.providerId); + expect(providerIds).toContain("anthropic"); + expect(providerIds).toContain("google"); + }); +}); From 3df9ad919cd1791a62d9ed3102b5506c0659ffda Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 11:24:22 -0800 Subject: [PATCH 15/18] feat: full-width table with proportional column flex-grow --- packages/web/src/index.css | 2 ++ packages/web/src/index.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index e87f6afaa..7fba8fdff 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -365,6 +365,8 @@ td { text-align: left; border-bottom: 1px solid var(--color-border); white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } tbody { diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index f01c9d631..53ddcd7bc 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -123,6 +123,26 @@ const virtualizer = new Virtualizer({ // the scroll element (frameworks call it automatically via lifecycle hooks). virtualizer._willUpdate(); +/** Compute proportional flex-grow so text-heavy columns absorb more extra space. */ +export function flexGrow(dataType: string | undefined, baseSize: number): number { + switch (dataType) { + case "text": + // Text columns grow proportionally to their base size + // e.g. Model (200) → grow 3, Provider (150) → grow 2, Family (120) → grow 2 + return Math.max(1, Math.round(baseSize / 80)); + case "cost": + case "number": + // Numbers/costs get minimal growth — they're fairly fixed width + return 1; + case "boolean": + case "modalities": + // Booleans and modality icons don't need extra space at all + return 0; + default: + return 1; + } +} + // ─── Render: thead ──────────────────────────────────────────────────────────── function renderHead() { tableHead.textContent = ""; @@ -139,7 +159,8 @@ function renderHead() { const th = document.createElement("th"); th.className = "sortable"; - th.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + const grow = flexGrow(meta?.dataType, colSize); + th.style.cssText = `width: ${colSize}px; flex: ${grow} 0 ${colSize}px; overflow: hidden;`; th.setAttribute("data-column-id", header.column.id); if (meta?.headerSubLabel) { @@ -206,7 +227,9 @@ function renderRows() { for (const cell of row.getVisibleCells()) { const td = document.createElement("td"); const colSize = cell.column.getSize(); - td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + const grow = flexGrow(meta?.dataType, colSize); + td.style.cssText = `width: ${colSize}px; flex: ${grow} 0 ${colSize}px; overflow: hidden;`; td.dataset.columnId = cell.column.id; const colDef = cell.column.columnDef; @@ -222,6 +245,11 @@ function renderRows() { td.textContent = v != null ? String(v) : "-"; } + // Add tooltip for text cells that might overflow + if (meta?.dataType === "text" && td.textContent) { + td.title = td.textContent; + } + tr.append(td); } From 145436b957620638baea2ba9a0ea59078ca8ce46 Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 13:24:31 -0800 Subject: [PATCH 16/18] feat: mobile-friendly header with full-width search bar on second row --- packages/web/src/index.css | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 7fba8fdff..7a30e198c 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -39,6 +39,12 @@ } } +@media (max-width: 45rem) { + :root { + --header-height: 92px; + } +} + html, body { font-family: "Rubik", sans-serif; @@ -71,6 +77,7 @@ a { header { top: 0; display: flex; + flex-wrap: nowrap; gap: 0.5rem; justify-content: space-between; align-items: center; @@ -190,20 +197,38 @@ header { border-radius: 0.25rem; } - @media (max-width: 32rem) { + @media (max-width: 45rem) { div.left { p, span.slash { display: none; } } - } - @media (max-width: 45rem) { + flex-wrap: wrap; + align-content: center; + gap: 0.75rem; + row-gap: 0.5rem; + padding: 0.5rem 0.75rem; + + div.left { + order: 0; + } + div.right { + display: contents; + .github, + .columns-container, + button#help { + order: 1; + flex: 0 0 auto; + } + .search-container { - display: none; + order: 2; + flex: 1 0 100%; + min-width: 0; } } } From c1bb22cc9b244711d6e7291e389ad5df7f80fe17 Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 13:34:33 -0800 Subject: [PATCH 17/18] fix: iOS sticky table header scroll desync and opaque header background --- packages/web/src/index.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 7a30e198c..a4a620bd6 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -322,6 +322,8 @@ header { overflow-y: auto; overflow-x: auto; margin-top: var(--header-height); + -webkit-overflow-scrolling: touch; + transform: translateZ(0); } #table-loading { @@ -354,8 +356,7 @@ table thead th { text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-secondary); - backdrop-filter: blur(6px); - background-color: var(--color-alpha-background); + background-color: var(--color-background); z-index: 10; } From 86cf5809e5aaf4954353d159adb249cbfa5d917b Mon Sep 17 00:00:00 2001 From: Sebi Unipan Date: Wed, 25 Feb 2026 15:20:44 -0800 Subject: [PATCH 18/18] fix: auto-size table columns to fit content Compute per-column widths from rendered data so full cell text remains visible, including model IDs with persistent copy controls and a consistently visible copy button. --- packages/web/src/index.css | 16 +-- packages/web/src/index.ts | 215 ++++++++++++++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 9 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index a4a620bd6..bbfaf15fe 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -468,31 +468,33 @@ tbody { .model-id-cell { display: flex; align-items: center; - justify-content: space-between; gap: 0.375rem; + min-width: 0; } .model-id-text { + flex: 0 0 auto; } .copy-button { flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; background: none; border: none; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; - color: var(--color-text-tertiary); - opacity: 0; + color: var(--color-text-secondary); + opacity: 1; transition: opacity 0.2s ease, color 0.2s ease; } - .model-id-cell:hover .copy-button { - opacity: 1; - } - .model-id-cell .copy-button svg { display: block; } diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 53ddcd7bc..11c1c598f 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -49,6 +49,215 @@ let rows: Row[] = []; let sorting: SortingState = []; let globalFilter = ""; let columnVisibility: VisibilityState = {}; +let computedColumnSizes: Partial> = {}; + +function textWidthPx(length: number, mono = false): number { + return length * (mono ? 8 : 7); +} + +function maxLength(items: T[], getLength: (item: T) => number): number { + let max = 0; + for (const item of items) max = Math.max(max, getLength(item)); + return max; +} + +function formatCost(value: number | undefined): string { + return value === undefined ? "-" : `$${value.toFixed(2)}`; +} + +function formatNumber(value: number | undefined): string { + return value == null ? "-" : value.toLocaleString(); +} + +function computeColumnSizes(data: Row[]): Partial> { + const tdHorizontalPaddingPx = 24; + const sortIndicatorPx = 18; + const extraSafetyPx = 16; + const iconSizePx = 16; + const iconGapPx = 6; + const copyButtonWidthPx = 30; + const modalityIconWidthPx = 20; + const modalityIconGapPx = 4; + const widths: Partial> = {}; + + for (const col of columnDefs) { + const id = String(col.id ?? ""); + if (!id) continue; + + const meta = col.meta as ColumnMeta | undefined; + const baseSize = col.size ?? 0; + const headerLabel = meta?.headerLabel ?? id; + const headerSubLabel = meta?.headerSubLabel ?? ""; + const headerTextWidth = + Math.max( + textWidthPx(headerLabel.length), + textWidthPx(headerSubLabel.length), + ) + + tdHorizontalPaddingPx + + sortIndicatorPx + + extraSafetyPx; + + let cellWidth = baseSize; + switch (id) { + case "provider": + cellWidth = + textWidthPx(maxLength(data, (row) => row.providerName.length)) + + iconSizePx + + iconGapPx + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "model": + cellWidth = + textWidthPx(maxLength(data, (row) => row.name.length)) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "family": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.family ?? "-").length)) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "provider-id": + cellWidth = + textWidthPx(maxLength(data, (row) => row.providerId.length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "model-id": + cellWidth = + textWidthPx(maxLength(data, (row) => row.modelId.length), true) + + copyButtonWidthPx + + iconGapPx + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-modalities": + cellWidth = + maxLength(data, (row) => { + const count = row.modalities.input.length; + return count * modalityIconWidthPx + Math.max(0, count - 1) * modalityIconGapPx; + }) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-modalities": + cellWidth = + maxLength(data, (row) => { + const count = row.modalities.output.length; + return count * modalityIconWidthPx + Math.max(0, count - 1) * modalityIconGapPx; + }) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.input).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.output).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "reasoning-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.reasoning).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "cache-read-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.cache_read).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "cache-write-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.cache_write).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "audio-input-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.input_audio).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "audio-output-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.output_audio).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "context-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.context).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.input).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.output).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "knowledge": + cellWidth = + textWidthPx( + maxLength(data, (row) => (row.knowledge ? row.knowledge.substring(0, 7) : "-").length), + true, + ) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "release-date": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.release_date ?? "-").length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "last-updated": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.last_updated ?? "-").length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + default: + if (meta?.dataType === "boolean") { + cellWidth = textWidthPx(3) + tdHorizontalPaddingPx + extraSafetyPx; + } else if (meta?.dataType === "text") { + cellWidth = + textWidthPx( + maxLength(data, (row) => { + if (id === "weights") return (row.open_weights ? "Open" : "Closed").length; + return 1; + }), + ) + + tdHorizontalPaddingPx + + extraSafetyPx; + } + break; + } + + widths[id] = Math.max(baseSize, headerTextWidth, cellWidth); + } + + return widths; +} + +function getColumnSize(columnId: string, fallbackSize: number): number { + return Math.max(fallbackSize, computedColumnSizes[columnId] ?? fallbackSize); +} // ─── Tanstack Table ─────────────────────────────────────────────────────────── const table = createTable({ @@ -154,7 +363,7 @@ function renderHead() { if (!header.column.getIsVisible()) continue; const meta = header.column.columnDef.meta as ColumnMeta | undefined; - const colSize = header.column.getSize(); + const colSize = getColumnSize(header.column.id, header.column.getSize()); const isSorted = header.column.getIsSorted(); const th = document.createElement("th"); @@ -226,7 +435,7 @@ function renderRows() { for (const cell of row.getVisibleCells()) { const td = document.createElement("td"); - const colSize = cell.column.getSize(); + const colSize = getColumnSize(cell.column.id, cell.column.getSize()); const meta = cell.column.columnDef.meta as ColumnMeta | undefined; const grow = flexGrow(meta?.dataType, colSize); td.style.cssText = `width: ${colSize}px; flex: ${grow} 0 ${colSize}px; overflow: hidden;`; @@ -521,6 +730,8 @@ function init() { return p !== 0 ? p : a.name.localeCompare(b.name); }); + computedColumnSizes = computeColumnSizes(allRows); + buildSearchIndex(allRows); rows = allRows; table.setOptions((prev) => ({ ...prev, data: rows }));