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 1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
2 2 <title>AI Atlas</title>
3 − <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.6"/>
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"/>
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"/>
6 − <path d="M3 16h26" stroke="currentColor" stroke-width="1.2" opacity="0.7"/>
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"/>
8 − <circle cx="16" cy="16" r="2.4" fill="#1f4fd8"/>
9 − <circle cx="9.4" cy="9.5" r="1.7" fill="currentColor"/>
10 − <circle cx="22.6" cy="9.5" r="1.7" fill="currentColor"/>
11 − <circle cx="10.8" cy="24.4" r="1.7" fill="currentColor"/>
12 − <circle cx="23.4" cy="21.6" r="1.7" fill="currentColor"/>
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 + <rect x="1" y="1" width="30" height="30" rx="7" fill="#0b0d11"/>
5 + <g stroke="rgba(233,235,240,0.22)" stroke-width="0.8">
6 + <line x1="4" x2="28" y1="10.5" y2="10.5"/>
7 + <line x1="4" x2="28" y1="17" y2="17"/>
8 + <line x1="10.5" x2="10.5" y1="4" y2="28"/>
9 + <line x1="21.5" x2="21.5" y1="4" y2="28"/>
10 + </g>
11 + <line x1="5" x2="27" y1="24" y2="24" stroke="#e9ebf0" stroke-width="1.2" opacity="0.75" stroke-linecap="round"/>
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 16 </svg>
modified apps/web/qa/screens.mjs +78 −7
@@ -1,5 +1,7 @@
1 1 /**
2 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 5 * Also asserts that the homepage counters match GET /api/v1/stats.
4 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 13 const OUT = new URL('./screens/', import.meta.url).pathname;
12 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 17 const WIDTHS = [390, 1440];
16 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 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 27 const browser = await chromium.launch();
20 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 58 for (const width of WIDTHS) {
23 59 const mobile = width < 768;
24 60 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
25 61 await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
26 62 const page = await ctx.newPage();
27 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 65 const errors = [];
30 66 const onErr = (e) => errors.push(String(e));
31 67 const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };
@@ -38,9 +74,7 @@ for (const theme of THEMES) {
38 74 const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);
39 75 const expected = /does-not-exist/.test(path) ? 404 : 200;
40 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
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)));
77 + const filtered = filterErrors(errors, expected);
44 78 const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;
45 79 if (!ok) failures++;
46 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 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 127 // ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load):
57 128 // the tolerance is derived from the observed ingestion rate and is zero when the database is static.
58 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 131 const t0 = Date.now();
61 132 const before = pick(await (await fetch(`${API}/api/v1/stats`)).json());
62 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 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 +import { LogoMark } from '@/components/brand/logo';
3 4 import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
4 5 import { api, safe } from '@/lib/api';
5 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 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 22 export default async function AboutPage() {
12 23 const stats = await safe(api.stats());
13 24 return (
14 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 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>
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>
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>
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>
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>
20 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 47 {stats && (
22 − <Section eyebrow="Right now" title="The atlas in numbers" hairline={false}>
23 − <StatGrid cols={5}>
48 + <Section eyebrow="Right now" title="The atlas in numbers">
49 + <StatGrid cols={6}>
24 50 <Stat label="Entities" value={fmtInt(stats.entities_total)} />
25 51 <Stat label="Claims" value={fmtInt(stats.claims)} hint={`${fmtInt(stats.claims_current)} current`} />
26 52 <Stat label="Relations" value={fmtInt(stats.relations)} />
53 + <Stat label="Change events" value={fmtInt(stats.change_events)} />
27 54 <Stat label="Snapshots archived" value={fmtInt(stats.snapshots)} hint={fmtBytes(stats.archive?.raw_bytes)} />
28 55 <Stat label="Since" value={fmtDate(stats.first_entity_at)} />
29 56 </StatGrid>
30 57 </Section>
31 58 )}
32 − <Section eyebrow="Who" title="Built and operated by Simon-Pierre Boucher">
33 − <p className="max-w-3xl text-sm leading-relaxed text-ink-2">
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>.
35 − </p>
59 +
60 + <Section eyebrow="Who" title={`Built by ${AUTHOR_NAME}`}>
61 + <div className="max-w-3xl space-y-3 text-sm leading-relaxed text-ink-2">
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 76 </Section>
37 77 </Container>
38 78 );
modified apps/web/src/app/apple-icon.tsx +4 −12
@@ -1,23 +1,15 @@
1 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 5 export const size = { width: 180, height: 180 };
5 6 export const contentType = 'image/png';
6 7
7 8 export default function AppleIcon() {
8 9 return new ImageResponse(
9 10 (
10 − <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11', borderRadius: 40 }}>
11 − <svg width="132" height="132" viewBox="0 0 32 32" fill="none">
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>
11 + <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
12 + <MarkImg px={180} colors={{ ...MARK_DARK, plate: '#12151b' }} radius={34 / (180 / 32)} />
21 13 </div>
22 14 ),
23 15 { ...size },
modified apps/web/src/app/globals.css +229 −7
@@ -55,13 +55,53 @@
55 55 --series-7: #7c8293;
56 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 64 --radius: 4px;
59 65 --radius-lg: 8px;
60 66 --header-h: 56px;
61 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 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 105 color-scheme: dark;
66 106 --canvas: #0b0d11;
67 107 --surface: #12151b;
@@ -175,7 +215,7 @@
175 215 }
176 216 body {
177 217 font-family: var(--font-sans);
178 − font-size: 15px;
218 + font-size: var(--d-base, 15px);
179 219 line-height: 1.5;
180 220 background: var(--canvas);
181 221 color: var(--ink);
@@ -349,7 +389,7 @@
349 389 width: 100%;
350 390 border-collapse: collapse;
351 391 font-variant-numeric: tabular-nums;
352 − font-size: 0.875rem;
392 + font-size: var(--d-table-fs, 0.875rem);
353 393 }
354 394 .data-table th {
355 395 text-align: left;
@@ -358,7 +398,7 @@
358 398 letter-spacing: 0.08em;
359 399 text-transform: uppercase;
360 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 402 border-bottom: 1px solid var(--rule-strong);
363 403 white-space: nowrap;
364 404 vertical-align: bottom;
@@ -372,7 +412,7 @@
372 412 padding-right: 0;
373 413 }
374 414 .data-table td {
375 − padding: 0.55rem 0.75rem;
415 + padding: var(--d-cell-y) var(--d-cell-x);
376 416 border-bottom: 1px solid var(--rule);
377 417 vertical-align: middle;
378 418 }
@@ -391,8 +431,8 @@
391 431 color: var(--ink);
392 432 }
393 433 .data-table.compact td {
394 − padding-top: 0.35rem;
395 − padding-bottom: 0.35rem;
434 + padding-top: calc(var(--d-cell-y) * 0.65);
435 + padding-bottom: calc(var(--d-cell-y) * 0.65);
396 436 }
397 437 .data-table a.row-link {
398 438 color: inherit;
@@ -467,7 +507,7 @@
467 507 display: grid;
468 508 grid-template-columns: 9.5rem minmax(0, 1fr);
469 509 gap: 0.25rem 1rem;
470 − padding: 0.5rem 0;
510 + padding: var(--d-kv-y) 0;
471 511 border-bottom: 1px solid var(--rule);
472 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 705 @media (prefers-reduced-motion: reduce) {
489 706 *,
490 707 *::before,
@@ -492,4 +709,9 @@
492 709 animation-duration: 0.01ms !important;
493 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 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 − * Colours are the dark canvas / ink / accent tokens; raw hex is unavoidable in ImageResponse (no CSS variables).
5 + * Square PNG app icon (manifest 192 / 512) drawn from the atlas-plate mark — same geometry as `icon.svg` and `apple-icon.tsx`.
6 + * The plate fills the tile (maskable-safe: the graph sits inside the central 80 %). Raw hex is unavoidable in ImageResponse.
6 7 */
7 8 export function renderIcon(px: number): ImageResponse {
8 − const mark = Math.round(px * 0.72);
9 9 return new ImageResponse(
10 10 (
11 − <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11' }}>
12 − <svg width={mark} height={mark} viewBox="0 0 32 32" fill="none">
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>
11 + <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
12 + <MarkImg px={px} colors={{ ...MARK_DARK, plate: '#12151b' }} radius={px >= 512 ? 5 : 6} />
22 13 </div>
23 14 ),
24 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 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"/>
3 − <circle cx="16" cy="16" r="11.5" stroke="#e9ebf0" stroke-width="1.5"/>
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"/>
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"/>
6 − <circle cx="16" cy="16" r="2.3" fill="#6d95ff"/>
7 − <circle cx="10.2" cy="10.3" r="1.6" fill="#e9ebf0"/>
8 − <circle cx="21.8" cy="10.3" r="1.6" fill="#e9ebf0"/>
9 − <circle cx="11.4" cy="23.4" r="1.6" fill="#e9ebf0"/>
10 − <circle cx="22.5" cy="21" r="1.6" fill="#e9ebf0"/>
2 + <rect x="1" y="1" width="30" height="30" rx="7" fill="#0b0d11"/>
3 + <line x1="4" x2="28" y1="10.5" y2="10.5" stroke="rgba(233,235,240,0.22)" stroke-width="1"/>
4 + <line x1="4" x2="28" y1="17" y2="17" stroke="rgba(233,235,240,0.22)" stroke-width="1"/>
5 + <line x1="5" x2="27" y1="24" y2="24" stroke="#e9ebf0" stroke-width="1.4" opacity="0.75" stroke-linecap="round"/>
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="16" cy="8" r="2.4" fill="#e9ebf0"/>
8 + <circle cx="8.5" cy="24" r="2.4" fill="#e9ebf0"/>
9 + <circle cx="23.5" cy="24" r="2.8" fill="#6d95ff"/>
11 10 </svg>
modified apps/web/src/app/layout.tsx +18 −13
@@ -1,14 +1,16 @@
1 1 import type { Metadata, Viewport } from 'next';
2 2 import Script from 'next/script';
3 3 import './globals.css';
4 +import { EvidenceDrawer } from '@/components/evidence/evidence-drawer';
5 +import { EvidenceProvider } from '@/components/evidence/evidence-context';
4 6 import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
5 7 import { SearchProvider } from '@/components/layout/search-context';
6 8 import { SearchDialog } from '@/components/layout/search-dialog';
7 9 import { SiteFooter } from '@/components/layout/site-footer';
8 10 import { SiteHeader } from '@/components/layout/site-header';
9 −import { THEME_SCRIPT } from '@/components/layout/theme';
10 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 15 export const metadata: Metadata = {
14 16 metadataBase: new URL(SITE_URL),
@@ -27,8 +29,8 @@ export const viewport: Viewport = {
27 29 initialScale: 1,
28 30 viewportFit: 'cover',
29 31 themeColor: [
30 − { media: '(prefers-color-scheme: light)', color: '#f6f6f3' },
31 − { media: '(prefers-color-scheme: dark)', color: '#0b0d11' },
32 + { media: '(prefers-color-scheme: light)', color: THEME_LIGHT },
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 38 return (
37 39 <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`} suppressHydrationWarning>
38 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 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 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 45 Skip to content
44 46 </a>
45 47 <SearchProvider>
46 − <SiteHeader />
47 − <main id="main" className="flex-1">
48 − {children}
49 − </main>
50 − <SiteFooter />
51 − <MobileTabBar />
52 − <SearchDialog />
48 + <EvidenceProvider>
49 + <SiteHeader />
50 + <main id="main" className="flex-1">
51 + {children}
52 + </main>
53 + <SiteFooter />
54 + <MobileTabBar />
55 + <SearchDialog />
56 + <EvidenceDrawer />
57 + </EvidenceProvider>
53 58 </SearchProvider>
54 59 </body>
55 60 </html>
modified apps/web/src/app/manifest.ts +8 −8
@@ -1,13 +1,12 @@
1 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
6 − * `themeColor` declared in the root layout. PNG icons are rendered on demand by `/icon-192.png` and `/icon-512.png`.
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.
7 6 */
8 7 export default function manifest(): MetadataRoute.Manifest {
9 8 return {
10 − name: SITE_NAME,
9 + name: `${SITE_NAME} — ${TAGLINE}`,
11 10 short_name: SITE_NAME,
12 11 description: DESCRIPTION,
13 12 id: '/',
@@ -15,11 +14,11 @@ export default function manifest(): MetadataRoute.Manifest {
15 14 scope: '/',
16 15 display: 'standalone',
17 16 orientation: 'any',
18 − background_color: '#f6f6f3',
19 − theme_color: '#f6f6f3',
17 + background_color: THEME_DARK,
18 + theme_color: THEME_LIGHT,
20 19 lang: 'en',
21 20 dir: 'ltr',
22 − categories: ['reference', 'productivity'],
21 + categories: ['reference', 'productivity', 'news'],
23 22 icons: [
24 23 { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
25 24 { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
@@ -30,8 +29,9 @@ export default function manifest(): MetadataRoute.Manifest {
30 29 shortcuts: [
31 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 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 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 1 import { ImageResponse } from 'next/og';
2 +import { Wallpaper } from '@/components/brand/og';
2 3 import { api, safe } from '@/lib/api';
3 4 import { fmtInt, num } from '@/lib/format';
4 5 import { SITE_NAME, TAGLINE } from '@/lib/site';
@@ -8,71 +9,19 @@ export const alt = `${SITE_NAME} — ${TAGLINE}`;
8 9 export const size = { width: 1200, height: 630 };
9 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 13 export default async function OpenGraphImage() {
13 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 17 const counters: [string, string][] = stats
15 − ? [
16 − ['Models', fmtInt(stats.entities?.model)],
17 − ['Companies', fmtInt(num(stats.entities?.company) ?? num(stats.entities?.organization))],
18 − ['Papers', fmtInt(stats.entities?.paper)],
19 − ['Change events', fmtInt(stats.change_events)],
20 − ['Sources', fmtInt(stats.sources)],
21 − ].filter((c) => c[1] !== '—') as [string, string][]
18 + ? ([
19 + ['Models', fmtInt(e.model)],
20 + ['Organizations', orgs ? fmtInt(orgs) : '—'],
21 + ['Benchmark results', fmtInt(stats.benchmark_results)],
22 + ['Source documents', fmtInt(stats.documents)],
23 + ].filter((c) => c[1] !== '—') as [string, string][])
22 24 : [];
23 25
24 − return new ImageResponse(
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 − );
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 });
78 27 }
modified apps/web/src/app/page.tsx +405 −141
@@ -2,14 +2,16 @@ import { ArrowRight, Search } from 'lucide-react';
2 2 import type { Metadata } from 'next';
3 3 import Link from 'next/link';
4 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 8 import { EntityLink } from '@/components/ui/entity';
7 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 11 import { Unavailable } from '@/components/ui/unavailable';
10 12 import { api, safe } from '@/lib/api';
11 −import { fmtDate, fmtInt, num } from '@/lib/format';
12 −import { DESCRIPTION, EXAMPLE_QUERIES, routes, SITE_NAME, SITE_URL, TAGLINE, typeLabel } from '@/lib/site';
13 +import { fmtDate, fmtDeltaPct, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, hostOf, num } from '@/lib/format';
14 +import { DESCRIPTION, EXAMPLE_QUERIES, eventTone, routes, signatureProducts, SITE_NAME, TAGLINE } from '@/lib/site';
13 15 import type { ChangeEvent, EntitySummary } from '@/lib/types';
14 16
15 17 export const metadata: Metadata = {
@@ -19,57 +21,147 @@ export const metadata: Metadata = {
19 21 };
20 22 export const revalidate = 60;
21 23
22 −const HOW = [
23 − { step: 'Connectors', text: 'First-party crawlers read official docs, pricing pages, model cards, papers, feeds and repositories directly — no third-party data APIs.' },
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.' },
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.' },
26 − { step: 'History', text: 'Material changes become events: new models, price moves, context changes, deprecations. Nothing is overwritten; conflicts are flagged, never averaged.' },
27 −];
28 −
29 −function pickFeed(daily: Awaited<ReturnType<typeof api.changesDaily>> | null, changes: ChangeEvent[] | null): { title: string; items: ChangeEvent[] }[] {
30 − const out: { title: string; items: ChangeEvent[] }[] = [];
31 − const all = changes ?? [];
32 − const by = (pred: (e: ChangeEvent) => boolean, title: string, n = 6) => {
33 − const items = all.filter(pred).slice(0, n);
34 − if (items.length) out.push({ title, items });
24 +/** Fallback "How counted" texts — replaced by `stats.definitions` when the API provides them. */
25 +const DEFINITIONS: Record<string, string> = {
26 + model: 'Entities typed “model” in the graph. After canonicalisation: canonical model releases only — artifacts (quantizations, conversions) and folded evaluation variants are excluded.',
27 + organization: 'Companies, labs, universities and other organizations (entity types company · organization · lab · university).',
28 + paper: 'Research papers ingested from arXiv and publisher feeds (one entity per paper, deduplicated by identifier).',
29 + dataset: 'Datasets known to the graph (Hugging Face and publisher pages).',
30 + benchmark_results: 'Live benchmark result rows (one per model × benchmark × metric × configuration). Superseded runs are closed, never deleted.',
31 + provider: 'API providers that serve models (each with its own model listing and prices).',
32 + prices_current: 'Price offers currently valid (USD per 1M tokens) across all providers — one live row per model × provider × provider model id.',
33 + documents: 'Source documents crawled by first-party connectors; every fact links back to one of them (snapshots are archived forever).',
34 +};
35 +
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');
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;
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 }));
44 56 }
57 +const NODE_TYPES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware'];
45 58
46 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))]);
48 − const feed = pickFeed(daily, changes?.items ?? null);
49 − const today = daily?.date ?? new Date().toISOString().slice(0, 10);
60 + const [stats, daily, frontier, majors, priceIndex, open, openFallback, benchmarks, papers, timeline, recent] = await Promise.all([
61 + safe(api.stats()),
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 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 138 return (
54 139 <>
55 − {/* ------------------------------------------------------------------------------------------------ hero */}
140 + {/* ------------------------------------------------------------------------------------------------ hero (compact) */}
56 141 <div className="relative overflow-hidden border-b border-rule">
57 142 <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden />
58 − <Container className="relative py-12 md:py-20">
59 − <p className="eyebrow">The AI ecosystem, mapped · with provenance and history</p>
60 − <h1 className="display mt-3 max-w-4xl text-[38px] md:text-[64px]">Explore the entire AI ecosystem.</h1>
61 − <p className="mt-4 max-w-2xl text-base leading-relaxed text-ink-2 md:text-lg">
62 − Models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — every fact linked to its source, every change kept.
63 − </p>
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">
143 + <Container className="relative py-8 md:py-12">
144 + <p className="eyebrow">The temporal knowledge graph of the AI ecosystem</p>
145 + <div className="mt-2 grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-end">
146 + <div>
147 + <h1 className="display text-[34px] md:text-[52px]">Explore the AI ecosystem.</h1>
148 + <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">
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 157 <label htmlFor="home-q" className="sr-only">Search</label>
66 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 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 161 Search <ArrowRight className="hidden size-4 md:block" aria-hidden />
70 162 </button>
71 163 </form>
72 − <ul className="mt-3 flex flex-wrap gap-2">
164 + <ul className="mt-2.5 flex flex-wrap gap-2">
73 165 {EXAMPLE_QUERIES.map((q) => (
74 166 <li key={q}>
75 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 169 </Link>
78 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 175 </ul>
81 176 </Container>
82 177 </div>
83 178
84 179 <Container>
85 − {/* ------------------------------------------------------------------------------------------------ counters */}
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}>
87 − {stats ? (
88 − <StatGrid cols={8}>
89 − <Stat label="Models" value={fmtInt(ent.model)} href={routes.models()} />
90 − <Stat label="Companies" value={fmtInt(companies)} href={routes.companies()} />
91 − <Stat label="Papers" value={fmtInt(ent.paper)} href={routes.papers()} />
92 − <Stat label="Providers" value={fmtInt(ent.provider)} href={routes.providers()} />
93 − <Stat label="Benchmarks" value={fmtInt(ent.benchmark)} href={routes.benchmarks()} />
94 − <Stat label="Hardware" value={fmtInt(ent.hardware)} href={routes.hardware()} />
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()} />
96 − <Stat label="Sources" value={fmtInt(stats.sources)} hint={`${fmtInt(stats.documents)} documents`} href={routes.sources()} />
97 − </StatGrid>
98 − ) : (
99 − <Unavailable what="Live counters" reason="The API did not answer. Counters are never cached or hardcoded." />
100 − )}
101 − </Section>
180 + {/* ------------------------------------------------------------------------------------------------ live strip */}
181 + <div className="pt-5">
182 + <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
183 + <p className="eyebrow flex items-center gap-2">
184 + Live ecosystem
185 + {stats && (
186 + <span className="flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal text-ink-3">
187 + <span className="dot pulse" aria-hidden /> computed <LiveAgo at={stats.computed_at} />
188 + {live24 !== null && <span>· {fmtInt(live24)} live events / 24 h</span>}
189 + </span>
190 + )}
191 + </p>
192 + <Link href={routes.methodology()} className="text-xs text-ink-3 hover:text-ink">
193 + How counts are computed →
194 + </Link>
195 + </div>
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 */}
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}>
105 − {feed.length ? (
106 − <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2">
107 − {feed.map((f) => (
108 − <div key={f.title}>
109 − <p className="eyebrow mb-1">{f.title}</p>
110 − <ul className="border-t border-rule">
111 − {f.items.map((e) => (
112 − <ChangeRow key={e.id} e={e} dense />
113 − ))}
114 − </ul>
200 + {/* ------------------------------------------------------------------------------------------------ editorial grid */}
201 + <div className="grid gap-x-12 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">
202 + <div className="min-w-0">
203 + {/* today */}
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}>
205 + {sections.length ? (
206 + <div className="space-y-6">
207 + {sections.map((s) => (
208 + <div key={s.category}>
209 + <p className="eyebrow mb-1 flex items-baseline justify-between">
210 + <span>{s.label}</span>
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 220 </div>
116 − ))}
117 − </div>
118 − ) : changes ? (
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>
120 − ) : (
121 − <Unavailable what="Change feed" />
122 − )}
123 − {daily?.new_models && daily.new_models.length > 0 && (
124 − <div className="mt-8">
125 − <p className="eyebrow mb-2">New models today <span className="tnum text-ink-3">{daily.new_models.length}</span></p>
126 − <ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
127 − {daily.new_models.slice(0, 16).map((m) => (
128 − <li key={m.id}>
129 − <EntityLink e={m} className="font-medium" />
130 − {m.organization && <span className="text-ink-3"> · {m.organization.name}</span>}
131 − </li>
132 − ))}
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>
221 + ) : daily ? (
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>
223 + ) : (
224 + <Unavailable what="Daily digest" />
225 + )}
226 + <p className="mt-4 text-xs text-ink-3">
227 + {daily?.backfill_excluded !== undefined ? (
228 + <>
229 + {fmtInt(daily.backfill_excluded)} historical backfill events are excluded — see the <Link href={routes.timeline()} className="link">Timeline</Link>.
230 + </>
231 + ) : (
232 + <>
233 + Digest keyed on observation time; historical backfill is shown on the <Link href={routes.timeline()} className="link">Timeline</Link> by effective date.
234 + </>
235 + )}
236 + </p>
237 + </Section>
140 238
141 − {/* ------------------------------------------------------------------------------------------------ explore */}
142 − <Section eyebrow="Explore" title="Every entity type, with live counts" action={{ href: routes.explore(), label: 'All types' }}>
143 − {types?.items?.length ? (
144 − <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-5">
145 − {types.items
146 − .filter((t) => (num(t.count) ?? 0) > 0)
147 − .sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0))
148 − .slice(0, 15)
149 − .map((t) => (
150 − <li key={t.entity_type} className="border-b border-r border-rule">
151 − <Link href={routes.listing(t.entity_type)} className="block px-4 py-4 hover:bg-surface-2">
152 − <EntityBadge type={t.entity_type} small />
153 − <p className="tnum mt-2 text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</p>
154 − <p className="text-sm text-ink-2">{t.label || typeLabel(t.entity_type, true)}</p>
155 − </Link>
156 − </li>
157 − ))}
158 − </ul>
239 + {/* frontier moves */}
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' }}>
241 + {frontierMoves.length ? (
242 + <ul className="border-t border-rule">
243 + {frontierMoves.map((e) => (
244 + <ChangeRow key={e.id} e={e} dense />
245 + ))}
246 + </ul>
247 + ) : majors ? (
248 + <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No major events recorded yet.</p>
249 + ) : (
250 + <Unavailable what="Frontier moves" compact />
251 + )}
252 + </Section>
253 +
254 + {/* benchmark leaders */}
255 + <Section eyebrow="Benchmark leaders" title="Who leads, on what" action={{ href: routes.benchmarks(), label: 'All benchmarks' }}>
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 414 </Section>
163 415
164 − {/* ------------------------------------------------------------------------------------------------ trending */}
165 − <Section eyebrow="Trending · 7 days" title="Most viewed" lede="Ranked by page views on AI Atlas, not by any external popularity signal.">
166 − {trending?.items?.length ? (
167 − <ol className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
168 − {trending.items.slice(0, 12).map((t, i) => (
169 − <li key={t.id} className="flex items-baseline gap-3 border-b border-rule py-2 text-sm">
170 − <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span>
171 − <EntityBadge type={t.entity_type} small />
172 − <EntityLink e={t as EntitySummary} className="min-w-0 flex-1 truncate font-medium" />
173 − <span className="tnum shrink-0 text-xs text-ink-3">{fmtInt(t.views)} views</span>
174 − </li>
175 − ))}
176 − </ol>
177 − ) : trending ? (
178 − <p className="text-sm text-ink-3">Not enough page views yet to rank entities.</p>
179 − ) : (
180 − <Unavailable what="Trending" compact />
181 − )}
416 + {/* ------------------------------------------------------------------------------------------------ graph teaser */}
417 + <Section eyebrow="Ecosystem graph" title="Every entity, every relation" action={{ href: routes.graph(), label: 'Explore the graph' }}>
418 + <div className="grid items-center gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
419 + <div className="text-sm text-ink-2">
420 + <p className="tnum text-2xl font-semibold tracking-tight text-ink">
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>
422 + </p>
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>
424 + <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
425 + <Link href={routes.graph()} className="link">Knowledge graph →</Link>
426 + <Link href={routes.families()} className="link">Model families →</Link>
427 + <Link href={routes.compare()} className="link">Compare →</Link>
428 + </p>
429 + </div>
430 + <Link href={routes.graph()} className="block border border-rule bg-surface/40 hover:border-rule-strong" aria-label="Open the knowledge graph">
431 + <svg viewBox="0 0 320 160" className="block h-auto w-full" aria-hidden>
432 + {nodes.map((n, i) =>
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 448 </Section>
183 449
184 − {/* ------------------------------------------------------------------------------------------------ how */}
185 − <Section eyebrow="How AI Atlas works" title="Connectors → archive → graph → history" action={{ href: routes.methodology(), label: 'Methodology' }}>
186 − <ol className="grid gap-6 md:grid-cols-4">
187 − {HOW.map((h, i) => (
188 − <li key={h.step} className="border-t-2 border-ink pt-3">
189 − <p className="mono text-xs text-ink-3">0{i + 1}</p>
190 − <p className="mt-1 font-semibold">{h.step}</p>
191 − <p className="mt-1.5 text-sm leading-relaxed text-ink-2">{h.text}</p>
450 + {/* ------------------------------------------------------------------------------------------------ signature products */}
451 + <Section eyebrow="Signature products" title="Twelve ways to read the atlas" hairline>
452 + <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-6">
453 + {signatureProducts.map((p) => (
454 + <li key={p.label} className="border-b border-r border-rule">
455 + <Link href={p.href} className="block h-full px-3 py-3 hover:bg-surface-2">
456 + <p className="text-sm font-medium text-ink">{p.label}</p>
457 + {p.hint && <p className="mt-0.5 text-xs leading-snug text-ink-3">{p.hint}</p>}
458 + </Link>
192 459 </li>
193 460 ))}
194 − </ol>
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>
461 + </ul>
198 462 </Section>
199 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