From b489d9ca85c951cc4bc9b872c7ffe35fbdb4d5d0 Mon Sep 17 00:00:00 2001 From: bytebrujo Date: Fri, 4 Sep 2026 15:45:01 +0800 Subject: [PATCH] site: link each model to its own page, add Released, fix narrow layout Three changes, found by actually rendering the site. Every Anthropic model linked to the same deprecations page. `sources` is stored sorted, so sources[0] is whatever sorts first, not the page about that model. primarySource() now scores each URL against the model id, including the family-stripped and undated forms, because provider docs write claude-opus-4-5-20251101 as /models/opus-4-5/overview. Verified across all three providers. Adds a Released column. Release dates exist for 20 of 25 records now and a lifecycle registry that hides them is not doing its job. Narrow-viewport hygiene: min-width:0 on the flex main so a wide table scrolls inside its wrapper instead of stretching the page, overflow-wrap on inline code and URLs, 16px form controls so iOS does not zoom on focus, and a full-width search field with larger sort targets under 640px. Measured, not guessed: both pages report documentElement.scrollWidth === clientWidth at 500px with no clipping rule anywhere. site-smoke now fails if a column's data-sort has no case in sortKey(), which would otherwise sort every row by undefined and read as "sorting is broken". Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 20 ++++++++++++++++++++ site/index.html | 1 + site/site.css | 30 ++++++++++++++++++++++++++++++ site/site.js | 43 ++++++++++++++++++++++++++++++++++++++++++- test/site-smoke.js | 25 ++++++++++++++++++++++++- 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81cd66e..4d5da8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,23 @@ files themselves. be tracked. Adding an id without seeding the record turns the next scrape red, because context windows and modalities are not scrapeable and `normalize()` will not invent them. Seed the record in the same PR. +- 2026-09-04 — `sources` is stored sorted, so `sources[0]` is alphabetical, not + the most useful link. Every Anthropic model on the site pointed at the same + deprecations page. `primarySource()` in `site/site.js` scores each URL against + the model id (including the family-stripped and undated forms, because doc + paths write claude-opus-4-5-20251101 as `/models/opus-4-5/overview`) and links + the most specific one. Never index into `sources` positionally. +- 2026-09-04 — Verifying the site needs a real browser, and both MCP browser + integrations were down. Chromium headless via the locally installed Brave + binary works and needs no extension: + `--headless=new --virtual-time-budget=8000 --window-size=W,H --screenshot=out.png URL`, + with `--dump-dom` for the rendered HTML. Two traps: it clamps the window to a + minimum width of 500 CSS px, so a 390-wide screenshot is a 500-wide page + cropped, which looks exactly like a broken mobile layout; and to measure + anything, inject a script that writes the numbers into `document.title`, then + read the title out of `--dump-dom`. +- 2026-09-04 — Never fix horizontal overflow with `overflow-x: hidden` on + html/body. It hides the symptom and makes the page untestable, because + `scrollWidth` then always equals `clientWidth`. Fix it with containment + (`min-width: 0` on flex items, `overflow-wrap` on URLs, a scrolling wrapper + around the table) and assert `documentElement.scrollWidth === clientWidth`. diff --git a/site/index.html b/site/index.html index 867b9d5..6f734cc 100644 --- a/site/index.html +++ b/site/index.html @@ -95,6 +95,7 @@

modelfax

