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 @@
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(/