SPB Git

spb/ai-atlas

Public

Web foundation (D0): atlas-plate brand, terminal shell, command palette, evidence drawer, density, charts, watchlist, homepage 3.0

Brand: new "atlas plate" mark (grid plate, A-as-graph constellation, timeline "now" node) shared by logo.svg,
lockups, favicon (simplified grid), apple/PWA icons, root OG wallpaper with live counters; theme-inverting
--brand-* tokens. Shell: header nav Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾
(keyboard menu), mobile tab bar with grouped More sheet, footer with credits line, About page rewritten.
Command palette: commands with fuzzy match, recents, type prefixes (m: b: o: p: h:), ">" mode, theme/density
actions. Evidence drawer (right panel / bottom sheet) fed by /provenance → /history → inline fallback, wired
into KeyValue (slug prop) and ProvenanceInline; SpecTable/Capabilities pass slug. Density toggle
(comfortable | compact | dense) via CSS variables; pre-paint scripts moved to lib/prepaint.ts (server-safe —
client-module string exports reached the server as stubs). Charts: InteractiveLineChart, StepChart,
Sparkline trend, ScatterChart, Heatmap, TimelineLanes, lineLayout. Terminal primitives: TerminalLayout,
FilterSheet, DataStrip, Ticker, SectionNav; Sheet + Hint UI. Local watchlist + WatchButton + /watchlist.
Homepage 3.0: compact hero, live strip with definitions, ticker, editorial grid (today, frontier moves,
leaders, price moves, open weights, research), release timeline lanes, graph teaser, signature products.
QA: screens.mjs shell sweep 320–1920 + touch targets, SHELL_ONLY mode; shell.mjs flows. docs/FRONTEND.md §2 §4 §9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent afabc36 Browse files

52 changed files +3,931 −659

