Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ <h1><a href="./">modelfax</a></h1>
<th scope="col" class="num">
<button type="button" data-sort="context_window">Context</button>
</th>
<th scope="col"><button type="button" data-sort="released">Released</button></th>
<th scope="col"><button type="button" data-sort="deprecated">Deprecated</button></th>
<th scope="col"><button type="button" data-sort="retired">Retired</button></th>
<th scope="col"><button type="button" data-sort="last_verified">Verified</button></th>
Expand Down
30 changes: 30 additions & 0 deletions site/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -114,6 +119,7 @@ main {
.lede {
color: var(--muted);
max-width: 70ch;
overflow-wrap: anywhere;
}

.notice {
Expand All @@ -128,6 +134,7 @@ main {
gap: 1rem;
align-items: flex-end;
margin: 1rem 0;
max-width: 100%;
}

.field {
Expand Down Expand Up @@ -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"] {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
43 changes: 42 additions & 1 deletion site/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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":
Expand Down Expand Up @@ -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 }),
Expand Down
25 changes: 24 additions & 1 deletion test/site-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <th> 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(/<th scope="col">/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 = [...]`");
Expand Down
Loading