+ diff --git a/site/site.css b/site/site.css index 4e1c9cd..b3ef7de 100644 --- a/site/site.css +++ b/site/site.css @@ -56,6 +56,8 @@ a { code { font-family: var(--mono); font-size: 0.9em; + /* File paths and URLs have no spaces to break at. */ + overflow-wrap: anywhere; } :focus-visible { @@ -105,6 +107,9 @@ code { main { flex: 1; + /* A flex item defaults to min-width:auto, which lets a wide table stretch its + parent instead of scrolling inside .table-wrap. */ + min-width: 0; width: 100%; max-width: 1200px; margin: 0 auto; @@ -114,6 +119,7 @@ main { .lede { color: var(--muted); max-width: 70ch; + overflow-wrap: anywhere; } .notice { @@ -128,6 +134,7 @@ main { gap: 1rem; align-items: flex-end; margin: 1rem 0; + max-width: 100%; } .field { @@ -160,6 +167,9 @@ select { background: var(--bg); color: var(--fg); min-width: 9rem; + max-width: 100%; + /* iOS zooms the page when a control's text is under 16px. */ + font-size: max(1rem, 16px); } input[type="checkbox"] { @@ -176,6 +186,9 @@ input[type="checkbox"] { .table-wrap { overflow-x: auto; + max-width: 100%; + /* Momentum scrolling on touch, and a hint that there is more to the right. */ + -webkit-overflow-scrolling: touch; border: 1px solid var(--line); border-radius: 6px; } @@ -290,4 +303,21 @@ td.model .id { td { padding: 0.4rem 0.5rem; } + + .site-header { + padding: 0.75rem 1rem; + } + + main { + padding: 0.75rem 1rem 1.5rem; + } + + .field.grow { + flex-basis: 100%; + } + + /* Fingertip-sized sort targets. */ + thead button { + padding: 0.35rem 0; + } } diff --git a/site/site.js b/site/site.js index ce2c72a..0357951 100644 --- a/site/site.js +++ b/site/site.js @@ -79,8 +79,46 @@ return el("span", { class: `badge ${status}`, text: status }); } + /* `sources` is stored sorted, so its first entry is alphabetical rather than + useful: every Anthropic model linked to the same deprecations page. Score + each URL and link the one that is actually about this model. */ + function primarySource(m) { + const sources = m.sources || []; + if (!sources.length) { + return null; + } + const id = m.model_id.toLowerCase(); + const undated = id.replace(/-\d{8}$/, ""); + const needles = new Set(); + for (const base of [id, undated]) { + needles.add(base); + needles.add(base.replace(/\./g, "-")); + // Provider doc paths usually drop the family prefix: claude-opus-4-5 + // lives at /models/opus-4-5/overview. + needles.add(base.replace(/^[a-z]+-/, "")); + needles.add(base.replace(/^[a-z]+-/, "").replace(/\./g, "-")); + } + let best = sources[0]; + let bestScore = -1; + for (const url of sources) { + const lower = url.toLowerCase(); + let score = lower.split("/").length; + for (const needle of needles) { + if (needle.length > 2 && lower.includes(needle)) { + score += 100; + break; + } + } + if (score > bestScore) { + best = url; + bestScore = score; + } + } + return best; + } + function modelCell(m) { - const src = m.sources && m.sources.length ? m.sources[0] : null; + const src = primarySource(m); const name = src ? el("a", { href: src, rel: "noopener", text: m.display_name }) : el("span", { text: m.display_name }); @@ -97,6 +135,8 @@ return price(m, "output_per_mtok"); case "cached": return price(m, "cached_input_per_mtok"); + case "released": + return m.dates.released; case "deprecated": return m.dates.deprecated; case "retired": @@ -148,6 +188,7 @@ el("td", { class: "num", text: fmtPrice(price(m, "output_per_mtok")) }), el("td", { class: "num", text: fmtPrice(price(m, "cached_input_per_mtok")) }), el("td", { class: "num", text: int.format(m.context_window) }), + el("td", { text: fmtDate(m.dates.released) }), el("td", { text: fmtDate(m.dates.deprecated) }), el("td", { text: fmtDate(m.dates.retired) }), el("td", { text: m.last_verified }), diff --git a/test/site-smoke.js b/test/site-smoke.js index ba22d17..cb6e703 100644 --- a/test/site-smoke.js +++ b/test/site-smoke.js @@ -74,8 +74,31 @@ for (const f of htmlFiles) { log.info("html ok", { file: f }); } -// Data paths referenced by site.js must exist; every data file must be referenced. +// Every sortable column must have a sort key site.js actually understands. +// Adding a with a new data-sort and forgetting the switch case silently +// sorts every row by undefined, which looks like "sorting is broken" rather +// than like a missing case. const siteJs = readFileSync(join(siteDir, "site.js"), "utf8"); +const indexHtml = readFileSync(join(siteDir, "index.html"), "utf8"); +const sortKeys = [...indexHtml.matchAll(/data-sort="([a-z_]+)"/g)].map((m) => m[1]); +if (sortKeys.length === 0) { + fail("index.html declares no sortable columns"); +} +const sortBody = siteJs.slice( + siteJs.indexOf("function sortKey("), + siteJs.indexOf("function compare("), +); +for (const key of sortKeys) { + const handled = sortBody.includes(`case "${key}"`) || /return m\[key\]/.test(sortBody); + if (!handled) { + fail("column has no sort key in site.js sortKey()", { key }); + } +} +// A column header count that does not match the rendered cell count means one +// column silently shows another column's values. +const headerCount = (indexHtml.match(//g) || []).length; +log.info("sortable columns ok", { columns: headerCount, keys: sortKeys.length }); + const providerListMatch = siteJs.match(/const PROVIDERS = \[([^\]]*)\]/); if (!providerListMatch) { fail("site.js must declare `const PROVIDERS = [...]`");