52 changed files
added apps/web/public/logo-lockup-light.svg +21 −0
@@ -0,0 +1,21 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="96" viewBox="0 0 360 96" fill="none">
2+ <title>AI Atlas</title>
3+ <rect width="360" height="96" rx="12" fill="#f6f6f3"/>
4+ <g transform="translate(20 16) scale(2)">
5+ <rect x="1" y="1" width="30" height="30" rx="7" fill="#101216"/>
6+ <g stroke="rgba(246,246,243,0.22)" stroke-width="0.8">
7+ <line x1="4" x2="28" y1="10.5" y2="10.5"/>
8+ <line x1="4" x2="28" y1="17" y2="17"/>
9+ <line x1="10.5" x2="10.5" y1="4" y2="28"/>
10+ <line x1="21.5" x2="21.5" y1="4" y2="28"/>
11+ </g>
12+ <line x1="5" x2="27" y1="24" y2="24" stroke="#f6f6f3" stroke-width="1.2" opacity="0.75" stroke-linecap="round"/>
13+ <path d="M8.5 24L16 8L23.5 24" fill="none" stroke="#f6f6f3" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
14+ <circle cx="16" cy="8" r="2.1" fill="#f6f6f3"/>
15+ <circle cx="8.5" cy="24" r="2.1" fill="#f6f6f3"/>
16+ <circle cx="23.5" cy="24" r="2.5" fill="#7fa3ff"/>
17+ </g>
18+ <text x="102" y="60" font-family="Geist, 'Geist Sans', Inter, system-ui, -apple-system, sans-serif" font-size="40" letter-spacing="-1.2" fill="#101216">
19+ <tspan font-weight="700">AI</tspan><tspan font-weight="500" fill="#4b5161" dx="10">Atlas</tspan>
20+ </text>
21+</svg>
added apps/web/public/logo-lockup.svg +21 −0
@@ -0,0 +1,21 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="96" viewBox="0 0 360 96" fill="none">
2+ <title>AI Atlas</title>
3+ <rect width="360" height="96" rx="12" fill="#0b0d11"/>
4+ <g transform="translate(20 16) scale(2)">
5+ <rect x="1" y="1" width="30" height="30" rx="7" fill="#12151b" stroke="rgba(233,235,240,0.18)" stroke-width="0.5"/>
6+ <g stroke="rgba(233,235,240,0.22)" stroke-width="0.8">
7+ <line x1="4" x2="28" y1="10.5" y2="10.5"/>
8+ <line x1="4" x2="28" y1="17" y2="17"/>
9+ <line x1="10.5" x2="10.5" y1="4" y2="28"/>
10+ <line x1="21.5" x2="21.5" y1="4" y2="28"/>
11+ </g>
12+ <line x1="5" x2="27" y1="24" y2="24" stroke="#e9ebf0" stroke-width="1.2" opacity="0.75" stroke-linecap="round"/>
13+ <path d="M8.5 24L16 8L23.5 24" fill="none" stroke="#e9ebf0" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
14+ <circle cx="16" cy="8" r="2.1" fill="#e9ebf0"/>
15+ <circle cx="8.5" cy="24" r="2.1" fill="#e9ebf0"/>
16+ <circle cx="23.5" cy="24" r="2.5" fill="#6d95ff"/>
17+ </g>
18+ <text x="102" y="60" font-family="Geist, 'Geist Sans', Inter, system-ui, -apple-system, sans-serif" font-size="40" letter-spacing="-1.2" fill="#e9ebf0">
19+ <tspan font-weight="700">AI</tspan><tspan font-weight="500" fill="#a3a9b8" dx="10">Atlas</tspan>
20+ </text>
21+</svg>
modified apps/web/public/logo.svg +13 −10
@@ -1,13 +1,16 @@
1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> 1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
2 <title>AI Atlas</title> 2 <title>AI Atlas</title>
3− <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.6"/> 3+ <desc>Atlas plate: a rounded square with a fine coordinate grid; three nodes and two edges draw the letter A as a graph; the accent node marks "now" on the timeline baseline.</desc>
4− <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> 4+ <rect x="1" y="1" width="30" height="30" rx="7" fill="#0b0d11"/>
5− <path d="M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> 5+ <g stroke="rgba(233,235,240,0.22)" stroke-width="0.8">
6− <path d="M3 16h26" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> 6+ <line x1="4" x2="28" y1="10.5" y2="10.5"/>
7− <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="currentColor" stroke-width="1.2" opacity="0.55"/> 7+ <line x1="4" x2="28" y1="17" y2="17"/>
8− <circle cx="16" cy="16" r="2.4" fill="#1f4fd8"/> 8+ <line x1="10.5" x2="10.5" y1="4" y2="28"/>
9− <circle cx="9.4" cy="9.5" r="1.7" fill="currentColor"/> 9+ <line x1="21.5" x2="21.5" y1="4" y2="28"/>
10− <circle cx="22.6" cy="9.5" r="1.7" fill="currentColor"/> 10+ </g>
11− <circle cx="10.8" cy="24.4" r="1.7" fill="currentColor"/> 11+ <line x1="5" x2="27" y1="24" y2="24" stroke="#e9ebf0" stroke-width="1.2" opacity="0.75" stroke-linecap="round"/>
12− <circle cx="23.4" cy="21.6" r="1.7" fill="currentColor"/> 12+ <path d="M8.5 24L16 8L23.5 24" fill="none" stroke="#e9ebf0" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
13+ <circle cx="16" cy="8" r="2.1" fill="#e9ebf0"/>
14+ <circle cx="8.5" cy="24" r="2.1" fill="#e9ebf0"/>
15+ <circle cx="23.5" cy="24" r="2.5" fill="#6d95ff"/>
13 </svg> 16 </svg>
modified apps/web/qa/screens.mjs +78 −7
@@ -1,5 +1,7 @@
1 /** 1 /**
2 * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot. 2 * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot.
3+ * The shell pages (`/`, `/about`, `/watchlist`, and `/` with the command palette open) are also swept at
4+ * 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 in both themes, with a touch-target check (≥ 44 px) on interactive elements.
3 * Also asserts that the homepage counters match GET /api/v1/stats. 5 * Also asserts that the homepage counters match GET /api/v1/stats.
4 * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331) 6 * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331)
5 */ 7 */
@@ -11,21 +13,55 @@ const API = process.argv[3] ?? 'http://127.0.0.1:8331';
11 const OUT = new URL('./screens/', import.meta.url).pathname; 13 const OUT = new URL('./screens/', import.meta.url).pathname;
12 mkdirSync(OUT, { recursive: true }); 14 mkdirSync(OUT, { recursive: true });
13 15
14−const PAGES = ['/', '/search?q=claude', '/models', '/models?openness=open-weights&sort=params', '/models/claude-opus-5', '/companies', '/companies/anthropic', '/providers', '/providers/anthropic-2', '/benchmarks', '/benchmarks/tau-bench', '/hardware/nvidia-dgx-b200', '/hardware', '/papers', '/frameworks', '/datasets', '/tools', '/changes', `/changes/${new Date().toISOString().slice(0, 10)}`, '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/developers', '/bot', '/models/does-not-exist', '/does-not-exist']; 16+const PAGES = ['/', '/search?q=claude', '/models', '/models?openness=open-weights&sort=params', '/models/claude-opus-5', '/companies', '/companies/anthropic', '/providers', '/providers/anthropic-2', '/benchmarks', '/benchmarks/tau-bench', '/hardware/nvidia-dgx-b200', '/hardware', '/papers', '/frameworks', '/datasets', '/tools', '/changes', `/changes/${new Date().toISOString().slice(0, 10)}`, '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/watchlist', '/developers', '/bot', '/models/does-not-exist', '/does-not-exist'];
15 const WIDTHS = [390, 1440]; 17 const WIDTHS = [390, 1440];
16 const THEMES = ['dark', 'light']; 18 const THEMES = ['dark', 'light'];
19+/** Shell sweep (D0): every width in the spec, both themes, palette open as a fourth "page". */
20+const SHELL_PAGES = ['/', '/about', '/watchlist', '/#palette'];
21+const SHELL_WIDTHS = [320, 360, 390, 430, 768, 1366, 1440, 1920];
17 const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); 22 const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
18 23
24+/** `SHELL_ONLY=1 node qa/screens.mjs` skips the route sweep and runs the shell sweep + counters check only (~2 min). */
25+const SHELL_ONLY = process.env.SHELL_ONLY === '1';
26+
19 const browser = await chromium.launch(); 27 const browser = await chromium.launch();
20 let failures = 0; 28 let failures = 0;
21−for (const theme of THEMES) { 29+
30+const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));
31+
32+/**
33+ * Interactive *controls* smaller than 44 × 44 CSS px: buttons, inputs, selects, icon links and nav links. Inline text links inside
34+ * content (feed rows, prose, tables, key–value, footers) are exempt — they are text, not controls. When a modal (`[data-palette]`,
35+ * `[role="dialog"]`) is open only its own controls are measured, since the page behind it is inert.
36+ */
37+const smallTargets = () => {
38+ const root = document.querySelector('[data-palette], [role="dialog"]') ?? document;
39+ return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')]
40+ .filter((el) => {
41+ const r = el.getBoundingClientRect();
42+ if (r.width === 0 || r.height === 0) return false;
43+ const cs = getComputedStyle(el);
44+ if (cs.visibility === 'hidden' || cs.display === 'none') return false;
45+ if (el.closest('.sr-only, p, dd, .prose-atlas, td, .kv, [data-ticker], .data-table, footer')) return false;
46+ if (el.tagName === 'A') {
47+ // Text links outside navigation (feed rows, list items, headings) are content, not controls; icon-only links are controls.
48+ const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]');
49+ const textLink = el.textContent.trim().length > 0;
50+ if (!inNav && textLink) return false;
51+ }
52+ return Math.min(r.width, r.height) < 44;
53+ })
54+ .map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 24)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`);
55+};
56+
57+for (const theme of SHELL_ONLY ? [] : THEMES) {
22 for (const width of WIDTHS) { 58 for (const width of WIDTHS) {
23 const mobile = width < 768; 59 const mobile = width < 768;
24 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); 60 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
25 await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); 61 await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
26 const page = await ctx.newPage(); 62 const page = await ctx.newPage();
27 for (const path of PAGES) { 63 for (const path of PAGES) {
28− if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic'].includes(path)) continue; 64+ if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic', '/about', '/watchlist'].includes(path)) continue;
29 const errors = []; 65 const errors = [];
30 const onErr = (e) => errors.push(String(e)); 66 const onErr = (e) => errors.push(String(e));
31 const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; 67 const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };
@@ -38,9 +74,7 @@ for (const theme of THEMES) {
38 const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); 74 const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);
39 const expected = /does-not-exist/.test(path) ? 404 : 200; 75 const expected = /does-not-exist/.test(path) ? 404 : 200;
40 const status = res.status(); 76 const status = res.status();
41− // Dynamic-route 404s use Next's client-rendered error shell; React DEV warns about the inline theme <script> there 77+ const filtered = filterErrors(errors, expected);
42− // (verified absent in `next start`). Everything else must be clean.
43− const filtered = errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));
44 const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme; 78 const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;
45 if (!ok) failures++; 79 if (!ok) failures++;
46 console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 140) : ''}`); 80 console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 140) : ''}`);
@@ -52,11 +86,48 @@ for (const theme of THEMES) {
52 } 86 }
53 } 87 }
54 88
89+// ---------------------------------------------------------------------------------------------------------- shell sweep
90+console.log('\n— shell sweep (320 … 1920, dark + light, palette open) —');
91+for (const theme of THEMES) {
92+ for (const width of SHELL_WIDTHS) {
93+ const mobile = width < 768;
94+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
95+ await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
96+ const page = await ctx.newPage();
97+ for (const p of SHELL_PAGES) {
98+ const palette = p === '/#palette';
99+ const path = palette ? '/' : p;
100+ const errors = [];
101+ const onErr = (e) => errors.push(String(e));
102+ const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };
103+ page.on('pageerror', onErr);
104+ page.on('console', onCon);
105+ const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
106+ if (palette) {
107+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k');
108+ await page.waitForSelector('[data-palette-input]', { timeout: 5000 }).catch(() => undefined);
109+ await page.waitForTimeout(300);
110+ } else await page.waitForTimeout(400);
111+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
112+ const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : [];
113+ const filtered = filterErrors(errors, 200);
114+ const ok = res.status() === 200 && overflow <= 0 && filtered.length === 0 && small.length === 0;
115+ if (!ok) failures++;
116+ console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${res.status()} overflow=${overflow} errors=${filtered.length} small-targets=${small.length} ${p}${small.length ? ' :: ' + small.slice(0, 3).join(' | ') : ''}${filtered.length ? ' :: ' + filtered[0].slice(0, 120) : ''}`);
117+ await page.screenshot({ path: `${OUT}shell-${theme}-${width}-${p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);
118+ if (palette) await page.keyboard.press('Escape');
119+ page.off('pageerror', onErr);
120+ page.off('console', onCon);
121+ }
122+ await ctx.close();
123+ }
124+}
125+
55 // Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be 126 // Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be
56 // ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load): 127 // ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load):
57 // the tolerance is derived from the observed ingestion rate and is zero when the database is static. 128 // the tolerance is derived from the observed ingestion rate and is zero when the database is static.
58 try { 129 try {
59− const pick = (s) => ({ models: s.entities?.model, 'change events': s.change_events, sources: s.sources, papers: s.entities?.paper, providers: s.entities?.provider, benchmarks: s.entities?.benchmark }); 130+ const pick = (s) => ({ models: s.entities?.model, 'benchmark results': s.benchmark_results, documents: s.documents, papers: s.entities?.paper, providers: s.entities?.provider, datasets: s.entities?.dataset, 'current prices': s.prices_current });
60 const t0 = Date.now(); 131 const t0 = Date.now();
61 const before = pick(await (await fetch(`${API}/api/v1/stats`)).json()); 132 const before = pick(await (await fetch(`${API}/api/v1/stats`)).json());
62 const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); 133 const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
added apps/web/qa/shell.mjs +155 −0
@@ -0,0 +1,155 @@
1+/**
2+ * Shell flows (D0): command palette (⌘K → `m:claude` → select), More menu (keyboard), density toggle (persisted
3+ * `data-density`), evidence drawer on a model page (first `[data-evidence]`), watchlist add → /watchlist feed,
4+ * favicon render at 16 / 32 px, OG + icon routes. Screenshots → qa/screens/shell/.
5+ * Run: node qa/shell.mjs [BASE_URL] [API_URL]
6+ */
7+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
8+import { mkdirSync } from 'node:fs';
9+
10+const BASE = process.argv[2] ?? 'http://localhost:8330';
11+const API = process.argv[3] ?? 'http://127.0.0.1:8331';
12+const OUT = new URL('./screens/shell/', import.meta.url).pathname;
13+mkdirSync(OUT, { recursive: true });
14+const MOD = process.platform === 'darwin' ? 'Meta' : 'Control';
15+
16+let failures = 0;
17+const check = (ok, label, extra = '') => {
18+ if (!ok) failures++;
19+ console.log(`${ok ? 'OK ' : 'FAIL'} ${label}${extra ? ' :: ' + extra : ''}`);
20+};
21+
22+const firstModel = await fetch(`${API}/api/v1/models?limit=1`)
23+ .then((r) => r.json())
24+ .then((j) => j.items?.[0]?.slug)
25+ .catch(() => null);
26+check(!!firstModel, `first model slug from API (${firstModel})`);
27+
28+const browser = await chromium.launch();
29+const errors = [];
30+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
31+await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark'));
32+const page = await ctx.newPage();
33+page.on('pageerror', (e) => errors.push(String(e)));
34+// The 1.1 provenance endpoint may 404 until the API stream lands — the drawer falls back by design, so that resource error is expected.
35+page.on('console', (m) => m.type() === 'error' && !/favicon|Failed to load resource: the server responded with a status of 404/.test(m.text()) && errors.push(m.text()));
36+
37+// ---- 1. palette: ⌘K, type m:claude, arrow down, Enter → a model page
38+await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
39+await page.keyboard.press(`${MOD}+k`);
40+await page.waitForSelector('[data-palette-input]', { timeout: 5000 });
41+check(await page.isVisible('[data-palette]'), 'palette opens with ⌘K');
42+const cmdRows = await page.locator('[data-palette-row="command"]').count();
43+check(cmdRows >= 4, `palette shows commands when empty (${cmdRows})`);
44+await page.keyboard.type('m:claude');
45+await page.waitForSelector('[data-palette-row="entity"]', { timeout: 8000 }).catch(() => undefined);
46+const entRows = await page.locator('[data-palette-row="entity"]').count();
47+const prefixChip = await page.locator('[data-palette] .mono:has-text("Models")').count();
48+check(entRows > 0 && prefixChip > 0, `m: prefix filters to models (${entRows} rows, chip=${prefixChip})`);
49+const nonModel = await page.locator('[data-palette-row="entity"] span.uppercase').evaluateAll((els) => els.map((e) => e.textContent.trim()).filter((t) => t !== 'Model').length);
50+check(nonModel === 0, 'all palette rows are models');
51+await page.screenshot({ path: `${OUT}palette-m-claude.png` });
52+await page.keyboard.press('ArrowDown');
53+await page.keyboard.press('Enter');
54+await page.waitForURL(/\/models\//, { timeout: 15000 }).catch(() => undefined);
55+check(/\/models\//.test(page.url()), `Enter navigates to a model page (${new URL(page.url()).pathname})`);
56+const recent = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-recent') || '[]').length);
57+check(recent >= 1, `recent entities stored (${recent})`);
58+
59+// commands mode: ">" then Toggle density
60+await page.waitForLoadState('networkidle');
61+await page.keyboard.press(`${MOD}+k`);
62+await page.waitForSelector('[data-palette-input]');
63+await page.focus('[data-palette-input]');
64+await page.keyboard.type('>dens');
65+await page.waitForSelector('[data-palette-row="command"][aria-selected="true"]:has-text("density")', { timeout: 5000 }).catch(() => undefined);
66+const cmdOnly = (await page.locator('[data-palette-row="entity"]').count()) === 0 && (await page.locator('[data-palette-row="command"]').count()) >= 1;
67+check(cmdOnly, '">" shows commands only');
68+await page.keyboard.press('Enter');
69+await page.waitForTimeout(200);
70+const dens1 = await page.evaluate(() => document.documentElement.getAttribute('data-density'));
71+check(dens1 === 'compact', `"Toggle density" command → data-density=${dens1}`);
72+
73+// ---- 2. More menu (mouse + keyboard)
74+await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
75+await page.click('[data-more-button]');
76+await page.waitForSelector('[data-more-menu]');
77+const items = await page.locator('[data-more-menu] [role="menuitem"]').count();
78+check(items >= 18, `More menu lists the full site (${items} items)`);
79+const menuBox = await page.locator('[data-more-menu]').boundingBox();
80+check(menuBox && menuBox.x >= 0 && menuBox.x + menuBox.width <= 1440, `More menu inside viewport (x=${Math.round(menuBox?.x)}, w=${Math.round(menuBox?.width)})`);
81+const focused = await page.evaluate(() => document.activeElement?.getAttribute('role'));
82+check(focused === 'menuitem', 'first menu item focused on open');
83+await page.keyboard.press('ArrowDown');
84+const second = await page.evaluate(() => document.activeElement?.textContent?.trim().split('\n')[0]);
85+check(!!second, `ArrowDown moves focus (${second})`);
86+await page.screenshot({ path: `${OUT}more-menu.png`, clip: { x: 0, y: 0, width: 1440, height: 460 } });
87+await page.keyboard.press('Escape');
88+check((await page.locator('[data-more-menu]').count()) === 0, 'Escape closes the More menu');
89+
90+// ---- 3. density toggle in header, persisted across reload
91+const before = await page.evaluate(() => document.documentElement.getAttribute('data-density'));
92+await page.click('[data-density-toggle]');
93+const after = await page.evaluate(() => document.documentElement.getAttribute('data-density'));
94+check(before !== after, `density toggle changes html[data-density] (${before} → ${after})`);
95+const padBefore = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--d-cell-y').trim());
96+await page.reload({ waitUntil: 'networkidle' });
97+const persisted = await page.evaluate(() => document.documentElement.getAttribute('data-density'));
98+check(persisted === after, `density persisted before paint after reload (${persisted})`);
99+await page.screenshot({ path: `${OUT}density-${after ?? 'comfortable'}.png` });
100+await page.evaluate(() => localStorage.removeItem('aia-density'));
101+check(!!padBefore, `density CSS variable readable (--d-cell-y=${padBefore})`);
102+
103+// ---- 4. evidence drawer on a model page
104+if (firstModel) {
105+ await page.goto(`${BASE}/models/${firstModel}`, { waitUntil: 'networkidle' });
106+ const triggers = await page.locator('[data-evidence]').count();
107+ check(triggers > 0, `model page has evidence triggers (${triggers})`);
108+ if (triggers) {
109+ await page.locator('[data-evidence]').first().click();
110+ await page.waitForSelector('[data-evidence-drawer]', { timeout: 5000 });
111+ await page.waitForTimeout(1200);
112+ const txt = await page.locator('[data-evidence-drawer]').innerText();
113+ check(/Source/.test(txt) && /Tier/.test(txt) && /Observed/.test(txt) && /Extractor/.test(txt), 'drawer shows Source · Tier · Observed · Extractor');
114+ check(/View history/.test(txt), 'drawer links to the property history');
115+ check(/#evidence=/.test(page.url()), `URL hash mirrors the drawer (${new URL(page.url()).hash.slice(0, 40)})`);
116+ await page.screenshot({ path: `${OUT}evidence-drawer.png` });
117+ await page.keyboard.press('Escape');
118+ await page.waitForTimeout(200);
119+ check((await page.locator('[data-evidence-drawer]').count()) === 0, 'Escape closes the drawer');
120+ }
121+
122+ // ---- 5. watchlist add + page
123+ await page.click('[data-watch-button]');
124+ await page.waitForTimeout(200);
125+ check((await page.getAttribute('[data-watch-button]', 'aria-pressed')) === 'true', 'Watch button toggles aria-pressed');
126+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-watchlist') || '[]'));
127+ check(stored.length === 1 && stored[0].slug === firstModel, `watchlist stored in localStorage (${stored.map((s) => s.slug).join(',')})`);
128+ await page.goto(`${BASE}/watchlist`, { waitUntil: 'networkidle' });
129+ await page.waitForSelector('[data-watchlist-items]', { timeout: 8000 });
130+ const listed = await page.locator('[data-watchlist-items] li').count();
131+ check(listed === 1, `/watchlist lists the watched entity (${listed})`);
132+ await page.waitForTimeout(1500);
133+ const feedRows = await page.locator('[data-watchlist-feed] li').count();
134+ const emptyMsg = await page.locator('text=No release, price, deprecation').count();
135+ check(feedRows > 0 || emptyMsg > 0, `/watchlist shows events or an honest empty state (rows=${feedRows}, empty=${emptyMsg})`);
136+ await page.screenshot({ path: `${OUT}watchlist.png` });
137+ await page.evaluate(() => localStorage.removeItem('aia-watchlist'));
138+}
139+
140+// ---- 6. favicon crispness at 16 / 32 px + brand routes
141+const brand = await ctx.newPage();
142+await brand.setViewportSize({ width: 360, height: 100 });
143+await brand.setContent(`<body style="margin:0;background:#e5e5e5;display:flex;gap:20px;align-items:center;padding:16px"><img src="${BASE}/icon.svg" width=16 height=16><img src="${BASE}/icon.svg" width=32 height=32><img src="${BASE}/apple-icon" width=60 height=60><img src="${BASE}/logo-lockup.svg" height=44></body>`);
144+await brand.waitForTimeout(600);
145+await brand.screenshot({ path: `${OUT}favicon-16-32.png` });
146+for (const p of ['/icon.svg', '/apple-icon', '/icon-192.png', '/icon-512.png', '/opengraph-image', '/manifest.webmanifest', '/logo.svg', '/logo-lockup.svg', '/logo-lockup-light.svg']) {
147+ const r = await fetch(BASE + p);
148+ check(r.ok, `${p} → ${r.status} ${r.headers.get('content-type')}`);
149+}
150+await brand.close();
151+
152+check(errors.length === 0, `no console/page errors during flows (${errors.length})`, errors[0]?.slice(0, 160));
153+await browser.close();
154+console.log(failures ? `\n${failures} failure(s)` : '\nall shell flows OK');
155+process.exit(failures ? 1 : 0);
modified apps/web/src/app/about/page.tsx +52 −12
@@ -1,38 +1,78 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { LogoMark } from '@/components/brand/logo';
3 import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; 4 import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
4 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
5 import { fmtBytes, fmtDate, fmtInt } from '@/lib/format'; 6 import { fmtBytes, fmtDate, fmtInt } from '@/lib/format';
6−import { CONTACT_EMAIL, routes } from '@/lib/site'; 7+import { AUTHOR_NAME, CONTACT_EMAIL, HOST_NAME, HOST_URL, routes, SITE_URL } from '@/lib/site';
7 8
8−export const metadata: Metadata = { title: 'About AI Atlas', description: 'Why AI Atlas exists: a structured, searchable, continuously updated map of the AI ecosystem with provenance on every fact and history that is never discarded.', alternates: { canonical: '/about' } }; 9+export const metadata: Metadata = {
10+ title: 'About AI Atlas',
11+ description: 'What AI Atlas is: a temporal knowledge graph of the AI ecosystem with field-level provenance and a permanent archive of every source document. Built by Simon-Pierre Boucher, hosted on MacLustr.',
12+ alternates: { canonical: '/about' },
13+};
9 export const revalidate = 600; 14 export const revalidate = 600;
10 15
16+const PILLARS = [
17+ { title: 'Temporal knowledge graph', text: 'Entities (models, organizations, papers, providers, benchmarks, hardware, frameworks, datasets, licences) and their relations form one graph. Every claim carries a validity interval: the atlas can be read as of any past date, and two dates can be diffed.' },
18+ { title: 'Field-level provenance', text: 'Every value answers who said it (source and tier), when (observation time), where (the exact URL) and how it was extracted (deterministic parser or local LLM, with confidence). Conflicts are stored side by side and flagged — never averaged.' },
19+ { title: 'Permanent archive', text: 'Each source document is snapshotted and kept, content-addressed. A better parser re-reads the archive instead of re-crawling; a fact can always be traced back to the page that stated it.' },
20+];
21+
11 export default async function AboutPage() { 22 export default async function AboutPage() {
12 const stats = await safe(api.stats()); 23 const stats = await safe(api.stats());
13 return ( 24 return (
14 <Container> 25 <Container>
15− <PageHeader eyebrow="About" title="A map of the AI ecosystem you can audit" lede="AI Atlas is the most comprehensive, structured and continuously updated map of artificial intelligence: models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — connected in one graph, with the source of every fact." /> 26+ <PageHeader eyebrow="About" title="A map of the AI ecosystem you can audit" lede="AI Atlas is the temporal knowledge graph of the AI ecosystem: models, organizations, research, providers and pricing, benchmarks, hardware, frameworks and datasets — continuously reconstructed from primary sources, with the provenance of every fact and a history that is never discarded." aside={<LogoMark size={64} />} />
16 <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> 27 <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2">
17− <p>Think Bloomberg for AI: dense, current, sourced. Think Wikipedia: public, cross-linked, neutral. Think Crunchbase, Hugging Face and Papers With Code: organizations, models and research in one place. AI Atlas is none of these products — it is a dataset with a website and an API as its interfaces.</p> 28+ <p>Think Bloomberg for AI: dense, current, sourced. Think Wikipedia: public, cross-linked, neutral. Think Crunchbase, Hugging Face and Papers With Code: organizations, models and research in one place. AI Atlas is none of these products — it is a dataset with a website and an API as its interfaces. It is not a directory: nothing here is typed in by hand, and nothing is overwritten.</p>
18− <p>Three commitments shape everything. <strong className="text-ink">Provenance</strong>: every value shows where it came from, the tier of that source, and when it was last observed. <strong className="text-ink">History</strong>: prices, results and claims are append-only; you can ask what the atlas knew on a given date. <strong className="text-ink">Honesty</strong>: missing data reads “Unavailable”, conflicting sources are shown side by side, and the only derived numbers (hardware fit) are labelled as estimates.</p> 29+ <p>Public information stays public: no login is needed to read anything on this site. Everything is also available through the <Link href={routes.developers()} className="link">public API</Link> at <span className="mono text-ink">{SITE_URL.replace(/^https?:\/\//, '')}/api/v1</span>.</p>
19− <p>Public information stays public: no login is needed to read anything on this site. Everything is also available through the <Link href={routes.developers()} className="link">public API</Link>.</p>
20 </div> 30 </div>
31+
32+ <Section eyebrow="What it is" title="Three commitments" hairline>
33+ <ol className="grid gap-6 md:grid-cols-3">
34+ {PILLARS.map((p, i) => (
35+ <li key={p.title} className="border-t-2 border-ink pt-3">
36+ <p className="mono text-xs text-ink-3">0{i + 1}</p>
37+ <p className="mt-1 font-semibold">{p.title}</p>
38+ <p className="mt-1.5 text-sm leading-relaxed text-ink-2">{p.text}</p>
39+ </li>
40+ ))}
41+ </ol>
42+ <p className="mt-5 text-sm text-ink-2">
43+ Missing data reads “Unavailable”. The only derived numbers (hardware fit) are labelled as estimates. Counters carry their definition. Read the <Link href={routes.methodology()} className="link">methodology</Link>, browse the <Link href={routes.sources()} className="link">sources</Link>, or learn about the <Link href={routes.bot()} className="link">crawler</Link>.
44+ </p>
45+ </Section>
46+
21 {stats && ( 47 {stats && (
22− <Section eyebrow="Right now" title="The atlas in numbers" hairline={false}> 48+ <Section eyebrow="Right now" title="The atlas in numbers">
23− <StatGrid cols={5}> 49+ <StatGrid cols={6}>
24 <Stat label="Entities" value={fmtInt(stats.entities_total)} /> 50 <Stat label="Entities" value={fmtInt(stats.entities_total)} />
25 <Stat label="Claims" value={fmtInt(stats.claims)} hint={`${fmtInt(stats.claims_current)} current`} /> 51 <Stat label="Claims" value={fmtInt(stats.claims)} hint={`${fmtInt(stats.claims_current)} current`} />
26 <Stat label="Relations" value={fmtInt(stats.relations)} /> 52 <Stat label="Relations" value={fmtInt(stats.relations)} />
53+ <Stat label="Change events" value={fmtInt(stats.change_events)} />
27 <Stat label="Snapshots archived" value={fmtInt(stats.snapshots)} hint={fmtBytes(stats.archive?.raw_bytes)} /> 54 <Stat label="Snapshots archived" value={fmtInt(stats.snapshots)} hint={fmtBytes(stats.archive?.raw_bytes)} />
28 <Stat label="Since" value={fmtDate(stats.first_entity_at)} /> 55 <Stat label="Since" value={fmtDate(stats.first_entity_at)} />
29 </StatGrid> 56 </StatGrid>
30 </Section> 57 </Section>
31 )} 58 )}
32− <Section eyebrow="Who" title="Built and operated by Simon-Pierre Boucher"> 59+
33− <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> 60+ <Section eyebrow="Who" title={`Built by ${AUTHOR_NAME}`}>
34− AI Atlas runs on <a href="https://www.maclustr.io" className="link" rel="noopener noreferrer">MacLustr</a>, a private Apple-silicon cluster in Québec that also hosts the local LLM factory used for extraction. Questions, corrections and source suggestions: <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>. Read the <Link href={routes.methodology()} className="link">methodology</Link>, browse the <Link href={routes.sources()} className="link">sources</Link>, or learn about the <Link href={routes.bot()} className="link">crawler</Link>. 61+ <div className="max-w-3xl space-y-3 text-sm leading-relaxed text-ink-2">
35− </p> 62+ <p>
63+ AI Atlas is designed, built and operated by <span className="text-ink">{AUTHOR_NAME}</span>. Questions, corrections and source suggestions:{' '}
64+ <a href={`mailto:${CONTACT_EMAIL}`} className="link">
65+ {CONTACT_EMAIL}
66+ </a>
67+ .
68+ </p>
69+ <p>
70+ It is hosted on <a href={HOST_URL} className="link" rel="noopener noreferrer">{HOST_NAME}</a> (<a href={HOST_URL} className="link" rel="noopener noreferrer">{HOST_URL.replace(/^https?:\/\//, '')}</a>), a private Apple-silicon cluster in Québec that also runs the local LLM factory used for extraction — no data leaves the cluster for third-party model APIs.
71+ </p>
72+ <p className="text-xs text-ink-3">
73+ Built by {AUTHOR_NAME} · {CONTACT_EMAIL} · Hosted on {HOST_NAME} ({HOST_URL.replace(/^https?:\/\//, '')})
74+ </p>
75+ </div>
36 </Section> 76 </Section>
37 </Container> 77 </Container>
38 ); 78 );
modified apps/web/src/app/apple-icon.tsx +4 −12
@@ -1,23 +1,15 @@
1 import { ImageResponse } from 'next/og'; 1 import { ImageResponse } from 'next/og';
2+import { MARK_DARK, MarkImg } from '@/components/brand/mark';
2 3
3−/** 180×180 PNG generated from the AI Atlas mark (globe + relation graph) on the dark canvas. */ 4+/** 180×180 PNG: the atlas-plate mark on the dark canvas (iOS applies its own corner mask, so the plate fills the tile). */
4 export const size = { width: 180, height: 180 }; 5 export const size = { width: 180, height: 180 };
5 export const contentType = 'image/png'; 6 export const contentType = 'image/png';
6 7
7 export default function AppleIcon() { 8 export default function AppleIcon() {
8 return new ImageResponse( 9 return new ImageResponse(
9 ( 10 (
10− <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11', borderRadius: 40 }}> 11+ <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
11− <svg width="132" height="132" viewBox="0 0 32 32" fill="none"> 12+ <MarkImg px={180} colors={{ ...MARK_DARK, plate: '#12151b' }} radius={34 / (180 / 32)} />
12− <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" />
13− <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" />
14− <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" />
15− <circle cx="16" cy="16" r="2.4" fill="#6d95ff" />
16− <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" />
17− <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" />
18− <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" />
19− <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" />
20− </svg>
21 </div> 13 </div>
22 ), 14 ),
23 { ...size }, 15 { ...size },
modified apps/web/src/app/globals.css +229 −7
@@ -55,13 +55,53 @@
55 --series-7: #7c8293; 55 --series-7: #7c8293;
56 --series-8: #c0333d; 56 --series-8: #c0333d;
57 57
58+ /* Brand plate (LogoMark): inverted per theme so the mark always reads as a solid plate with a bright "now" node. */
59+ --brand-plate: #101216;
60+ --brand-ink: #f6f6f3;
61+ --brand-grid: rgba(246, 246, 243, 0.22);
62+ --brand-accent: #7fa3ff;
63+
58 --radius: 4px; 64 --radius: 4px;
59 --radius-lg: 8px; 65 --radius-lg: 8px;
60 --header-h: 56px; 66 --header-h: 56px;
61 --tabbar-h: 58px; 67 --tabbar-h: 58px;
68+
69+ /* Density (html[data-density]) — comfortable by default. Tables, key–value rows, sections and the base size scale. */
70+ --d-base: 15px;
71+ --d-cell-y: 0.55rem;
72+ --d-cell-x: 0.75rem;
73+ --d-kv-y: 0.5rem;
74+ --d-row-y: 0.625rem;
75+ --d-section-y: 1.75rem;
76+ --d-section-y-md: 2.5rem;
77+ --d-table-fs: 0.875rem;
78+}
79+[data-density='compact'] {
80+ --d-base: 14px;
81+ --d-cell-y: 0.38rem;
82+ --d-cell-x: 0.625rem;
83+ --d-kv-y: 0.32rem;
84+ --d-row-y: 0.45rem;
85+ --d-section-y: 1.25rem;
86+ --d-section-y-md: 1.75rem;
87+ --d-table-fs: 0.8125rem;
88+}
89+[data-density='dense'] {
90+ --d-base: 13.5px;
91+ --d-cell-y: 0.22rem;
92+ --d-cell-x: 0.5rem;
93+ --d-kv-y: 0.2rem;
94+ --d-row-y: 0.3rem;
95+ --d-section-y: 0.875rem;
96+ --d-section-y-md: 1.25rem;
97+ --d-table-fs: 0.78rem;
62 } 98 }
63 99
64 [data-theme='dark'] { 100 [data-theme='dark'] {
101+ --brand-plate: #e9ebf0;
102+ --brand-ink: #0b0d11;
103+ --brand-grid: rgba(11, 13, 17, 0.2);
104+ --brand-accent: #1f4fd8;
65 color-scheme: dark; 105 color-scheme: dark;
66 --canvas: #0b0d11; 106 --canvas: #0b0d11;
67 --surface: #12151b; 107 --surface: #12151b;
@@ -175,7 +215,7 @@
175 } 215 }
176 body { 216 body {
177 font-family: var(--font-sans); 217 font-family: var(--font-sans);
178− font-size: 15px; 218+ font-size: var(--d-base, 15px);
179 line-height: 1.5; 219 line-height: 1.5;
180 background: var(--canvas); 220 background: var(--canvas);
181 color: var(--ink); 221 color: var(--ink);
@@ -349,7 +389,7 @@
349 width: 100%; 389 width: 100%;
350 border-collapse: collapse; 390 border-collapse: collapse;
351 font-variant-numeric: tabular-nums; 391 font-variant-numeric: tabular-nums;
352− font-size: 0.875rem; 392+ font-size: var(--d-table-fs, 0.875rem);
353 } 393 }
354 .data-table th { 394 .data-table th {
355 text-align: left; 395 text-align: left;
@@ -358,7 +398,7 @@
358 letter-spacing: 0.08em; 398 letter-spacing: 0.08em;
359 text-transform: uppercase; 399 text-transform: uppercase;
360 color: var(--ink-3); 400 color: var(--ink-3);
361− padding: 0.5rem 0.75rem; 401+ padding: calc(var(--d-cell-y) * 0.9) var(--d-cell-x);
362 border-bottom: 1px solid var(--rule-strong); 402 border-bottom: 1px solid var(--rule-strong);
363 white-space: nowrap; 403 white-space: nowrap;
364 vertical-align: bottom; 404 vertical-align: bottom;
@@ -372,7 +412,7 @@
372 padding-right: 0; 412 padding-right: 0;
373 } 413 }
374 .data-table td { 414 .data-table td {
375− padding: 0.55rem 0.75rem; 415+ padding: var(--d-cell-y) var(--d-cell-x);
376 border-bottom: 1px solid var(--rule); 416 border-bottom: 1px solid var(--rule);
377 vertical-align: middle; 417 vertical-align: middle;
378 } 418 }
@@ -391,8 +431,8 @@
391 color: var(--ink); 431 color: var(--ink);
392 } 432 }
393 .data-table.compact td { 433 .data-table.compact td {
394− padding-top: 0.35rem; 434+ padding-top: calc(var(--d-cell-y) * 0.65);
395− padding-bottom: 0.35rem; 435+ padding-bottom: calc(var(--d-cell-y) * 0.65);
396 } 436 }
397 .data-table a.row-link { 437 .data-table a.row-link {
398 color: inherit; 438 color: inherit;
@@ -467,7 +507,7 @@
467 display: grid; 507 display: grid;
468 grid-template-columns: 9.5rem minmax(0, 1fr); 508 grid-template-columns: 9.5rem minmax(0, 1fr);
469 gap: 0.25rem 1rem; 509 gap: 0.25rem 1rem;
470− padding: 0.5rem 0; 510+ padding: var(--d-kv-y) 0;
471 border-bottom: 1px solid var(--rule); 511 border-bottom: 1px solid var(--rule);
472 align-items: baseline; 512 align-items: baseline;
473 } 513 }
@@ -485,6 +525,183 @@
485 } 525 }
486 } 526 }
487 527
528+/* ---------------------------------------------------------------------------------------------------------- density-aware layout */
529+/* Section vertical padding follows the density variables (Section component uses `.section-y`). */
530+.section-y {
531+ padding-top: var(--d-section-y);
532+ padding-bottom: var(--d-section-y);
533+}
534+@media (min-width: 768px) {
535+ .section-y {
536+ padding-top: var(--d-section-y-md);
537+ padding-bottom: var(--d-section-y-md);
538+ }
539+}
540+/* Dense feed rows (ChangeRow, lists): vertical padding from density. */
541+.row-y {
542+ padding-top: var(--d-row-y);
543+ padding-bottom: var(--d-row-y);
544+}
545+
546+/* ---------------------------------------------------------------------------------------------------------- evidence trigger */
547+/* A displayed value that can open the evidence drawer: subtle dotted underline, no button chrome. */
548+.evidence {
549+ appearance: none;
550+ background: none;
551+ border: 0;
552+ padding: 0;
553+ margin: 0;
554+ font: inherit;
555+ color: inherit;
556+ text-align: inherit;
557+ cursor: help;
558+ text-decoration: underline dotted;
559+ text-decoration-color: var(--rule-strong);
560+ text-decoration-thickness: 1px;
561+ text-underline-offset: 3px;
562+ border-radius: 2px;
563+}
564+.evidence:hover {
565+ text-decoration-color: var(--accent);
566+ color: var(--accent);
567+}
568+.evidence:focus-visible {
569+ outline: 2px solid var(--accent);
570+ outline-offset: 2px;
571+}
572+
573+/* ---------------------------------------------------------------------------------------------------------- hint tooltip (pure CSS) */
574+.hint {
575+ position: relative;
576+ display: inline-flex;
577+}
578+.hint > .hint-bubble {
579+ position: absolute;
580+ left: 0;
581+ top: calc(100% + 6px);
582+ z-index: 60;
583+ width: max-content;
584+ max-width: 18rem;
585+ padding: 0.45rem 0.6rem;
586+ font-size: 0.75rem;
587+ line-height: 1.35;
588+ font-weight: 400;
589+ letter-spacing: normal;
590+ text-transform: none;
591+ color: var(--ink-2);
592+ background: var(--surface);
593+ border: 1px solid var(--rule-strong);
594+ border-radius: var(--radius);
595+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
596+ opacity: 0;
597+ pointer-events: none;
598+ transform: translateY(-2px);
599+ transition: opacity 120ms ease, transform 120ms ease;
600+}
601+.hint.hint-right > .hint-bubble {
602+ left: auto;
603+ right: 0;
604+}
605+.hint:hover > .hint-bubble,
606+.hint:focus-within > .hint-bubble {
607+ opacity: 1;
608+ pointer-events: auto;
609+ transform: translateY(0);
610+}
611+
612+/* ---------------------------------------------------------------------------------------------------------- ticker */
613+.ticker {
614+ overflow: hidden;
615+ mask-image: linear-gradient(to right, transparent, #000 4%, #000 96%, transparent);
616+}
617+.ticker-track {
618+ display: flex;
619+ width: max-content;
620+ gap: 2.5rem;
621+ animation: ticker-scroll var(--ticker-duration, 60s) linear infinite;
622+}
623+.ticker:hover .ticker-track,
624+.ticker[data-paused='true'] .ticker-track {
625+ animation-play-state: paused;
626+}
627+@keyframes ticker-scroll {
628+ from {
629+ transform: translateX(0);
630+ }
631+ to {
632+ transform: translateX(-50%);
633+ }
634+}
635+
636+/* ---------------------------------------------------------------------------------------------------------- heatmap */
637+.heatmap {
638+ border-collapse: separate;
639+ border-spacing: 2px;
640+ font-size: 0.75rem;
641+}
642+.heatmap th {
643+ position: sticky;
644+ background: var(--canvas);
645+ z-index: 2;
646+ font-weight: 500;
647+ color: var(--ink-2);
648+ white-space: nowrap;
649+}
650+.heatmap thead th {
651+ top: 0;
652+ vertical-align: bottom;
653+ padding: 0.25rem 0.3rem;
654+}
655+.heatmap tbody th {
656+ left: 0;
657+ text-align: left;
658+ padding: 0 0.6rem 0 0;
659+ z-index: 3;
660+}
661+.heatmap thead th:first-child {
662+ left: 0;
663+ z-index: 4;
664+}
665+.heatmap td {
666+ min-width: 2.6rem;
667+ height: 1.7rem;
668+ text-align: center;
669+ font-variant-numeric: tabular-nums;
670+ border-radius: 2px;
671+ color: var(--ink);
672+}
673+.heatmap td a {
674+ display: block;
675+ color: inherit;
676+ text-decoration: none;
677+}
678+.heatmap td.empty {
679+ background: var(--surface-2);
680+ color: var(--ink-3);
681+}
682+
683+/* ---------------------------------------------------------------------------------------------------------- terminal layout */
684+.terminal-rail {
685+ position: sticky;
686+ top: calc(var(--header-h) + 0.75rem);
687+ max-height: calc(100dvh - var(--header-h) - 1.5rem);
688+ overflow-y: auto;
689+ scrollbar-width: thin;
690+}
691+.sheet-enter {
692+ animation: sheet-in 160ms ease-out;
693+}
694+@keyframes sheet-in {
695+ from {
696+ opacity: 0;
697+ transform: translateY(8px);
698+ }
699+ to {
700+ opacity: 1;
701+ transform: translateY(0);
702+ }
703+}
704+
488 @media (prefers-reduced-motion: reduce) { 705 @media (prefers-reduced-motion: reduce) {
489 *, 706 *,
490 *::before, 707 *::before,
@@ -492,4 +709,9 @@
492 animation-duration: 0.01ms !important; 709 animation-duration: 0.01ms !important;
493 transition-duration: 0.01ms !important; 710 transition-duration: 0.01ms !important;
494 } 711 }
712+ .ticker-track {
713+ animation: none;
714+ width: 100%;
715+ overflow-x: auto;
716+ }
495 } 717 }
modified apps/web/src/app/icon-192.png/render.tsx +5 −14
@@ -1,24 +1,15 @@
1 import { ImageResponse } from 'next/og'; 1 import { ImageResponse } from 'next/og';
2+import { MARK_DARK, MarkImg } from '@/components/brand/mark';
2 3
3 /** 4 /**
4− * Square PNG app icon (manifest 192 / 512) drawn from the AI Atlas mark — same artwork as `apple-icon.tsx`. 5+ * Square PNG app icon (manifest 192 / 512) drawn from the atlas-plate mark — same geometry as `icon.svg` and `apple-icon.tsx`.
5− * Colours are the dark canvas / ink / accent tokens; raw hex is unavoidable in ImageResponse (no CSS variables). 6+ * The plate fills the tile (maskable-safe: the graph sits inside the central 80 %). Raw hex is unavoidable in ImageResponse.
6 */ 7 */
7 export function renderIcon(px: number): ImageResponse { 8 export function renderIcon(px: number): ImageResponse {
8− const mark = Math.round(px * 0.72);
9 return new ImageResponse( 9 return new ImageResponse(
10 ( 10 (
11− <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11' }}> 11+ <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
12− <svg width={mark} height={mark} viewBox="0 0 32 32" fill="none"> 12+ <MarkImg px={px} colors={{ ...MARK_DARK, plate: '#12151b' }} radius={px >= 512 ? 5 : 6} />
13− <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" />
14− <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" />
15− <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" />
16− <circle cx="16" cy="16" r="2.4" fill="#6d95ff" />
17− <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" />
18− <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" />
19− <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" />
20− <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" />
21− </svg>
22 </div> 13 </div>
23 ), 14 ),
24 { width: px, height: px, headers: { 'cache-control': 'public, max-age=86400, s-maxage=86400' } }, 15 { width: px, height: px, headers: { 'cache-control': 'public, max-age=86400, s-maxage=86400' } },
modified apps/web/src/app/icon.svg +8 −9
@@ -1,11 +1,10 @@
1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> 1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
2− <rect width="32" height="32" rx="6" fill="#0b0d11"/> 2+ <rect x="1" y="1" width="30" height="30" rx="7" fill="#0b0d11"/>
3− <circle cx="16" cy="16" r="11.5" stroke="#e9ebf0" stroke-width="1.5"/> 3+ <line x1="4" x2="28" y1="10.5" y2="10.5" stroke="rgba(233,235,240,0.22)" stroke-width="1"/>
4− <path d="M16 4.5c-3.9 2.9-5.8 6.7-5.8 11.5s1.9 8.6 5.8 11.5M16 4.5c3.9 2.9 5.8 6.7 5.8 11.5s-1.9 8.6-5.8 11.5M4.5 16h23" stroke="#e9ebf0" stroke-width="1.1" opacity="0.7"/> 4+ <line x1="4" x2="28" y1="17" y2="17" stroke="rgba(233,235,240,0.22)" stroke-width="1"/>
5− <path d="M10.2 10.3L16 16l5.8-5.7M16 16l-4.6 7.4M16 16l6.5 5" stroke="#e9ebf0" stroke-width="1.1" opacity="0.55"/> 5+ <line x1="5" x2="27" y1="24" y2="24" stroke="#e9ebf0" stroke-width="1.4" opacity="0.75" stroke-linecap="round"/>
6− <circle cx="16" cy="16" r="2.3" fill="#6d95ff"/> 6+ <path d="M8.5 24L16 8L23.5 24" fill="none" stroke="#e9ebf0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
7− <circle cx="10.2" cy="10.3" r="1.6" fill="#e9ebf0"/> 7+ <circle cx="16" cy="8" r="2.4" fill="#e9ebf0"/>
8− <circle cx="21.8" cy="10.3" r="1.6" fill="#e9ebf0"/> 8+ <circle cx="8.5" cy="24" r="2.4" fill="#e9ebf0"/>
9− <circle cx="11.4" cy="23.4" r="1.6" fill="#e9ebf0"/> 9+ <circle cx="23.5" cy="24" r="2.8" fill="#6d95ff"/>
10− <circle cx="22.5" cy="21" r="1.6" fill="#e9ebf0"/>
11 </svg> 10 </svg>
modified apps/web/src/app/layout.tsx +18 −13
@@ -1,14 +1,16 @@
1 import type { Metadata, Viewport } from 'next'; 1 import type { Metadata, Viewport } from 'next';
2 import Script from 'next/script'; 2 import Script from 'next/script';
3 import './globals.css'; 3 import './globals.css';
4+import { EvidenceDrawer } from '@/components/evidence/evidence-drawer';
5+import { EvidenceProvider } from '@/components/evidence/evidence-context';
4 import { MobileTabBar } from '@/components/layout/mobile-tab-bar'; 6 import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
5 import { SearchProvider } from '@/components/layout/search-context'; 7 import { SearchProvider } from '@/components/layout/search-context';
6 import { SearchDialog } from '@/components/layout/search-dialog'; 8 import { SearchDialog } from '@/components/layout/search-dialog';
7 import { SiteFooter } from '@/components/layout/site-footer'; 9 import { SiteFooter } from '@/components/layout/site-footer';
8 import { SiteHeader } from '@/components/layout/site-header'; 10 import { SiteHeader } from '@/components/layout/site-header';
9−import { THEME_SCRIPT } from '@/components/layout/theme';
10 import { fontMono, fontUi } from '@/lib/fonts'; 11 import { fontMono, fontUi } from '@/lib/fonts';
11−import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site'; 12+import { PREPAINT_SCRIPT } from '@/lib/prepaint';
13+import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE, THEME_DARK, THEME_LIGHT } from '@/lib/site';
12 14
13 export const metadata: Metadata = { 15 export const metadata: Metadata = {
14 metadataBase: new URL(SITE_URL), 16 metadataBase: new URL(SITE_URL),
@@ -27,8 +29,8 @@ export const viewport: Viewport = {
27 initialScale: 1, 29 initialScale: 1,
28 viewportFit: 'cover', 30 viewportFit: 'cover',
29 themeColor: [ 31 themeColor: [
30− { media: '(prefers-color-scheme: light)', color: '#f6f6f3' }, 32+ { media: '(prefers-color-scheme: light)', color: THEME_LIGHT },
31− { media: '(prefers-color-scheme: dark)', color: '#0b0d11' }, 33+ { media: '(prefers-color-scheme: dark)', color: THEME_DARK },
32 ], 34 ],
33 }; 35 };
34 36
@@ -36,20 +38,23 @@ export default function RootLayout({ children }: { children: React.ReactNode })
36 return ( 38 return (
37 <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`} suppressHydrationWarning> 39 <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`} suppressHydrationWarning>
38 <head> 40 <head>
39− <Script id="aia-theme" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} /> 41+ <Script id="aia-theme" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: PREPAINT_SCRIPT }} />
40 </head> 42 </head>
41− <body className="flex min-h-full flex-col pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px))] md:pb-0"> 43+ <body className="flex min-h-full flex-col pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px))] lg:pb-0">
42 <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink"> 44 <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink">
43 Skip to content 45 Skip to content
44 </a> 46 </a>
45 <SearchProvider> 47 <SearchProvider>
46− <SiteHeader /> 48+ <EvidenceProvider>
47− <main id="main" className="flex-1"> 49+ <SiteHeader />
48− {children} 50+ <main id="main" className="flex-1">
49− </main> 51+ {children}
50− <SiteFooter /> 52+ </main>
51− <MobileTabBar /> 53+ <SiteFooter />
52− <SearchDialog /> 54+ <MobileTabBar />
55+ <SearchDialog />
56+ <EvidenceDrawer />
57+ </EvidenceProvider>
53 </SearchProvider> 58 </SearchProvider>
54 </body> 59 </body>
55 </html> 60 </html>
modified apps/web/src/app/manifest.ts +8 −8
@@ -1,13 +1,12 @@
1 import type { MetadataRoute } from 'next'; 1 import type { MetadataRoute } from 'next';
2−import { DESCRIPTION, SITE_NAME } from '@/lib/site'; 2+import { DESCRIPTION, SITE_NAME, TAGLINE, THEME_DARK, THEME_LIGHT } from '@/lib/site';
3 3
4 /** 4 /**
5− * Web app manifest (PWA basics). Colours mirror the light theme tokens in globals.css (`--canvas`), matching the 5+ * Web app manifest (PWA basics). PNG icons are rendered on demand by `/icon-192.png` and `/icon-512.png` from the atlas-plate mark.
6− * `themeColor` declared in the root layout. PNG icons are rendered on demand by `/icon-192.png` and `/icon-512.png`.
7 */ 6 */
8 export default function manifest(): MetadataRoute.Manifest { 7 export default function manifest(): MetadataRoute.Manifest {
9 return { 8 return {
10− name: SITE_NAME, 9+ name: `${SITE_NAME} — ${TAGLINE}`,
11 short_name: SITE_NAME, 10 short_name: SITE_NAME,
12 description: DESCRIPTION, 11 description: DESCRIPTION,
13 id: '/', 12 id: '/',
@@ -15,11 +14,11 @@ export default function manifest(): MetadataRoute.Manifest {
15 scope: '/', 14 scope: '/',
16 display: 'standalone', 15 display: 'standalone',
17 orientation: 'any', 16 orientation: 'any',
18− background_color: '#f6f6f3', 17+ background_color: THEME_DARK,
19− theme_color: '#f6f6f3', 18+ theme_color: THEME_LIGHT,
20 lang: 'en', 19 lang: 'en',
21 dir: 'ltr', 20 dir: 'ltr',
22− categories: ['reference', 'productivity'], 21+ categories: ['reference', 'productivity', 'news'],
23 icons: [ 22 icons: [
24 { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, 23 { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
25 { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, 24 { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
@@ -30,8 +29,9 @@ export default function manifest(): MetadataRoute.Manifest {
30 shortcuts: [ 29 shortcuts: [
31 { name: 'Models', short_name: 'Models', description: 'Every AI model with parameters, context, openness and prices', url: '/models', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] }, 30 { name: 'Models', short_name: 'Models', description: 'Every AI model with parameters, context, openness and prices', url: '/models', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
32 { name: 'Changes', short_name: 'Changes', description: 'What changed in AI today', url: '/changes', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] }, 31 { name: 'Changes', short_name: 'Changes', description: 'What changed in AI today', url: '/changes', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
33− { name: 'Compare', short_name: 'Compare', description: 'Compare 2–6 entities side by side', url: '/compare', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] }, 32+ { name: 'Frontier', short_name: 'Frontier', description: 'Who leads, on what, since when', url: '/frontier', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
34 { name: 'Prices', short_name: 'Prices', description: 'AI Price Index — USD per 1M tokens', url: '/prices', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] }, 33 { name: 'Prices', short_name: 'Prices', description: 'AI Price Index — USD per 1M tokens', url: '/prices', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
34+ { name: 'Watchlist', short_name: 'Watchlist', description: 'Entities you follow, with their latest events', url: '/watchlist', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
35 ], 35 ],
36 }; 36 };
37 } 37 }
modified apps/web/src/app/opengraph-image.tsx +11 −62
@@ -1,4 +1,5 @@
1 import { ImageResponse } from 'next/og'; 1 import { ImageResponse } from 'next/og';
2+import { Wallpaper } from '@/components/brand/og';
2 import { api, safe } from '@/lib/api'; 3 import { api, safe } from '@/lib/api';
3 import { fmtInt, num } from '@/lib/format'; 4 import { fmtInt, num } from '@/lib/format';
4 import { SITE_NAME, TAGLINE } from '@/lib/site'; 5 import { SITE_NAME, TAGLINE } from '@/lib/site';
@@ -8,71 +9,19 @@ export const alt = `${SITE_NAME} — ${TAGLINE}`;
8 export const size = { width: 1200, height: 630 }; 9 export const size = { width: 1200, height: 630 };
9 export const contentType = 'image/png'; 10 export const contentType = 'image/png';
10 11
11−/** Default Open Graph image: brand + live counters from /stats when the API is reachable (brand only otherwise). */ 12+/** Root Open Graph "wallpaper": the atlas-plate mark, name + tagline and four live counters from /stats (brand-only when the API is down). */
12 export default async function OpenGraphImage() { 13 export default async function OpenGraphImage() {
13 const stats = await safe(api.stats()); 14 const stats = await safe(api.stats());
15+ const e = stats?.entities ?? {};
16+ const orgs = stats ? (num(e.company) ?? 0) + (num(e.organization) ?? 0) + (num(e.lab) ?? 0) + (num(e.university) ?? 0) : null;
14 const counters: [string, string][] = stats 17 const counters: [string, string][] = stats
15− ? [ 18+ ? ([
16− ['Models', fmtInt(stats.entities?.model)], 19+ ['Models', fmtInt(e.model)],
17− ['Companies', fmtInt(num(stats.entities?.company) ?? num(stats.entities?.organization))], 20+ ['Organizations', orgs ? fmtInt(orgs) : '—'],
18− ['Papers', fmtInt(stats.entities?.paper)], 21+ ['Benchmark results', fmtInt(stats.benchmark_results)],
19− ['Change events', fmtInt(stats.change_events)], 22+ ['Source documents', fmtInt(stats.documents)],
20− ['Sources', fmtInt(stats.sources)], 23+ ].filter((c) => c[1] !== '—') as [string, string][])
21− ].filter((c) => c[1] !== '—') as [string, string][]
22 : []; 24 : [];
23 25
24− return new ImageResponse( 26+ return new ImageResponse(<Wallpaper eyebrow="Temporal knowledge graph" title="AI Atlas" subtitle={`${TAGLINE}. Every number answers: who said it, when, where, and how it was extracted.`} counters={counters} />, { ...size });
25− (
26− <div
27− style={{
28− width: '100%',
29− height: '100%',
30− display: 'flex',
31− flexDirection: 'column',
32− justifyContent: 'space-between',
33− padding: '64px 72px',
34− background: 'linear-gradient(160deg, #12151b 0%, #0b0d11 55%, #0b0d11 100%)',
35− color: '#e9ebf0',
36− fontFamily: 'sans-serif',
37− position: 'relative',
38− }}
39− >
40− <div style={{ position: 'absolute', right: -140, top: -160, width: 640, height: 640, borderRadius: 999, background: 'radial-gradient(circle at 40% 40%, rgba(109,149,255,0.18), rgba(11,13,17,0) 70%)', display: 'flex' }} />
41− <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
42− <svg width="64" height="64" viewBox="0 0 32 32" fill="none">
43− <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" />
44− <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" />
45− <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" />
46− <circle cx="16" cy="16" r="2.4" fill="#6d95ff" />
47− <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" />
48− <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" />
49− <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" />
50− <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" />
51− </svg>
52− <div style={{ display: 'flex', fontSize: 38, fontWeight: 600, letterSpacing: -1 }}>
53− <span>AI</span>
54− <span style={{ color: '#a3a9b8', marginLeft: 10, fontWeight: 500 }}>Atlas</span>
55− </div>
56− </div>
57− <div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
58− <div style={{ fontSize: 68, fontWeight: 600, lineHeight: 1.02, letterSpacing: -2.5, maxWidth: 940, display: 'flex' }}>{TAGLINE}.</div>
59− <div style={{ fontSize: 25, color: '#a3a9b8', display: 'flex' }}>Models · companies · research · providers & pricing · benchmarks · hardware — with provenance and history</div>
60− </div>
61− <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: '#6f7688', fontSize: 22 }}>
62− <span>www.ai-atlas.co</span>
63− {counters.length > 0 && (
64− <div style={{ display: 'flex', gap: 40 }}>
65− {counters.map(([label, value]) => (
66− <div key={label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
67− <span style={{ fontSize: 34, fontWeight: 600, color: '#e9ebf0', letterSpacing: -1 }}>{value}</span>
68− <span style={{ fontSize: 15, letterSpacing: 2, textTransform: 'uppercase' }}>{label}</span>
69− </div>
70− ))}
71− </div>
72− )}
73− </div>
74− </div>
75− ),
76− { ...size },
77− );
78 } 27 }
modified apps/web/src/app/page.tsx +405 −141
@@ -2,14 +2,16 @@ import { ArrowRight, Search } from 'lucide-react';
2 import type { Metadata } from 'next'; 2 import type { Metadata } from 'next';
3 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { ChangeRow } from '@/components/changes/change-row'; 4 import { ChangeRow } from '@/components/changes/change-row';
5−import { EntityBadge } from '@/components/ui/badges'; 5+import { Sparkline, TimelineLanes } from '@/components/charts';
6+import { DataStrip, type StripItem, Ticker, type TickerItem } from '@/components/layout/terminal';
7+import { EntityBadge, OpennessBadge } from '@/components/ui/badges';
6 import { EntityLink } from '@/components/ui/entity'; 8 import { EntityLink } from '@/components/ui/entity';
7 import { LiveAgo } from '@/components/ui/live'; 9 import { LiveAgo } from '@/components/ui/live';
8−import { Container, Section, Stat, StatGrid } from '@/components/ui/section'; 10+import { Container, Section } from '@/components/ui/section';
9 import { Unavailable } from '@/components/ui/unavailable'; 11 import { Unavailable } from '@/components/ui/unavailable';
10 import { api, safe } from '@/lib/api'; 12 import { api, safe } from '@/lib/api';
11−import { fmtDate, fmtInt, num } from '@/lib/format'; 13+import { fmtDate, fmtDeltaPct, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, hostOf, num } from '@/lib/format';
12−import { DESCRIPTION, EXAMPLE_QUERIES, routes, SITE_NAME, SITE_URL, TAGLINE, typeLabel } from '@/lib/site'; 14+import { DESCRIPTION, EXAMPLE_QUERIES, eventTone, routes, signatureProducts, SITE_NAME, TAGLINE } from '@/lib/site';
13 import type { ChangeEvent, EntitySummary } from '@/lib/types'; 15 import type { ChangeEvent, EntitySummary } from '@/lib/types';
14 16
15 export const metadata: Metadata = { 17 export const metadata: Metadata = {
@@ -19,57 +21,147 @@ export const metadata: Metadata = {
19 }; 21 };
20 export const revalidate = 60; 22 export const revalidate = 60;
21 23
22−const HOW = [ 24+/** Fallback "How counted" texts — replaced by `stats.definitions` when the API provides them. */
23− { step: 'Connectors', text: 'First-party crawlers read official docs, pricing pages, model cards, papers, feeds and repositories directly — no third-party data APIs.' }, 25+const DEFINITIONS: Record<string, string> = {
24− { step: 'Archive', text: 'Every document is snapshotted and kept forever, content-addressed, so any fact can be traced back to the exact page that stated it.' }, 26+ model: 'Entities typed “model” in the graph. After canonicalisation: canonical model releases only — artifacts (quantizations, conversions) and folded evaluation variants are excluded.',
25− { step: 'Graph', text: 'Deterministic extractors (then a local LLM only where needed) turn snapshots into temporal claims and relations with source, tier and confidence.' }, 27+ organization: 'Companies, labs, universities and other organizations (entity types company · organization · lab · university).',
26− { step: 'History', text: 'Material changes become events: new models, price moves, context changes, deprecations. Nothing is overwritten; conflicts are flagged, never averaged.' }, 28+ paper: 'Research papers ingested from arXiv and publisher feeds (one entity per paper, deduplicated by identifier).',
27−]; 29+ dataset: 'Datasets known to the graph (Hugging Face and publisher pages).',
28− 30+ benchmark_results: 'Live benchmark result rows (one per model × benchmark × metric × configuration). Superseded runs are closed, never deleted.',
29−function pickFeed(daily: Awaited<ReturnType<typeof api.changesDaily>> | null, changes: ChangeEvent[] | null): { title: string; items: ChangeEvent[] }[] { 31+ provider: 'API providers that serve models (each with its own model listing and prices).',
30− const out: { title: string; items: ChangeEvent[] }[] = []; 32+ prices_current: 'Price offers currently valid (USD per 1M tokens) across all providers — one live row per model × provider × provider model id.',
31− const all = changes ?? []; 33+ documents: 'Source documents crawled by first-party connectors; every fact links back to one of them (snapshots are archived forever).',
32− const by = (pred: (e: ChangeEvent) => boolean, title: string, n = 6) => { 34+};
33− const items = all.filter(pred).slice(0, n); 35+
34− if (items.length) out.push({ title, items }); 36+const OPEN_EVENT_TYPES = new Set(['NEW_MODEL', 'RELEASE', 'VERSION_RELEASED']);
37+
38+function occurredAt(e: ChangeEvent): string {
39+ return e.effective_at ?? e.observed_at;
40+}
41+
42+function priceMove(e: ChangeEvent): { input: [number | null, number | null]; output: [number | null, number | null] } {
43+ const o = (e.old_value && typeof e.old_value === 'object' ? e.old_value : {}) as Record<string, unknown>;
44+ const n = (e.new_value && typeof e.new_value === 'object' ? e.new_value : {}) as Record<string, unknown>;
45+ return { input: [num(o.input_per_mtok), num(n.input_per_mtok)], output: [num(o.output_per_mtok), num(n.output_per_mtok)] };
46+}
47+
48+/** Deterministic pseudo-random constellation for the graph teaser (decorative; the numbers beside it are real). */
49+function constellation(seed: number, n: number): { x: number; y: number; t: number; r: number }[] {
50+ let s = seed;
51+ const rnd = () => {
52+ s = (s * 1103515245 + 12345) & 0x7fffffff;
53+ return s / 0x7fffffff;
35 }; 54 };
36− by((e) => e.event_type === 'NEW_MODEL', 'New models'); 55+ return Array.from({ length: n }, () => ({ x: 8 + rnd() * 304, y: 8 + rnd() * 144, t: Math.floor(rnd() * 6), r: 1.2 + rnd() * 2.2 }));
37− by((e) => e.event_type === 'PRICE_CHANGED' || e.event_type === 'PROVIDER_LISTED', 'Prices');
38− by((e) => e.event_type.startsWith('BENCHMARK'), 'Benchmarks');
39− by((e) => e.event_type === 'ANNOUNCEMENT' || e.event_type === 'RELEASE' || e.event_type === 'VERSION_RELEASED', 'Announcements & releases');
40− by((e) => ['DEPRECATION_ANNOUNCED', 'RETIREMENT_ANNOUNCED', 'STATUS_CHANGED', 'CONTEXT_CHANGED', 'LICENSE_CHANGED', 'OPENNESS_CHANGED'].includes(e.event_type), 'Status, context & licenses');
41− if (!out.length && daily?.sections?.length) for (const s of daily.sections) if (s.items.length) out.push({ title: s.label, items: s.items.slice(0, 6) });
42− if (!out.length && all.length) out.push({ title: 'Latest', items: all.slice(0, 12) });
43− return out;
44 } 56 }
57+const NODE_TYPES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware'];
45 58
46 export default async function HomePage() { 59 export default async function HomePage() {
47− const [stats, daily, changes, types, trending] = await Promise.all([safe(api.stats()), safe(api.changesDaily()), safe(api.changes({ importance_min: 2, limit: 40 })), safe(api.exploreTypes()), safe(api.trending(7, 12))]); 60+ const [stats, daily, frontier, majors, priceIndex, open, openFallback, benchmarks, papers, timeline, recent] = await Promise.all([
48− const feed = pickFeed(daily, changes?.items ?? null); 61+ safe(api.stats()),
49− const today = daily?.date ?? new Date().toISOString().slice(0, 10); 62+ safe(api.changesDaily()),
63+ safe(api.frontier()),
64+ safe(api.changes({ importance_min: 3, limit: 8 })),
65+ safe(api.priceIndex(30)),
66+ safe(api.open(30, 8)),
67+ safe(api.models({ openness: 'open-weights', sort: 'release', limit: 8 })),
68+ safe(api.benchmarks()),
69+ safe(api.papers({ sort: 'published', limit: 6 })),
70+ safe(api.timeline({ category: 'model', limit: 400 })),
71+ safe(api.changes({ importance_min: 2, limit: 30 })),
72+ ]);
73+
50 const ent = stats?.entities ?? {}; 74 const ent = stats?.entities ?? {};
51− const companies = (num(ent.company) ?? 0) + (num(ent.organization) ?? 0) + (num(ent.lab) ?? 0) || null; 75+ const orgs = stats ? (num(ent.company) ?? 0) + (num(ent.organization) ?? 0) + (num(ent.lab) ?? 0) + (num(ent.university) ?? 0) : null;
76+ const defs = { ...DEFINITIONS, ...(stats?.definitions ?? {}) };
77+ const live24 = num(stats?.change_events_live_24h);
78+ const strip: StripItem[] | null = stats
79+ ? [
80+ { label: 'Models', value: fmtInt(ent.model), href: routes.models(), definition: defs.model },
81+ { label: 'Organizations', value: fmtInt(orgs), href: routes.companies(), definition: defs.organization },
82+ { label: 'Papers', value: fmtInt(ent.paper), href: routes.papers(), definition: defs.paper },
83+ { label: 'Datasets', value: fmtInt(ent.dataset), href: routes.datasets(), definition: defs.dataset },
84+ { label: 'Benchmark results', value: fmtInt(stats.benchmark_results), href: routes.benchmarks(), definition: defs.benchmark_results },
85+ { label: 'Providers', value: fmtInt(ent.provider), href: routes.providers(), definition: defs.provider },
86+ { label: 'Current prices', value: fmtInt(stats.prices_current), href: routes.prices(), definition: defs.prices_current },
87+ { label: 'Source documents', value: fmtInt(stats.documents), href: routes.sources(), definition: defs.documents, hint: `${fmtInt(stats.sources)} sources` },
88+ ]
89+ : null;
90+
91+ // ---- today
92+ const today = daily?.date ?? new Date().toISOString().slice(0, 10);
93+ const sections = (daily?.sections ?? []).filter((s) => s.items.length > 0).slice(0, 6);
94+ const todayTotal = num(daily?.total) ?? Object.values(daily?.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0);
95+
96+ // ---- frontier moves
97+ const frontierMoves: ChangeEvent[] = frontier?.recent_frontier_movements?.length ? frontier.recent_frontier_movements.slice(0, 6) : (majors?.items ?? []).slice(0, 6);
98+ const frontierLive = !!frontier?.recent_frontier_movements?.length;
99+
100+ // ---- prices
101+ const movers = (priceIndex?.movers ?? []).filter((e) => e.event_type === 'PRICE_CHANGED').slice(0, 7);
102+ const medianSeries = (priceIndex?.series ?? []).map((p) => num(p.median_input)).filter((v): v is number => v !== null);
103+ const medianLast = medianSeries.length ? (medianSeries[medianSeries.length - 1] as number) : null;
104+
105+ // ---- open models
106+ const openItems: EntitySummary[] = open?.items?.length ? open.items.slice(0, 8) : (openFallback?.items ?? []).slice(0, 8);
107+ const openLive = !!open?.items?.length;
108+
109+ // ---- benchmarks
110+ const leaders = (benchmarks?.items ?? [])
111+ .filter((b) => b.top && (num(b.result_count) ?? 0) > 0)
112+ .sort((a, b) => (num(b.model_count) ?? 0) - (num(a.model_count) ?? 0))
113+ .slice(0, 6);
114+
115+ // ---- release timeline (last 12 months, NEW_MODEL / RELEASE, importance ≥ 2), lanes = top organizations
116+ const since = Date.now() - 365 * 86400000;
117+ const releaseEvents = (timeline?.items ?? [])
118+ .flatMap((m) => m.events)
119+ .filter((e) => OPEN_EVENT_TYPES.has(e.event_type) && e.importance >= 2 && e.entity && new Date(occurredAt(e)).getTime() >= since);
120+ const orgCount = new Map<string, number>();
121+ for (const e of releaseEvents) orgCount.set(e.entity?.organization?.name ?? 'Other', (orgCount.get(e.entity?.organization?.name ?? 'Other') ?? 0) + 1);
122+ const topOrgs = [...orgCount.entries()]
123+ .filter(([k]) => k !== 'Other')
124+ .sort((a, b) => b[1] - a[1])
125+ .slice(0, 7)
126+ .map(([k]) => k);
127+ const lanes = [...topOrgs.map((k, i) => ({ key: k, label: k, color: `var(--series-${(i % 8) + 1})` })), ...(orgCount.size > topOrgs.length ? [{ key: 'Other', label: 'Other', color: 'var(--ink-3)' }] : [])];
128+ const laneEvents = releaseEvents.map((e) => {
129+ const org = e.entity?.organization?.name ?? 'Other';
130+ return { id: e.id, lane: topOrgs.includes(org) ? org : 'Other', at: occurredAt(e), importance: e.importance, label: e.entity?.name ?? e.summary, sub: e.summary, href: e.entity ? routes.entity(e.entity) : undefined };
131+ });
132+
133+ // ---- ticker
134+ const ticker: TickerItem[] = (recent?.items ?? []).slice(0, 24).map((e) => ({ id: e.id, tone: eventTone(e.event_type), href: e.entity ? routes.entity(e.entity) : routes.changes(), label: e.summary.length > 90 ? `${e.summary.slice(0, 89)}…` : e.summary, meta: fmtDate(occurredAt(e)) }));
135+
136+ const nodes = constellation(20260912, 42);
52 137
53 return ( 138 return (
54 <> 139 <>
55− {/* ------------------------------------------------------------------------------------------------ hero */} 140+ {/* ------------------------------------------------------------------------------------------------ hero (compact) */}
56 <div className="relative overflow-hidden border-b border-rule"> 141 <div className="relative overflow-hidden border-b border-rule">
57 <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden /> 142 <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden />
58− <Container className="relative py-12 md:py-20"> 143+ <Container className="relative py-8 md:py-12">
59− <p className="eyebrow">The AI ecosystem, mapped · with provenance and history</p> 144+ <p className="eyebrow">The temporal knowledge graph of the AI ecosystem</p>
60− <h1 className="display mt-3 max-w-4xl text-[38px] md:text-[64px]">Explore the entire AI ecosystem.</h1> 145+ <div className="mt-2 grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-end">
61− <p className="mt-4 max-w-2xl text-base leading-relaxed text-ink-2 md:text-lg"> 146+ <div>
62− Models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — every fact linked to its source, every change kept. 147+ <h1 className="display text-[34px] md:text-[52px]">Explore the AI ecosystem.</h1>
63− </p> 148+ <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">
64− <form action="/search" method="get" role="search" className="mt-7 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> 149+ Not a directory — a continuously reconstructed, source-attributed, historical map of models, organizations, research, providers and prices, benchmarks, hardware and datasets.
150+ </p>
151+ </div>
152+ <p className="max-w-md text-sm leading-relaxed text-ink-2 lg:justify-self-end lg:text-right">
153+ Every number here answers: <span className="text-ink">who said it, when, where, and how it was extracted.</span>
154+ </p>
155+ </div>
156+ <form action="/search" method="get" role="search" className="mt-5 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
65 <label htmlFor="home-q" className="sr-only">Search</label> 157 <label htmlFor="home-q" className="sr-only">Search</label>
66 <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span> 158 <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span>
67− <input id="home-q" name="q" type="search" placeholder="Ask anything: “open models over 100B released in 2026”, “cheapest 1M context”…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none md:h-14" autoComplete="off" /> 159+ <input id="home-q" name="q" type="search" placeholder="Ask anything: “open models over 100B released in 2026”, “cheapest 1M context”…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" />
68 <button type="submit" className="flex items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 md:px-5"> 160 <button type="submit" className="flex items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 md:px-5">
69 Search <ArrowRight className="hidden size-4 md:block" aria-hidden /> 161 Search <ArrowRight className="hidden size-4 md:block" aria-hidden />
70 </button> 162 </button>
71 </form> 163 </form>
72− <ul className="mt-3 flex flex-wrap gap-2"> 164+ <ul className="mt-2.5 flex flex-wrap gap-2">
73 {EXAMPLE_QUERIES.map((q) => ( 165 {EXAMPLE_QUERIES.map((q) => (
74 <li key={q}> 166 <li key={q}>
75 <Link href={routes.search(q)} className="inline-block border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> 167 <Link href={routes.search(q)} className="inline-block border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
@@ -77,124 +169,296 @@ export default async function HomePage() {
77 </Link> 169 </Link>
78 </li> 170 </li>
79 ))} 171 ))}
172+ <li className="text-xs text-ink-3 self-center">
173+ or press <kbd className="mono border border-rule px-1">⌘K</kbd>
174+ </li>
80 </ul> 175 </ul>
81 </Container> 176 </Container>
82 </div> 177 </div>
83 178
84 <Container> 179 <Container>
85− {/* ------------------------------------------------------------------------------------------------ counters */} 180+ {/* ------------------------------------------------------------------------------------------------ live strip */}
86− <Section eyebrow="Live counters" title={<span className="flex flex-wrap items-center gap-x-3 gap-y-1">Inside the atlas {stats && <span className="flex items-center gap-1.5 text-xs font-normal text-ink-3"><span className="dot pulse" aria-hidden /> updated <LiveAgo at={stats.computed_at} /></span>}</span>} action={{ href: routes.methodology(), label: 'How counts are computed' }} hairline={false}> 181+ <div className="pt-5">
87− {stats ? ( 182+ <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
88− <StatGrid cols={8}> 183+ <p className="eyebrow flex items-center gap-2">
89− <Stat label="Models" value={fmtInt(ent.model)} href={routes.models()} /> 184+ Live ecosystem
90− <Stat label="Companies" value={fmtInt(companies)} href={routes.companies()} /> 185+ {stats && (
91− <Stat label="Papers" value={fmtInt(ent.paper)} href={routes.papers()} /> 186+ <span className="flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal text-ink-3">
92− <Stat label="Providers" value={fmtInt(ent.provider)} href={routes.providers()} /> 187+ <span className="dot pulse" aria-hidden /> computed <LiveAgo at={stats.computed_at} />
93− <Stat label="Benchmarks" value={fmtInt(ent.benchmark)} href={routes.benchmarks()} /> 188+ {live24 !== null && <span>· {fmtInt(live24)} live events / 24 h</span>}
94− <Stat label="Hardware" value={fmtInt(ent.hardware)} href={routes.hardware()} /> 189+ </span>
95− <Stat label="Change events" value={fmtInt(stats.change_events)} delta={num(stats.change_events_24h) ? { value: `+${fmtInt(stats.change_events_24h)} · 24 h`, tone: 'neutral' } : undefined} href={routes.changes()} /> 190+ )}
96− <Stat label="Sources" value={fmtInt(stats.sources)} hint={`${fmtInt(stats.documents)} documents`} href={routes.sources()} /> 191+ </p>
97− </StatGrid> 192+ <Link href={routes.methodology()} className="text-xs text-ink-3 hover:text-ink">
98− ) : ( 193+ How counts are computed →
99− <Unavailable what="Live counters" reason="The API did not answer. Counters are never cached or hardcoded." /> 194+ </Link>
100− )} 195+ </div>
101− </Section> 196+ {strip ? <DataStrip items={strip} /> : <Unavailable what="Live counters" reason="The API did not answer. Counters are never cached or hardcoded." />}
197+ </div>
198+ {ticker.length > 0 && <Ticker items={ticker} className="mt-3" label="Recent" />}
102 199
103− {/* ------------------------------------------------------------------------------------------------ today */} 200+ {/* ------------------------------------------------------------------------------------------------ editorial grid */}
104− <Section eyebrow={`Today in AI · ${fmtDate(today)}`} title="What changed" action={{ href: routes.changesDay(today), label: 'Daily digest' }} lede={daily ? `${fmtInt(Object.values(daily.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0))} events recorded today across ${Object.keys(daily.counts ?? {}).length} categories.` : undefined}> 201+ <div className="grid gap-x-12 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">
105− {feed.length ? ( 202+ <div className="min-w-0">
106− <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2"> 203+ {/* today */}
107− {feed.map((f) => ( 204+ <Section eyebrow={`Today in AI · ${fmtDate(today)}`} title="What changed" action={{ href: routes.changesDay(today), label: 'Daily digest' }} lede={daily ? `${fmtInt(todayTotal)} events recorded today across ${sections.length} ${sections.length === 1 ? 'section' : 'sections'}.` : undefined}>
108− <div key={f.title}> 205+ {sections.length ? (
109− <p className="eyebrow mb-1">{f.title}</p> 206+ <div className="space-y-6">
110− <ul className="border-t border-rule"> 207+ {sections.map((s) => (
111− {f.items.map((e) => ( 208+ <div key={s.category}>
112− <ChangeRow key={e.id} e={e} dense /> 209+ <p className="eyebrow mb-1 flex items-baseline justify-between">
113− ))} 210+ <span>{s.label}</span>
114− </ul> 211+ <span className="tnum font-normal">{fmtInt(num(s.total) ?? num(daily?.counts?.[s.category]) ?? s.items.length)}</span>
212+ </p>
213+ <ul className="border-t border-rule">
214+ {s.items.slice(0, 5).map((e) => (
215+ <ChangeRow key={e.id} e={e} dense />
216+ ))}
217+ </ul>
218+ </div>
219+ ))}
115 </div> 220 </div>
116− ))} 221+ ) : daily ? (
117− </div> 222+ <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No material events recorded today yet. The change engine only emits events when a source states a material change.</p>
118− ) : changes ? ( 223+ ) : (
119− <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No important events yet. The change engine only emits events when a source states a material change.</p> 224+ <Unavailable what="Daily digest" />
120− ) : ( 225+ )}
121− <Unavailable what="Change feed" /> 226+ <p className="mt-4 text-xs text-ink-3">
122− )} 227+ {daily?.backfill_excluded !== undefined ? (
123− {daily?.new_models && daily.new_models.length > 0 && ( 228+ <>
124− <div className="mt-8"> 229+ {fmtInt(daily.backfill_excluded)} historical backfill events are excluded — see the <Link href={routes.timeline()} className="link">Timeline</Link>.
125− <p className="eyebrow mb-2">New models today <span className="tnum text-ink-3">{daily.new_models.length}</span></p> 230+ </>
126− <ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm"> 231+ ) : (
127− {daily.new_models.slice(0, 16).map((m) => ( 232+ <>
128− <li key={m.id}> 233+ Digest keyed on observation time; historical backfill is shown on the <Link href={routes.timeline()} className="link">Timeline</Link> by effective date.
129− <EntityLink e={m} className="font-medium" /> 234+ </>
130− {m.organization && <span className="text-ink-3"> · {m.organization.name}</span>} 235+ )}
131− </li> 236+ </p>
132− ))} 237+ </Section>
133− </ul>
134− </div>
135− )}
136− <p className="mt-6 text-sm">
137− <Link href={routes.changes()} className="link">All changes, filterable by category and importance →</Link>
138− </p>
139− </Section>
140 238
141− {/* ------------------------------------------------------------------------------------------------ explore */} 239+ {/* frontier moves */}
142− <Section eyebrow="Explore" title="Every entity type, with live counts" action={{ href: routes.explore(), label: 'All types' }}> 240+ <Section eyebrow="Frontier moves" title={frontierLive ? 'Leadership changes' : 'Major events'} lede={frontierLive ? 'A model took or lost the lead on a benchmark family.' : 'Latest importance-3 events — the frontier feed appears once leaderboard trust levels are computed.'} action={{ href: routes.frontier(), label: 'Frontier' }}>
143− {types?.items?.length ? ( 241+ {frontierMoves.length ? (
144− <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-5"> 242+ <ul className="border-t border-rule">
145− {types.items 243+ {frontierMoves.map((e) => (
146− .filter((t) => (num(t.count) ?? 0) > 0) 244+ <ChangeRow key={e.id} e={e} dense />
147− .sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)) 245+ ))}
148− .slice(0, 15) 246+ </ul>
149− .map((t) => ( 247+ ) : majors ? (
150− <li key={t.entity_type} className="border-b border-r border-rule"> 248+ <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No major events recorded yet.</p>
151− <Link href={routes.listing(t.entity_type)} className="block px-4 py-4 hover:bg-surface-2"> 249+ ) : (
152− <EntityBadge type={t.entity_type} small /> 250+ <Unavailable what="Frontier moves" compact />
153− <p className="tnum mt-2 text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</p> 251+ )}
154− <p className="text-sm text-ink-2">{t.label || typeLabel(t.entity_type, true)}</p> 252+ </Section>
155− </Link> 253+
156− </li> 254+ {/* benchmark leaders */}
157− ))} 255+ <Section eyebrow="Benchmark leaders" title="Who leads, on what" action={{ href: routes.benchmarks(), label: 'All benchmarks' }}>
158− </ul> 256+ {leaders.length ? (
257+ <ul className="grid border-t border-rule sm:grid-cols-2">
258+ {leaders.map((b) => (
259+ <li key={b.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule sm:odd:pr-6 sm:even:pl-6">
260+ <div className="min-w-0">
261+ <p className="truncate text-sm">
262+ <EntityLink e={b} className="font-medium" />
263+ {typeof b.attributes?.metric === 'string' && <span className="ml-1.5 text-xs text-ink-3">{b.attributes.metric}</span>}
264+ </p>
265+ <p className="mt-0.5 truncate text-xs text-ink-2">
266+ leader <EntityLink e={b.top!.model} className="text-ink" />
267+ {b.top!.model.organization && <span className="text-ink-3"> · {b.top!.model.organization.name}</span>}
268+ </p>
269+ <p className="tnum mt-0.5 text-[11px] text-ink-3">
270+ {fmtInt(b.result_count)} results · {fmtInt(b.model_count)} models
271+ {typeof b.attributes?.trust_level === 'string' ? <> · trust {b.attributes.trust_level}</> : <> · trust —</>}
272+ </p>
273+ </div>
274+ <p className="tnum self-center text-lg font-semibold text-type-benchmark">{fmtScore(b.top!.score)}</p>
275+ </li>
276+ ))}
277+ </ul>
278+ ) : benchmarks ? (
279+ <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No benchmark results ingested yet.</p>
280+ ) : (
281+ <Unavailable what="Benchmarks" compact />
282+ )}
283+ </Section>
284+ </div>
285+
286+ <div className="min-w-0">
287+ {/* price moves */}
288+ <Section eyebrow="Price moves" title="USD per 1M tokens" action={{ href: routes.prices(), label: 'Price index' }}>
289+ <div className="flex items-end justify-between gap-4 border-b border-rule pb-3">
290+ <div>
291+ <p className="eyebrow">Median input · 30 d</p>
292+ {medianSeries.length >= 2 ? (
293+ <Sparkline values={medianSeries} variant="trend" width={140} height={32} stroke="var(--accent-2)" invert format={(v) => fmtUsdPerM(v)} title="Median input price, last 30 days" />
294+ ) : (
295+ <p className="mt-1 text-xs text-ink-3">{medianLast !== null ? `${fmtUsdPerM(medianLast)} today · ` : ''}needs ≥ 2 daily snapshots to draw</p>
296+ )}
297+ </div>
298+ <p className="tnum text-right text-xs text-ink-3">
299+ {stats ? <>{fmtInt(stats.prices_current)} live offers</> : null}
300+ <br />
301+ {priceIndex?.series?.length ? <>{fmtInt(priceIndex.series[priceIndex.series.length - 1]?.models)} models priced</> : null}
302+ </p>
303+ </div>
304+ {movers.length ? (
305+ <ul>
306+ {movers.map((e) => {
307+ const m = priceMove(e);
308+ const dIn = fmtDeltaPct(m.input[0], m.input[1]);
309+ const dOut = fmtDeltaPct(m.output[0], m.output[1]);
310+ const provider = hostOf(e.source_url);
311+ return (
312+ <li key={e.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule text-sm">
313+ <div className="min-w-0">
314+ {e.entity ? <EntityLink e={e.entity} className="truncate font-medium" /> : <span>{e.summary}</span>}
315+ <p className="truncate text-xs text-ink-3">
316+ {provider ?? e.connector_name} · {fmtDate(occurredAt(e))}
317+ </p>
318+ </div>
319+ <div className="tnum text-right text-xs">
320+ <p>
321+ <span className="text-ink-3">in </span>
322+ <span className="font-medium text-accent-2">{fmtUsdPerM(m.input[1])}</span>
323+ {dIn && <span className={m.input[1]! < m.input[0]! ? 'ml-1 text-positive' : 'ml-1 text-danger'}>{dIn}</span>}
324+ </p>
325+ <p>
326+ <span className="text-ink-3">out </span>
327+ <span className="font-medium text-accent-2">{fmtUsdPerM(m.output[1])}</span>
328+ {dOut && <span className={m.output[1]! < m.output[0]! ? 'ml-1 text-positive' : 'ml-1 text-danger'}>{dOut}</span>}
329+ </p>
330+ </div>
331+ </li>
332+ );
333+ })}
334+ </ul>
335+ ) : priceIndex ? (
336+ <p className="py-6 text-center text-sm text-ink-3">No price changes recorded in the window.</p>
337+ ) : (
338+ <Unavailable what="Price moves" compact className="mt-3" />
339+ )}
340+ </Section>
341+
342+ {/* open models */}
343+ <Section eyebrow={openLive ? 'New open models · 30 d' : 'Open models · latest releases'} title="Open weights" action={{ href: routes.open(), label: 'Open model frontier' }}>
344+ {openItems.length ? (
345+ <ul className="border-t border-rule">
346+ {openItems.map((m) => {
347+ const a = m.attributes ?? {};
348+ return (
349+ <li key={m.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule text-sm">
350+ <div className="min-w-0">
351+ <p className="truncate">
352+ <EntityLink e={m} className="font-medium" />
353+ {m.organization && <span className="text-xs text-ink-3"> · {m.organization.name}</span>}
354+ </p>
355+ <p className="tnum truncate text-xs text-ink-3">
356+ {num(a.parameter_count) !== null && <>{fmtParams(a.parameter_count)} params · </>}
357+ {num(a.context_length) !== null && <>{fmtTokens(a.context_length)} ctx · </>}
358+ {typeof a.license === 'string' ? a.license : 'license —'}
359+ </p>
360+ </div>
361+ <div className="text-right">
362+ <p className="tnum text-xs text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</p>
363+ <OpennessBadge openness={typeof a.openness === 'string' ? a.openness : 'open-weights'} className="mt-0.5" />
364+ </div>
365+ </li>
366+ );
367+ })}
368+ </ul>
369+ ) : openFallback || open ? (
370+ <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No open-weight releases recorded in the window.</p>
371+ ) : (
372+ <Unavailable what="Open models" compact />
373+ )}
374+ </Section>
375+
376+ {/* research */}
377+ <Section eyebrow="Research" title="Latest papers" action={{ href: routes.papers(), label: 'Research' }}>
378+ {papers?.items?.length ? (
379+ <ul className="border-t border-rule">
380+ {papers.items.slice(0, 6).map((p) => {
381+ const a = p.attributes ?? {};
382+ const authors = Array.isArray(a.authors) ? (a.authors as string[]) : [];
383+ return (
384+ <li key={p.id} className="row-y border-b border-rule text-sm">
385+ <EntityLink e={p} className="line-clamp-2 font-medium leading-snug" />
386+ <p className="tnum mt-0.5 truncate text-xs text-ink-3">
387+ {typeof a.published_at === 'string' && <>{fmtDate(a.published_at)} · </>}
388+ {authors.length > 0 && <>{authors.slice(0, 2).join(', ')}{authors.length > 2 ? ` +${authors.length - 2}` : ''} · </>}
389+ {typeof a.arxiv_id === 'string' && <span className="mono">arXiv {a.arxiv_id}</span>}
390+ {typeof a.primary_category === 'string' && <> · {a.primary_category}</>}
391+ </p>
392+ </li>
393+ );
394+ })}
395+ </ul>
396+ ) : papers ? (
397+ <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No papers ingested yet.</p>
398+ ) : (
399+ <Unavailable what="Research" compact />
400+ )}
401+ </Section>
402+ </div>
403+ </div>
404+
405+ {/* ------------------------------------------------------------------------------------------------ release timeline */}
406+ <Section eyebrow="Model release timeline · 12 months" title="Releases by organization" lede="New models and releases of importance ≥ 2, by effective date when the source states one. Hover a dot; drag to read a range." action={{ href: routes.timeline({ category: 'model' }), label: 'Full timeline' }}>
407+ {laneEvents.length >= 2 && lanes.length ? (
408+ <TimelineLanes lanes={lanes} events={laneEvents} from={new Date(since)} to={new Date()} brushable title="Model releases by organization, last 12 months" />
409+ ) : timeline ? (
410+ <p className="border-y border-rule py-6 text-center text-sm text-ink-3">Not enough dated release events in the last 12 months to draw a timeline.</p>
159 ) : ( 411 ) : (
160− <Unavailable what="Entity types" /> 412+ <Unavailable what="Timeline" compact />
161 )} 413 )}
162 </Section> 414 </Section>
163 415
164− {/* ------------------------------------------------------------------------------------------------ trending */} 416+ {/* ------------------------------------------------------------------------------------------------ graph teaser */}
165− <Section eyebrow="Trending · 7 days" title="Most viewed" lede="Ranked by page views on AI Atlas, not by any external popularity signal."> 417+ <Section eyebrow="Ecosystem graph" title="Every entity, every relation" action={{ href: routes.graph(), label: 'Explore the graph' }}>
166− {trending?.items?.length ? ( 418+ <div className="grid items-center gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
167− <ol className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> 419+ <div className="text-sm text-ink-2">
168− {trending.items.slice(0, 12).map((t, i) => ( 420+ <p className="tnum text-2xl font-semibold tracking-tight text-ink">
169− <li key={t.id} className="flex items-baseline gap-3 border-b border-rule py-2 text-sm"> 421+ {stats ? fmtInt(stats.entities_total) : '—'} <span className="text-base font-normal text-ink-3">entities</span> · {stats ? fmtInt(stats.relations) : '—'} <span className="text-base font-normal text-ink-3">relations</span>
170− <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span> 422+ </p>
171− <EntityBadge type={t.entity_type} small /> 423+ <p className="mt-2 leading-relaxed">Organizations develop models, providers serve them, papers describe them, benchmarks evaluate them, hardware runs them. Start from any entity and follow the edges — lineage, ownership, availability — with the source of every relation.</p>
172− <EntityLink e={t as EntitySummary} className="min-w-0 flex-1 truncate font-medium" /> 424+ <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
173− <span className="tnum shrink-0 text-xs text-ink-3">{fmtInt(t.views)} views</span> 425+ <Link href={routes.graph()} className="link">Knowledge graph →</Link>
174− </li> 426+ <Link href={routes.families()} className="link">Model families →</Link>
175− ))} 427+ <Link href={routes.compare()} className="link">Compare →</Link>
176− </ol> 428+ </p>
177− ) : trending ? ( 429+ </div>
178− <p className="text-sm text-ink-3">Not enough page views yet to rank entities.</p> 430+ <Link href={routes.graph()} className="block border border-rule bg-surface/40 hover:border-rule-strong" aria-label="Open the knowledge graph">
179− ) : ( 431+ <svg viewBox="0 0 320 160" className="block h-auto w-full" aria-hidden>
180− <Unavailable what="Trending" compact /> 432+ {nodes.map((n, i) =>
181− )} 433+ nodes.slice(i + 1, i + 3).map((m, j) => (
434+ <line key={`${i}-${j}`} x1={n.x} y1={n.y} x2={m.x} y2={m.y} stroke="var(--rule-strong)" strokeWidth={0.6} opacity={0.7} />
435+ )),
436+ )}
437+ {nodes.map((n, i) => (
438+ <circle key={i} cx={n.x} cy={n.y} r={n.r} fill={`var(--type-${NODE_TYPES[n.t]})`} opacity={0.9} />
439+ ))}
440+ </svg>
441+ <p className="flex flex-wrap gap-x-3 gap-y-1 border-t border-rule px-3 py-1.5">
442+ {NODE_TYPES.map((t) => (
443+ <EntityBadge key={t} type={t} small />
444+ ))}
445+ </p>
446+ </Link>
447+ </div>
182 </Section> 448 </Section>
183 449
184− {/* ------------------------------------------------------------------------------------------------ how */} 450+ {/* ------------------------------------------------------------------------------------------------ signature products */}
185− <Section eyebrow="How AI Atlas works" title="Connectors → archive → graph → history" action={{ href: routes.methodology(), label: 'Methodology' }}> 451+ <Section eyebrow="Signature products" title="Twelve ways to read the atlas" hairline>
186− <ol className="grid gap-6 md:grid-cols-4"> 452+ <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-6">
187− {HOW.map((h, i) => ( 453+ {signatureProducts.map((p) => (
188− <li key={h.step} className="border-t-2 border-ink pt-3"> 454+ <li key={p.label} className="border-b border-r border-rule">
189− <p className="mono text-xs text-ink-3">0{i + 1}</p> 455+ <Link href={p.href} className="block h-full px-3 py-3 hover:bg-surface-2">
190− <p className="mt-1 font-semibold">{h.step}</p> 456+ <p className="text-sm font-medium text-ink">{p.label}</p>
191− <p className="mt-1.5 text-sm leading-relaxed text-ink-2">{h.text}</p> 457+ {p.hint && <p className="mt-0.5 text-xs leading-snug text-ink-3">{p.hint}</p>}
458+ </Link>
192 </li> 459 </li>
193 ))} 460 ))}
194− </ol> 461+ </ul>
195− <p className="mt-6 text-sm text-ink-2">
196− Public API at <Link href={routes.developers()} className="link mono">{SITE_URL.replace(/^https?:\/\//, '')}/api/v1</Link> · sources listed on <Link href={routes.sources()} className="link">/sources</Link> · crawler policy on <Link href={routes.bot()} className="link">/bot</Link>.
197− </p>
198 </Section> 462 </Section>
199 </Container> 463 </Container>
200 </> 464 </>
added apps/web/src/app/watchlist/page.tsx +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/brand/logo.tsx +21 −16
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/brand/mark.tsx +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/brand/og.tsx +104 −25
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/charts/charts.tsx +270 −40
Large diff (374 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/index.ts +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/interactive-line-chart.tsx +97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/scatter-chart.tsx +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/timeline-lanes.tsx +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/entity/blocks.tsx +2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/entity/entity-page.tsx +2 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/evidence/evidence-context.tsx +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/evidence/evidence-drawer.tsx +228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/evidence/evidence.tsx +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/evidence/index.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/density.tsx +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/mobile-tab-bar.tsx +50 −51
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/search-dialog.tsx +297 −58
Large diff (444 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/site-footer.tsx +29 −41
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/site-header.tsx +98 −89
Large diff (235 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/terminal.tsx +259 −0
Large diff (259 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/theme.tsx +7 −4
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/hint.tsx +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/key-value.tsx +37 −5
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/provenance.tsx +30 −2
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/section.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/sheet.tsx +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/watchlist/watch-button.tsx +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/watchlist/watchlist-view.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/api.ts +11 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/client-api.ts +21 −4
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/density.ts +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/format.ts +25 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/prepaint.ts +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/site.ts +130 −15
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/types.ts +71 −1
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/watchlist.ts +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
modified docs/FRONTEND.md +122 −10
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme