openvibers/openvibe.exchange Commits
Models: the registry as provider folds, compact rows on a phone (#212)
Changes
@@@ -227,24 +227,102 @@.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }--/* ── The model rows (home's popular models, /models): Opus, 11-09 ── */--.xm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }--.xm-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 14px 16px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }--.xm-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }--.xm-name > a { font-weight: 700; font-size: 1.05rem; color: var(--ovk-text); text-decoration: none; min-height: 0; }++/* ── The model rows (home's popular models, /models): Opus, 11-09; compact on a phone 11-11 ──++ A row is the name with its maker and badges, the three numbers, and two actions. On a phone the actions are round++ icon buttons beside the name (their names stay in aria-label and title), so a model is one short card, not a screen. */++.xm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.xm-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name acts" "facts facts"; align-items: center; gap: 6px 10px; padding: 10px 12px 12px 14px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }++.xm-name { grid-area: name; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }++.xm-name > a { font-weight: 700; font-size: 1.02rem; color: var(--ovk-text); text-decoration: none; min-height: 0; overflow-wrap: anywhere; }.xm-name > a:hover { text-decoration: underline; }--.xm-maker { color: var(--ovk-muted); font-size: .86rem; }++.xm-maker { color: var(--ovk-muted); font-size: .84rem; }.xm-badges { display: inline-flex; flex-wrap: wrap; gap: 6px; }--.xm-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }--.xm-facts > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }--.xm-facts dt { color: var(--ovk-muted); font-size: .76rem; }--.xm-facts dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }--.xm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }++.xm-facts { grid-area: facts; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }++.xm-facts > div { display: flex; flex-direction: column; gap: 1px; min-width: 0; }++.xm-facts dt { color: var(--ovk-muted); font-size: .74rem; }++.xm-facts dd { margin: 0; font-weight: 600; font-size: .95rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }++.xm-acts { grid-area: acts; display: flex; gap: 6px; margin: 0; }.xm-acts .ovk-btn { min-height: 40px; }.xm-empty { color: var(--ovk-muted); }--.ex-chip.is-empty { opacity: .45; cursor: default; }++@media (max-width: 899px) {++ .xp .xm-acts .ovk-btn { width: 44px; min-height: 44px; padding: 0; justify-content: center; border-radius: 999px; }++ .xm-acts .ovk-btn > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++ .xm-name > a { display: inline-flex; align-items: center; min-height: 44px; }++}@media (min-width: 900px) {-- .xm-row { grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2fr) auto; align-items: center; gap: 16px; }++ .xm-row { grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2fr) auto; grid-template-areas: "name facts acts"; gap: 16px; padding: 12px 16px; }++ .xm-facts dd { font-size: 1rem; }++}++++/* ── /models: the search and the filters as one block; on a phone the filters are one row that scrolls sideways ── */++.xm-page { gap: 18px; }++.xm-find { display: grid; gap: 10px; min-width: 0; }++.xm-find .ovt-composer { width: 100%; max-width: 760px; margin: 0; justify-self: stretch; }++.xm-find .ex-chips { margin: 0; }++.xp .ex-chip { min-height: 36px; gap: 6px; }++.ex-chip > span { color: var(--ovk-muted); font-variant-numeric: tabular-nums; font-size: .82em; }++.ex-chip.is-on > span { color: inherit; }++.ex-chip.is-empty { opacity: .45; cursor: default; }++@media (max-width: 760px) {++ .xm-find .ex-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-inline: -16px; padding: 2px 16px; scroll-padding-inline: 16px;++ -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 22px), transparent 100%); mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 22px), transparent 100%); }++ .xm-find .ex-chips::-webkit-scrollbar { display: none; }++ .xm-find .ex-chip { flex: 0 0 auto; white-space: nowrap; }++}++@media (pointer: coarse) { .xp .ex-chip, .xr .xr-tab { min-height: 44px; } }++++/* ── The registry under /models (Opus, 2026-11-11): providers as folds, a model as one line ──++ A fold's summary is the provider, its note, how many and from what price; open, its models are rows with the name++ first and context and price at the end (under the name on a phone). The status switch is a segmented row. */++.xr { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding-block: 8px 40px; }++.xr > * { min-width: 0; }++.xr-head h2 { margin: 0; font-size: 1.2rem; letter-spacing: -.01em; }++.xr-head p { margin: 4px 0 0; color: var(--ovk-muted); font-size: .9rem; max-width: 70ch; }++.xr .xr-tabs { display: flex; flex-wrap: nowrap; gap: 4px; padding: 4px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); width: max-content; max-width: 100%; overflow-x: auto; scrollbar-width: none; }++.xr-tabs::-webkit-scrollbar { display: none; }++.xr .xr-tab { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; color: var(--ovk-text); text-decoration: none; font-size: .88rem; white-space: nowrap; }++.xr-tab > span { color: var(--ovk-muted); font-size: .82em; font-variant-numeric: tabular-nums; }++.xr-tab:hover { background: var(--ovk-panel-2); }++.xr-tab.is-on { background: color-mix(in srgb, var(--ovk-accent) 18%, transparent); font-weight: 600; }++.xr-tab.is-on > span { color: inherit; }++.xr-tab.is-empty { opacity: .45; }++.xr-groups { border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }++.xr-group { border-top: 1px solid var(--ovk-line); }++.xr-group:first-child { border-top: 0; }++.xr-group > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 10px; min-height: 52px; padding: 8px 44px 8px 16px; position: relative; cursor: pointer; list-style: none; }++.xr-group > summary::-webkit-details-marker { display: none; }++.xr-group > summary::after { content: ""; position: absolute; right: 18px; top: 50%; width: 8px; height: 8px; margin-top: -6px; border-right: 2px solid var(--ovk-muted); border-bottom: 2px solid var(--ovk-muted); transform: rotate(45deg); transition: transform .18s ease; }++.xr-group[open] > summary::after { transform: rotate(-135deg); margin-top: -2px; }++.xr-group > summary:hover { background: var(--ovk-panel-2); }++.xr-group[open] > summary { background: var(--ovk-panel-2); }++.xr-prov { font-weight: 650; }++.xr-prov-note { color: var(--ovk-muted); font-size: .84rem; }++.xr-sum { display: inline-flex; gap: 10px; margin-left: auto; color: var(--ovk-muted); font-size: .84rem; font-variant-numeric: tabular-nums; }++.xr-n { color: var(--ovk-text); }++.xr-free { color: var(--ovk-ok-text, #22c55e); }++.xr-list { list-style: none; margin: 0; padding: 0; }++.xr-row { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; padding: 9px 16px; border-top: 1px solid color-mix(in srgb, var(--ovk-line) 60%, transparent); }++.xr-row > a { flex: 1 1 260px; min-width: 0; min-height: 0; display: inline; color: var(--ovk-text); text-decoration: none; overflow-wrap: anywhere; }++.xr-row > a::after { content: ""; position: absolute; inset: 0; }++.xr-row:hover { background: var(--ovk-panel-2); }++.xr-row:hover > a { color: var(--ovk-accent); }++.xr-row > a:focus-visible { outline: none; }++.xr-row:has(> a:focus-visible) { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }++.xr-row.is-retired > a { color: var(--ovk-muted); }++.xr-meta { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; color: var(--ovk-muted); font-size: .84rem; font-variant-numeric: tabular-nums; }++.xr-meta i { font-style: normal; opacity: .75; }++.xr-price { min-width: 8.5em; text-align: right; color: var(--ovk-text); }++.xr-ctx { min-width: 7em; text-align: right; }++.xr-status { padding: 1px 8px; border-radius: 999px; font-size: .76rem; background: var(--ovk-panel-2); color: var(--ovk-muted); }++.xr-status.is-preview { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); }++.xr-status.is-retired { text-decoration: line-through; }++@media (max-width: 760px) {++ .xr-group > summary { padding-left: 14px; }++ .xr-sum { margin-left: 0; flex-basis: 100%; }++ .xr-row { padding: 10px 14px; display: grid; gap: 2px; }++ .xr-row > a { display: block; }++ .xr-meta { gap: 2px 12px; }++ .xr-price, .xr-ctx { min-width: 0; text-align: left; }}/* ── /search: engines by kind, the try form as a card, results side by side ── */@@@ -279,11 +357,6 @@}/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */.xs > * { min-width: 0; }--@media (max-width: 760px) {-- /* 44 px targets on a phone: a model's name and its two buttons. */-- .xm-name > a { display: inline-flex; align-items: center; min-height: 44px; }-- .xm-acts .ovk-btn { min-height: 44px; }--}/* ── Pricing: one row per way of calling, the name above what it costs ── */.xpr-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }@@@ -35,13 +35,81 @@ function select(models, query = {}) {&& (!allowedStatus.has(status) || m.status === status) && (!provider || m.provider.toLowerCase() === provider));}const usd = value => value == null || !Number.isFinite(Number(value)) ? 'Price not listed' : `$${Number(value)} per million tokens`;++/** "128K" / "1M": a context window the way people say it (binary sizes in K of 1,024, as providers name them). */++const tokens = (n) => {++ const v = Number(n);++ if (n == null || !Number.isFinite(v) || v <= 0) return null;++ if (v >= 1e6) return `${Number((v % 1048576 === 0 ? v / 1048576 : v / 1e6).toFixed(1))}M`;++ if (v >= 1000) return `${Math.round(v % 1024 === 0 ? v / 1024 : v / 1000)}K`;++ return String(v);++};++/** "$3/M out": the output price per million tokens, short. */++const perMOut = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v) === 0 ? 'Free' : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}/M out`);++// Raw provider ids some registry rows carry instead of a name.++const PROVIDER_NAMES = { gemini: 'Google Gemini', google: 'Google', huggingface: 'Hugging Face', openai: 'OpenAI', anthropic: 'Anthropic',++ openrouter: 'OpenRouter', deepseek: 'DeepSeek', mistral: 'Mistral', xai: 'xAI', meta: 'Meta', arena: 'LMArena' };++/** A provider's name and its note: "OpenRouter (the last fallback)" → OpenRouter + "the last fallback". */++function providerOf(m) {++ const raw = String(m.provider_name || PROVIDER_NAMES[String(m.provider || '').toLowerCase()] || m.provider || 'Other');++ const hit = raw.match(/^(.*?)\s*\(([^)]*)\)\s*$/);++ return hit ? { name: hit[1], note: hit[2] } : { name: raw, note: '' };++}++const STATUSES = ['available', 'preview', 'spotted', 'retired'];++const STATUS_LABEL = { all: 'All', available: 'Available', preview: 'Preview', spotted: 'Spotted', retired: 'Retired' };++/**++ * The registry under /models (Opus, 2026-11-11): every provider-listed model, grouped by provider in folds that say++ * how many and from what price, so 900+ rows read as one screen of providers. Every row stays in the HTML (crawlers++ * and find-in-page see them all); a search, a status or a provider opens the folds. The status switch keeps the++ * search and says how many each status holds.++ */function directoryList(models, query, activeSlugs = []) {const active = new Set(activeSlugs);-- const rows = select(models, query).filter(m => !active.has(m.id));-- return `<section class="ex-wrap" aria-labelledby="directory-more"><h2 id="directory-more">More models in the registry</h2>--<p>Provider-listed models stay here when routing is off or a provider retires them. Prices and availability are dated at their sources.</p>--<nav aria-label="Model status">${['all', 'available', 'preview', 'spotted', 'retired'].map(s => `<a class="ovk-btn ovk-btn--quiet" href="/models${s === 'all' ? '' : `?status=${s}`}">${esc(s)}</a>`).join(' ')}</nav>--${rows.length ? `<ul>${rows.map(m => `<li><a href="${href(m.id)}">${esc(m.name)}</a> · ${esc(m.provider_name || m.provider)} · ${esc(m.status)} · ${esc(m.context_window == null ? 'Context not listed' : `${m.context_window} tokens`)} · ${esc(usd(m.price?.output_per_million))}</li>`).join('')}</ul>` : '<p>No additional models match this filter.</p>'}</section>`;++ const pool = select(models, { ...query, status: '' }).filter(m => !active.has(m.id));++ const status = STATUSES.includes(String(query.status || '').toLowerCase()) ? String(query.status).toLowerCase() : 'all';++ const rows = status === 'all' ? pool : pool.filter(m => m.status === status);++ const counts = { all: pool.length };++ for (const s of STATUSES) counts[s] = pool.filter(m => m.status === s).length;++ const keep = new URLSearchParams(Object.entries(query).filter(([k, v]) => k !== 'status' && typeof v === 'string' && v));++ const statusHref = (s) => { const p = new URLSearchParams(keep); if (s !== 'all') p.set('status', s); const qs = p.toString(); return `/models${qs ? `?${qs}` : ''}#directory-more`; };++ const tabs = ['all', ...STATUSES].map(s => counts[s] || s === status++ ? `<a class="xr-tab${s === status ? ' is-on' : ''}" href="${esc(statusHref(s))}"${s === status ? ' aria-current="true"' : ''}>${esc(STATUS_LABEL[s])}<span>${counts[s]}</span></a>`++ : `<span class="xr-tab is-empty" aria-disabled="true">${esc(STATUS_LABEL[s])}<span>0</span></span>`).join('');++ const groups = new Map();++ for (const m of rows) {++ const p = providerOf(m);++ const g = groups.get(p.name) || { name: p.name, note: p.note, rows: [] };++ if (!g.note && p.note) g.note = p.note;++ g.rows.push(m);++ groups.set(p.name, g);++ }++ const order = (m) => STATUSES.indexOf(m.status);++ const list = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));++ const narrowed = Boolean(query.q || query.provider || status !== 'all') || rows.length <= 40;++ const row = (m) => {++ const ctx = tokens(m.context_window);++ const out = perMOut(m.price?.output_per_million);++ return `<li class="xr-row${m.status === 'retired' ? ' is-retired' : ''}"><a href="${href(m.id)}">${esc(m.name)}</a><span class="xr-meta">`++ + `${m.status !== 'available' ? `<span class="xr-status is-${esc(m.status)}">${esc(STATUS_LABEL[m.status] || m.status)}</span>` : ''}`++ + `${ctx ? `<span class="xr-ctx" title="${esc(`${Number(m.context_window).toLocaleString('en-US')} tokens of context`)}">${esc(ctx)} context</span>` : ''}`++ + `<span class="xr-price">${out ? esc(out) : '<i>Not priced</i>'}</span></span></li>`;++ };++ const fold = (g) => {++ const prices = g.rows.map(m => m.price?.output_per_million).filter(v => v != null && Number.isFinite(Number(v))).map(Number);++ const free = prices.filter(v => v === 0).length;++ const paid = prices.filter(v => v > 0);++ const from = paid.length ? Math.min(...paid) : null;++ const sorted = [...g.rows].sort((a, b) => order(a) - order(b) || String(a.name).localeCompare(String(b.name), 'en', { numeric: true, sensitivity: 'base' }));++ return `<details class="xr-group"${narrowed ? ' open' : ''}><summary><span class="xr-prov">${esc(g.name)}</span>`++ + `${g.note ? `<span class="xr-prov-note">${esc(g.note)}</span>` : ''}<span class="xr-sum">`++ + `<span class="xr-n">${g.rows.length} ${g.rows.length === 1 ? 'model' : 'models'}</span>`++ + `${free ? `<span class="xr-free">${free === g.rows.length ? 'free' : `${free} free`}</span>` : ''}`++ + `${from != null ? `<span class="xr-from">from ${esc(perMOut(from))}</span>` : ''}</span></summary>`++ + `<ul class="xr-list">${sorted.map(row).join('')}</ul></details>`;++ };++ return `<section class="ex-wrap xr" aria-labelledby="directory-more"><div class="xr-head"><h2 id="directory-more">More models in the registry</h2>++<p>Every model a provider lists, routed or not: ${counts.all.toLocaleString('en-US')} from ${new Set(pool.map(m => providerOf(m).name)).size} providers. Prices and availability are dated at their sources.</p></div>++<nav class="xr-tabs" aria-label="Model status">${tabs}</nav>++${list.length ? `<div class="xr-groups">${list.map(fold).join('')}</div>` : '<p class="xm-empty">No more models match this search.</p>'}</section>`;}function directoryDetail(m) {const price = m.price || {};@@@ -64,7 +64,9 @@ const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString(* uptime and speed with their labels, and Try in Chat / How it ranks as small quiet buttons. Opus, 11-09.*/function table(models, searchMeta = null) {-- if (!models.length) return '<p class="xm-empty">No models match these filters.</p>';++ if (!models.length) return kit.empty({ icon: 'search', title: 'Nothing you can call now matches',++ text: 'The registry below lists every model a provider names, routed or not.',++ action: { href: '/models#directory-more', label: 'See the registry', kind: 'quiet' } });return `<ul class="xm-list">${models.map((m) => {const found = searchMeta && searchMeta.get(id(m));const o = found ? { price: found.price, speed: found.speed, provider_name: found.provider } : best(m);@@@ -76,7 +78,7 @@ function table(models, searchMeta = null) {const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;return `<li class="xm-row"><div class="xm-name"><a href="/models/${encodeURIComponent(id(m))}">${esc(m.name)}</a><span class="xm-maker">${esc(legacy.familyLabel(m))}</span>${badges ? `<span class="xm-badges">${badges}</span>` : ''}</div>`+ `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second != null ? `${dash(o.speed.tokens_per_second)} tok/s` : o.speed?.p50_ms == null ? '–' : `${dash(o.speed.p50_ms)} ms`)}${found ? fact('Quality', found.quality ? `${dash(found.quality.elo)} Elo · ${found.quality.games} games` : 'Not measured') : ''}${found ? fact('Provider', found.provider) : ''}${found?.task_match ? fact('Task match', found.task_match) : ''}</dl>`-- + `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', kind: 'quiet', small: true })} ${kit.button({ href: rank(m), label: 'How it ranks', kind: 'quiet', small: true })}</p></li>`;++ + `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', icon: 'chat', kind: 'quiet', small: true, attrs: { 'aria-label': `Try ${m.name} in Chat`, title: 'Try in Chat' } })} ${kit.button({ href: rank(m), label: 'How it ranks', icon: 'trophy', kind: 'quiet', small: true, attrs: { 'aria-label': `How ${m.name} ranks`, title: 'How it ranks' } })}</p></li>`;}).join('')}</ul>`;}// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.@@@ -138,8 +140,8 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {);if (q.get(key) === value) q.delete(key);else q.set(key, value);-- if (!count && query[key] !== value) return `<span class="ex-chip is-empty" aria-disabled="true">${esc(label)} (0)</span>`;-- return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;++ if (!count && query[key] !== value) return `<span class="ex-chip is-empty" aria-disabled="true">${esc(label)} <span>0</span></span>`;++ return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}"${query[key] === value ? ' aria-current="true"' : ''}>${esc(label)} <span>${count}</span></a>`;};for (const [key, test] of Object.entries(tests))if (query[key]) rows = rows.filter((m) => test(m, query[key]));@@@ -175,10 +177,13 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {for (const [key, label, value] of [['capability', 'Vision', 'vision'], ['capability', 'Tools', 'tools'], ['max_price', 'Under $1/M out', '1'], ['max_speed_ms', 'Under 500 ms', '500']]) {const params = new URLSearchParams(Object.entries(query).filter(([, v]) => typeof v === 'string'));if (params.get(key) === value) params.delete(key); else params.set(key, value);-- chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}">${label}</a>`);++ chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}"${query[key] === value ? ' aria-current="true"' : ''}>${label}</a>`);}const searchMeta = searchResults && new Map(searchResults.map((r) => [r.model, r]));const filters = modelSearch(query, true);-- return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${filters}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;++ // Filters that match nothing go last (still there, still saying 0), so the row starts with what narrows.++ const ordered = [...chips.filter((c) => !c.includes('is-empty')), ...chips.filter((c) => c.includes('is-empty'))];++ const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';++ return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;}module.exports = { home, modelsPage, pricing, pricingPage, table };
@@ -227,24 +227,102 @@
@@ -227,24 +227,102 @@
.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
-/* ── The model rows (home's popular models, /models): Opus, 11-09 ── */
-.xm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
-.xm-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 14px 16px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
-.xm-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
-.xm-name > a { font-weight: 700; font-size: 1.05rem; color: var(--ovk-text); text-decoration: none; min-height: 0; }
+/* ── The model rows (home's popular models, /models): Opus, 11-09; compact on a phone 11-11 ──
+ A row is the name with its maker and badges, the three numbers, and two actions. On a phone the actions are round
+ icon buttons beside the name (their names stay in aria-label and title), so a model is one short card, not a screen. */
+.xm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.xm-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name acts" "facts facts"; align-items: center; gap: 6px 10px; padding: 10px 12px 12px 14px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
+.xm-name { grid-area: name; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }
+.xm-name > a { font-weight: 700; font-size: 1.02rem; color: var(--ovk-text); text-decoration: none; min-height: 0; overflow-wrap: anywhere; }
.xm-name > a:hover { text-decoration: underline; }
.xm-name > a:hover { text-decoration: underline; }
-.xm-maker { color: var(--ovk-muted); font-size: .86rem; }
+.xm-maker { color: var(--ovk-muted); font-size: .84rem; }
.xm-badges { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.xm-badges { display: inline-flex; flex-wrap: wrap; gap: 6px; }
-.xm-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
-.xm-facts > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
-.xm-facts dt { color: var(--ovk-muted); font-size: .76rem; }
-.xm-facts dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
-.xm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
+.xm-facts { grid-area: facts; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
+.xm-facts > div { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.xm-facts dt { color: var(--ovk-muted); font-size: .74rem; }
+.xm-facts dd { margin: 0; font-weight: 600; font-size: .95rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.xm-acts { grid-area: acts; display: flex; gap: 6px; margin: 0; }
.xm-acts .ovk-btn { min-height: 40px; }
.xm-acts .ovk-btn { min-height: 40px; }
.xm-empty { color: var(--ovk-muted); }
.xm-empty { color: var(--ovk-muted); }
-.ex-chip.is-empty { opacity: .45; cursor: default; }
+@media (max-width: 899px) {
+ .xp .xm-acts .ovk-btn { width: 44px; min-height: 44px; padding: 0; justify-content: center; border-radius: 999px; }
+ .xm-acts .ovk-btn > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ .xm-name > a { display: inline-flex; align-items: center; min-height: 44px; }
+}
@media (min-width: 900px) {
@media (min-width: 900px) {
- .xm-row { grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2fr) auto; align-items: center; gap: 16px; }
+ .xm-row { grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2fr) auto; grid-template-areas: "name facts acts"; gap: 16px; padding: 12px 16px; }
+ .xm-facts dd { font-size: 1rem; }
+}
+
+/* ── /models: the search and the filters as one block; on a phone the filters are one row that scrolls sideways ── */
+.xm-page { gap: 18px; }
+.xm-find { display: grid; gap: 10px; min-width: 0; }
+.xm-find .ovt-composer { width: 100%; max-width: 760px; margin: 0; justify-self: stretch; }
+.xm-find .ex-chips { margin: 0; }
+.xp .ex-chip { min-height: 36px; gap: 6px; }
+.ex-chip > span { color: var(--ovk-muted); font-variant-numeric: tabular-nums; font-size: .82em; }
+.ex-chip.is-on > span { color: inherit; }
+.ex-chip.is-empty { opacity: .45; cursor: default; }
+@media (max-width: 760px) {
+ .xm-find .ex-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-inline: -16px; padding: 2px 16px; scroll-padding-inline: 16px;
+ -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 22px), transparent 100%); mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 22px), transparent 100%); }
+ .xm-find .ex-chips::-webkit-scrollbar { display: none; }
+ .xm-find .ex-chip { flex: 0 0 auto; white-space: nowrap; }
+}
+@media (pointer: coarse) { .xp .ex-chip, .xr .xr-tab { min-height: 44px; } }
+
+/* ── The registry under /models (Opus, 2026-11-11): providers as folds, a model as one line ──
+ A fold's summary is the provider, its note, how many and from what price; open, its models are rows with the name
+ first and context and price at the end (under the name on a phone). The status switch is a segmented row. */
+.xr { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding-block: 8px 40px; }
+.xr > * { min-width: 0; }
+.xr-head h2 { margin: 0; font-size: 1.2rem; letter-spacing: -.01em; }
+.xr-head p { margin: 4px 0 0; color: var(--ovk-muted); font-size: .9rem; max-width: 70ch; }
+.xr .xr-tabs { display: flex; flex-wrap: nowrap; gap: 4px; padding: 4px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); width: max-content; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
+.xr-tabs::-webkit-scrollbar { display: none; }
+.xr .xr-tab { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; color: var(--ovk-text); text-decoration: none; font-size: .88rem; white-space: nowrap; }
+.xr-tab > span { color: var(--ovk-muted); font-size: .82em; font-variant-numeric: tabular-nums; }
+.xr-tab:hover { background: var(--ovk-panel-2); }
+.xr-tab.is-on { background: color-mix(in srgb, var(--ovk-accent) 18%, transparent); font-weight: 600; }
+.xr-tab.is-on > span { color: inherit; }
+.xr-tab.is-empty { opacity: .45; }
+.xr-groups { border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }
+.xr-group { border-top: 1px solid var(--ovk-line); }
+.xr-group:first-child { border-top: 0; }
+.xr-group > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 10px; min-height: 52px; padding: 8px 44px 8px 16px; position: relative; cursor: pointer; list-style: none; }
+.xr-group > summary::-webkit-details-marker { display: none; }
+.xr-group > summary::after { content: ""; position: absolute; right: 18px; top: 50%; width: 8px; height: 8px; margin-top: -6px; border-right: 2px solid var(--ovk-muted); border-bottom: 2px solid var(--ovk-muted); transform: rotate(45deg); transition: transform .18s ease; }
+.xr-group[open] > summary::after { transform: rotate(-135deg); margin-top: -2px; }
+.xr-group > summary:hover { background: var(--ovk-panel-2); }
+.xr-group[open] > summary { background: var(--ovk-panel-2); }
+.xr-prov { font-weight: 650; }
+.xr-prov-note { color: var(--ovk-muted); font-size: .84rem; }
+.xr-sum { display: inline-flex; gap: 10px; margin-left: auto; color: var(--ovk-muted); font-size: .84rem; font-variant-numeric: tabular-nums; }
+.xr-n { color: var(--ovk-text); }
+.xr-free { color: var(--ovk-ok-text, #22c55e); }
+.xr-list { list-style: none; margin: 0; padding: 0; }
+.xr-row { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; padding: 9px 16px; border-top: 1px solid color-mix(in srgb, var(--ovk-line) 60%, transparent); }
+.xr-row > a { flex: 1 1 260px; min-width: 0; min-height: 0; display: inline; color: var(--ovk-text); text-decoration: none; overflow-wrap: anywhere; }
+.xr-row > a::after { content: ""; position: absolute; inset: 0; }
+.xr-row:hover { background: var(--ovk-panel-2); }
+.xr-row:hover > a { color: var(--ovk-accent); }
+.xr-row > a:focus-visible { outline: none; }
+.xr-row:has(> a:focus-visible) { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
+.xr-row.is-retired > a { color: var(--ovk-muted); }
+.xr-meta { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; color: var(--ovk-muted); font-size: .84rem; font-variant-numeric: tabular-nums; }
+.xr-meta i { font-style: normal; opacity: .75; }
+.xr-price { min-width: 8.5em; text-align: right; color: var(--ovk-text); }
+.xr-ctx { min-width: 7em; text-align: right; }
+.xr-status { padding: 1px 8px; border-radius: 999px; font-size: .76rem; background: var(--ovk-panel-2); color: var(--ovk-muted); }
+.xr-status.is-preview { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); }
+.xr-status.is-retired { text-decoration: line-through; }
+@media (max-width: 760px) {
+ .xr-group > summary { padding-left: 14px; }
+ .xr-sum { margin-left: 0; flex-basis: 100%; }
+ .xr-row { padding: 10px 14px; display: grid; gap: 2px; }
+ .xr-row > a { display: block; }
+ .xr-meta { gap: 2px 12px; }
+ .xr-price, .xr-ctx { min-width: 0; text-align: left; }
}
}
/* ── /search: engines by kind, the try form as a card, results side by side ── */
/* ── /search: engines by kind, the try form as a card, results side by side ── */
@@ -279,11 +357,6 @@
@@ -279,11 +357,6 @@
}
}
/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */
/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */
.xs > * { min-width: 0; }
.xs > * { min-width: 0; }
-@media (max-width: 760px) {
- /* 44 px targets on a phone: a model's name and its two buttons. */
- .xm-name > a { display: inline-flex; align-items: center; min-height: 44px; }
- .xm-acts .ovk-btn { min-height: 44px; }
-}
/* ── Pricing: one row per way of calling, the name above what it costs ── */
/* ── Pricing: one row per way of calling, the name above what it costs ── */
.xpr-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }
.xpr-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }
@@ -35,13 +35,81 @@ function select(models, query = {}) {
@@ -35,13 +35,81 @@ function select(models, query = {}) {
&& (!allowedStatus.has(status) || m.status === status) && (!provider || m.provider.toLowerCase() === provider));
&& (!allowedStatus.has(status) || m.status === status) && (!provider || m.provider.toLowerCase() === provider));
}
}
const usd = value => value == null || !Number.isFinite(Number(value)) ? 'Price not listed' : `$${Number(value)} per million tokens`;
const usd = value => value == null || !Number.isFinite(Number(value)) ? 'Price not listed' : `$${Number(value)} per million tokens`;
+/** "128K" / "1M": a context window the way people say it (binary sizes in K of 1,024, as providers name them). */
+const tokens = (n) => {
+ const v = Number(n);
+ if (n == null || !Number.isFinite(v) || v <= 0) return null;
+ if (v >= 1e6) return `${Number((v % 1048576 === 0 ? v / 1048576 : v / 1e6).toFixed(1))}M`;
+ if (v >= 1000) return `${Math.round(v % 1024 === 0 ? v / 1024 : v / 1000)}K`;
+ return String(v);
+};
+/** "$3/M out": the output price per million tokens, short. */
+const perMOut = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v) === 0 ? 'Free' : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}/M out`);
+// Raw provider ids some registry rows carry instead of a name.
+const PROVIDER_NAMES = { gemini: 'Google Gemini', google: 'Google', huggingface: 'Hugging Face', openai: 'OpenAI', anthropic: 'Anthropic',
+ openrouter: 'OpenRouter', deepseek: 'DeepSeek', mistral: 'Mistral', xai: 'xAI', meta: 'Meta', arena: 'LMArena' };
+/** A provider's name and its note: "OpenRouter (the last fallback)" → OpenRouter + "the last fallback". */
+function providerOf(m) {
+ const raw = String(m.provider_name || PROVIDER_NAMES[String(m.provider || '').toLowerCase()] || m.provider || 'Other');
+ const hit = raw.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
+ return hit ? { name: hit[1], note: hit[2] } : { name: raw, note: '' };
+}
+const STATUSES = ['available', 'preview', 'spotted', 'retired'];
+const STATUS_LABEL = { all: 'All', available: 'Available', preview: 'Preview', spotted: 'Spotted', retired: 'Retired' };
+/**
+ * The registry under /models (Opus, 2026-11-11): every provider-listed model, grouped by provider in folds that say
+ * how many and from what price, so 900+ rows read as one screen of providers. Every row stays in the HTML (crawlers
+ * and find-in-page see them all); a search, a status or a provider opens the folds. The status switch keeps the
+ * search and says how many each status holds.
+ */
function directoryList(models, query, activeSlugs = []) {
function directoryList(models, query, activeSlugs = []) {
const active = new Set(activeSlugs);
const active = new Set(activeSlugs);
- const rows = select(models, query).filter(m => !active.has(m.id));
- return `<section class="ex-wrap" aria-labelledby="directory-more"><h2 id="directory-more">More models in the registry</h2>
-<p>Provider-listed models stay here when routing is off or a provider retires them. Prices and availability are dated at their sources.</p>
-<nav aria-label="Model status">${['all', 'available', 'preview', 'spotted', 'retired'].map(s => `<a class="ovk-btn ovk-btn--quiet" href="/models${s === 'all' ? '' : `?status=${s}`}">${esc(s)}</a>`).join(' ')}</nav>
-${rows.length ? `<ul>${rows.map(m => `<li><a href="${href(m.id)}">${esc(m.name)}</a> · ${esc(m.provider_name || m.provider)} · ${esc(m.status)} · ${esc(m.context_window == null ? 'Context not listed' : `${m.context_window} tokens`)} · ${esc(usd(m.price?.output_per_million))}</li>`).join('')}</ul>` : '<p>No additional models match this filter.</p>'}</section>`;
+ const pool = select(models, { ...query, status: '' }).filter(m => !active.has(m.id));
+ const status = STATUSES.includes(String(query.status || '').toLowerCase()) ? String(query.status).toLowerCase() : 'all';
+ const rows = status === 'all' ? pool : pool.filter(m => m.status === status);
+ const counts = { all: pool.length };
+ for (const s of STATUSES) counts[s] = pool.filter(m => m.status === s).length;
+ const keep = new URLSearchParams(Object.entries(query).filter(([k, v]) => k !== 'status' && typeof v === 'string' && v));
+ const statusHref = (s) => { const p = new URLSearchParams(keep); if (s !== 'all') p.set('status', s); const qs = p.toString(); return `/models${qs ? `?${qs}` : ''}#directory-more`; };
+ const tabs = ['all', ...STATUSES].map(s => counts[s] || s === status
+ ? `<a class="xr-tab${s === status ? ' is-on' : ''}" href="${esc(statusHref(s))}"${s === status ? ' aria-current="true"' : ''}>${esc(STATUS_LABEL[s])}<span>${counts[s]}</span></a>`
+ : `<span class="xr-tab is-empty" aria-disabled="true">${esc(STATUS_LABEL[s])}<span>0</span></span>`).join('');
+ const groups = new Map();
+ for (const m of rows) {
+ const p = providerOf(m);
+ const g = groups.get(p.name) || { name: p.name, note: p.note, rows: [] };
+ if (!g.note && p.note) g.note = p.note;
+ g.rows.push(m);
+ groups.set(p.name, g);
+ }
+ const order = (m) => STATUSES.indexOf(m.status);
+ const list = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
+ const narrowed = Boolean(query.q || query.provider || status !== 'all') || rows.length <= 40;
+ const row = (m) => {
+ const ctx = tokens(m.context_window);
+ const out = perMOut(m.price?.output_per_million);
+ return `<li class="xr-row${m.status === 'retired' ? ' is-retired' : ''}"><a href="${href(m.id)}">${esc(m.name)}</a><span class="xr-meta">`
+ + `${m.status !== 'available' ? `<span class="xr-status is-${esc(m.status)}">${esc(STATUS_LABEL[m.status] || m.status)}</span>` : ''}`
+ + `${ctx ? `<span class="xr-ctx" title="${esc(`${Number(m.context_window).toLocaleString('en-US')} tokens of context`)}">${esc(ctx)} context</span>` : ''}`
+ + `<span class="xr-price">${out ? esc(out) : '<i>Not priced</i>'}</span></span></li>`;
+ };
+ const fold = (g) => {
+ const prices = g.rows.map(m => m.price?.output_per_million).filter(v => v != null && Number.isFinite(Number(v))).map(Number);
+ const free = prices.filter(v => v === 0).length;
+ const paid = prices.filter(v => v > 0);
+ const from = paid.length ? Math.min(...paid) : null;
+ const sorted = [...g.rows].sort((a, b) => order(a) - order(b) || String(a.name).localeCompare(String(b.name), 'en', { numeric: true, sensitivity: 'base' }));
+ return `<details class="xr-group"${narrowed ? ' open' : ''}><summary><span class="xr-prov">${esc(g.name)}</span>`
+ + `${g.note ? `<span class="xr-prov-note">${esc(g.note)}</span>` : ''}<span class="xr-sum">`
+ + `<span class="xr-n">${g.rows.length} ${g.rows.length === 1 ? 'model' : 'models'}</span>`
+ + `${free ? `<span class="xr-free">${free === g.rows.length ? 'free' : `${free} free`}</span>` : ''}`
+ + `${from != null ? `<span class="xr-from">from ${esc(perMOut(from))}</span>` : ''}</span></summary>`
+ + `<ul class="xr-list">${sorted.map(row).join('')}</ul></details>`;
+ };
+ return `<section class="ex-wrap xr" aria-labelledby="directory-more"><div class="xr-head"><h2 id="directory-more">More models in the registry</h2>
+<p>Every model a provider lists, routed or not: ${counts.all.toLocaleString('en-US')} from ${new Set(pool.map(m => providerOf(m).name)).size} providers. Prices and availability are dated at their sources.</p></div>
+<nav class="xr-tabs" aria-label="Model status">${tabs}</nav>
+${list.length ? `<div class="xr-groups">${list.map(fold).join('')}</div>` : '<p class="xm-empty">No more models match this search.</p>'}</section>`;
}
}
function directoryDetail(m) {
function directoryDetail(m) {
const price = m.price || {};
const price = m.price || {};
@@ -64,7 +64,9 @@ const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString(
@@ -64,7 +64,9 @@ const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString(
* uptime and speed with their labels, and Try in Chat / How it ranks as small quiet buttons. Opus, 11-09.
* uptime and speed with their labels, and Try in Chat / How it ranks as small quiet buttons. Opus, 11-09.
*/
*/
function table(models, searchMeta = null) {
function table(models, searchMeta = null) {
- if (!models.length) return '<p class="xm-empty">No models match these filters.</p>';
+ if (!models.length) return kit.empty({ icon: 'search', title: 'Nothing you can call now matches',
+ text: 'The registry below lists every model a provider names, routed or not.',
+ action: { href: '/models#directory-more', label: 'See the registry', kind: 'quiet' } });
return `<ul class="xm-list">${models.map((m) => {
return `<ul class="xm-list">${models.map((m) => {
const found = searchMeta && searchMeta.get(id(m));
const found = searchMeta && searchMeta.get(id(m));
const o = found ? { price: found.price, speed: found.speed, provider_name: found.provider } : best(m);
const o = found ? { price: found.price, speed: found.speed, provider_name: found.provider } : best(m);
@@ -76,7 +78,7 @@ function table(models, searchMeta = null) {
@@ -76,7 +78,7 @@ function table(models, searchMeta = null) {
const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;
const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;
return `<li class="xm-row"><div class="xm-name"><a href="/models/${encodeURIComponent(id(m))}">${esc(m.name)}</a><span class="xm-maker">${esc(legacy.familyLabel(m))}</span>${badges ? `<span class="xm-badges">${badges}</span>` : ''}</div>`
return `<li class="xm-row"><div class="xm-name"><a href="/models/${encodeURIComponent(id(m))}">${esc(m.name)}</a><span class="xm-maker">${esc(legacy.familyLabel(m))}</span>${badges ? `<span class="xm-badges">${badges}</span>` : ''}</div>`
+ `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second != null ? `${dash(o.speed.tokens_per_second)} tok/s` : o.speed?.p50_ms == null ? '–' : `${dash(o.speed.p50_ms)} ms`)}${found ? fact('Quality', found.quality ? `${dash(found.quality.elo)} Elo · ${found.quality.games} games` : 'Not measured') : ''}${found ? fact('Provider', found.provider) : ''}${found?.task_match ? fact('Task match', found.task_match) : ''}</dl>`
+ `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second != null ? `${dash(o.speed.tokens_per_second)} tok/s` : o.speed?.p50_ms == null ? '–' : `${dash(o.speed.p50_ms)} ms`)}${found ? fact('Quality', found.quality ? `${dash(found.quality.elo)} Elo · ${found.quality.games} games` : 'Not measured') : ''}${found ? fact('Provider', found.provider) : ''}${found?.task_match ? fact('Task match', found.task_match) : ''}</dl>`
- + `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', kind: 'quiet', small: true })} ${kit.button({ href: rank(m), label: 'How it ranks', kind: 'quiet', small: true })}</p></li>`;
+ + `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', icon: 'chat', kind: 'quiet', small: true, attrs: { 'aria-label': `Try ${m.name} in Chat`, title: 'Try in Chat' } })} ${kit.button({ href: rank(m), label: 'How it ranks', icon: 'trophy', kind: 'quiet', small: true, attrs: { 'aria-label': `How ${m.name} ranks`, title: 'How it ranks' } })}</p></li>`;
}).join('')}</ul>`;
}).join('')}</ul>`;
}
}
// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.
// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.
@@ -138,8 +140,8 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
@@ -138,8 +140,8 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
);
);
if (q.get(key) === value) q.delete(key);
if (q.get(key) === value) q.delete(key);
else q.set(key, value);
else q.set(key, value);
- if (!count && query[key] !== value) return `<span class="ex-chip is-empty" aria-disabled="true">${esc(label)} (0)</span>`;
- return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;
+ if (!count && query[key] !== value) return `<span class="ex-chip is-empty" aria-disabled="true">${esc(label)} <span>0</span></span>`;
+ return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}"${query[key] === value ? ' aria-current="true"' : ''}>${esc(label)} <span>${count}</span></a>`;
};
};
for (const [key, test] of Object.entries(tests))
for (const [key, test] of Object.entries(tests))
if (query[key]) rows = rows.filter((m) => test(m, query[key]));
if (query[key]) rows = rows.filter((m) => test(m, query[key]));
@@ -175,10 +177,13 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
@@ -175,10 +177,13 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
for (const [key, label, value] of [['capability', 'Vision', 'vision'], ['capability', 'Tools', 'tools'], ['max_price', 'Under $1/M out', '1'], ['max_speed_ms', 'Under 500 ms', '500']]) {
for (const [key, label, value] of [['capability', 'Vision', 'vision'], ['capability', 'Tools', 'tools'], ['max_price', 'Under $1/M out', '1'], ['max_speed_ms', 'Under 500 ms', '500']]) {
const params = new URLSearchParams(Object.entries(query).filter(([, v]) => typeof v === 'string'));
const params = new URLSearchParams(Object.entries(query).filter(([, v]) => typeof v === 'string'));
if (params.get(key) === value) params.delete(key); else params.set(key, value);
if (params.get(key) === value) params.delete(key); else params.set(key, value);
- chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}">${label}</a>`);
+ chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}"${query[key] === value ? ' aria-current="true"' : ''}>${label}</a>`);
}
}
const searchMeta = searchResults && new Map(searchResults.map((r) => [r.model, r]));
const searchMeta = searchResults && new Map(searchResults.map((r) => [r.model, r]));
const filters = modelSearch(query, true);
const filters = modelSearch(query, true);
- return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${filters}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
+ // Filters that match nothing go last (still there, still saying 0), so the row starts with what narrows.
+ const ordered = [...chips.filter((c) => !c.includes('is-empty')), ...chips.filter((c) => c.includes('is-empty'))];
+ const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';
+ return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
}
}
module.exports = { home, modelsPage, pricing, pricingPage, table };
module.exports = { home, modelsPage, pricing, pricingPage, table };