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>
52 changed files +3,931 −659
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
@@ -0,0 +1,19 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 3 | +import { WatchlistView } from '@/components/watchlist/watchlist-view'; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { | |
| 6 | + title: 'Watchlist', | |
| 7 | + description: 'Entities you follow on AI Atlas and their latest release, price, deprecation, benchmark, provider and licence events. Stored in your browser — no account.', | |
| 8 | + alternates: { canonical: '/watchlist' }, | |
| 9 | + robots: { index: false, follow: true }, | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export default function WatchlistPage() { | |
| 13 | + return ( | |
| 14 | + <Container> | |
| 15 | + <PageHeader eyebrow="Local · this browser only" title="Watchlist" lede="Follow models, organizations, providers or benchmarks and see their material events in one feed: new versions, price moves, deprecations, benchmark results, provider listings and licence changes. Nothing leaves your browser." /> | |
| 16 | + <WatchlistView /> | |
| 17 | + </Container> | |
| 18 | + ); | |
| 19 | +} | |
modified
apps/web/src/components/brand/logo.tsx
+21 −16
@@ -1,29 +1,34 @@ | ||
| 1 | 1 | import { cn } from '@/lib/cn'; |
| 2 | +import { MarkArt, MARK_TOKENS } from './mark'; | |
| 2 | 3 | |
| 3 | −/** The AI Atlas mark: a globe with meridians and a small relation graph (5 nodes) — legible at 16 px. Inherits `currentColor`. */ | |
| 4 | −export function LogoMark({ size = 24, className }: { size?: number; className?: string }) { | |
| 4 | +/** | |
| 5 | + * The AI Atlas mark (atlas plate + "A" constellation + timeline "now" node). `plate` (default) follows the theme through the | |
| 6 | + * `--brand-*` tokens; `mono` draws the graph alone in `currentColor` for footers and monochrome contexts. | |
| 7 | + */ | |
| 8 | +export function LogoMark({ size = 24, className, variant = 'plate', title }: { size?: number; className?: string; variant?: 'plate' | 'mono'; title?: string }) { | |
| 9 | + const colors = variant === 'plate' ? MARK_TOKENS : { plate: 'transparent', ink: 'currentColor', grid: 'currentColor', accent: 'var(--accent)' }; | |
| 5 | 10 | return ( |
| 6 | − <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn('shrink-0', className)} aria-hidden> | |
| 7 | − <circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="1.6" /> | |
| 8 | − <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 9 | − <path d="M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 10 | − <path d="M3 16h26" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 11 | − <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="currentColor" strokeWidth="1.2" opacity="0.55" /> | |
| 12 | − <circle cx="16" cy="16" r="2.4" className="fill-accent" /> | |
| 13 | − <circle cx="9.4" cy="9.5" r="1.7" fill="currentColor" /> | |
| 14 | − <circle cx="22.6" cy="9.5" r="1.7" fill="currentColor" /> | |
| 15 | − <circle cx="10.8" cy="24.4" r="1.7" fill="currentColor" /> | |
| 16 | − <circle cx="23.4" cy="21.6" r="1.7" fill="currentColor" /> | |
| 11 | + <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn('shrink-0', className)} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined}> | |
| 12 | + {title && <title>{title}</title>} | |
| 13 | + {variant === 'mono' ? ( | |
| 14 | + <g opacity={1}> | |
| 15 | + <rect x="1" y="1" width="30" height="30" rx="7" stroke="currentColor" strokeWidth="1.2" opacity="0.5" /> | |
| 16 | + <MarkArt colors={colors} plate={false} simplified={size <= 20} /> | |
| 17 | + </g> | |
| 18 | + ) : ( | |
| 19 | + <MarkArt colors={colors} simplified={size <= 20} /> | |
| 20 | + )} | |
| 17 | 21 | </svg> |
| 18 | 22 | ); |
| 19 | 23 | } |
| 20 | 24 | |
| 21 | −export function Wordmark({ className, markSize = 22 }: { className?: string; markSize?: number }) { | |
| 25 | +/** Lockup: mark + "AI Atlas" (Geist, "AI" heavy, "Atlas" medium). */ | |
| 26 | +export function Wordmark({ className, markSize = 22, textClassName }: { className?: string; markSize?: number; textClassName?: string }) { | |
| 22 | 27 | return ( |
| 23 | 28 | <span className={cn('inline-flex items-center gap-2 text-ink', className)}> |
| 24 | 29 | <LogoMark size={markSize} /> |
| 25 | − <span className="text-[17px] font-semibold tracking-tight"> | |
| 26 | − AI <span className="font-medium text-ink-2">Atlas</span> | |
| 30 | + <span className={cn('text-[17px] tracking-tight', textClassName)}> | |
| 31 | + <span className="font-bold">AI</span> <span className="font-medium text-ink-2">Atlas</span> | |
| 27 | 32 | </span> |
| 28 | 33 | </span> |
| 29 | 34 | ); |
added
apps/web/src/components/brand/mark.tsx
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +/** | |
| 2 | + * The AI Atlas mark — an "atlas plate": a rounded-square plate carrying a fine coordinate grid, over which three nodes and | |
| 3 | + * two edges draw the letter A as a graph. The horizontal baseline is a timeline; the accent node at its right end marks "now". | |
| 4 | + * One geometry (32 × 32 viewBox) for every size: 16 px favicon (simplified grid), 32 px tab, 180 px Apple icon, 512 px PWA, | |
| 5 | + * the in-page logo (CSS-variable colours) and the OG images (hex colours — ImageResponse cannot read CSS variables). | |
| 6 | + */ | |
| 7 | +export type MarkColors = { plate: string; ink: string; grid: string; accent: string }; | |
| 8 | + | |
| 9 | +/** Dark plate (favicon / PWA / OG). */ | |
| 10 | +export const MARK_DARK: MarkColors = { plate: '#0b0d11', ink: '#e9ebf0', grid: 'rgba(233,235,240,0.22)', accent: '#6d95ff' }; | |
| 11 | +/** Light plate (lockup on light backgrounds). */ | |
| 12 | +export const MARK_LIGHT: MarkColors = { plate: '#e9ebf0', ink: '#0b0d11', grid: 'rgba(11,13,17,0.2)', accent: '#1f4fd8' }; | |
| 13 | +/** In-page: follows the theme through the `--brand-*` tokens (globals.css). */ | |
| 14 | +export const MARK_TOKENS: MarkColors = { plate: 'var(--brand-plate)', ink: 'var(--brand-ink)', grid: 'var(--brand-grid)', accent: 'var(--brand-accent)' }; | |
| 15 | + | |
| 16 | +export const MARK_GEOMETRY = { | |
| 17 | + apex: { x: 16, y: 8 }, | |
| 18 | + left: { x: 8.5, y: 24 }, | |
| 19 | + now: { x: 23.5, y: 24 }, | |
| 20 | + baseline: { x1: 5, x2: 27, y: 24 }, | |
| 21 | + gridH: [10.5, 17], | |
| 22 | + gridV: [10.5, 21.5], | |
| 23 | +}; | |
| 24 | + | |
| 25 | +type MarkOpts = { simplified?: boolean; plate?: boolean; radius?: number }; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * Inner artwork (no <svg> wrapper) so callers control size/attributes. `simplified` keeps two grid lines only — crisp at 16 px. | |
| 29 | + * `plate=false` draws the graph alone (monochrome contexts). | |
| 30 | + */ | |
| 31 | +export function MarkArt({ colors, simplified = false, plate = true, radius = 7 }: { colors: MarkColors } & MarkOpts) { | |
| 32 | + const g = MARK_GEOMETRY; | |
| 33 | + const strokeW = simplified ? 2 : 1.7; | |
| 34 | + const nodeR = simplified ? 2.4 : 2.1; | |
| 35 | + const gridW = simplified ? 1 : 0.8; | |
| 36 | + return ( | |
| 37 | + <> | |
| 38 | + {plate && <rect x="1" y="1" width="30" height="30" rx={radius} fill={colors.plate} />} | |
| 39 | + {g.gridH.map((y) => ( | |
| 40 | + <line key={`h${y}`} x1="4" x2="28" y1={y} y2={y} stroke={colors.grid} strokeWidth={gridW} /> | |
| 41 | + ))} | |
| 42 | + {!simplified && g.gridV.map((x) => <line key={`v${x}`} x1={x} x2={x} y1="4" y2="28" stroke={colors.grid} strokeWidth={gridW} />)} | |
| 43 | + <line x1={g.baseline.x1} x2={g.baseline.x2} y1={g.baseline.y} y2={g.baseline.y} stroke={colors.ink} strokeWidth={simplified ? 1.4 : 1.2} opacity={0.75} strokeLinecap="round" /> | |
| 44 | + <path d={`M${g.left.x} ${g.left.y}L${g.apex.x} ${g.apex.y}L${g.now.x} ${g.now.y}`} fill="none" stroke={colors.ink} strokeWidth={strokeW} strokeLinecap="round" strokeLinejoin="round" /> | |
| 45 | + <circle cx={g.apex.x} cy={g.apex.y} r={nodeR} fill={colors.ink} /> | |
| 46 | + <circle cx={g.left.x} cy={g.left.y} r={nodeR} fill={colors.ink} /> | |
| 47 | + <circle cx={g.now.x} cy={g.now.y} r={nodeR + 0.4} fill={colors.accent} /> | |
| 48 | + </> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** The same artwork as a standalone SVG string (static assets, data URIs for ImageResponse). */ | |
| 53 | +export function markSvgString(colors: MarkColors, { simplified = false, plate = true, radius = 7 }: MarkOpts = {}): string { | |
| 54 | + const g = MARK_GEOMETRY; | |
| 55 | + const strokeW = simplified ? 2 : 1.7; | |
| 56 | + const nodeR = simplified ? 2.4 : 2.1; | |
| 57 | + const gridW = simplified ? 1 : 0.8; | |
| 58 | + const parts: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">`]; | |
| 59 | + if (plate) parts.push(`<rect x="1" y="1" width="30" height="30" rx="${radius}" fill="${colors.plate}"/>`); | |
| 60 | + for (const y of g.gridH) parts.push(`<line x1="4" x2="28" y1="${y}" y2="${y}" stroke="${colors.grid}" stroke-width="${gridW}"/>`); | |
| 61 | + if (!simplified) for (const x of g.gridV) parts.push(`<line x1="${x}" x2="${x}" y1="4" y2="28" stroke="${colors.grid}" stroke-width="${gridW}"/>`); | |
| 62 | + parts.push(`<line x1="${g.baseline.x1}" x2="${g.baseline.x2}" y1="${g.baseline.y}" y2="${g.baseline.y}" stroke="${colors.ink}" stroke-width="${simplified ? 1.4 : 1.2}" opacity="0.75" stroke-linecap="round"/>`); | |
| 63 | + parts.push(`<path d="M${g.left.x} ${g.left.y}L${g.apex.x} ${g.apex.y}L${g.now.x} ${g.now.y}" fill="none" stroke="${colors.ink}" stroke-width="${strokeW}" stroke-linecap="round" stroke-linejoin="round"/>`); | |
| 64 | + parts.push(`<circle cx="${g.apex.x}" cy="${g.apex.y}" r="${nodeR}" fill="${colors.ink}"/>`); | |
| 65 | + parts.push(`<circle cx="${g.left.x}" cy="${g.left.y}" r="${nodeR}" fill="${colors.ink}"/>`); | |
| 66 | + parts.push(`<circle cx="${g.now.x}" cy="${g.now.y}" r="${nodeR + 0.4}" fill="${colors.accent}"/>`); | |
| 67 | + parts.push('</svg>'); | |
| 68 | + return parts.join(''); | |
| 69 | +} | |
| 70 | + | |
| 71 | +export function markDataUri(colors: MarkColors, opts?: MarkOpts): string { | |
| 72 | + return `data:image/svg+xml;utf8,${encodeURIComponent(markSvgString(colors, opts))}`; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * The mark for ImageResponse routes (OG images, PWA/Apple icons): a data-URI <img>, which satori rasterises reliably | |
| 77 | + * (nested SVG element trees are not). Fixed hex colours only. | |
| 78 | + */ | |
| 79 | +export function MarkImg({ px, colors = MARK_DARK, simplified = false, plate = true, radius }: { px: number; colors?: MarkColors } & MarkOpts) { | |
| 80 | + // eslint-disable-next-line @next/next/no-img-element | |
| 81 | + return <img width={px} height={px} src={markDataUri(colors, { simplified, plate, radius })} alt="" style={{ width: px, height: px }} />; | |
| 82 | +} | |
modified
apps/web/src/components/brand/og.tsx
+104 −25
@@ -1,39 +1,63 @@ | ||
| 1 | −/** Shared chrome for per-entity Open Graph images (ImageResponse cannot read CSS variables → token hex values inlined). */ | |
| 1 | +import { MARK_DARK, MarkImg } from './mark'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Shared chrome for Open Graph images (ImageResponse cannot read CSS variables → dark-theme token hex values inlined). | |
| 5 | + * `Frame` = header lockup + content + footer; `Wallpaper` = the dark atlas plate with a faint coordinate grid and the mark | |
| 6 | + * large at left — used by the root OG image and by the per-type OG images (D1–D3) through `Wallpaper` + `Counters`. | |
| 7 | + */ | |
| 2 | 8 | export const INK = '#e9ebf0'; |
| 3 | 9 | export const INK2 = '#a3a9b8'; |
| 4 | 10 | export const INK3 = '#6f7688'; |
| 5 | 11 | export const ACCENT = '#6d95ff'; |
| 12 | +export const ACCENT2 = '#f2b04a'; | |
| 13 | +export const CANVAS = '#0b0d11'; | |
| 14 | +export const SURFACE = '#12151b'; | |
| 15 | +export const RULE = 'rgba(190,200,225,0.14)'; | |
| 16 | + | |
| 17 | +export function Mark({ px = 56 }: { px?: number }) { | |
| 18 | + return <MarkImg px={px} colors={{ ...MARK_DARK, plate: SURFACE }} />; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** Faint coordinate grid covering the whole image (absolute, behind content). */ | |
| 22 | +export function Grid({ step = 60, opacity = 1 }: { step?: number; opacity?: number }) { | |
| 23 | + return ( | |
| 24 | + <div | |
| 25 | + style={{ | |
| 26 | + position: 'absolute', | |
| 27 | + inset: 0, | |
| 28 | + display: 'flex', | |
| 29 | + opacity, | |
| 30 | + backgroundImage: `linear-gradient(to right, ${RULE} 1px, transparent 1px), linear-gradient(to bottom, ${RULE} 1px, transparent 1px)`, | |
| 31 | + backgroundSize: `${step}px ${step}px`, | |
| 32 | + }} | |
| 33 | + /> | |
| 34 | + ); | |
| 35 | +} | |
| 6 | 36 | |
| 7 | −function Mark({ px = 56 }: { px?: number }) { | |
| 37 | +export function Lockup({ px = 56, fontSize = 32 }: { px?: number; fontSize?: number }) { | |
| 8 | 38 | return ( |
| 9 | − <svg width={px} height={px} viewBox="0 0 32 32" fill="none"> | |
| 10 | − <circle cx="16" cy="16" r="13" stroke={INK} strokeWidth="1.6" /> | |
| 11 | − <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={INK} strokeWidth="1.2" opacity="0.7" /> | |
| 12 | − <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke={INK} strokeWidth="1.2" opacity="0.55" /> | |
| 13 | − <circle cx="16" cy="16" r="2.4" fill={ACCENT} /> | |
| 14 | − <circle cx="9.4" cy="9.5" r="1.7" fill={INK} /> | |
| 15 | − <circle cx="22.6" cy="9.5" r="1.7" fill={INK} /> | |
| 16 | − <circle cx="10.8" cy="24.4" r="1.7" fill={INK} /> | |
| 17 | − <circle cx="23.4" cy="21.6" r="1.7" fill={INK} /> | |
| 18 | − </svg> | |
| 39 | + <div style={{ display: 'flex', alignItems: 'center', gap: Math.round(px * 0.3) }}> | |
| 40 | + <Mark px={px} /> | |
| 41 | + <div style={{ display: 'flex', fontSize, letterSpacing: -1, alignItems: 'baseline' }}> | |
| 42 | + <span style={{ fontWeight: 700 }}>AI</span> | |
| 43 | + <span style={{ color: INK2, marginLeft: Math.round(fontSize * 0.28), fontWeight: 500 }}>Atlas</span> | |
| 44 | + </div> | |
| 45 | + </div> | |
| 19 | 46 | ); |
| 20 | 47 | } |
| 21 | 48 | |
| 22 | 49 | export function Frame({ children, footer }: { children: React.ReactNode; footer: string }) { |
| 23 | 50 | return ( |
| 24 | − <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px', background: 'linear-gradient(160deg, #12151b 0%, #0b0d11 55%, #0b0d11 100%)', color: INK, fontFamily: 'sans-serif', position: 'relative' }}> | |
| 25 | − <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' }} /> | |
| 26 | − <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> | |
| 27 | − <Mark /> | |
| 28 | − <div style={{ display: 'flex', fontSize: 32, fontWeight: 600, letterSpacing: -1 }}> | |
| 29 | − <span>AI</span> | |
| 30 | − <span style={{ color: INK2, marginLeft: 9, fontWeight: 500 }}>Atlas</span> | |
| 31 | − </div> | |
| 51 | + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px', background: CANVAS, color: INK, fontFamily: 'sans-serif', position: 'relative' }}> | |
| 52 | + <Grid /> | |
| 53 | + <div style={{ position: 'absolute', right: -160, top: -200, width: 720, height: 720, borderRadius: 999, background: 'radial-gradient(circle at 40% 40%, rgba(109,149,255,0.16), rgba(11,13,17,0) 70%)', display: 'flex' }} /> | |
| 54 | + <div style={{ display: 'flex', position: 'relative' }}> | |
| 55 | + <Lockup /> | |
| 32 | 56 | </div> |
| 33 | − {children} | |
| 34 | − <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: INK3, fontSize: 21 }}> | |
| 57 | + <div style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}>{children}</div> | |
| 58 | + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: INK3, fontSize: 21, position: 'relative' }}> | |
| 35 | 59 | <span>{footer}</span> |
| 36 | − <span>source-attributed · with history</span> | |
| 60 | + <span>who said it · when · where · how it was extracted</span> | |
| 37 | 61 | </div> |
| 38 | 62 | </div> |
| 39 | 63 | ); |
@@ -66,13 +90,68 @@ export function Facts({ items }: { items: [string, string][] }) { | ||
| 66 | 90 | ); |
| 67 | 91 | } |
| 68 | 92 | |
| 93 | +/** Live counter strip (label above value, hairline separators). Empty array → renders nothing (graceful fallback). */ | |
| 94 | +export function Counters({ items, size = 'md' }: { items: [string, string][]; size?: 'sm' | 'md' }) { | |
| 95 | + if (!items.length) return null; | |
| 96 | + const vs = size === 'sm' ? 30 : 38; | |
| 97 | + return ( | |
| 98 | + <div style={{ display: 'flex', borderTop: `1px solid ${RULE}`, paddingTop: 18, gap: 0 }}> | |
| 99 | + {items.map(([label, value], i) => ( | |
| 100 | + <div key={label} style={{ display: 'flex', flexDirection: 'column', paddingRight: 36, paddingLeft: i === 0 ? 0 : 36, borderLeft: i === 0 ? 'none' : `1px solid ${RULE}` }}> | |
| 101 | + <span style={{ fontSize: 14, letterSpacing: 2.5, textTransform: 'uppercase', color: INK3, fontWeight: 600 }}>{label}</span> | |
| 102 | + <span style={{ fontSize: vs, fontWeight: 600, color: INK, letterSpacing: -1, marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>{value}</span> | |
| 103 | + </div> | |
| 104 | + ))} | |
| 105 | + </div> | |
| 106 | + ); | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** | |
| 110 | + * The "wallpaper": dark plate, faint grid, the mark large at left, title + tagline at right and a counter strip at the bottom. | |
| 111 | + * `eyebrow` defaults to the site tagline; `title` to "AI Atlas". Per-type OG images pass their own eyebrow/title/counters. | |
| 112 | + */ | |
| 113 | +export function Wallpaper({ eyebrow, title = 'AI Atlas', subtitle, counters = [], footer = 'www.ai-atlas.co', markPx = 300 }: { eyebrow?: string; title?: string; subtitle?: string; counters?: [string, string][]; footer?: string; markPx?: number }) { | |
| 114 | + return ( | |
| 115 | + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px 48px', background: CANVAS, color: INK, fontFamily: 'sans-serif', position: 'relative' }}> | |
| 116 | + <Grid step={50} /> | |
| 117 | + <div style={{ position: 'absolute', left: -120, bottom: -260, width: 760, height: 760, borderRadius: 999, background: 'radial-gradient(circle at 50% 50%, rgba(109,149,255,0.14), rgba(11,13,17,0) 68%)', display: 'flex' }} /> | |
| 118 | + <div style={{ display: 'flex', alignItems: 'center', gap: 56, position: 'relative', flex: 1 }}> | |
| 119 | + <div style={{ display: 'flex', flexShrink: 0 }}> | |
| 120 | + <MarkImg px={markPx} colors={{ ...MARK_DARK, plate: SURFACE }} /> | |
| 121 | + </div> | |
| 122 | + <div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}> | |
| 123 | + {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>} | |
| 124 | + <div style={{ display: 'flex', fontSize: title.length > 22 ? 56 : 84, fontWeight: 700, lineHeight: 1, letterSpacing: -3, maxHeight: 180, overflow: 'hidden' }}> | |
| 125 | + {title === 'AI Atlas' ? ( | |
| 126 | + <> | |
| 127 | + <span>AI</span> | |
| 128 | + <span style={{ color: INK2, marginLeft: 22, fontWeight: 500 }}>Atlas</span> | |
| 129 | + </> | |
| 130 | + ) : ( | |
| 131 | + <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span> | |
| 132 | + )} | |
| 133 | + </div> | |
| 134 | + {subtitle && <div style={{ display: 'flex', fontSize: 27, color: INK2, lineHeight: 1.3, maxWidth: 720 }}>{subtitle}</div>} | |
| 135 | + </div> | |
| 136 | + </div> | |
| 137 | + <div style={{ display: 'flex', flexDirection: 'column', gap: 16, position: 'relative' }}> | |
| 138 | + <Counters items={counters} /> | |
| 139 | + <div style={{ display: 'flex', justifyContent: 'space-between', color: INK3, fontSize: 19 }}> | |
| 140 | + <span>{footer}</span> | |
| 141 | + <span>who said it · when · where · how it was extracted</span> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + ); | |
| 146 | +} | |
| 147 | + | |
| 69 | 148 | export function Fallback({ label }: { label: string }) { |
| 70 | 149 | return ( |
| 71 | 150 | <Frame footer="www.ai-atlas.co"> |
| 72 | 151 | <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}> |
| 73 | 152 | <Eyebrow>{label}</Eyebrow> |
| 74 | − <Title>Explore the entire AI ecosystem.</Title> | |
| 75 | − <div style={{ fontSize: 24, color: INK2, display: 'flex' }}>Models · companies · research · providers & pricing · benchmarks · hardware</div> | |
| 153 | + <Title>Explore the AI ecosystem.</Title> | |
| 154 | + <div style={{ fontSize: 24, color: INK2, display: 'flex' }}>Models · organizations · research · providers & pricing · benchmarks · hardware</div> | |
| 76 | 155 | </div> |
| 77 | 156 | </Frame> |
| 78 | 157 | ); |
modified
apps/web/src/components/charts/charts.tsx
+270 −40
@@ -1,17 +1,47 @@ | ||
| 1 | −import { extent, max } from 'd3-array'; | |
| 1 | +import { extent, max, min } from 'd3-array'; | |
| 2 | 2 | import { scaleLinear, scaleLog, scaleTime } from 'd3-scale'; |
| 3 | −import { area, curveMonotoneX, line } from 'd3-shape'; | |
| 3 | +import { area, curveMonotoneX, curveStepAfter, line } from 'd3-shape'; | |
| 4 | +import type { ReactNode } from 'react'; | |
| 4 | 5 | import { cn } from '@/lib/cn'; |
| 5 | −import { fmtCompact } from '@/lib/format'; | |
| 6 | +import { fmtCompact, fmtDeltaPct } from '@/lib/format'; | |
| 6 | 7 | |
| 7 | 8 | /* |
| 8 | 9 | Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they |
| 9 | 10 | follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%. |
| 11 | + Interactive variants (hover crosshair, brushing) live in client files next to this one and reuse `lineLayout()`. | |
| 10 | 12 | */ |
| 11 | 13 | |
| 12 | 14 | export type Point = { x: number | Date; y: number }; |
| 13 | 15 | |
| 14 | −export function Sparkline({ values, width = 120, height = 28, className, stroke = 'var(--accent)', fill = true, strokeWidth = 1.5 }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; strokeWidth?: number }) { | |
| 16 | +/** | |
| 17 | + * Sparkline. `variant="trend"` adds a delta label (last vs first, %) coloured positive/negative — pass `invert` when | |
| 18 | + * lower is better (prices) so a drop reads as positive. | |
| 19 | + */ | |
| 20 | +export function Sparkline({ | |
| 21 | + values, | |
| 22 | + width = 120, | |
| 23 | + height = 28, | |
| 24 | + className, | |
| 25 | + stroke = 'var(--accent)', | |
| 26 | + fill = true, | |
| 27 | + strokeWidth = 1.5, | |
| 28 | + variant = 'line', | |
| 29 | + invert = false, | |
| 30 | + format, | |
| 31 | + title, | |
| 32 | +}: { | |
| 33 | + values: number[]; | |
| 34 | + width?: number; | |
| 35 | + height?: number; | |
| 36 | + className?: string; | |
| 37 | + stroke?: string; | |
| 38 | + fill?: boolean; | |
| 39 | + strokeWidth?: number; | |
| 40 | + variant?: 'line' | 'trend'; | |
| 41 | + invert?: boolean; | |
| 42 | + format?: (v: number) => string; | |
| 43 | + title?: string; | |
| 44 | +}) { | |
| 15 | 45 | const clean = values.filter((v) => Number.isFinite(v)); |
| 16 | 46 | if (clean.length < 2) return <span className={cn('inline-block text-xs text-ink-3', className)}>—</span>; |
| 17 | 47 | const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]); |
@@ -19,14 +49,28 @@ export function Sparkline({ values, width = 120, height = 28, className, stroke | ||
| 19 | 49 | const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]); |
| 20 | 50 | const l = line<number>().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX); |
| 21 | 51 | const a = area<number>().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX); |
| 52 | + const first = clean[0] as number; | |
| 22 | 53 | const last = clean[clean.length - 1] as number; |
| 23 | − return ( | |
| 24 | − <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', className)} aria-hidden> | |
| 54 | + const delta = fmtDeltaPct(first, last); | |
| 55 | + const up = last > first; | |
| 56 | + const good = invert ? !up : up; | |
| 57 | + const svg = ( | |
| 58 | + <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', variant === 'line' && className)} role={title ? 'img' : undefined} aria-label={title} aria-hidden={title ? undefined : true}> | |
| 25 | 59 | {fill && <path d={a(clean) ?? ''} fill={stroke} opacity={0.1} />} |
| 26 | 60 | <path d={l(clean) ?? ''} fill="none" stroke={stroke} strokeWidth={strokeWidth} /> |
| 27 | 61 | <circle cx={x(clean.length - 1)} cy={y(last)} r={2} fill={stroke} /> |
| 28 | 62 | </svg> |
| 29 | 63 | ); |
| 64 | + if (variant === 'line') return svg; | |
| 65 | + return ( | |
| 66 | + <span className={cn('inline-flex items-center gap-2', className)}> | |
| 67 | + {svg} | |
| 68 | + <span className="tnum flex flex-col leading-tight"> | |
| 69 | + {format && <span className="text-sm font-medium text-ink">{format(last)}</span>} | |
| 70 | + {delta && <span className={cn('text-[11px] font-medium', last === first ? 'text-ink-3' : good ? 'text-positive' : 'text-danger')}>{delta}</span>} | |
| 71 | + </span> | |
| 72 | + </span> | |
| 73 | + ); | |
| 30 | 74 | } |
| 31 | 75 | |
| 32 | 76 | /** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */ |
@@ -97,21 +141,56 @@ export function HBars({ data, className, color = 'var(--series-1)', format = fmt | ||
| 97 | 141 | |
| 98 | 142 | export type Series = { name: string; color?: string; points: Point[] }; |
| 99 | 143 | |
| 100 | −/** | |
| 101 | − * Multi-series line chart with time or linear x axis, light grid, end labels. | |
| 102 | − * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices | |
| 103 | − * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0). | |
| 104 | − */ | |
| 105 | −export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear' }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log' }) { | |
| 144 | +export interface LineChartProps { | |
| 145 | + series: Series[]; | |
| 146 | + height?: number; | |
| 147 | + className?: string; | |
| 148 | + yFormat?: (v: number) => string; | |
| 149 | + xTime?: boolean; | |
| 150 | + yLabel?: string; | |
| 151 | + showDots?: boolean; | |
| 152 | + yDomain?: [number, number]; | |
| 153 | + yScale?: 'linear' | 'log'; | |
| 154 | + /** Step interpolation (prices, statuses): the value holds until the next point. */ | |
| 155 | + step?: boolean; | |
| 156 | + /** Extra SVG layers (crosshair, tooltips) rendered inside the chart — used by `InteractiveLineChart`. */ | |
| 157 | + children?: ReactNode; | |
| 158 | + /** Layout width in viewBox units (default 720). */ | |
| 159 | + width?: number; | |
| 160 | +} | |
| 161 | + | |
| 162 | +export const LINE_W = 720; | |
| 163 | +export const LINE_PAD = { l: 44, r: 16, t: 12, b: 24 }; | |
| 164 | + | |
| 165 | +export type LineLayout = { | |
| 166 | + w: number; | |
| 167 | + height: number; | |
| 168 | + pad: typeof LINE_PAD; | |
| 169 | + x: (v: number) => number; | |
| 170 | + y: (v: number) => number; | |
| 171 | + /** Inverse of x for hover: viewBox x → time/number. */ | |
| 172 | + xInvert: (px: number) => number; | |
| 173 | + series: { name: string; color: string; pts: { x: number; y: number }[] }[]; | |
| 174 | + ticksY: number[]; | |
| 175 | + ticksX: number[]; | |
| 176 | + fmtX: (v: number) => string; | |
| 177 | + log: boolean; | |
| 178 | + xTime: boolean; | |
| 179 | +}; | |
| 180 | + | |
| 181 | +const toNum = (v: number | Date) => (v instanceof Date ? v.getTime() : Number(v)); | |
| 182 | + | |
| 183 | +/** Shared scale/layout computation for LineChart and its interactive wrapper. Returns null with < 2 usable points. */ | |
| 184 | +export function lineLayout({ series, height = 220, xTime = true, yDomain, yScale = 'linear', width = LINE_W }: Pick<LineChartProps, 'series' | 'height' | 'xTime' | 'yDomain' | 'yScale' | 'width'>): LineLayout | null { | |
| 106 | 185 | const log = yScale === 'log'; |
| 107 | 186 | const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0); |
| 108 | 187 | const all = series.flatMap((s) => s.points).filter(keep); |
| 109 | − if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>; | |
| 110 | − const w = 720; | |
| 111 | − const pad = { l: 44, r: 16, t: 12, b: 24 }; | |
| 112 | − const xs = all.map((p) => (p.x instanceof Date ? p.x.getTime() : Number(p.x))); | |
| 188 | + if (all.length < 2) return null; | |
| 189 | + const w = width; | |
| 190 | + const pad = LINE_PAD; | |
| 191 | + const xs = all.map((p) => toNum(p.x)); | |
| 113 | 192 | const [x0, x1] = extent(xs) as [number, number]; |
| 114 | − const x = xTime ? scaleTime().domain([new Date(x0), new Date(x1 === x0 ? x0 + 86400000 : x1)]).range([pad.l, w - pad.r]) : scaleLinear().domain([x0, x1 === x0 ? x0 + 1 : x1]).range([pad.l, w - pad.r]); | |
| 193 | + const xScale = xTime ? scaleTime().domain([new Date(x0), new Date(x1 === x0 ? x0 + 86400000 : x1)]).range([pad.l, w - pad.r]) : scaleLinear().domain([x0, x1 === x0 ? x0 + 1 : x1]).range([pad.l, w - pad.r]); | |
| 115 | 194 | const ys = all.map((p) => p.y); |
| 116 | 195 | let [y0, y1] = yDomain ?? (extent(ys) as [number, number]); |
| 117 | 196 | if (y0 === y1) { |
@@ -120,49 +199,93 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac | ||
| 120 | 199 | } |
| 121 | 200 | if (!yDomain && !log) y0 = Math.min(0, y0); |
| 122 | 201 | if (log) { |
| 123 | − y0 = Math.max(Number.EPSILON, y0 <= 0 ? Math.min(...ys.filter((v) => v > 0)) : y0) / 1.25; | |
| 202 | + y0 = Math.max(Number.EPSILON, y0 <= 0 ? (min(ys.filter((v) => v > 0)) as number) : y0) / 1.25; | |
| 124 | 203 | y1 = y1 * 1.25; |
| 125 | 204 | } |
| 126 | − const y = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); | |
| 127 | − const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number; | |
| 128 | − const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX); | |
| 129 | − const ticks = log ? logTicks(y0, y1) : y.ticks(4); | |
| 130 | − const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5); | |
| 131 | − const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); | |
| 205 | + const yScaleFn = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); | |
| 206 | + const x = (v: number) => (xTime ? (xScale as ReturnType<typeof scaleTime>)(new Date(v)) : (xScale as ReturnType<typeof scaleLinear>)(v)) as number; | |
| 207 | + const xInvert = (px: number) => { | |
| 208 | + const inv = (xScale as { invert: (p: number) => Date | number }).invert(px); | |
| 209 | + return inv instanceof Date ? inv.getTime() : Number(inv); | |
| 210 | + }; | |
| 211 | + const ticksY = log ? logTicks(y0, y1) : (yScaleFn as ReturnType<typeof scaleLinear>).ticks(4); | |
| 212 | + const ticksX = (xScale as { ticks: (n: number) => (Date | number)[] }).ticks(5).map(toNum); | |
| 213 | + const fmtX = (v: number) => (xTime ? new Date(v).toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); | |
| 214 | + return { | |
| 215 | + w, | |
| 216 | + height, | |
| 217 | + pad, | |
| 218 | + x, | |
| 219 | + y: (v: number) => yScaleFn(v) as number, | |
| 220 | + xInvert, | |
| 221 | + series: series.map((s, i) => ({ | |
| 222 | + name: s.name, | |
| 223 | + color: s.color ?? `var(--series-${(i % 8) + 1})`, | |
| 224 | + pts: s.points | |
| 225 | + .filter(keep) | |
| 226 | + .map((p) => ({ x: toNum(p.x), y: p.y })) | |
| 227 | + .sort((a, b) => a.x - b.x), | |
| 228 | + })), | |
| 229 | + ticksY, | |
| 230 | + ticksX, | |
| 231 | + fmtX, | |
| 232 | + log, | |
| 233 | + xTime, | |
| 234 | + }; | |
| 235 | +} | |
| 236 | + | |
| 237 | +/** | |
| 238 | + * Multi-series line chart with time or linear x axis, light grid, end labels. | |
| 239 | + * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices | |
| 240 | + * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0). | |
| 241 | + * `step` switches to step-after interpolation (see `StepChart`). Pass `children` to draw extra layers. | |
| 242 | + */ | |
| 243 | +export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear', step = false, children, width }: LineChartProps) { | |
| 244 | + const L = lineLayout({ series, height, xTime, yDomain, yScale, width }); | |
| 245 | + if (!L) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>; | |
| 246 | + const l = line<{ x: number; y: number }>() | |
| 247 | + .x((p) => L.x(p.x)) | |
| 248 | + .y((p) => L.y(p.y)) | |
| 249 | + .curve(step ? curveStepAfter : curveMonotoneX); | |
| 132 | 250 | return ( |
| 133 | − <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}> | |
| 134 | − {ticks.map((t) => ( | |
| 251 | + <svg viewBox={`0 0 ${L.w} ${L.height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}> | |
| 252 | + {yLabel && <title>{yLabel}</title>} | |
| 253 | + {L.ticksY.map((t) => ( | |
| 135 | 254 | <g key={t}> |
| 136 | − <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" /> | |
| 137 | − <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum"> | |
| 255 | + <line x1={L.pad.l} x2={L.w - L.pad.r} y1={L.y(t)} y2={L.y(t)} stroke="var(--rule)" /> | |
| 256 | + <text x={L.pad.l - 6} y={L.y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum"> | |
| 138 | 257 | {yFormat(t)} |
| 139 | 258 | </text> |
| 140 | 259 | </g> |
| 141 | 260 | ))} |
| 142 | − {xt.map((t, i) => ( | |
| 143 | − <text key={i} x={x(t as never) as number} y={height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)"> | |
| 144 | − {fmtX(t)} | |
| 261 | + {L.ticksX.map((t, i) => ( | |
| 262 | + <text key={i} x={L.x(t)} y={L.height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)"> | |
| 263 | + {L.fmtX(t)} | |
| 145 | 264 | </text> |
| 146 | 265 | ))} |
| 147 | − {series.map((s, i) => { | |
| 148 | − const pts = s.points.filter(keep).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x))); | |
| 149 | − if (pts.length === 0) return null; | |
| 150 | − const color = s.color ?? `var(--series-${(i % 8) + 1})`; | |
| 151 | − const last = pts[pts.length - 1] as Point; | |
| 266 | + {L.series.map((s) => { | |
| 267 | + if (s.pts.length === 0) return null; | |
| 268 | + const last = s.pts[s.pts.length - 1] as { x: number; y: number }; | |
| 152 | 269 | return ( |
| 153 | 270 | <g key={s.name}> |
| 154 | − <path d={l(pts) ?? ''} fill="none" stroke={color} strokeWidth={1.75} /> | |
| 155 | − {(showDots || pts.length < 12) && pts.map((p, j) => <circle key={j} cx={toX(p)} cy={y(p.y)} r={2.2} fill={color} />)} | |
| 156 | − <circle cx={toX(last)} cy={y(last.y)} r={3} fill={color} /> | |
| 271 | + <path d={l(s.pts) ?? ''} fill="none" stroke={s.color} strokeWidth={1.75} /> | |
| 272 | + {(showDots || s.pts.length < 12) && s.pts.map((p, j) => <circle key={j} cx={L.x(p.x)} cy={L.y(p.y)} r={2.2} fill={s.color} />)} | |
| 273 | + <circle cx={L.x(last.x)} cy={L.y(last.y)} r={3} fill={s.color} /> | |
| 157 | 274 | </g> |
| 158 | 275 | ); |
| 159 | 276 | })} |
| 277 | + {children} | |
| 160 | 278 | </svg> |
| 161 | 279 | ); |
| 162 | 280 | } |
| 163 | 281 | |
| 282 | +/** Step chart = LineChart in step mode (prices, statuses). */ | |
| 283 | +export function StepChart(props: Omit<LineChartProps, 'step'>) { | |
| 284 | + return <LineChart {...props} step />; | |
| 285 | +} | |
| 286 | + | |
| 164 | 287 | /** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */ |
| 165 | −function logTicks(lo: number, hi: number): number[] { | |
| 288 | +export function logTicks(lo: number, hi: number): number[] { | |
| 166 | 289 | const out: number[] = []; |
| 167 | 290 | for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e); |
| 168 | 291 | if (out.length < 2) return [lo, hi]; |
@@ -195,3 +318,110 @@ export function stepPoints(rows: { at: string; value: number | null }[]): Point[ | ||
| 195 | 318 | } |
| 196 | 319 | return out; |
| 197 | 320 | } |
| 321 | + | |
| 322 | +/* ------------------------------------------------------------------------------------------------------------ Heatmap */ | |
| 323 | +export type HeatCell = { value: number | null; label?: string; href?: string; title?: string }; | |
| 324 | + | |
| 325 | +/** | |
| 326 | + * Matrix heatmap (server-safe HTML table): row/column labels, sticky headers inside `.table-scroll`, colour scale from tokens | |
| 327 | + * (`color-mix` of `--accent` — or a per-column `colors`) between `min` and `max` (defaults: matrix extent). Empty cells read "—". | |
| 328 | + * `direction="lower"` inverts the scale for lower-is-better metrics. | |
| 329 | + */ | |
| 330 | +export function Heatmap({ | |
| 331 | + rows, | |
| 332 | + cols, | |
| 333 | + cell, | |
| 334 | + format = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)), | |
| 335 | + min: minOverride, | |
| 336 | + max: maxOverride, | |
| 337 | + color = 'var(--accent)', | |
| 338 | + direction = 'higher', | |
| 339 | + className, | |
| 340 | + caption, | |
| 341 | + rowHeader = '', | |
| 342 | +}: { | |
| 343 | + rows: { key: string; label: string; href?: string; sub?: string }[]; | |
| 344 | + cols: { key: string; label: string; href?: string; sub?: string }[]; | |
| 345 | + cell: (rowKey: string, colKey: string) => HeatCell | null | undefined; | |
| 346 | + format?: (v: number) => string; | |
| 347 | + min?: number; | |
| 348 | + max?: number; | |
| 349 | + color?: string; | |
| 350 | + direction?: 'higher' | 'lower'; | |
| 351 | + className?: string; | |
| 352 | + caption?: string; | |
| 353 | + rowHeader?: string; | |
| 354 | +}) { | |
| 355 | + const values: number[] = []; | |
| 356 | + for (const r of rows) for (const c of cols) { | |
| 357 | + const v = cell(r.key, c.key)?.value; | |
| 358 | + if (v !== null && v !== undefined && Number.isFinite(v)) values.push(v); | |
| 359 | + } | |
| 360 | + if (!rows.length || !cols.length || !values.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>; | |
| 361 | + const lo = minOverride ?? (min(values) as number); | |
| 362 | + const hi = maxOverride ?? (max(values) as number); | |
| 363 | + const t = (v: number) => { | |
| 364 | + const r = hi === lo ? 1 : (v - lo) / (hi - lo); | |
| 365 | + const k = direction === 'lower' ? 1 - r : r; | |
| 366 | + return Math.max(0, Math.min(1, k)); | |
| 367 | + }; | |
| 368 | + return ( | |
| 369 | + <div className={cn('table-scroll', className)}> | |
| 370 | + <table className="heatmap"> | |
| 371 | + {caption && <caption className="sr-only">{caption}</caption>} | |
| 372 | + <thead> | |
| 373 | + <tr> | |
| 374 | + <th scope="col" className="text-left">{rowHeader}</th> | |
| 375 | + {cols.map((c) => ( | |
| 376 | + <th key={c.key} scope="col"> | |
| 377 | + {c.href ? ( | |
| 378 | + <a href={c.href} className="hover:text-accent"> | |
| 379 | + {c.label} | |
| 380 | + </a> | |
| 381 | + ) : ( | |
| 382 | + c.label | |
| 383 | + )} | |
| 384 | + {c.sub && <span className="block text-[10px] font-normal text-ink-3">{c.sub}</span>} | |
| 385 | + </th> | |
| 386 | + ))} | |
| 387 | + </tr> | |
| 388 | + </thead> | |
| 389 | + <tbody> | |
| 390 | + {rows.map((r) => ( | |
| 391 | + <tr key={r.key}> | |
| 392 | + <th scope="row"> | |
| 393 | + {r.href ? ( | |
| 394 | + <a href={r.href} className="hover:text-accent"> | |
| 395 | + {r.label} | |
| 396 | + </a> | |
| 397 | + ) : ( | |
| 398 | + r.label | |
| 399 | + )} | |
| 400 | + {r.sub && <span className="block text-[10px] font-normal text-ink-3">{r.sub}</span>} | |
| 401 | + </th> | |
| 402 | + {cols.map((c) => { | |
| 403 | + const h = cell(r.key, c.key); | |
| 404 | + const v = h?.value; | |
| 405 | + if (v === null || v === undefined || !Number.isFinite(v)) | |
| 406 | + return ( | |
| 407 | + <td key={c.key} className="empty" title={h?.title}> | |
| 408 | + — | |
| 409 | + </td> | |
| 410 | + ); | |
| 411 | + const k = t(v); | |
| 412 | + const pct = Math.round(8 + k * 72); | |
| 413 | + const style = { background: `color-mix(in srgb, ${color} ${pct}%, var(--surface))`, color: k > 0.62 ? 'var(--accent-ink)' : 'var(--ink)' }; | |
| 414 | + const text = h?.label ?? format(v); | |
| 415 | + return ( | |
| 416 | + <td key={c.key} style={style} title={h?.title ?? `${r.label} · ${c.label}: ${text}`}> | |
| 417 | + {h?.href ? <a href={h.href}>{text}</a> : text} | |
| 418 | + </td> | |
| 419 | + ); | |
| 420 | + })} | |
| 421 | + </tr> | |
| 422 | + ))} | |
| 423 | + </tbody> | |
| 424 | + </table> | |
| 425 | + </div> | |
| 426 | + ); | |
| 427 | +} | |
added
apps/web/src/components/charts/index.ts
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * Charts barrel. Server-safe: Sparkline · Bars · HBars · LineChart · StepChart · Legend · Heatmap · stepPoints · lineLayout. | |
| 3 | + * Client (progressive enhancement): InteractiveLineChart · ScatterChart · TimelineLanes. | |
| 4 | + */ | |
| 5 | +export { Bars, HBars, Heatmap, Legend, LineChart, LINE_PAD, LINE_W, lineLayout, logTicks, Sparkline, StepChart, stepPoints } from './charts'; | |
| 6 | +export type { HeatCell, LineChartProps, LineLayout, Point, Series } from './charts'; | |
| 7 | +export { InteractiveLineChart } from './interactive-line-chart'; | |
| 8 | +export { ScatterChart } from './scatter-chart'; | |
| 9 | +export type { ScatterPoint } from './scatter-chart'; | |
| 10 | +export { TimelineLanes } from './timeline-lanes'; | |
| 11 | +export type { Lane, LaneEvent } from './timeline-lanes'; | |
added
apps/web/src/components/charts/interactive-line-chart.tsx
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useMemo, useRef, useState } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { fmtCompact } from '@/lib/format'; | |
| 5 | +import { LineChart, type LineChartProps, lineLayout } from './charts'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Progressive enhancement of `LineChart`: the same SSR SVG, plus a crosshair and a nearest-point tooltip on hover / touch. | |
| 9 | + * Pointer position is mapped from CSS pixels to viewBox units, so it works at any rendered width. | |
| 10 | + */ | |
| 11 | +export function InteractiveLineChart({ xFormat, ...props }: LineChartProps & { xFormat?: (x: number) => string }) { | |
| 12 | + const L = useMemo(() => lineLayout(props), [props]); | |
| 13 | + const wrap = useRef<HTMLDivElement>(null); | |
| 14 | + const [hover, setHover] = useState<{ x: number; idx: number[] } | null>(null); | |
| 15 | + const yFormat = props.yFormat ?? fmtCompact; | |
| 16 | + | |
| 17 | + if (!L) return <LineChart {...props} />; | |
| 18 | + const fmtXFull = xFormat ?? ((v: number) => (L.xTime ? new Date(v).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : String(v))); | |
| 19 | + | |
| 20 | + const onMove = (clientX: number) => { | |
| 21 | + const el = wrap.current; | |
| 22 | + if (!el) return; | |
| 23 | + const rect = el.getBoundingClientRect(); | |
| 24 | + const vx = ((clientX - rect.left) / rect.width) * L.w; | |
| 25 | + if (vx < L.pad.l || vx > L.w - L.pad.r) { | |
| 26 | + setHover(null); | |
| 27 | + return; | |
| 28 | + } | |
| 29 | + const tx = L.xInvert(vx); | |
| 30 | + // nearest point index per series | |
| 31 | + const idx = L.series.map((s) => { | |
| 32 | + let best = -1; | |
| 33 | + let bd = Infinity; | |
| 34 | + s.pts.forEach((p, i) => { | |
| 35 | + const d = Math.abs(p.x - tx); | |
| 36 | + if (d < bd) { | |
| 37 | + bd = d; | |
| 38 | + best = i; | |
| 39 | + } | |
| 40 | + }); | |
| 41 | + return best; | |
| 42 | + }); | |
| 43 | + const anchor = L.series.map((s, i) => (idx[i] as number) >= 0 ? (s.pts[idx[i] as number] as { x: number }).x : NaN).filter(Number.isFinite); | |
| 44 | + if (!anchor.length) return; | |
| 45 | + // snap the crosshair to the point nearest the pointer among all series | |
| 46 | + const snap = anchor.reduce((a, b) => (Math.abs(b - tx) < Math.abs(a - tx) ? b : a)); | |
| 47 | + setHover({ x: snap, idx }); | |
| 48 | + }; | |
| 49 | + | |
| 50 | + const rows = hover | |
| 51 | + ? L.series | |
| 52 | + .map((s, i) => { | |
| 53 | + const p = s.pts[hover.idx[i] as number]; | |
| 54 | + return p ? { name: s.name, color: s.color, p } : null; | |
| 55 | + }) | |
| 56 | + .filter((r): r is { name: string; color: string; p: { x: number; y: number } } => !!r) | |
| 57 | + : []; | |
| 58 | + const hx = hover ? L.x(hover.x) : 0; | |
| 59 | + const leftPct = (hx / L.w) * 100; | |
| 60 | + const flip = leftPct > 60; | |
| 61 | + | |
| 62 | + return ( | |
| 63 | + <div | |
| 64 | + ref={wrap} | |
| 65 | + className={cn('relative', props.className)} | |
| 66 | + onPointerMove={(e) => onMove(e.clientX)} | |
| 67 | + onPointerDown={(e) => onMove(e.clientX)} | |
| 68 | + onPointerLeave={() => setHover(null)} | |
| 69 | + onTouchMove={(e) => e.touches[0] && onMove(e.touches[0].clientX)} | |
| 70 | + onTouchEnd={() => setTimeout(() => setHover(null), 1500)} | |
| 71 | + data-interactive-chart | |
| 72 | + > | |
| 73 | + <LineChart {...props} className={undefined}> | |
| 74 | + {hover && ( | |
| 75 | + <g pointerEvents="none"> | |
| 76 | + <line x1={hx} x2={hx} y1={L.pad.t} y2={L.height - L.pad.b} stroke="var(--rule-strong)" strokeDasharray="3 3" /> | |
| 77 | + {rows.map((r) => ( | |
| 78 | + <circle key={r.name} cx={L.x(r.p.x)} cy={L.y(r.p.y)} r={4} fill={r.color} stroke="var(--canvas)" strokeWidth={1.5} /> | |
| 79 | + ))} | |
| 80 | + </g> | |
| 81 | + )} | |
| 82 | + </LineChart> | |
| 83 | + {hover && rows.length > 0 && ( | |
| 84 | + <div role="status" className="panel pointer-events-none absolute top-2 z-10 min-w-[9rem] px-2.5 py-1.5 text-xs shadow-lg" style={flip ? { right: `${100 - leftPct + 1}%` } : { left: `${leftPct + 1}%` }}> | |
| 85 | + <p className="tnum text-ink-3">{fmtXFull(hover.x)}</p> | |
| 86 | + {rows.map((r) => ( | |
| 87 | + <p key={r.name} className="mt-0.5 flex items-center gap-1.5"> | |
| 88 | + <span className="inline-block h-[3px] w-3 rounded-sm" style={{ background: r.color }} /> | |
| 89 | + <span className="truncate text-ink-2">{r.name}</span> | |
| 90 | + <span className="tnum ml-auto pl-2 font-medium text-ink">{yFormat(r.p.y)}</span> | |
| 91 | + </p> | |
| 92 | + ))} | |
| 93 | + </div> | |
| 94 | + )} | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} | |
added
apps/web/src/components/charts/scatter-chart.tsx
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { extent } from 'd3-array'; | |
| 3 | +import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale'; | |
| 4 | +import Link from 'next/link'; | |
| 5 | +import { useMemo, useState } from 'react'; | |
| 6 | +import { cn } from '@/lib/cn'; | |
| 7 | +import { fmtCompact } from '@/lib/format'; | |
| 8 | +import { logTicks } from './charts'; | |
| 9 | + | |
| 10 | +export type ScatterPoint = { id: string; x: number; y: number; /** bubble size (raw; scaled with sqrt) */ r?: number; label: string; sub?: string; href?: string; color?: string; group?: string }; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Scatter / bubble chart for Pareto views (cost vs performance): linear or log axes, bubble size, highlighted set, | |
| 14 | + * hover tooltip (touch: tap), optional frontier polyline (`frontier` = ordered points) and quadrant guide lines. | |
| 15 | + * Theme-aware through CSS variables; every point has an accessible <title>. | |
| 16 | + */ | |
| 17 | +export function ScatterChart({ | |
| 18 | + points, | |
| 19 | + xScale = 'linear', | |
| 20 | + yScale = 'linear', | |
| 21 | + xLabel, | |
| 22 | + yLabel, | |
| 23 | + xFormat = fmtCompact, | |
| 24 | + yFormat = fmtCompact, | |
| 25 | + frontier, | |
| 26 | + highlight, | |
| 27 | + quadrant, | |
| 28 | + height = 360, | |
| 29 | + className, | |
| 30 | + color = 'var(--series-1)', | |
| 31 | + labelTop = 6, | |
| 32 | +}: { | |
| 33 | + points: ScatterPoint[]; | |
| 34 | + xScale?: 'linear' | 'log'; | |
| 35 | + yScale?: 'linear' | 'log'; | |
| 36 | + xLabel?: string; | |
| 37 | + yLabel?: string; | |
| 38 | + xFormat?: (v: number) => string; | |
| 39 | + yFormat?: (v: number) => string; | |
| 40 | + frontier?: { x: number; y: number }[]; | |
| 41 | + highlight?: Set<string> | string[]; | |
| 42 | + /** Guide lines at x / y (e.g. medians). */ | |
| 43 | + quadrant?: { x?: number; y?: number }; | |
| 44 | + height?: number; | |
| 45 | + className?: string; | |
| 46 | + color?: string; | |
| 47 | + /** Label the n largest/highest points inline. */ | |
| 48 | + labelTop?: number; | |
| 49 | +}) { | |
| 50 | + const [hover, setHover] = useState<string | null>(null); | |
| 51 | + const hl = useMemo(() => (highlight instanceof Set ? highlight : new Set(highlight ?? [])), [highlight]); | |
| 52 | + const w = 720; | |
| 53 | + const pad = { l: 52, r: 20, t: 16, b: 36 }; | |
| 54 | + const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (xScale !== 'log' || p.x > 0) && (yScale !== 'log' || p.y > 0)); | |
| 55 | + if (pts.length < 1) return <p className={cn('text-xs text-ink-3', className)}>No data</p>; | |
| 56 | + const [x0, x1] = extent(pts, (p) => p.x) as [number, number]; | |
| 57 | + const [y0, y1] = extent(pts, (p) => p.y) as [number, number]; | |
| 58 | + const padDom = (lo: number, hi: number, log: boolean): [number, number] => (log ? [lo / 1.4, hi * 1.4] : lo === hi ? [lo - 1, hi + 1] : [lo - (hi - lo) * 0.06, hi + (hi - lo) * 0.08]); | |
| 59 | + const xd = padDom(x0, x1, xScale === 'log'); | |
| 60 | + const yd = padDom(y0, y1, yScale === 'log'); | |
| 61 | + const x = (xScale === 'log' ? scaleLog() : scaleLinear()).domain(xd).range([pad.l, w - pad.r]); | |
| 62 | + const y = (yScale === 'log' ? scaleLog() : scaleLinear()).domain(yd).range([height - pad.b, pad.t]); | |
| 63 | + const [r0, r1] = extent(pts, (p) => p.r ?? 1) as [number, number]; | |
| 64 | + const r = scaleSqrt().domain([r0 ?? 1, r1 === r0 ? (r0 ?? 1) + 1 : r1 ?? 1]).range([3, 12]); | |
| 65 | + const xt = xScale === 'log' ? logTicks(xd[0], xd[1]) : (x as ReturnType<typeof scaleLinear>).ticks(6); | |
| 66 | + const yt = yScale === 'log' ? logTicks(yd[0], yd[1]) : (y as ReturnType<typeof scaleLinear>).ticks(5); | |
| 67 | + const labelled = new Set([...pts].sort((a, b) => (b.r ?? 0) - (a.r ?? 0) || b.y - a.y).slice(0, labelTop).map((p) => p.id)); | |
| 68 | + const active = hover ? pts.find((p) => p.id === hover) : null; | |
| 69 | + const tipLeft = active ? (x(active.x) / w) * 100 : 0; | |
| 70 | + const tipTop = active ? (y(active.y) / height) * 100 : 0; | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <div className={cn('relative', className)} data-scatter> | |
| 74 | + <svg viewBox={`0 0 ${w} ${height}`} className="block w-full" role="img" aria-label={`${yLabel ?? 'y'} vs ${xLabel ?? 'x'}`} onPointerLeave={() => setHover(null)}> | |
| 75 | + {yt.map((t) => ( | |
| 76 | + <g key={`y${t}`}> | |
| 77 | + <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" /> | |
| 78 | + <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum"> | |
| 79 | + {yFormat(t)} | |
| 80 | + </text> | |
| 81 | + </g> | |
| 82 | + ))} | |
| 83 | + {xt.map((t) => ( | |
| 84 | + <g key={`x${t}`}> | |
| 85 | + <line x1={x(t)} x2={x(t)} y1={pad.t} y2={height - pad.b} stroke="var(--rule)" /> | |
| 86 | + <text x={x(t)} y={height - pad.b + 14} textAnchor="middle" fontSize={10} fill="var(--ink-3)" className="tnum"> | |
| 87 | + {xFormat(t)} | |
| 88 | + </text> | |
| 89 | + </g> | |
| 90 | + ))} | |
| 91 | + {xLabel && ( | |
| 92 | + <text x={w - pad.r} y={height - 4} textAnchor="end" fontSize={10} fill="var(--ink-2)"> | |
| 93 | + {xLabel} → | |
| 94 | + </text> | |
| 95 | + )} | |
| 96 | + {yLabel && ( | |
| 97 | + <text x={pad.l} y={pad.t - 4} textAnchor="start" fontSize={10} fill="var(--ink-2)"> | |
| 98 | + ↑ {yLabel} | |
| 99 | + </text> | |
| 100 | + )} | |
| 101 | + {quadrant?.x !== undefined && Number.isFinite(quadrant.x) && <line x1={x(quadrant.x)} x2={x(quadrant.x)} y1={pad.t} y2={height - pad.b} stroke="var(--rule-strong)" strokeDasharray="4 4" />} | |
| 102 | + {quadrant?.y !== undefined && Number.isFinite(quadrant.y) && <line x1={pad.l} x2={w - pad.r} y1={y(quadrant.y)} y2={y(quadrant.y)} stroke="var(--rule-strong)" strokeDasharray="4 4" />} | |
| 103 | + {frontier && frontier.length > 1 && <polyline points={frontier.map((p) => `${x(p.x)},${y(p.y)}`).join(' ')} fill="none" stroke="var(--accent-2)" strokeWidth={1.5} strokeDasharray="6 3" opacity={0.9} />} | |
| 104 | + {pts.map((p) => { | |
| 105 | + const isHl = hl.has(p.id); | |
| 106 | + const dim = hl.size > 0 && !isHl; | |
| 107 | + const isHover = hover === p.id; | |
| 108 | + return ( | |
| 109 | + <g key={p.id} onPointerEnter={() => setHover(p.id)} onClick={() => setHover(p.id)} style={{ cursor: p.href ? 'pointer' : 'default' }}> | |
| 110 | + <circle cx={x(p.x)} cy={y(p.y)} r={r(p.r ?? 1) + (isHover ? 2 : 0)} fill={p.color ?? color} fillOpacity={dim ? 0.18 : isHl || isHover ? 0.95 : 0.6} stroke={isHl || isHover ? 'var(--ink)' : 'var(--canvas)'} strokeWidth={isHl || isHover ? 1.5 : 0.8}> | |
| 111 | + <title>{`${p.label}: ${xFormat(p.x)} · ${yFormat(p.y)}`}</title> | |
| 112 | + </circle> | |
| 113 | + {(labelled.has(p.id) || isHl) && !dim && ( | |
| 114 | + <text x={x(p.x) + r(p.r ?? 1) + 3} y={y(p.y) + 3} fontSize={10} fill="var(--ink-2)"> | |
| 115 | + {p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label} | |
| 116 | + </text> | |
| 117 | + )} | |
| 118 | + </g> | |
| 119 | + ); | |
| 120 | + })} | |
| 121 | + </svg> | |
| 122 | + {active && ( | |
| 123 | + <div role="status" className="panel pointer-events-none absolute z-10 min-w-[10rem] px-2.5 py-1.5 text-xs shadow-lg" style={{ left: `min(${tipLeft + 1.5}%, calc(100% - 11rem))`, top: `${tipTop}%`, transform: 'translateY(-110%)' }}> | |
| 124 | + <p className="truncate font-medium text-ink">{active.label}</p> | |
| 125 | + {active.sub && <p className="truncate text-ink-3">{active.sub}</p>} | |
| 126 | + <p className="tnum mt-0.5 text-ink-2"> | |
| 127 | + {xLabel ?? 'x'} <span className="text-ink">{xFormat(active.x)}</span> · {yLabel ?? 'y'} <span className="text-ink">{yFormat(active.y)}</span> | |
| 128 | + </p> | |
| 129 | + {active.href && <p className="mt-0.5 text-accent">Open →</p>} | |
| 130 | + </div> | |
| 131 | + )} | |
| 132 | + {/* accessible list + real links (SVG circles are not focusable) */} | |
| 133 | + <ul className="sr-only"> | |
| 134 | + {pts.map((p) => ( | |
| 135 | + <li key={p.id}>{p.href ? <Link href={p.href}>{`${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`}</Link> : `${p.label}: ${xFormat(p.x)}, ${yFormat(p.y)}`}</li> | |
| 136 | + ))} | |
| 137 | + </ul> | |
| 138 | + </div> | |
| 139 | + ); | |
| 140 | +} | |
added
apps/web/src/components/charts/timeline-lanes.tsx
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { scaleTime } from 'd3-scale'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useMemo, useRef, useState } from 'react'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | + | |
| 7 | +export type Lane = { key: string; label: string; color?: string }; | |
| 8 | +export type LaneEvent = { id: string; lane: string; at: string | number | Date; importance?: number; label: string; href?: string; sub?: string }; | |
| 9 | + | |
| 10 | +const toT = (v: string | number | Date) => (v instanceof Date ? v.getTime() : typeof v === 'number' ? v : new Date(v).getTime()); | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Lanes × time: one row per lane, dots sized by importance (0–3), month ticks, hover tooltip, optional brushable range | |
| 14 | + * (drag on the chart; `onRange(from, to)` fires on release; double-click clears). Reduced-motion safe (no animation). | |
| 15 | + * Server renders nothing interactive — the SVG is static markup until hydration. | |
| 16 | + */ | |
| 17 | +export function TimelineLanes({ | |
| 18 | + lanes, | |
| 19 | + events, | |
| 20 | + from, | |
| 21 | + to, | |
| 22 | + height, | |
| 23 | + brushable = false, | |
| 24 | + onRange, | |
| 25 | + className, | |
| 26 | + laneWidth = 96, | |
| 27 | + title = 'Timeline', | |
| 28 | +}: { | |
| 29 | + lanes: Lane[]; | |
| 30 | + events: LaneEvent[]; | |
| 31 | + from?: string | number | Date; | |
| 32 | + to?: string | number | Date; | |
| 33 | + height?: number; | |
| 34 | + brushable?: boolean; | |
| 35 | + onRange?: (from: Date, to: Date) => void; | |
| 36 | + className?: string; | |
| 37 | + /** Width reserved for lane labels (viewBox units). */ | |
| 38 | + laneWidth?: number; | |
| 39 | + title?: string; | |
| 40 | +}) { | |
| 41 | + const w = 760; | |
| 42 | + const rowH = 26; | |
| 43 | + const pad = { l: laneWidth, r: 12, t: 8, b: 22 }; | |
| 44 | + const H = height ?? pad.t + pad.b + rowH * Math.max(1, lanes.length); | |
| 45 | + const evs = useMemo(() => events.map((e) => ({ ...e, t: toT(e.at) })).filter((e) => Number.isFinite(e.t) && lanes.some((l) => l.key === e.lane)), [events, lanes]); | |
| 46 | + const [t0, t1] = useMemo(() => { | |
| 47 | + const ts = evs.map((e) => e.t); | |
| 48 | + const lo = from !== undefined ? toT(from) : Math.min(...ts); | |
| 49 | + const hi = to !== undefined ? toT(to) : Math.max(...ts); | |
| 50 | + return lo === hi || !Number.isFinite(lo) || !Number.isFinite(hi) ? [Date.now() - 365 * 86400000, Date.now()] : [lo, hi]; | |
| 51 | + }, [evs, from, to]); | |
| 52 | + const x = useMemo(() => scaleTime().domain([new Date(t0), new Date(t1)]).range([pad.l, w - pad.r]), [t0, t1, pad.l]); | |
| 53 | + const ticks = x.ticks(Math.min(12, Math.max(3, Math.round((t1 - t0) / (30 * 86400000))))); | |
| 54 | + const [hover, setHover] = useState<string | null>(null); | |
| 55 | + const [brush, setBrush] = useState<{ a: number; b: number } | null>(null); | |
| 56 | + const dragging = useRef<number | null>(null); | |
| 57 | + const svgRef = useRef<SVGSVGElement>(null); | |
| 58 | + | |
| 59 | + const vx = (clientX: number) => { | |
| 60 | + const rect = svgRef.current?.getBoundingClientRect(); | |
| 61 | + if (!rect) return 0; | |
| 62 | + return Math.max(pad.l, Math.min(w - pad.r, ((clientX - rect.left) / rect.width) * w)); | |
| 63 | + }; | |
| 64 | + const onDown = (e: React.PointerEvent) => { | |
| 65 | + if (!brushable) return; | |
| 66 | + dragging.current = vx(e.clientX); | |
| 67 | + setBrush({ a: dragging.current, b: dragging.current }); | |
| 68 | + }; | |
| 69 | + const onMove = (e: React.PointerEvent) => { | |
| 70 | + if (!brushable || dragging.current === null) return; | |
| 71 | + setBrush({ a: dragging.current, b: vx(e.clientX) }); | |
| 72 | + }; | |
| 73 | + const onUp = () => { | |
| 74 | + if (!brushable || dragging.current === null) return; | |
| 75 | + dragging.current = null; | |
| 76 | + if (brush && Math.abs(brush.a - brush.b) > 4) onRange?.(x.invert(Math.min(brush.a, brush.b)), x.invert(Math.max(brush.a, brush.b))); | |
| 77 | + else setBrush(null); | |
| 78 | + }; | |
| 79 | + if (!lanes.length) return <p className={cn('text-xs text-ink-3', className)}>No lanes</p>; | |
| 80 | + const active = hover ? evs.find((e) => e.id === hover) : null; | |
| 81 | + const laneY = (key: string) => pad.t + rowH * lanes.findIndex((l) => l.key === key) + rowH / 2; | |
| 82 | + const rad = (imp: number | undefined) => 2 + Math.max(0, Math.min(3, imp ?? 1)) * 1.1; | |
| 83 | + | |
| 84 | + return ( | |
| 85 | + <div className={cn('relative', className)} data-timeline-lanes> | |
| 86 | + <svg ref={svgRef} viewBox={`0 0 ${w} ${H}`} className={cn('block w-full select-none', brushable && 'cursor-crosshair touch-pan-y')} role="img" aria-label={title} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={() => { onUp(); setHover(null); }} onDoubleClick={() => setBrush(null)}> | |
| 87 | + <title>{title}</title> | |
| 88 | + {lanes.map((l, i) => ( | |
| 89 | + <g key={l.key}> | |
| 90 | + <rect x={pad.l} y={pad.t + i * rowH} width={w - pad.l - pad.r} height={rowH} fill={i % 2 ? 'var(--surface-2)' : 'transparent'} opacity={0.6} /> | |
| 91 | + <line x1={pad.l} x2={w - pad.r} y1={pad.t + (i + 1) * rowH} y2={pad.t + (i + 1) * rowH} stroke="var(--rule)" /> | |
| 92 | + <text x={pad.l - 8} y={pad.t + i * rowH + rowH / 2 + 3.5} textAnchor="end" fontSize={10.5} fill="var(--ink-2)"> | |
| 93 | + {l.label.length > 14 ? `${l.label.slice(0, 13)}…` : l.label} | |
| 94 | + </text> | |
| 95 | + </g> | |
| 96 | + ))} | |
| 97 | + {ticks.map((t, i) => ( | |
| 98 | + <g key={i}> | |
| 99 | + <line x1={x(t)} x2={x(t)} y1={pad.t} y2={H - pad.b} stroke="var(--rule)" strokeDasharray="2 3" /> | |
| 100 | + <text x={x(t)} y={H - 7} textAnchor="middle" fontSize={9.5} fill="var(--ink-3)"> | |
| 101 | + {t.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' })} | |
| 102 | + </text> | |
| 103 | + </g> | |
| 104 | + ))} | |
| 105 | + {brush && <rect x={Math.min(brush.a, brush.b)} y={pad.t} width={Math.abs(brush.b - brush.a)} height={H - pad.t - pad.b} fill="var(--accent-soft)" stroke="var(--accent)" strokeWidth={0.75} />} | |
| 106 | + {evs.map((e) => { | |
| 107 | + const lane = lanes.find((l) => l.key === e.lane); | |
| 108 | + const c = lane?.color ?? 'var(--series-1)'; | |
| 109 | + const isH = hover === e.id; | |
| 110 | + return ( | |
| 111 | + <circle key={e.id} cx={x(new Date(e.t))} cy={laneY(e.lane)} r={rad(e.importance) + (isH ? 1.5 : 0)} fill={c} fillOpacity={(e.importance ?? 1) >= 3 ? 0.95 : 0.65} stroke={isH ? 'var(--ink)' : 'none'} onPointerEnter={() => setHover(e.id)} onClick={(ev) => { ev.stopPropagation(); setHover(e.id); }}> | |
| 112 | + <title>{`${e.label} — ${new Date(e.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}`}</title> | |
| 113 | + </circle> | |
| 114 | + ); | |
| 115 | + })} | |
| 116 | + </svg> | |
| 117 | + {active && ( | |
| 118 | + <div role="status" className="panel pointer-events-none absolute z-10 max-w-[16rem] px-2.5 py-1.5 text-xs shadow-lg" style={{ left: `min(${(x(new Date(active.t)) / w) * 100 + 1}%, calc(100% - 16rem))`, top: `${(laneY(active.lane) / H) * 100}%`, transform: 'translateY(-115%)' }}> | |
| 119 | + <p className="tnum text-ink-3">{new Date(active.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}</p> | |
| 120 | + <p className="truncate font-medium text-ink">{active.label}</p> | |
| 121 | + {active.sub && <p className="truncate text-ink-3">{active.sub}</p>} | |
| 122 | + </div> | |
| 123 | + )} | |
| 124 | + {brushable && brush && Math.abs(brush.a - brush.b) > 4 && ( | |
| 125 | + <p className="tnum mt-1 text-[11px] text-ink-3"> | |
| 126 | + Range: {x.invert(Math.min(brush.a, brush.b)).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })} → {x.invert(Math.max(brush.a, brush.b)).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })} · double-click to clear | |
| 127 | + </p> | |
| 128 | + )} | |
| 129 | + <ul className="sr-only"> | |
| 130 | + {evs.map((e) => ( | |
| 131 | + <li key={e.id}>{e.href ? <Link href={e.href}>{e.label}</Link> : e.label}</li> | |
| 132 | + ))} | |
| 133 | + </ul> | |
| 134 | + </div> | |
| 135 | + ); | |
| 136 | +} | |
modified
apps/web/src/components/entity/blocks.tsx
+2 −2
@@ -27,7 +27,7 @@ export function SpecTable({ d, exclude = [] }: { d: EntityDetail; exclude?: stri | ||
| 27 | 27 | const order = new Map(MODEL_ORDER.map((k, i) => [k, i])); |
| 28 | 28 | keys.sort((a, b) => (order.get(a) ?? 999) - (order.get(b) ?? 999) || a.localeCompare(b)); |
| 29 | 29 | const rows: KVRow[] = keys.map((k) => ({ key: k, raw: attrs[k] })); |
| 30 | − return <KeyValue rows={rows} provenance={d.provenance} />; | |
| 30 | + return <KeyValue rows={rows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} />; | |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | /** Identity block: aliases + identifiers (scheme: value). */ |
@@ -109,7 +109,7 @@ export function Capabilities({ d }: { d: EntityDetail }) { | ||
| 109 | 109 | </ul> |
| 110 | 110 | {known.length === 0 && <Note className="mt-2">No capability flags have been observed from a source yet — we do not infer them.</Note>} |
| 111 | 111 | </div> |
| 112 | − <KeyValue rows={[{ key: 'context_length', raw: a.context_length }, { key: 'max_output_tokens', raw: a.max_output_tokens }, { key: 'knowledge_cutoff', raw: a.knowledge_cutoff }, { key: 'languages', raw: a.languages }, { key: 'tokenizer', raw: a.tokenizer }]} provenance={d.provenance} /> | |
| 112 | + <KeyValue rows={[{ key: 'context_length', raw: a.context_length }, { key: 'max_output_tokens', raw: a.max_output_tokens }, { key: 'knowledge_cutoff', raw: a.knowledge_cutoff }, { key: 'languages', raw: a.languages }, { key: 'tokenizer', raw: a.tokenizer }]} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} /> | |
| 113 | 113 | </div> |
| 114 | 114 | ); |
| 115 | 115 | } |
modified
apps/web/src/components/entity/entity-page.tsx
+2 −0
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { ExternalLink, GitFork, MemoryStick } from 'lucide-react'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { CompareButton } from '@/components/compare/compare-button'; |
| 4 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 4 | 5 | import { ViewBeacon } from '@/components/layout/view-beacon'; |
| 5 | 6 | import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges'; |
| 6 | 7 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
@@ -267,6 +268,7 @@ export async function EntityPage({ d, canonical, related, asof: asofRaw, history | ||
| 267 | 268 | {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} |
| 268 | 269 | <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions"> |
| 269 | 270 | <CompareButton e={d} /> |
| 271 | + <WatchButton e={d} /> | |
| 270 | 272 | <Link href={routes.graph(d.slug)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> |
| 271 | 273 | <GitFork className="size-3.5" aria-hidden /> Explore graph |
| 272 | 274 | </Link> |
added
apps/web/src/components/evidence/evidence-context.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; | |
| 3 | +import type { ProvenanceEntry } from '@/lib/types'; | |
| 4 | + | |
| 5 | +/** What the drawer needs to explain one displayed value. `fallback` = the inline provenance the caller already has. */ | |
| 6 | +export interface EvidenceTarget { | |
| 7 | + slug: string; | |
| 8 | + property: string; | |
| 9 | + /** The displayed value (raw) — formatted in the drawer with `fmtValue(value, property)` unless `display` is given. */ | |
| 10 | + value?: unknown; | |
| 11 | + display?: string; | |
| 12 | + unit?: string | null; | |
| 13 | + label?: string; | |
| 14 | + fallback?: ProvenanceEntry | null; | |
| 15 | + entity?: { name?: string; entity_type?: string } | null; | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface Ctx { | |
| 19 | + target: EvidenceTarget | null; | |
| 20 | + open: (t: EvidenceTarget) => void; | |
| 21 | + close: () => void; | |
| 22 | +} | |
| 23 | +const EvidenceCtx = createContext<Ctx>({ target: null, open: () => undefined, close: () => undefined }); | |
| 24 | + | |
| 25 | +const HASH_RE = /^#evidence=([^:]+):(.+)$/; | |
| 26 | + | |
| 27 | +/** Mounted once in the root layout. Mirrors the open target to `#evidence=<slug>:<property>` so a drawer can be linked. */ | |
| 28 | +export function EvidenceProvider({ children }: { children: ReactNode }) { | |
| 29 | + const [target, setTarget] = useState<EvidenceTarget | null>(null); | |
| 30 | + const open = useCallback((t: EvidenceTarget) => { | |
| 31 | + setTarget(t); | |
| 32 | + try { | |
| 33 | + history.replaceState(null, '', `#evidence=${encodeURIComponent(t.slug)}:${encodeURIComponent(t.property)}`); | |
| 34 | + } catch { | |
| 35 | + /* ignore */ | |
| 36 | + } | |
| 37 | + }, []); | |
| 38 | + const close = useCallback(() => { | |
| 39 | + setTarget(null); | |
| 40 | + try { | |
| 41 | + if (HASH_RE.test(location.hash)) history.replaceState(null, '', location.pathname + location.search); | |
| 42 | + } catch { | |
| 43 | + /* ignore */ | |
| 44 | + } | |
| 45 | + }, []); | |
| 46 | + useEffect(() => { | |
| 47 | + const m = HASH_RE.exec(location.hash); | |
| 48 | + if (m) setTarget({ slug: decodeURIComponent(m[1] as string), property: decodeURIComponent(m[2] as string) }); | |
| 49 | + }, []); | |
| 50 | + const value = useMemo(() => ({ target, open, close }), [target, open, close]); | |
| 51 | + return <EvidenceCtx.Provider value={value}>{children}</EvidenceCtx.Provider>; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function useEvidence(): Ctx { | |
| 55 | + return useContext(EvidenceCtx); | |
| 56 | +} | |
added
apps/web/src/components/evidence/evidence-drawer.tsx
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ExternalLink, History, TriangleAlert } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useState } from 'react'; | |
| 5 | +import { ConfidenceBadge, TierBadge } from '@/components/ui/badges'; | |
| 6 | +import { Sheet } from '@/components/ui/sheet'; | |
| 7 | +import { ClientApiError, clientApi } from '@/lib/client-api'; | |
| 8 | +import { cn } from '@/lib/cn'; | |
| 9 | +import { DASH, fmtAgo, fmtDateTime, fmtValue, hostOf } from '@/lib/format'; | |
| 10 | +import { propertyLabel, routes, TIER_LABELS } from '@/lib/site'; | |
| 11 | +import type { Claim, ProvenanceDetail } from '@/lib/types'; | |
| 12 | +import { type EvidenceTarget, useEvidence } from './evidence-context'; | |
| 13 | + | |
| 14 | +type State = { status: 'loading' | 'ready' | 'none'; detail: ProvenanceDetail | null; source: 'api' | 'history' | 'fallback' | null }; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Right-side panel (≥ lg) / bottom sheet (< lg) explaining one displayed value: VALUE · SOURCE · TIER · OBSERVED · EXTRACTOR · | |
| 18 | + * CONFIDENCE · VALID SINCE · links (View source · View history · View conflicts · Claim id). | |
| 19 | + * Data: `GET /entities/{slug}/provenance/{property}` (1.1) → else the property's claim history (exists today) → else the | |
| 20 | + * inline `fallback` the trigger passed. Mount once in the root layout, inside `EvidenceProvider`. | |
| 21 | + */ | |
| 22 | +export function EvidenceDrawer() { | |
| 23 | + const { target, close } = useEvidence(); | |
| 24 | + const [state, setState] = useState<State>({ status: 'none', detail: null, source: null }); | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + if (!target) return; | |
| 28 | + const ctrl = new AbortController(); | |
| 29 | + setState({ status: 'loading', detail: fromFallback(target), source: target.fallback ? 'fallback' : null }); | |
| 30 | + (async () => { | |
| 31 | + try { | |
| 32 | + const d = await clientApi.provenance(target.slug, target.property, ctrl.signal); | |
| 33 | + setState({ status: 'ready', detail: { ...fromFallback(target), ...d, property: d.property ?? target.property }, source: 'api' }); | |
| 34 | + return; | |
| 35 | + } catch (e) { | |
| 36 | + if ((e as Error).name === 'AbortError') return; | |
| 37 | + if (!(e instanceof ClientApiError) || (e.status !== 404 && e.status !== 405)) { | |
| 38 | + setState((s) => ({ ...s, status: 'ready' })); | |
| 39 | + return; | |
| 40 | + } | |
| 41 | + } | |
| 42 | + try { | |
| 43 | + const h = await clientApi.history(target.slug, target.property, ctrl.signal); | |
| 44 | + setState({ status: 'ready', detail: fromHistory(target, h.items ?? []), source: 'history' }); | |
| 45 | + } catch (e) { | |
| 46 | + if ((e as Error).name === 'AbortError') return; | |
| 47 | + setState((s) => ({ ...s, status: 'ready' })); | |
| 48 | + } | |
| 49 | + })(); | |
| 50 | + return () => ctrl.abort(); | |
| 51 | + }, [target]); | |
| 52 | + | |
| 53 | + const d = state.detail; | |
| 54 | + const open = target !== null; | |
| 55 | + const entityRef = target ? { entity_type: target.entity?.entity_type ?? 'model', slug: target.slug } : null; | |
| 56 | + const conflicts = d?.conflicts ?? []; | |
| 57 | + const sourceLabel = d?.source_name ?? d?.domain ?? hostOf(d?.url) ?? null; | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <Sheet open={open} onClose={close} eyebrow="Evidence" title={target ? `${propertyLabel(target.property)}${target.entity?.name ? ` · ${target.entity.name}` : ''}` : undefined} labelledBy="evidence-title"> | |
| 61 | + {target && ( | |
| 62 | + <div className="space-y-4 text-sm" data-evidence-drawer> | |
| 63 | + <div> | |
| 64 | + <p className="eyebrow">Value</p> | |
| 65 | + <p className={cn('mt-1 text-[22px] font-semibold leading-tight tracking-tight text-ink', typeof (d?.value ?? target.value) === 'number' && 'tnum')}> | |
| 66 | + {target.display ?? fmtValue(d?.value ?? target.value, target.property)} | |
| 67 | + {(d?.unit ?? target.unit) && !/tokens|GB|W|\$|%/.test(target.display ?? '') && <span className="ml-1.5 text-sm font-normal text-ink-3">{d?.unit ?? target.unit}</span>} | |
| 68 | + </p> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + {state.status === 'loading' && !d && <p className="text-xs text-ink-3">Loading evidence…</p>} | |
| 72 | + {state.status === 'ready' && !d && ( | |
| 73 | + <p className="border border-dashed border-rule-strong px-3 py-3 text-xs text-ink-3" role="status"> | |
| 74 | + No provenance recorded for this value. Every fact on AI Atlas should carry one — this is a data gap, not a hidden source. | |
| 75 | + </p> | |
| 76 | + )} | |
| 77 | + | |
| 78 | + {d && ( | |
| 79 | + <dl className="kv [&>div]:grid-cols-[6.5rem_minmax(0,1fr)]"> | |
| 80 | + <Row k="Source"> | |
| 81 | + {d.url ? ( | |
| 82 | + <a href={d.url} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1 break-all"> | |
| 83 | + {sourceLabel ?? d.url} <ExternalLink className="size-3 shrink-0" aria-hidden /> | |
| 84 | + </a> | |
| 85 | + ) : ( | |
| 86 | + <span>{sourceLabel ?? DASH}</span> | |
| 87 | + )} | |
| 88 | + {d.domain && sourceLabel !== d.domain && <span className="mono ml-1.5 text-[11px] text-ink-3">{d.domain}</span>} | |
| 89 | + </Row> | |
| 90 | + <Row k="Tier"> | |
| 91 | + <TierBadge tier={d.tier} withLabel /> {d.tier ? <span className="text-xs text-ink-3">{tierHint(d.tier)}</span> : null} | |
| 92 | + </Row> | |
| 93 | + <Row k="Observed"> | |
| 94 | + <span title={d.observed_at ?? undefined}>{d.observed_at ? `${fmtAgo(d.observed_at)} · ${fmtDateTime(d.observed_at)}` : DASH}</span> | |
| 95 | + </Row> | |
| 96 | + <Row k="Extractor"> | |
| 97 | + <span className="mono text-xs">{d.extractor ?? DASH}</span> | |
| 98 | + {d.extractor?.startsWith('llm') && <span className="ml-1.5 text-xs text-ink-3">local LLM factory · claims one tier lower</span>} | |
| 99 | + </Row> | |
| 100 | + <Row k="Confidence"> | |
| 101 | + <ConfidenceBadge confidence={d.confidence} /> {!d.confidence && DASH} | |
| 102 | + </Row> | |
| 103 | + <Row k="Valid since"> | |
| 104 | + <span title={d.valid_from ?? undefined}>{d.valid_from ? fmtDateTime(d.valid_from) : DASH}</span> | |
| 105 | + {d.valid_to && <span className="ml-1.5 text-xs text-warning">until {fmtDateTime(d.valid_to)}</span>} | |
| 106 | + </Row> | |
| 107 | + {d.status && d.status !== 'current' && ( | |
| 108 | + <Row k="Status"> | |
| 109 | + <span className={cn('text-xs uppercase tracking-wide', d.status === 'conflicting' ? 'text-danger' : 'text-ink-2')}>{d.status}</span> | |
| 110 | + </Row> | |
| 111 | + )} | |
| 112 | + </dl> | |
| 113 | + )} | |
| 114 | + | |
| 115 | + {conflicts.length > 0 && ( | |
| 116 | + <div className="border-l-2 border-danger pl-3"> | |
| 117 | + <p className="flex items-center gap-1.5 text-xs font-medium text-danger"> | |
| 118 | + <TriangleAlert className="size-3.5" aria-hidden /> {conflicts.length} conflicting {conflicts.length === 1 ? 'claim' : 'claims'} — stored side by side, never averaged | |
| 119 | + </p> | |
| 120 | + <ul className="mt-1.5 space-y-1.5"> | |
| 121 | + {conflicts.slice(0, 5).map((c) => ( | |
| 122 | + <li key={c.id} className="text-xs text-ink-2"> | |
| 123 | + <span className="tnum font-medium text-ink">{fmtValue(c.value, c.property)}</span> · <TierBadge tier={c.tier} /> ·{' '} | |
| 124 | + {c.source_url ? ( | |
| 125 | + <a href={c.source_url} target="_blank" rel="noopener noreferrer" className="link"> | |
| 126 | + {c.source_name ?? hostOf(c.source_url)} | |
| 127 | + </a> | |
| 128 | + ) : ( | |
| 129 | + c.source_name ?? 'source' | |
| 130 | + )}{' '} | |
| 131 | + · {fmtAgo(c.observed_at)} | |
| 132 | + </li> | |
| 133 | + ))} | |
| 134 | + </ul> | |
| 135 | + </div> | |
| 136 | + )} | |
| 137 | + | |
| 138 | + <div className="flex flex-wrap gap-x-4 gap-y-1.5 border-t border-rule pt-3 text-xs"> | |
| 139 | + {d?.url && ( | |
| 140 | + <a href={d.url} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1"> | |
| 141 | + View source <ExternalLink className="size-3" aria-hidden /> | |
| 142 | + </a> | |
| 143 | + )} | |
| 144 | + {entityRef && ( | |
| 145 | + <Link href={routes.entityHistory(entityRef, target.property)} onClick={close} className="link inline-flex items-center gap-1"> | |
| 146 | + <History className="size-3" aria-hidden /> View history | |
| 147 | + </Link> | |
| 148 | + )} | |
| 149 | + {conflicts.length > 0 && entityRef && ( | |
| 150 | + <Link href={routes.entityHistory(entityRef, target.property)} onClick={close} className="link text-danger"> | |
| 151 | + View conflicts | |
| 152 | + </Link> | |
| 153 | + )} | |
| 154 | + <Link href={routes.methodology()} onClick={close} className="text-ink-3 hover:text-ink"> | |
| 155 | + Methodology | |
| 156 | + </Link> | |
| 157 | + </div> | |
| 158 | + {d?.claim_id && ( | |
| 159 | + <p className="mono break-all text-[11px] text-ink-3"> | |
| 160 | + claim {d.claim_id} | |
| 161 | + {d.snapshot_id && <> · snapshot {d.snapshot_id}</>} | |
| 162 | + </p> | |
| 163 | + )} | |
| 164 | + {state.source && state.source !== 'api' && <p className="text-[11px] text-ink-3">{state.source === 'history' ? 'From the claim history endpoint (the field-level provenance endpoint is not live yet).' : 'From the inline provenance shipped with the page.'}</p>} | |
| 165 | + </div> | |
| 166 | + )} | |
| 167 | + </Sheet> | |
| 168 | + ); | |
| 169 | +} | |
| 170 | + | |
| 171 | +function Row({ k, children }: { k: string; children: React.ReactNode }) { | |
| 172 | + return ( | |
| 173 | + <div> | |
| 174 | + <dt>{k}</dt> | |
| 175 | + <dd className="text-ink">{children}</dd> | |
| 176 | + </div> | |
| 177 | + ); | |
| 178 | +} | |
| 179 | + | |
| 180 | +function tierHint(t: number): string { | |
| 181 | + const n = Math.min(4, Math.max(1, Math.round(t))); | |
| 182 | + return TIER_LABELS[n] ? `— ${TIER_LABELS[n]?.toLowerCase()} source` : ''; | |
| 183 | +} | |
| 184 | + | |
| 185 | +function fromFallback(t: EvidenceTarget): ProvenanceDetail | null { | |
| 186 | + const f = t.fallback; | |
| 187 | + if (!f) return null; | |
| 188 | + return { | |
| 189 | + slug: t.slug, | |
| 190 | + property: t.property, | |
| 191 | + value: t.value, | |
| 192 | + unit: f.unit ?? t.unit ?? null, | |
| 193 | + source_id: f.source_id, | |
| 194 | + source_name: f.source_name ?? null, | |
| 195 | + domain: hostOf(f.url), | |
| 196 | + url: f.url, | |
| 197 | + tier: f.tier, | |
| 198 | + confidence: f.confidence, | |
| 199 | + extractor: f.extractor, | |
| 200 | + observed_at: f.observed_at, | |
| 201 | + valid_from: null, | |
| 202 | + conflicts: [], | |
| 203 | + }; | |
| 204 | +} | |
| 205 | + | |
| 206 | +function fromHistory(t: EvidenceTarget, items: Claim[]): ProvenanceDetail | null { | |
| 207 | + const current = items.find((c) => c.status === 'current') ?? items[0]; | |
| 208 | + const base = fromFallback(t); | |
| 209 | + if (!current) return base; | |
| 210 | + return { | |
| 211 | + ...(base ?? { slug: t.slug, property: t.property, value: t.value, tier: null }), | |
| 212 | + value: current.value ?? t.value, | |
| 213 | + unit: current.unit ?? base?.unit ?? null, | |
| 214 | + claim_id: current.id, | |
| 215 | + source_name: current.source_name ?? base?.source_name ?? null, | |
| 216 | + domain: hostOf(current.source_url) ?? base?.domain ?? null, | |
| 217 | + url: current.source_url ?? base?.url ?? null, | |
| 218 | + tier: current.tier, | |
| 219 | + confidence: current.confidence, | |
| 220 | + extractor: current.extractor, | |
| 221 | + observed_at: current.observed_at, | |
| 222 | + valid_from: current.valid_from, | |
| 223 | + valid_to: current.valid_to, | |
| 224 | + status: current.status, | |
| 225 | + conflicts: items.filter((c) => c.status === 'conflicting'), | |
| 226 | + history_count: items.length, | |
| 227 | + }; | |
| 228 | +} | |
added
apps/web/src/components/evidence/evidence.tsx
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +'use client'; | |
| 2 | +import type { ReactNode } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import type { ProvenanceEntry } from '@/lib/types'; | |
| 5 | +import { useEvidence } from './evidence-context'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Trigger wrapper: renders its children as a subtle dotted-underline button that opens the evidence drawer for | |
| 9 | + * `<slug>.<property>`. Server components can use it (it is a client leaf that only needs serialisable props). | |
| 10 | + * `data-evidence` is the QA hook. | |
| 11 | + */ | |
| 12 | +export function Evidence({ | |
| 13 | + slug, | |
| 14 | + property, | |
| 15 | + value, | |
| 16 | + display, | |
| 17 | + unit, | |
| 18 | + label, | |
| 19 | + fallback, | |
| 20 | + entity, | |
| 21 | + children, | |
| 22 | + className, | |
| 23 | + title = 'Show evidence: source, tier, observation time, extractor', | |
| 24 | +}: { | |
| 25 | + slug: string; | |
| 26 | + property: string; | |
| 27 | + value?: unknown; | |
| 28 | + display?: string; | |
| 29 | + unit?: string | null; | |
| 30 | + label?: string; | |
| 31 | + fallback?: ProvenanceEntry | null; | |
| 32 | + entity?: { name?: string; entity_type?: string } | null; | |
| 33 | + children: ReactNode; | |
| 34 | + className?: string; | |
| 35 | + title?: string; | |
| 36 | +}) { | |
| 37 | + const { open } = useEvidence(); | |
| 38 | + return ( | |
| 39 | + <button | |
| 40 | + type="button" | |
| 41 | + className={cn('evidence', className)} | |
| 42 | + data-evidence={`${slug}:${property}`} | |
| 43 | + title={title} | |
| 44 | + onClick={(e) => { | |
| 45 | + e.stopPropagation(); | |
| 46 | + open({ slug, property, value, display, unit, label, fallback: fallback ?? null, entity: entity ?? null }); | |
| 47 | + }} | |
| 48 | + > | |
| 49 | + {children} | |
| 50 | + </button> | |
| 51 | + ); | |
| 52 | +} | |
added
apps/web/src/components/evidence/index.ts
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +export { Evidence } from './evidence'; | |
| 2 | +export { EvidenceDrawer } from './evidence-drawer'; | |
| 3 | +export { EvidenceProvider, useEvidence, type EvidenceTarget } from './evidence-context'; | |
added
apps/web/src/components/layout/density.tsx
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Rows2, Rows3, Rows4 } from 'lucide-react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { DENSITY_LABELS, useDensity } from '@/lib/density'; | |
| 5 | + | |
| 6 | +/** Three-state density toggle (comfortable → compact → dense). Touch target 44 px; `aria-label` announces the current state. */ | |
| 7 | +export function DensityToggle({ className }: { className?: string }) { | |
| 8 | + const [d, , cycle] = useDensity(); | |
| 9 | + const Icon = d === 'comfortable' ? Rows2 : d === 'compact' ? Rows3 : Rows4; | |
| 10 | + const label = `Density: ${DENSITY_LABELS[d].toLowerCase()}`; | |
| 11 | + return ( | |
| 12 | + <button type="button" onClick={cycle} data-density-toggle className={cn('flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:size-10', className)} aria-label={`${label} — switch`} title={label}> | |
| 13 | + <Icon className="size-[18px]" aria-hidden /> | |
| 14 | + </button> | |
| 15 | + ); | |
| 16 | +} | |
modified
apps/web/src/components/layout/mobile-tab-bar.tsx
+50 −51
@@ -1,79 +1,78 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { Activity, Compass, Home, MoreHorizontal, Search } from 'lucide-react'; | |
| 2 | +import { Activity, Boxes, Home, MoreHorizontal, Search } from 'lucide-react'; | |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { usePathname } from 'next/navigation'; |
| 5 | 5 | import { useEffect, useState } from 'react'; |
| 6 | +import { Sheet } from '@/components/ui/sheet'; | |
| 6 | 7 | import { cn } from '@/lib/cn'; |
| 7 | −import { exploreNav, moreNav, primaryNav, routes } from '@/lib/site'; | |
| 8 | +import { moreGroups, routes } from '@/lib/site'; | |
| 8 | 9 | import { useOpenSearch } from './search-context'; |
| 9 | 10 | |
| 11 | +const ICONS = { '/': Home, '/models': Boxes, '/changes': Activity } as const; | |
| 12 | + | |
| 10 | 13 | /** |
| 11 | − * Bottom tab bar (< md): Home · Explore · Changes · Search · More. Safe-area aware; <body> reserves | |
| 12 | − * `pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom))]` so it never covers content. | |
| 14 | + * Bottom tab bar (< lg): Home · Models · Changes · Search · More. "More" opens a bottom sheet with the full site grouped | |
| 15 | + * (Explore / Intelligence / Temporal / About). Safe-area aware; <body> reserves `pb-[calc(var(--tabbar-h)+safe-area)]`. | |
| 13 | 16 | */ |
| 14 | 17 | export function MobileTabBar() { |
| 15 | 18 | const pathname = usePathname(); |
| 16 | 19 | const openSearch = useOpenSearch(); |
| 17 | 20 | const [more, setMore] = useState(false); |
| 18 | 21 | useEffect(() => setMore(false), [pathname]); |
| 19 | − const exploreActive = pathname.startsWith('/explore') || exploreNav.some((n) => pathname.startsWith(n.href)); | |
| 22 | + const isActive = (href: string) => (href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/')); | |
| 20 | 23 | const cls = (active: boolean) => cn('flex h-full w-full flex-col items-center justify-center gap-0.5 text-[10.5px]', active ? 'text-accent' : 'text-ink-2'); |
| 24 | + const tabs = [ | |
| 25 | + { href: '/', label: 'Home' }, | |
| 26 | + { href: '/models', label: 'Models' }, | |
| 27 | + { href: '/changes', label: 'Changes' }, | |
| 28 | + ] as const; | |
| 21 | 29 | return ( |
| 22 | 30 | <> |
| 23 | − {more && ( | |
| 24 | − <div className="fixed inset-0 z-[45] bg-black/40 md:hidden" onClick={() => setMore(false)}> | |
| 25 | − <div className="panel absolute inset-x-2 bottom-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px)+8px)] max-h-[72vh] overflow-y-auto p-3" onClick={(e) => e.stopPropagation()}> | |
| 26 | − <p className="eyebrow px-2 pb-1">Explore</p> | |
| 27 | − <ul className="grid grid-cols-2 gap-0.5"> | |
| 28 | − {exploreNav.map((n) => ( | |
| 29 | − <li key={n.href}> | |
| 30 | − <Link href={n.href} className="block px-3 py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 31 | − {n.label} | |
| 32 | − </Link> | |
| 33 | − </li> | |
| 34 | − ))} | |
| 35 | − </ul> | |
| 36 | − <p className="eyebrow px-2 pt-3 pb-1">Track & about</p> | |
| 37 | − <ul className="grid grid-cols-2 gap-0.5"> | |
| 38 | − {[...primaryNav.filter((n) => n.href !== '/changes'), ...moreNav.filter((n) => n.href !== '/developers')].map((n) => ( | |
| 39 | − <li key={n.href}> | |
| 40 | − <Link href={n.href} className="block px-3 py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 41 | − {n.label} | |
| 42 | − </Link> | |
| 43 | − </li> | |
| 44 | − ))} | |
| 45 | − </ul> | |
| 46 | − </div> | |
| 31 | + <Sheet open={more} onClose={() => setMore(false)} side="bottom" eyebrow="AI Atlas" title="Everything on the site" id="mobile-more" className="lg:hidden"> | |
| 32 | + <div className="grid gap-5 pb-2 sm:grid-cols-2"> | |
| 33 | + {moreGroups.map((g) => ( | |
| 34 | + <div key={g.label}> | |
| 35 | + <p className="eyebrow mb-1">{g.label}</p> | |
| 36 | + <ul className="grid grid-cols-2 gap-x-3 sm:grid-cols-1"> | |
| 37 | + {g.items.map((n) => ( | |
| 38 | + <li key={n.href + n.label}> | |
| 39 | + <Link href={n.href} className={cn('flex min-h-11 items-center py-2 text-[15px]', isActive(n.href) ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink')}> | |
| 40 | + {n.label} | |
| 41 | + </Link> | |
| 42 | + </li> | |
| 43 | + ))} | |
| 44 | + </ul> | |
| 45 | + </div> | |
| 46 | + ))} | |
| 47 | 47 | </div> |
| 48 | − )} | |
| 49 | − <nav aria-label="Primary (mobile)" className="safe-bottom fixed inset-x-0 bottom-0 z-[46] border-t border-rule bg-canvas/95 backdrop-blur-md md:hidden"> | |
| 48 | + <p className="border-t border-rule pt-3 text-xs text-ink-3"> | |
| 49 | + <Link href={routes.explore()} className="link"> | |
| 50 | + All entity types with live counts → | |
| 51 | + </Link> | |
| 52 | + </p> | |
| 53 | + </Sheet> | |
| 54 | + <nav aria-label="Primary (mobile)" className="safe-bottom fixed inset-x-0 bottom-0 z-[46] border-t border-rule bg-canvas/95 backdrop-blur-md lg:hidden"> | |
| 50 | 55 | <ul className="grid h-[var(--tabbar-h)] grid-cols-5"> |
| 56 | + {tabs.map((t) => { | |
| 57 | + const Icon = ICONS[t.href]; | |
| 58 | + const active = isActive(t.href); | |
| 59 | + return ( | |
| 60 | + <li key={t.href} className="min-w-0"> | |
| 61 | + <Link href={t.href} aria-current={active ? 'page' : undefined} className={cls(active)}> | |
| 62 | + <Icon size={21} aria-hidden strokeWidth={active ? 2.25 : 1.75} /> | |
| 63 | + <span className="truncate">{t.label}</span> | |
| 64 | + </Link> | |
| 65 | + </li> | |
| 66 | + ); | |
| 67 | + })} | |
| 51 | 68 | <li className="min-w-0"> |
| 52 | − <Link href={routes.home()} aria-current={pathname === '/' ? 'page' : undefined} className={cls(pathname === '/')}> | |
| 53 | − <Home size={21} aria-hidden strokeWidth={pathname === '/' ? 2.25 : 1.75} /> | |
| 54 | − <span className="truncate">Home</span> | |
| 55 | − </Link> | |
| 56 | − </li> | |
| 57 | − <li className="min-w-0"> | |
| 58 | − <Link href={routes.explore()} aria-current={exploreActive ? 'page' : undefined} className={cls(exploreActive)}> | |
| 59 | − <Compass size={21} aria-hidden strokeWidth={exploreActive ? 2.25 : 1.75} /> | |
| 60 | − <span className="truncate">Explore</span> | |
| 61 | − </Link> | |
| 62 | − </li> | |
| 63 | − <li className="min-w-0"> | |
| 64 | − <Link href={routes.changes()} aria-current={pathname.startsWith('/changes') ? 'page' : undefined} className={cls(pathname.startsWith('/changes'))}> | |
| 65 | − <Activity size={21} aria-hidden strokeWidth={pathname.startsWith('/changes') ? 2.25 : 1.75} /> | |
| 66 | − <span className="truncate">Changes</span> | |
| 67 | − </Link> | |
| 68 | − </li> | |
| 69 | − <li className="min-w-0"> | |
| 70 | − <button type="button" onClick={openSearch} className={cls(false)}> | |
| 69 | + <button type="button" onClick={openSearch} className={cls(false)} aria-label="Search"> | |
| 71 | 70 | <Search size={21} aria-hidden strokeWidth={1.75} /> |
| 72 | 71 | <span className="truncate">Search</span> |
| 73 | 72 | </button> |
| 74 | 73 | </li> |
| 75 | 74 | <li className="min-w-0"> |
| 76 | − <button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={cls(more)}> | |
| 75 | + <button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} aria-controls="mobile-more" className={cls(more)} data-mobile-more> | |
| 77 | 76 | <MoreHorizontal size={21} aria-hidden strokeWidth={1.75} /> |
| 78 | 77 | <span className="truncate">More</span> |
| 79 | 78 | </button> |
modified
apps/web/src/components/layout/search-dialog.tsx
+297 −58
@@ -1,35 +1,141 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { ArrowRight, CornerDownLeft, Search, X } from 'lucide-react'; | |
| 2 | +import { ArrowRight, Clock, CornerDownLeft, Search, Terminal, X } from 'lucide-react'; | |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { useRouter } from 'next/navigation'; |
| 5 | −import { useEffect, useRef, useState } from 'react'; | |
| 5 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| 6 | 6 | import { EntityBadge } from '@/components/ui/badges'; |
| 7 | 7 | import { clientApi } from '@/lib/client-api'; |
| 8 | 8 | import { cn } from '@/lib/cn'; |
| 9 | −import { EXAMPLE_QUERIES, exploreNav, routes } from '@/lib/site'; | |
| 9 | +import { DENSITY_LABELS, nextDensity, readDensity, setDensity } from '@/lib/density'; | |
| 10 | +import { EXAMPLE_QUERIES, PALETTE_PREFIXES, primaryNav, routes } from '@/lib/site'; | |
| 10 | 11 | import type { Suggestion } from '@/lib/types'; |
| 11 | 12 | import { useSearch } from './search-context'; |
| 13 | +import { useTheme } from './theme'; | |
| 12 | 14 | |
| 13 | −/** Command-palette global search (⌘K or /): prefix suggestions from /search/suggest; Enter → /search?q=. Mobile: full-screen sheet. */ | |
| 15 | +/* ------------------------------------------------------------------------------------------------------------ commands */ | |
| 16 | +type Command = { | |
| 17 | + id: string; | |
| 18 | + label: string; | |
| 19 | + hint?: string; | |
| 20 | + keywords?: string; | |
| 21 | + href?: string; | |
| 22 | + /** Switch the palette into entity mode with this type prefix (e.g. "Open benchmark …" → `b:`). */ | |
| 23 | + prefix?: keyof typeof PALETTE_PREFIXES; | |
| 24 | + /** What to do with the picked entity in prefix mode: open its page (default) or its graph. */ | |
| 25 | + then?: 'entity' | 'graph'; | |
| 26 | + action?: 'theme' | 'density'; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +const today = () => new Date().toISOString().slice(0, 10); | |
| 30 | +const COMMANDS: Command[] = [ | |
| 31 | + { id: 'compare', label: 'Compare models', hint: 'Side by side, 2–6 entities', keywords: 'compare matrix versus', href: routes.compare() }, | |
| 32 | + { id: 'local', label: 'Find a local model', hint: 'What fits your hardware', keywords: 'run locally hardware fit memory gpu apple', href: routes.runLocally() }, | |
| 33 | + { id: 'benchmark', label: 'Open benchmark …', hint: 'type to pick a benchmark', keywords: 'leaderboard score', prefix: 'b' }, | |
| 34 | + { id: 'today', label: "Show today's changes", hint: 'Daily digest', keywords: 'changes today digest new', href: routes.changesDay(today()) }, | |
| 35 | + { id: 'diff', label: 'Diff two dates', hint: 'What changed between A and B', keywords: 'diff compare dates history', href: routes.diff() }, | |
| 36 | + { id: 'provider', label: 'Find provider …', hint: 'type to pick a provider', keywords: 'api pricing host inference', prefix: 'p' }, | |
| 37 | + { id: 'lineage', label: 'Explore lineage of …', hint: 'type to pick a model → graph', keywords: 'lineage graph derived fine-tuned quantized family', prefix: 'm', then: 'graph' }, | |
| 38 | + { id: 'theme', label: 'Toggle theme', hint: 'system → light → dark', keywords: 'dark light mode appearance', action: 'theme' }, | |
| 39 | + { id: 'density', label: 'Toggle density', hint: 'comfortable → compact → dense', keywords: 'compact dense rows spacing', action: 'density' }, | |
| 40 | + { id: 'go-frontier', label: 'Go to Frontier', keywords: 'leaders best', href: routes.frontier() }, | |
| 41 | + { id: 'go-prices', label: 'Go to Prices', keywords: 'price index usd tokens cost', href: routes.prices() }, | |
| 42 | + { id: 'go-graph', label: 'Go to Graph', keywords: 'knowledge graph relations', href: routes.graph() }, | |
| 43 | + { id: 'go-timeline', label: 'Go to Timeline', keywords: 'history months events', href: routes.timeline() }, | |
| 44 | + { id: 'go-time-machine', label: 'Go to Time Machine', keywords: 'as of date past', href: routes.timeMachine() }, | |
| 45 | + { id: 'go-pulse', label: 'Go to Pulse', keywords: 'live activity connectors', href: routes.pulse() }, | |
| 46 | + { id: 'go-open', label: 'Go to Open models', keywords: 'open weights license', href: routes.open() }, | |
| 47 | + { id: 'go-calculator', label: 'Go to Calculator', keywords: 'cost workload tokens estimate', href: routes.calculator() }, | |
| 48 | + { id: 'go-watchlist', label: 'Go to Watchlist', keywords: 'follow star saved', href: routes.watchlist() }, | |
| 49 | + { id: 'go-find', label: 'Go to Find a model', keywords: 'finder requirements', href: routes.findAModel() }, | |
| 50 | +]; | |
| 51 | + | |
| 52 | +/** Fuzzy score: exact prefix > word prefix > substring > subsequence; 0 = no match. */ | |
| 53 | +function fuzzy(q: string, text: string): number { | |
| 54 | + const t = text.toLowerCase(); | |
| 55 | + const s = q.toLowerCase().trim(); | |
| 56 | + if (!s) return 1; | |
| 57 | + if (t.startsWith(s)) return 100 - t.length * 0.01; | |
| 58 | + if (t.split(/[\s…]+/).some((w) => w.startsWith(s))) return 80; | |
| 59 | + const idx = t.indexOf(s); | |
| 60 | + if (idx >= 0) return 60 - idx * 0.1; | |
| 61 | + let i = 0; | |
| 62 | + for (const ch of t) if (ch === s[i]) i++; | |
| 63 | + return i === s.length ? 30 - (t.length - s.length) * 0.05 : 0; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/* -------------------------------------------------------------------------------------------------------------- recent */ | |
| 67 | +const RECENT_KEY = 'aia-recent'; | |
| 68 | +const RECENT_MAX = 8; | |
| 69 | +type Recent = Pick<Suggestion, 'id' | 'slug' | 'name' | 'entity_type' | 'organization_name'>; | |
| 70 | +function readRecent(): Recent[] { | |
| 71 | + try { | |
| 72 | + const arr = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as unknown; | |
| 73 | + return Array.isArray(arr) ? (arr.filter((x) => x && typeof (x as Recent).slug === 'string') as Recent[]).slice(0, RECENT_MAX) : []; | |
| 74 | + } catch { | |
| 75 | + return []; | |
| 76 | + } | |
| 77 | +} | |
| 78 | +function pushRecent(s: Recent) { | |
| 79 | + try { | |
| 80 | + const next = [{ id: s.id, slug: s.slug, name: s.name, entity_type: s.entity_type, organization_name: s.organization_name ?? null }, ...readRecent().filter((r) => r.slug !== s.slug)].slice(0, RECENT_MAX); | |
| 81 | + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); | |
| 82 | + } catch { | |
| 83 | + /* ignore */ | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/* -------------------------------------------------------------------------------------------------------------- parse */ | |
| 88 | +type Parsed = { mode: 'all' | 'commands' | 'entities'; term: string; types: string[] | null; prefixLabel: string | null }; | |
| 89 | +function parse(input: string): Parsed { | |
| 90 | + const s = input.replace(/^\s+/, ''); | |
| 91 | + if (s.startsWith('>')) return { mode: 'commands', term: s.slice(1).trim(), types: null, prefixLabel: 'Commands' }; | |
| 92 | + const m = /^([a-z]):\s*(.*)$/i.exec(s); | |
| 93 | + if (m && PALETTE_PREFIXES[(m[1] as string).toLowerCase()]) { | |
| 94 | + const p = PALETTE_PREFIXES[(m[1] as string).toLowerCase()] as { types: string[]; label: string }; | |
| 95 | + return { mode: 'entities', term: (m[2] ?? '').trim(), types: p.types, prefixLabel: p.label }; | |
| 96 | + } | |
| 97 | + return { mode: 'all', term: s.trim(), types: null, prefixLabel: null }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +type Row = { key: string; kind: 'entity' | 'command' | 'search' | 'recent'; href?: string; run?: () => void; node: React.ReactNode; section: string }; | |
| 101 | + | |
| 102 | +/** | |
| 103 | + * Command palette (⌘K / Ctrl+K / "/"): entity suggestions + commands with fuzzy match, recent entities (last 8), | |
| 104 | + * type prefixes (`m:` models · `b:` benchmarks · `o:` orgs · `p:` providers · `h:` hardware) and `>` for commands only. | |
| 105 | + * Keyboard: ↑↓ move · ↵ run · esc close · ⌫ on an empty prefixed input clears the prefix. Mobile: full-screen sheet. | |
| 106 | + */ | |
| 14 | 107 | export function SearchDialog() { |
| 15 | 108 | const { open, setOpen } = useSearch(); |
| 16 | 109 | const router = useRouter(); |
| 110 | + const [, setTheme] = useTheme(); | |
| 17 | 111 | const [q, setQ] = useState(''); |
| 18 | 112 | const [items, setItems] = useState<Suggestion[]>([]); |
| 19 | 113 | const [active, setActive] = useState(0); |
| 20 | 114 | const [loading, setLoading] = useState(false); |
| 21 | 115 | const [failed, setFailed] = useState(false); |
| 116 | + const [recent, setRecent] = useState<Recent[]>([]); | |
| 117 | + const [then, setThen] = useState<'entity' | 'graph'>('entity'); | |
| 22 | 118 | const inputRef = useRef<HTMLInputElement>(null); |
| 119 | + const listRef = useRef<HTMLUListElement>(null); | |
| 120 | + const parsed = useMemo(() => parse(q), [q]); | |
| 121 | + // keep the active row visible while navigating with the keyboard | |
| 122 | + useEffect(() => { | |
| 123 | + listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); | |
| 124 | + }, [active]); | |
| 23 | 125 | |
| 24 | 126 | useEffect(() => { |
| 25 | 127 | if (open) { |
| 128 | + inputRef.current?.focus(); | |
| 26 | 129 | setTimeout(() => inputRef.current?.focus(), 20); |
| 27 | 130 | document.body.style.overflow = 'hidden'; |
| 131 | + setRecent(readRecent()); | |
| 132 | + setActive(0); | |
| 28 | 133 | } else { |
| 29 | 134 | document.body.style.overflow = ''; |
| 30 | 135 | setQ(''); |
| 31 | 136 | setItems([]); |
| 32 | 137 | setFailed(false); |
| 138 | + setThen('entity'); | |
| 33 | 139 | } |
| 34 | 140 | return () => { |
| 35 | 141 | document.body.style.overflow = ''; |
@@ -37,8 +143,11 @@ export function SearchDialog() { | ||
| 37 | 143 | }, [open]); |
| 38 | 144 | |
| 39 | 145 | useEffect(() => { |
| 40 | − if (!open) return; | |
| 41 | − const term = q.trim(); | |
| 146 | + if (!open || parsed.mode === 'commands') { | |
| 147 | + setItems([]); | |
| 148 | + return; | |
| 149 | + } | |
| 150 | + const term = parsed.term; | |
| 42 | 151 | if (term.length < 1) { |
| 43 | 152 | setItems([]); |
| 44 | 153 | return; |
@@ -61,104 +170,234 @@ export function SearchDialog() { | ||
| 61 | 170 | clearTimeout(t); |
| 62 | 171 | ctrl.abort(); |
| 63 | 172 | }; |
| 64 | − }, [q, open]); | |
| 173 | + }, [parsed.term, parsed.mode, open]); | |
| 174 | + | |
| 175 | + const close = useCallback(() => setOpen(false), [setOpen]); | |
| 176 | + const go = useCallback( | |
| 177 | + (href: string) => { | |
| 178 | + router.push(href); | |
| 179 | + close(); | |
| 180 | + }, | |
| 181 | + [router, close], | |
| 182 | + ); | |
| 183 | + const runCommand = useCallback( | |
| 184 | + (c: Command) => { | |
| 185 | + if (c.action === 'theme') { | |
| 186 | + const cur = (localStorage.getItem('aia-theme') as 'light' | 'dark' | null) ?? 'system'; | |
| 187 | + setTheme(cur === 'system' ? 'light' : cur === 'light' ? 'dark' : 'system'); | |
| 188 | + close(); | |
| 189 | + return; | |
| 190 | + } | |
| 191 | + if (c.action === 'density') { | |
| 192 | + setDensity(nextDensity(readDensity())); | |
| 193 | + close(); | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + if (c.prefix) { | |
| 197 | + setQ(`${c.prefix}:`); | |
| 198 | + setThen(c.then ?? 'entity'); | |
| 199 | + setActive(0); | |
| 200 | + inputRef.current?.focus(); | |
| 201 | + return; | |
| 202 | + } | |
| 203 | + if (c.href) go(c.href); | |
| 204 | + }, | |
| 205 | + [close, go, setTheme], | |
| 206 | + ); | |
| 65 | 207 | |
| 66 | 208 | if (!open) return null; |
| 67 | − const rows: { key: string; href: string; node: React.ReactNode }[] = items.map((s) => ({ | |
| 68 | − key: s.id, | |
| 69 | − href: routes.entity(s), | |
| 70 | − node: ( | |
| 71 | − <> | |
| 72 | − <EntityBadge type={s.entity_type} small /> | |
| 73 | − <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span> | |
| 74 | − {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>} | |
| 75 | − </> | |
| 76 | − ), | |
| 77 | − })); | |
| 78 | − const term = q.trim(); | |
| 79 | − const allRows = term ? [...rows, { key: '__all', href: routes.search(term), node: (<><Search className="size-4 text-accent" aria-hidden /><span className="flex-1 text-sm text-accent">Search everything for “{term}”</span><CornerDownLeft className="size-3.5 text-ink-3" aria-hidden /></>) }] : rows; | |
| 80 | 209 | |
| 81 | − const go = (href: string) => { | |
| 82 | − router.push(href); | |
| 83 | − setOpen(false); | |
| 210 | + /* ------------------------------------------------------------------------------------------------------ build rows */ | |
| 211 | + const term = parsed.term; | |
| 212 | + const rows: Row[] = []; | |
| 213 | + const entityNode = (s: Recent | Suggestion, icon?: React.ReactNode) => ( | |
| 214 | + <> | |
| 215 | + {icon ?? <EntityBadge type={s.entity_type} small />} | |
| 216 | + <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span> | |
| 217 | + {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>} | |
| 218 | + {then === 'graph' && <span className="text-[11px] text-ink-3">→ graph</span>} | |
| 219 | + </> | |
| 220 | + ); | |
| 221 | + const entityHref = (s: { entity_type: string; slug: string }) => (then === 'graph' ? routes.graph(s.slug) : routes.entity(s)); | |
| 222 | + | |
| 223 | + if (parsed.mode !== 'entities') { | |
| 224 | + const scored = COMMANDS.map((c) => ({ c, score: parsed.mode === 'commands' && !term ? 1 : Math.max(fuzzy(term, c.label), fuzzy(term, c.keywords ?? '') * 0.8) })) | |
| 225 | + .filter((x) => x.score > 0) | |
| 226 | + .sort((a, b) => b.score - a.score) | |
| 227 | + .slice(0, parsed.mode === 'commands' ? COMMANDS.length : term ? 4 : 6); | |
| 228 | + for (const { c } of scored) | |
| 229 | + rows.push({ | |
| 230 | + key: `cmd-${c.id}`, | |
| 231 | + kind: 'command', | |
| 232 | + section: 'Commands', | |
| 233 | + run: () => runCommand(c), | |
| 234 | + node: ( | |
| 235 | + <> | |
| 236 | + <Terminal className="size-4 shrink-0 text-ink-3" aria-hidden /> | |
| 237 | + <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{c.label}</span> | |
| 238 | + {c.hint && <span className="hidden truncate text-xs text-ink-3 sm:block">{c.hint}</span>} | |
| 239 | + {c.action === 'density' && <span className="mono text-[10px] text-ink-3">{DENSITY_LABELS[readDensity()]}</span>} | |
| 240 | + </> | |
| 241 | + ), | |
| 242 | + }); | |
| 243 | + } | |
| 244 | + if (parsed.mode !== 'commands') { | |
| 245 | + const filtered = parsed.types ? items.filter((s) => (parsed.types as string[]).includes(s.entity_type)) : items; | |
| 246 | + for (const s of filtered) | |
| 247 | + rows.push({ | |
| 248 | + key: `ent-${s.id}`, | |
| 249 | + kind: 'entity', | |
| 250 | + section: parsed.prefixLabel ?? 'Entities', | |
| 251 | + href: entityHref(s), | |
| 252 | + run: () => pushRecent(s), | |
| 253 | + node: entityNode(s), | |
| 254 | + }); | |
| 255 | + if (!term && recent.length) | |
| 256 | + for (const r of recent) | |
| 257 | + rows.push({ key: `rec-${r.slug}`, kind: 'recent', section: 'Recent', href: entityHref(r), run: () => pushRecent(r), node: entityNode(r, <Clock className="size-4 shrink-0 text-ink-3" aria-hidden />) }); | |
| 258 | + if (term) | |
| 259 | + rows.push({ | |
| 260 | + key: '__all', | |
| 261 | + kind: 'search', | |
| 262 | + section: 'Search', | |
| 263 | + href: routes.search(term, parsed.types?.[0]), | |
| 264 | + node: ( | |
| 265 | + <> | |
| 266 | + <Search className="size-4 text-accent" aria-hidden /> | |
| 267 | + <span className="flex-1 text-sm text-accent">Search everything for “{term}”</span> | |
| 268 | + <CornerDownLeft className="size-3.5 text-ink-3" aria-hidden /> | |
| 269 | + </> | |
| 270 | + ), | |
| 271 | + }); | |
| 272 | + } | |
| 273 | + const clampActive = Math.min(active, Math.max(0, rows.length - 1)); | |
| 274 | + | |
| 275 | + const pick = (r: Row) => { | |
| 276 | + r.run?.(); | |
| 277 | + if (r.href) go(r.href); | |
| 84 | 278 | }; |
| 85 | 279 | const onKey = (e: React.KeyboardEvent) => { |
| 86 | 280 | if (e.key === 'ArrowDown') { |
| 87 | 281 | e.preventDefault(); |
| 88 | − setActive((a) => Math.min(a + 1, allRows.length - 1)); | |
| 282 | + setActive((a) => Math.min(a + 1, rows.length - 1)); | |
| 89 | 283 | } else if (e.key === 'ArrowUp') { |
| 90 | 284 | e.preventDefault(); |
| 91 | 285 | setActive((a) => Math.max(a - 1, 0)); |
| 92 | 286 | } else if (e.key === 'Enter') { |
| 93 | 287 | e.preventDefault(); |
| 94 | − const it = allRows[active]; | |
| 95 | − if (it) go(it.href); | |
| 288 | + const it = rows[clampActive]; | |
| 289 | + if (it) pick(it); | |
| 96 | 290 | else if (term) go(routes.search(term)); |
| 97 | − } else if (e.key === 'Escape') setOpen(false); | |
| 291 | + } else if (e.key === 'Escape') close(); | |
| 292 | + else if (e.key === 'Backspace' && /^[a-z]:$/i.test(q)) { | |
| 293 | + e.preventDefault(); | |
| 294 | + setQ(''); | |
| 295 | + setThen('entity'); | |
| 296 | + } | |
| 98 | 297 | }; |
| 99 | − | |
| 298 | + let lastSection = ''; | |
| 100 | 299 | return ( |
| 101 | − <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[12vh]" role="dialog" aria-modal="true" aria-label="Search AI Atlas" onClick={() => setOpen(false)}> | |
| 102 | − <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[70vh] md:w-[680px] md:rounded-lg" onClick={(e) => e.stopPropagation()}> | |
| 300 | + <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[10vh]" role="dialog" aria-modal="true" aria-label="Search and commands" onClick={close} data-palette> | |
| 301 | + <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[72vh] md:w-[720px] md:rounded-lg" onClick={(e) => e.stopPropagation()}> | |
| 103 | 302 | <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5"> |
| 104 | − <Search className="size-5 shrink-0 text-ink-3" aria-hidden /> | |
| 303 | + {parsed.mode === 'commands' ? <Terminal className="size-5 shrink-0 text-accent" aria-hidden /> : <Search className="size-5 shrink-0 text-ink-3" aria-hidden />} | |
| 304 | + {parsed.prefixLabel && parsed.mode === 'entities' && <span className="mono shrink-0 rounded-[3px] bg-accent-soft px-1.5 py-0.5 text-[11px] text-accent">{parsed.prefixLabel}</span>} | |
| 105 | 305 | <input |
| 106 | 306 | ref={inputRef} |
| 107 | 307 | value={q} |
| 108 | − onChange={(e) => setQ(e.target.value)} | |
| 308 | + onChange={(e) => { | |
| 309 | + setQ(e.target.value); | |
| 310 | + setActive(0); | |
| 311 | + }} | |
| 109 | 312 | onKeyDown={onKey} |
| 110 | − placeholder="Search models, companies, papers, benchmarks… or ask in plain English" | |
| 313 | + placeholder={parsed.mode === 'commands' ? 'Type a command…' : 'Search models, orgs, papers, benchmarks… or type > for commands'} | |
| 111 | 314 | className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" |
| 112 | 315 | autoComplete="off" |
| 316 | + autoFocus | |
| 113 | 317 | spellCheck={false} |
| 114 | − aria-label="Search" | |
| 115 | − aria-activedescendant={allRows[active] ? `sugg-${allRows[active].key}` : undefined} | |
| 318 | + aria-label="Search or command" | |
| 319 | + role="combobox" | |
| 320 | + aria-expanded={rows.length > 0} | |
| 321 | + aria-controls="palette-list" | |
| 322 | + aria-activedescendant={rows[clampActive] ? `pal-${rows[clampActive].key}` : undefined} | |
| 323 | + data-palette-input | |
| 116 | 324 | /> |
| 117 | − <button type="button" onClick={() => setOpen(false)} className="flex size-10 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close search"> | |
| 325 | + <button type="button" onClick={close} className="-mr-1 flex size-11 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close"> | |
| 118 | 326 | <X className="size-5" aria-hidden /> |
| 119 | 327 | </button> |
| 120 | 328 | </div> |
| 121 | 329 | <div className="scrollbar-thin flex-1 overflow-y-auto"> |
| 122 | − {term === '' ? ( | |
| 123 | − <div className="px-4 py-4"> | |
| 330 | + <ul id="palette-list" ref={listRef} className="py-1" role="listbox"> | |
| 331 | + {failed && parsed.mode !== 'commands' && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>} | |
| 332 | + {term && parsed.mode !== 'commands' && !loading && !failed && rows.filter((r) => r.kind === 'entity').length === 0 && <li className="px-4 py-2 text-xs text-ink-3">No direct {parsed.prefixLabel?.toLowerCase() ?? 'entity'} match — search everything below.</li>} | |
| 333 | + {rows.map((r, i) => { | |
| 334 | + const header = r.section !== lastSection; | |
| 335 | + lastSection = r.section; | |
| 336 | + const inner = ( | |
| 337 | + <> | |
| 338 | + {r.node} | |
| 339 | + {i === clampActive && <kbd className="mono hidden text-[10px] text-ink-3 sm:block">↵</kbd>} | |
| 340 | + </> | |
| 341 | + ); | |
| 342 | + const cls = cn('flex min-h-[44px] w-full items-center gap-3 px-4 py-2 text-left', i === clampActive ? 'bg-surface-3' : 'hover:bg-surface-2'); | |
| 343 | + return ( | |
| 344 | + <li key={r.key} id={`pal-${r.key}`} role="option" aria-selected={i === clampActive} data-palette-row={r.kind}> | |
| 345 | + {header && <p className="eyebrow px-4 pb-1 pt-3">{r.section}</p>} | |
| 346 | + {r.href ? ( | |
| 347 | + <Link href={r.href} onClick={() => { r.run?.(); close(); }} onMouseEnter={() => setActive(i)} className={cls}> | |
| 348 | + {inner} | |
| 349 | + </Link> | |
| 350 | + ) : ( | |
| 351 | + <button type="button" onClick={() => pick(r)} onMouseEnter={() => setActive(i)} className={cls}> | |
| 352 | + {inner} | |
| 353 | + </button> | |
| 354 | + )} | |
| 355 | + </li> | |
| 356 | + ); | |
| 357 | + })} | |
| 358 | + </ul> | |
| 359 | + {!term && parsed.mode === 'all' && ( | |
| 360 | + <div className="border-t border-rule px-4 py-4"> | |
| 124 | 361 | <p className="eyebrow mb-2">Try asking</p> |
| 125 | 362 | <ul className="flex flex-wrap gap-2"> |
| 126 | 363 | {EXAMPLE_QUERIES.map((ex) => ( |
| 127 | 364 | <li key={ex}> |
| 128 | − <button type="button" onClick={() => setQ(ex)} className="border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 365 | + <button type="button" onClick={() => setQ(ex)} className="min-h-11 border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:min-h-9"> | |
| 129 | 366 | {ex} |
| 130 | 367 | </button> |
| 131 | 368 | </li> |
| 132 | 369 | ))} |
| 133 | 370 | </ul> |
| 134 | − <p className="eyebrow mt-6 mb-2">Browse</p> | |
| 135 | − <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-3"> | |
| 136 | − {exploreNav.map((n) => ( | |
| 371 | + <p className="eyebrow mt-5 mb-2">Browse</p> | |
| 372 | + <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4"> | |
| 373 | + {primaryNav.map((n) => ( | |
| 137 | 374 | <li key={n.href}> |
| 138 | − <Link href={n.href} onClick={() => setOpen(false)} className="flex h-10 items-center gap-2 text-sm text-ink-2 hover:text-accent"> | |
| 375 | + <Link href={n.href} onClick={close} className="flex h-11 items-center gap-2 text-sm text-ink-2 hover:text-accent md:h-10"> | |
| 139 | 376 | <ArrowRight className="size-3.5" aria-hidden /> {n.label} |
| 140 | 377 | </Link> |
| 141 | 378 | </li> |
| 142 | 379 | ))} |
| 143 | 380 | </ul> |
| 144 | − <p className="mt-6 text-xs text-ink-3"> | |
| 145 | − <kbd className="mono border border-rule px-1">↵</kbd> full results · <kbd className="mono border border-rule px-1">esc</kbd> close · <kbd className="mono border border-rule px-1">⌘K</kbd> anywhere | |
| 146 | − </p> | |
| 147 | 381 | </div> |
| 148 | − ) : ( | |
| 149 | − <ul className="py-1" role="listbox"> | |
| 150 | − {failed && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>} | |
| 151 | − {!loading && !failed && rows.length === 0 && <li className="px-4 py-3 text-sm text-ink-3">No direct match — search everything below.</li>} | |
| 152 | − {allRows.map((r, i) => ( | |
| 153 | − <li key={r.key} id={`sugg-${r.key}`} role="option" aria-selected={i === active}> | |
| 154 | − <Link href={r.href} onClick={() => setOpen(false)} onMouseEnter={() => setActive(i)} className={cn('flex min-h-[46px] items-center gap-3 px-4 py-2', i === active ? 'bg-surface-3' : 'hover:bg-surface-2')}> | |
| 155 | − {r.node} | |
| 156 | − </Link> | |
| 157 | − </li> | |
| 158 | − ))} | |
| 159 | − </ul> | |
| 160 | 382 | )} |
| 161 | 383 | </div> |
| 384 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-ink-3"> | |
| 385 | + <span> | |
| 386 | + <kbd className="mono border border-rule px-1">↵</kbd> open | |
| 387 | + </span> | |
| 388 | + <span> | |
| 389 | + <kbd className="mono border border-rule px-1">↑↓</kbd> move | |
| 390 | + </span> | |
| 391 | + <span> | |
| 392 | + <kbd className="mono border border-rule px-1">></kbd> commands | |
| 393 | + </span> | |
| 394 | + <span className="hidden sm:inline"> | |
| 395 | + <kbd className="mono border border-rule px-1">m:</kbd> models · <kbd className="mono border border-rule px-1">b:</kbd> benchmarks · <kbd className="mono border border-rule px-1">o:</kbd> orgs · <kbd className="mono border border-rule px-1">p:</kbd> providers · <kbd className="mono border border-rule px-1">h:</kbd> hardware | |
| 396 | + </span> | |
| 397 | + <span className="ml-auto"> | |
| 398 | + <kbd className="mono border border-rule px-1">esc</kbd> close | |
| 399 | + </span> | |
| 400 | + </div> | |
| 162 | 401 | </div> |
| 163 | 402 | </div> |
| 164 | 403 | ); |
modified
apps/web/src/components/layout/site-footer.tsx
+29 −41
@@ -1,61 +1,49 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | 2 | import { LogoMark } from '@/components/brand/logo'; |
| 3 | −import { CONTACT_EMAIL, exploreNav, routes } from '@/lib/site'; | |
| 3 | +import { AUTHOR_NAME, CONTACT_EMAIL, footerGroups, HOST_NAME, HOST_URL, SITE_URL, TAGLINE } from '@/lib/site'; | |
| 4 | 4 | |
| 5 | 5 | export function SiteFooter() { |
| 6 | 6 | return ( |
| 7 | 7 | <footer className="mt-16 border-t border-rule"> |
| 8 | 8 | <div className="container-x mx-auto max-w-[1600px] py-10"> |
| 9 | − <div className="grid gap-8 md:grid-cols-[1.4fr_1fr_1fr_1fr]"> | |
| 9 | + <div className="grid gap-8 md:grid-cols-[1.4fr_1fr_1fr_1fr_1fr]"> | |
| 10 | 10 | <div> |
| 11 | 11 | <p className="flex items-center gap-2 text-ink"> |
| 12 | − <LogoMark size={20} /> <span className="font-semibold">AI Atlas</span> | |
| 12 | + <LogoMark size={22} /> <span className="tracking-tight"><span className="font-bold">AI</span> <span className="font-medium text-ink-2">Atlas</span></span> | |
| 13 | 13 | </p> |
| 14 | 14 | <p className="mt-3 max-w-sm text-sm leading-relaxed text-ink-2"> |
| 15 | − A continuously updated, source-attributed map of the global AI ecosystem. Every number links back to its source, tier and observation time; history is never discarded. | |
| 15 | + {TAGLINE}. Every number links back to its source, tier and observation time; history is never discarded; conflicts are shown, never averaged. | |
| 16 | 16 | </p> |
| 17 | + <p className="mono mt-3 text-xs text-ink-3">{SITE_URL.replace(/^https?:\/\//, '')}/api/v1</p> | |
| 17 | 18 | </div> |
| 18 | − <div> | |
| 19 | − <p className="eyebrow mb-2">Explore</p> | |
| 20 | − <ul className="space-y-1 text-sm"> | |
| 21 | − {exploreNav.map((n) => ( | |
| 22 | − <li key={n.href}> | |
| 23 | − <Link href={n.href} className="inline-block py-0.5 text-ink-2 hover:text-ink"> | |
| 24 | − {n.label} | |
| 25 | − </Link> | |
| 26 | − </li> | |
| 27 | − ))} | |
| 28 | − </ul> | |
| 29 | − </div> | |
| 30 | − <div> | |
| 31 | − <p className="eyebrow mb-2">Track</p> | |
| 32 | − <ul className="space-y-1 text-sm"> | |
| 33 | − <li><Link href={routes.changes()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Changes</Link></li> | |
| 34 | − <li><Link href={routes.timeline()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Timeline</Link></li> | |
| 35 | − <li><Link href={routes.compare()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Compare</Link></li> | |
| 36 | − <li><Link href={routes.search('')} className="inline-block py-0.5 text-ink-2 hover:text-ink">Search</Link></li> | |
| 37 | − </ul> | |
| 38 | − </div> | |
| 39 | − <div> | |
| 40 | − <p className="eyebrow mb-2">About</p> | |
| 41 | − <ul className="space-y-1 text-sm"> | |
| 42 | − <li><Link href={routes.about()} className="inline-block py-0.5 text-ink-2 hover:text-ink">About</Link></li> | |
| 43 | − <li><Link href={routes.methodology()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Methodology</Link></li> | |
| 44 | − <li><Link href={routes.sources()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Sources</Link></li> | |
| 45 | − <li><Link href={routes.developers()} className="inline-block py-0.5 text-ink-2 hover:text-ink">API / Developers</Link></li> | |
| 46 | − <li><Link href={routes.bot()} className="inline-block py-0.5 text-ink-2 hover:text-ink">AIAtlasBot</Link></li> | |
| 47 | − <li><a href={`mailto:${CONTACT_EMAIL}`} className="inline-block py-0.5 text-ink-2 hover:text-ink">Contact</a></li> | |
| 48 | − </ul> | |
| 49 | − </div> | |
| 19 | + {footerGroups.map((g) => ( | |
| 20 | + <div key={g.label}> | |
| 21 | + <p className="eyebrow mb-2">{g.label}</p> | |
| 22 | + <ul className="space-y-1 text-sm"> | |
| 23 | + {g.items.map((n) => ( | |
| 24 | + <li key={n.href + n.label}> | |
| 25 | + <Link href={n.href} className="inline-block py-0.5 text-ink-2 hover:text-ink"> | |
| 26 | + {n.label} | |
| 27 | + </Link> | |
| 28 | + </li> | |
| 29 | + ))} | |
| 30 | + </ul> | |
| 31 | + </div> | |
| 32 | + ))} | |
| 50 | 33 | </div> |
| 51 | 34 | <div className="mt-10 flex flex-col gap-2 border-t border-rule pt-5 text-xs text-ink-3 md:flex-row md:items-center md:justify-between"> |
| 52 | − <p>© 2026 Simon-Pierre Boucher · AI Atlas. Data is aggregated from public sources and attributed on every page.</p> | |
| 53 | 35 | <p> |
| 54 | − Hosted on{' '} | |
| 55 | − <a href="https://www.maclustr.io" className="text-ink-2 hover:text-ink" rel="noopener noreferrer"> | |
| 56 | − MacLustr | |
| 57 | − </a> | |
| 36 | + Built by <span className="text-ink-2">{AUTHOR_NAME}</span> ·{' '} | |
| 37 | + <a href={`mailto:${CONTACT_EMAIL}`} className="text-ink-2 hover:text-ink"> | |
| 38 | + {CONTACT_EMAIL} | |
| 39 | + </a>{' '} | |
| 40 | + · Hosted on{' '} | |
| 41 | + <a href={HOST_URL} className="text-ink-2 hover:text-ink" rel="noopener noreferrer"> | |
| 42 | + {HOST_NAME} | |
| 43 | + </a>{' '} | |
| 44 | + (<a href={HOST_URL} className="hover:text-ink" rel="noopener noreferrer">{HOST_URL.replace(/^https?:\/\//, '')}</a>) | |
| 58 | 45 | </p> |
| 46 | + <p>© 2026 {AUTHOR_NAME} · AI Atlas. Data is aggregated from public sources and attributed on every page.</p> | |
| 59 | 47 | </div> |
| 60 | 48 | </div> |
| 61 | 49 | </footer> |
modified
apps/web/src/components/layout/site-header.tsx
+98 −89
@@ -1,41 +1,71 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { ChevronDown, Menu, Search, X } from 'lucide-react'; | |
| 2 | +import { ChevronDown, Search } from 'lucide-react'; | |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { usePathname } from 'next/navigation'; |
| 5 | 5 | import { useEffect, useRef, useState } from 'react'; |
| 6 | 6 | import { Wordmark } from '@/components/brand/logo'; |
| 7 | 7 | import { cn } from '@/lib/cn'; |
| 8 | −import { exploreNav, moreNav, primaryNav, routes } from '@/lib/site'; | |
| 8 | +import { moreGroups, primaryNav, routes } from '@/lib/site'; | |
| 9 | +import { DensityToggle } from './density'; | |
| 9 | 10 | import { useOpenSearch } from './search-context'; |
| 10 | 11 | import { ThemeToggle } from './theme'; |
| 11 | 12 | |
| 12 | −const EXPLORE_PREFIXES = exploreNav.map((n) => n.href); | |
| 13 | +const MORE_HREFS = moreGroups.flatMap((g) => g.items.map((i) => i.href)); | |
| 13 | 14 | |
| 15 | +/** | |
| 16 | + * Header: `AI Atlas | Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾ | Search ⌘K | theme | density`. | |
| 17 | + * "More" is a keyboard-accessible menu panel (Enter/Space/↓ opens, ↑↓ move, Home/End, Esc closes, click outside closes). | |
| 18 | + * Below lg the primary nav lives in the bottom tab bar (`MobileTabBar`); the header keeps search, theme and density. | |
| 19 | + */ | |
| 14 | 20 | export function SiteHeader() { |
| 15 | 21 | const pathname = usePathname(); |
| 16 | 22 | const openSearch = useOpenSearch(); |
| 17 | − const [menu, setMenu] = useState(false); | |
| 18 | − const [explore, setExplore] = useState(false); | |
| 19 | − const exploreRef = useRef<HTMLDivElement>(null); | |
| 20 | − useEffect(() => { | |
| 21 | − setMenu(false); | |
| 22 | − setExplore(false); | |
| 23 | − }, [pathname]); | |
| 23 | + const [more, setMore] = useState(false); | |
| 24 | + const moreRef = useRef<HTMLDivElement>(null); | |
| 25 | + const moreBtn = useRef<HTMLButtonElement>(null); | |
| 26 | + | |
| 27 | + useEffect(() => setMore(false), [pathname]); | |
| 24 | 28 | useEffect(() => { |
| 25 | − if (!explore) return; | |
| 29 | + if (!more) return; | |
| 26 | 30 | const onDoc = (e: MouseEvent) => { |
| 27 | − if (!exploreRef.current?.contains(e.target as Node)) setExplore(false); | |
| 31 | + if (!moreRef.current?.contains(e.target as Node)) setMore(false); | |
| 32 | + }; | |
| 33 | + const onKey = (e: KeyboardEvent) => { | |
| 34 | + if (e.key === 'Escape') { | |
| 35 | + setMore(false); | |
| 36 | + moreBtn.current?.focus(); | |
| 37 | + } | |
| 28 | 38 | }; |
| 29 | − const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setExplore(false); | |
| 30 | 39 | document.addEventListener('mousedown', onDoc); |
| 31 | 40 | document.addEventListener('keydown', onKey); |
| 41 | + const first = moreRef.current?.querySelector<HTMLElement>('[role="menuitem"]'); | |
| 42 | + first?.focus(); | |
| 32 | 43 | return () => { |
| 33 | 44 | document.removeEventListener('mousedown', onDoc); |
| 34 | 45 | document.removeEventListener('keydown', onKey); |
| 35 | 46 | }; |
| 36 | − }, [explore]); | |
| 47 | + }, [more]); | |
| 48 | + | |
| 49 | + const onMenuKey = (e: React.KeyboardEvent) => { | |
| 50 | + const items = [...(moreRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]; | |
| 51 | + const i = items.indexOf(document.activeElement as HTMLElement); | |
| 52 | + if (e.key === 'ArrowDown') { | |
| 53 | + e.preventDefault(); | |
| 54 | + items[(i + 1) % items.length]?.focus(); | |
| 55 | + } else if (e.key === 'ArrowUp') { | |
| 56 | + e.preventDefault(); | |
| 57 | + items[(i - 1 + items.length) % items.length]?.focus(); | |
| 58 | + } else if (e.key === 'Home') { | |
| 59 | + e.preventDefault(); | |
| 60 | + items[0]?.focus(); | |
| 61 | + } else if (e.key === 'End') { | |
| 62 | + e.preventDefault(); | |
| 63 | + items[items.length - 1]?.focus(); | |
| 64 | + } else if (e.key === 'Tab') setMore(false); | |
| 65 | + }; | |
| 37 | 66 | |
| 38 | − const exploreActive = pathname === '/explore' || EXPLORE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + '/')); | |
| 67 | + const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/'); | |
| 68 | + const moreActive = MORE_HREFS.some(isActive) && !primaryNav.some((n) => isActive(n.href)); | |
| 39 | 69 | const navCls = (active: boolean) => cn('flex h-9 items-center gap-1 rounded-sm px-2.5 text-[13.5px] transition-colors', active ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink'); |
| 40 | 70 | |
| 41 | 71 | return ( |
@@ -44,96 +74,75 @@ export function SiteHeader() { | ||
| 44 | 74 | <Link href={routes.home()} className="flex h-11 items-center" aria-label="AI Atlas home"> |
| 45 | 75 | <Wordmark /> |
| 46 | 76 | </Link> |
| 47 | − <nav aria-label="Primary" className="ml-3 hidden items-center gap-0.5 lg:flex"> | |
| 48 | − <div className="relative" ref={exploreRef}> | |
| 49 | − <button type="button" onClick={() => setExplore((v) => !v)} aria-expanded={explore} aria-haspopup="menu" className={navCls(exploreActive)}> | |
| 50 | − Explore <ChevronDown className={cn('size-3.5 transition-transform', explore && 'rotate-180')} aria-hidden /> | |
| 77 | + <nav aria-label="Primary" className="relative ml-3 hidden items-center gap-0.5 lg:flex"> | |
| 78 | + {primaryNav.map((n) => ( | |
| 79 | + <Link key={n.href} href={n.href} aria-current={isActive(n.href) ? 'page' : undefined} className={navCls(isActive(n.href))}> | |
| 80 | + {n.label} | |
| 81 | + </Link> | |
| 82 | + ))} | |
| 83 | + <div ref={moreRef} onKeyDown={onMenuKey}> | |
| 84 | + <button | |
| 85 | + ref={moreBtn} | |
| 86 | + type="button" | |
| 87 | + onClick={() => setMore((v) => !v)} | |
| 88 | + onKeyDown={(e) => { | |
| 89 | + if (e.key === 'ArrowDown' && !more) { | |
| 90 | + e.preventDefault(); | |
| 91 | + setMore(true); | |
| 92 | + } | |
| 93 | + }} | |
| 94 | + aria-expanded={more} | |
| 95 | + aria-haspopup="menu" | |
| 96 | + aria-controls="more-menu" | |
| 97 | + className={navCls(moreActive)} | |
| 98 | + data-more-button | |
| 99 | + > | |
| 100 | + More <ChevronDown className={cn('size-3.5 transition-transform', more && 'rotate-180')} aria-hidden /> | |
| 51 | 101 | </button> |
| 52 | − {explore && ( | |
| 53 | − <div role="menu" className="panel absolute left-0 top-full mt-1 w-[520px] p-2 shadow-lg"> | |
| 54 | − <ul className="grid grid-cols-2 gap-0.5"> | |
| 55 | − {exploreNav.map((n) => ( | |
| 56 | − <li key={n.href}> | |
| 57 | − <Link role="menuitem" href={n.href} className={cn('block rounded-sm px-3 py-2 text-sm hover:bg-surface-2', pathname.startsWith(n.href) ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink')}> | |
| 58 | − {n.label} | |
| 59 | − </Link> | |
| 60 | − </li> | |
| 102 | + {more && ( | |
| 103 | + <div id="more-menu" role="menu" aria-label="More" className="panel absolute left-0 top-full mt-1 w-[min(880px,calc(100vw-2rem))] p-3 shadow-lg" data-more-menu> | |
| 104 | + <div className="grid grid-cols-4 gap-x-6"> | |
| 105 | + {moreGroups.map((g) => ( | |
| 106 | + <div key={g.label}> | |
| 107 | + <p className="eyebrow px-2 pb-1">{g.label}</p> | |
| 108 | + <ul> | |
| 109 | + {g.items.map((n) => ( | |
| 110 | + <li key={n.href + n.label}> | |
| 111 | + <Link role="menuitem" href={n.href} tabIndex={-1} className={cn('block rounded-sm px-2 py-1.5 text-sm leading-tight hover:bg-surface-2 focus:bg-surface-2 focus:outline-none', isActive(n.href) ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink')}> | |
| 112 | + {n.label} | |
| 113 | + {n.hint && <span className="mt-0.5 block text-[11px] text-ink-3">{n.hint}</span>} | |
| 114 | + </Link> | |
| 115 | + </li> | |
| 116 | + ))} | |
| 117 | + </ul> | |
| 118 | + </div> | |
| 61 | 119 | ))} |
| 62 | − <li className="col-span-2 mt-1 border-t border-rule pt-1"> | |
| 63 | − <Link role="menuitem" href={routes.explore()} className="block rounded-sm px-3 py-2 text-sm text-accent hover:bg-surface-2"> | |
| 64 | − All entity types with live counts → | |
| 65 | − </Link> | |
| 66 | − </li> | |
| 67 | − </ul> | |
| 120 | + </div> | |
| 121 | + <div className="mt-2 flex items-center justify-between border-t border-rule px-2 pt-2 text-xs text-ink-3"> | |
| 122 | + <span> | |
| 123 | + <kbd className="mono border border-rule px-1">⌘K</kbd> search anything · <kbd className="mono border border-rule px-1">></kbd> commands | |
| 124 | + </span> | |
| 125 | + <Link role="menuitem" href={routes.explore()} tabIndex={-1} className="link"> | |
| 126 | + All entity types with live counts → | |
| 127 | + </Link> | |
| 128 | + </div> | |
| 68 | 129 | </div> |
| 69 | 130 | )} |
| 70 | 131 | </div> |
| 71 | − {primaryNav.map((n) => { | |
| 72 | − const active = pathname === n.href || pathname.startsWith(n.href + '/'); | |
| 73 | − return ( | |
| 74 | − <Link key={n.href} href={n.href} aria-current={active ? 'page' : undefined} className={navCls(active)}> | |
| 75 | − {n.label} | |
| 76 | − </Link> | |
| 77 | − ); | |
| 78 | − })} | |
| 79 | 132 | </nav> |
| 80 | 133 | <div className="ml-auto flex items-center gap-1"> |
| 81 | − <button type="button" onClick={openSearch} className="hidden h-9 min-w-[280px] items-center gap-2 rounded-sm border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex" aria-label="Open search"> | |
| 134 | + <button type="button" onClick={openSearch} className="hidden h-9 min-w-[260px] items-center gap-2 rounded-sm border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex" aria-label="Open search and commands" data-open-palette> | |
| 82 | 135 | <Search className="size-4" aria-hidden /> |
| 83 | − <span className="flex-1 text-left">Search the AI ecosystem…</span> | |
| 136 | + <span className="flex-1 text-left">Search or run a command…</span> | |
| 84 | 137 | <kbd className="mono rounded-[3px] border border-rule px-1.5 py-0.5 text-[10px]">⌘K</kbd> |
| 85 | 138 | </button> |
| 86 | 139 | <button type="button" onClick={openSearch} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:hidden" aria-label="Open search"> |
| 87 | 140 | <Search className="size-5" aria-hidden /> |
| 88 | 141 | </button> |
| 89 | 142 | <ThemeToggle /> |
| 90 | − <button type="button" onClick={() => setMenu((m) => !m)} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink lg:hidden" aria-label={menu ? 'Close menu' : 'Open menu'} aria-expanded={menu}> | |
| 91 | − {menu ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />} | |
| 92 | − </button> | |
| 143 | + <DensityToggle /> | |
| 93 | 144 | </div> |
| 94 | 145 | </div> |
| 95 | − {menu && ( | |
| 96 | − <div className="border-t border-rule bg-canvas lg:hidden"> | |
| 97 | − <div className="container-x mx-auto grid max-w-[1600px] gap-6 py-5 sm:grid-cols-3"> | |
| 98 | − <div> | |
| 99 | − <p className="eyebrow mb-2">Explore</p> | |
| 100 | − <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 101 | − {exploreNav.map((n) => ( | |
| 102 | − <li key={n.href}> | |
| 103 | − <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 104 | − {n.label} | |
| 105 | − </Link> | |
| 106 | − </li> | |
| 107 | − ))} | |
| 108 | − </ul> | |
| 109 | − </div> | |
| 110 | − <div> | |
| 111 | − <p className="eyebrow mb-2">Track</p> | |
| 112 | − <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 113 | − {primaryNav.map((n) => ( | |
| 114 | − <li key={n.href}> | |
| 115 | − <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 116 | − {n.label} | |
| 117 | − </Link> | |
| 118 | − </li> | |
| 119 | − ))} | |
| 120 | − </ul> | |
| 121 | − </div> | |
| 122 | − <div> | |
| 123 | − <p className="eyebrow mb-2">About</p> | |
| 124 | − <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 125 | − {moreNav.map((n) => ( | |
| 126 | − <li key={n.href}> | |
| 127 | − <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 128 | − {n.label} | |
| 129 | − </Link> | |
| 130 | − </li> | |
| 131 | − ))} | |
| 132 | − </ul> | |
| 133 | − </div> | |
| 134 | − </div> | |
| 135 | − </div> | |
| 136 | − )} | |
| 137 | 146 | </header> |
| 138 | 147 | ); |
| 139 | 148 | } |
added
apps/web/src/components/layout/terminal.tsx
+259 −0
@@ -0,0 +1,259 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { PanelRightClose, PanelRightOpen, Pause, Play, SlidersHorizontal } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; | |
| 5 | +import { Hint } from '@/components/ui/hint'; | |
| 6 | +import { Sheet } from '@/components/ui/sheet'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | + | |
| 9 | +/* | |
| 10 | + Terminal layout primitives (density-aware through the CSS variables in globals.css): | |
| 11 | + - TerminalLayout: desktop 3-pane (filter rail 15rem sticky · main · inspector 22rem sticky, collapsible, state in localStorage); | |
| 12 | + mobile: filters and inspector in bottom sheets. | |
| 13 | + - FilterSheet: the mobile bottom sheet used for filters (also usable standalone). | |
| 14 | + - DataStrip: horizontal live counters with definition tooltips. | |
| 15 | + - Ticker: scrolling strip of recent events, pausable, reduced-motion aware. | |
| 16 | + - SectionNav: sticky in-page anchor nav with the active section highlighted. | |
| 17 | +*/ | |
| 18 | + | |
| 19 | +const INSPECTOR_KEY = 'aia-inspector'; | |
| 20 | + | |
| 21 | +export function TerminalLayout({ | |
| 22 | + filters, | |
| 23 | + children, | |
| 24 | + inspector, | |
| 25 | + filtersTitle = 'Filters', | |
| 26 | + inspectorTitle = 'Inspector', | |
| 27 | + storageKey = INSPECTOR_KEY, | |
| 28 | + className, | |
| 29 | + filterCount, | |
| 30 | + wide = true, | |
| 31 | +}: { | |
| 32 | + filters?: ReactNode; | |
| 33 | + children: ReactNode; | |
| 34 | + inspector?: ReactNode; | |
| 35 | + filtersTitle?: string; | |
| 36 | + inspectorTitle?: string; | |
| 37 | + /** localStorage key for the inspector collapsed state (per page when needed). */ | |
| 38 | + storageKey?: string; | |
| 39 | + className?: string; | |
| 40 | + /** Number of active filters, shown on the mobile filter button. */ | |
| 41 | + filterCount?: number; | |
| 42 | + wide?: boolean; | |
| 43 | +}) { | |
| 44 | + const [collapsed, setCollapsed] = useState(false); | |
| 45 | + const [ready, setReady] = useState(false); | |
| 46 | + const [filtersOpen, setFiltersOpen] = useState(false); | |
| 47 | + const [inspectorOpen, setInspectorOpen] = useState(false); | |
| 48 | + useEffect(() => { | |
| 49 | + try { | |
| 50 | + setCollapsed(localStorage.getItem(storageKey) === 'collapsed'); | |
| 51 | + } catch { | |
| 52 | + /* ignore */ | |
| 53 | + } | |
| 54 | + setReady(true); | |
| 55 | + }, [storageKey]); | |
| 56 | + const toggle = useCallback(() => { | |
| 57 | + setCollapsed((c) => { | |
| 58 | + try { | |
| 59 | + localStorage.setItem(storageKey, c ? 'open' : 'collapsed'); | |
| 60 | + } catch { | |
| 61 | + /* ignore */ | |
| 62 | + } | |
| 63 | + return !c; | |
| 64 | + }); | |
| 65 | + }, [storageKey]); | |
| 66 | + const showInspector = !!inspector && (!ready || !collapsed); | |
| 67 | + return ( | |
| 68 | + <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}> | |
| 69 | + {/* mobile toolbar */} | |
| 70 | + {(filters || inspector) && ( | |
| 71 | + <div className="flex items-center gap-2 border-b border-rule py-2 lg:hidden"> | |
| 72 | + {filters && ( | |
| 73 | + <button type="button" onClick={() => setFiltersOpen(true)} className="inline-flex h-10 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" aria-haspopup="dialog" data-open-filters> | |
| 74 | + <SlidersHorizontal className="size-4" aria-hidden /> {filtersTitle} | |
| 75 | + {filterCount ? <span className="tnum ml-1 rounded-sm bg-accent-soft px-1.5 text-xs text-accent">{filterCount}</span> : null} | |
| 76 | + </button> | |
| 77 | + )} | |
| 78 | + {inspector && ( | |
| 79 | + <button type="button" onClick={() => setInspectorOpen(true)} className="ml-auto inline-flex h-10 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" aria-haspopup="dialog" data-open-inspector> | |
| 80 | + <PanelRightOpen className="size-4" aria-hidden /> {inspectorTitle} | |
| 81 | + </button> | |
| 82 | + )} | |
| 83 | + </div> | |
| 84 | + )} | |
| 85 | + <div className={cn('lg:grid lg:gap-8', filters && showInspector ? 'lg:grid-cols-[15rem_minmax(0,1fr)_22rem]' : filters ? 'lg:grid-cols-[15rem_minmax(0,1fr)]' : showInspector ? 'lg:grid-cols-[minmax(0,1fr)_22rem]' : '')}> | |
| 86 | + {filters && ( | |
| 87 | + <aside className="terminal-rail hidden lg:block" aria-label={filtersTitle}> | |
| 88 | + <p className="eyebrow mb-2">{filtersTitle}</p> | |
| 89 | + {filters} | |
| 90 | + </aside> | |
| 91 | + )} | |
| 92 | + <div className="min-w-0"> | |
| 93 | + {inspector && ( | |
| 94 | + <div className="hidden justify-end lg:flex"> | |
| 95 | + <button type="button" onClick={toggle} className="-mt-1 mb-1 inline-flex h-8 items-center gap-1 text-xs text-ink-3 hover:text-ink" aria-pressed={!collapsed} aria-label={collapsed ? `Show ${inspectorTitle.toLowerCase()}` : `Hide ${inspectorTitle.toLowerCase()}`} data-toggle-inspector> | |
| 96 | + {collapsed ? <PanelRightOpen className="size-3.5" aria-hidden /> : <PanelRightClose className="size-3.5" aria-hidden />} | |
| 97 | + {collapsed ? inspectorTitle : 'Hide'} | |
| 98 | + </button> | |
| 99 | + </div> | |
| 100 | + )} | |
| 101 | + {children} | |
| 102 | + </div> | |
| 103 | + {showInspector && ( | |
| 104 | + <aside className="terminal-rail hidden lg:block" aria-label={inspectorTitle}> | |
| 105 | + <p className="eyebrow mb-2">{inspectorTitle}</p> | |
| 106 | + {inspector} | |
| 107 | + </aside> | |
| 108 | + )} | |
| 109 | + </div> | |
| 110 | + {filters && ( | |
| 111 | + <FilterSheet open={filtersOpen} onClose={() => setFiltersOpen(false)} title={filtersTitle}> | |
| 112 | + {filters} | |
| 113 | + </FilterSheet> | |
| 114 | + )} | |
| 115 | + {inspector && ( | |
| 116 | + <Sheet open={inspectorOpen} onClose={() => setInspectorOpen(false)} side="bottom" eyebrow="Inspector" title={inspectorTitle}> | |
| 117 | + {inspector} | |
| 118 | + </Sheet> | |
| 119 | + )} | |
| 120 | + </div> | |
| 121 | + ); | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** Mobile bottom sheet for filters; the filter form is server-rendered by the page and simply moved inside. */ | |
| 125 | +export function FilterSheet({ open, onClose, title = 'Filters', children }: { open: boolean; onClose: () => void; title?: string; children: ReactNode }) { | |
| 126 | + return ( | |
| 127 | + <Sheet open={open} onClose={onClose} side="bottom" eyebrow="Refine" title={title} footer={<button type="button" onClick={onClose} className="h-11 w-full bg-ink text-sm font-medium text-canvas">Done</button>}> | |
| 128 | + {children} | |
| 129 | + </Sheet> | |
| 130 | + ); | |
| 131 | +} | |
| 132 | + | |
| 133 | +/* ---------------------------------------------------------------------------------------------------------- DataStrip */ | |
| 134 | +export type StripItem = { label: string; value: ReactNode; hint?: ReactNode; definition?: string; href?: string; delta?: { value: string; tone?: 'positive' | 'negative' | 'neutral' }; live?: boolean }; | |
| 135 | + | |
| 136 | +/** Horizontal live counters strip: label + big tabular number + optional delta and a "How counted" definition tooltip. */ | |
| 137 | +export function DataStrip({ items, className, dense = false }: { items: StripItem[]; className?: string; dense?: boolean }) { | |
| 138 | + return ( | |
| 139 | + <div className={cn('no-scrollbar -mx-4 overflow-x-auto px-4 md:mx-0 md:px-0', className)} role="list" aria-label="Live counters"> | |
| 140 | + <div className={cn('flex min-w-max border-y border-rule md:grid md:min-w-0', items.length > 4 ? 'md:grid-cols-4 xl:grid-cols-[repeat(var(--n),minmax(0,1fr))]' : 'md:grid-cols-[repeat(var(--n),minmax(0,1fr))]')} style={{ ['--n' as string]: items.length }}> | |
| 141 | + {items.map((it, i) => { | |
| 142 | + const body = ( | |
| 143 | + <> | |
| 144 | + <p className="eyebrow flex flex-wrap items-center gap-0.5 leading-tight"> | |
| 145 | + {it.live && <span className="dot mr-1" aria-hidden />} | |
| 146 | + {it.label} | |
| 147 | + {it.definition && <Hint text={it.definition} align={i >= items.length - 2 ? 'right' : 'left'} />} | |
| 148 | + </p> | |
| 149 | + <p className={cn('tnum mt-0.5 font-semibold leading-none tracking-tight text-ink', dense ? 'text-[20px]' : 'text-[22px] md:text-[26px]')}>{it.value}</p> | |
| 150 | + {(it.delta || it.hint) && ( | |
| 151 | + <p className="mt-1 flex items-center gap-1.5 whitespace-nowrap text-[11px] text-ink-3"> | |
| 152 | + {it.delta && <span className={cn('tnum font-medium', it.delta.tone === 'positive' && 'text-positive', it.delta.tone === 'negative' && 'text-danger', (!it.delta.tone || it.delta.tone === 'neutral') && 'text-accent-2')}>{it.delta.value}</span>} | |
| 153 | + {it.hint} | |
| 154 | + </p> | |
| 155 | + )} | |
| 156 | + </> | |
| 157 | + ); | |
| 158 | + const cls = cn('block min-w-[9rem] border-r border-rule px-3 py-2.5 last:border-r-0 md:min-w-0 md:[&:nth-child(4n)]:border-r-0 md:[&:nth-child(4n+1)]:pl-0 md:[&:nth-child(n+5)]:border-t xl:[&:nth-child(4n)]:border-r xl:[&:nth-child(4n+1)]:pl-3 xl:[&:nth-child(n+5)]:border-t-0 xl:first:pl-0 xl:last:border-r-0', items.length <= 4 && 'md:first:pl-0', it.href && 'hover:bg-surface-2'); | |
| 159 | + return it.href ? ( | |
| 160 | + <Link key={it.label} href={it.href} className={cls} role="listitem"> | |
| 161 | + {body} | |
| 162 | + </Link> | |
| 163 | + ) : ( | |
| 164 | + <div key={it.label} className={cls} role="listitem"> | |
| 165 | + {body} | |
| 166 | + </div> | |
| 167 | + ); | |
| 168 | + })} | |
| 169 | + </div> | |
| 170 | + </div> | |
| 171 | + ); | |
| 172 | +} | |
| 173 | + | |
| 174 | +/* ------------------------------------------------------------------------------------------------------------- Ticker */ | |
| 175 | +export type TickerItem = { id: string; label: ReactNode; href?: string; tone?: 'new' | 'price' | 'warn' | 'danger' | 'bench' | 'neutral'; meta?: ReactNode }; | |
| 176 | +const TICK_TONE: Record<NonNullable<TickerItem['tone']>, string> = { new: 'text-positive', price: 'text-accent-2', warn: 'text-warning', danger: 'text-danger', bench: 'text-type-benchmark', neutral: 'text-ink-3' }; | |
| 177 | + | |
| 178 | +/** Horizontal scrolling strip of recent events (duplicated track for a seamless loop). Pausable; static + scrollable under reduced motion. */ | |
| 179 | +export function Ticker({ items, className, speed = 60, label = 'Live' }: { items: TickerItem[]; className?: string; /** seconds per loop */ speed?: number; label?: string }) { | |
| 180 | + const [paused, setPaused] = useState(false); | |
| 181 | + const [reduced, setReduced] = useState(false); | |
| 182 | + useEffect(() => { | |
| 183 | + const m = window.matchMedia('(prefers-reduced-motion: reduce)'); | |
| 184 | + setReduced(m.matches); | |
| 185 | + const on = () => setReduced(m.matches); | |
| 186 | + m.addEventListener('change', on); | |
| 187 | + return () => m.removeEventListener('change', on); | |
| 188 | + }, []); | |
| 189 | + if (!items.length) return null; | |
| 190 | + const track = (dup: boolean) => ( | |
| 191 | + <ul className={cn('flex shrink-0 items-center gap-10 whitespace-nowrap text-xs', dup && 'aria-hidden')} aria-hidden={dup || undefined}> | |
| 192 | + {items.map((it) => ( | |
| 193 | + <li key={`${dup ? 'd' : 'o'}-${it.id}`} className="flex items-center gap-2"> | |
| 194 | + <span className={cn('inline-block size-1.5 rounded-full bg-current', TICK_TONE[it.tone ?? 'neutral'])} aria-hidden /> | |
| 195 | + {it.href ? ( | |
| 196 | + <Link href={it.href} className="text-ink-2 hover:text-ink hover:underline" tabIndex={dup ? -1 : 0}> | |
| 197 | + {it.label} | |
| 198 | + </Link> | |
| 199 | + ) : ( | |
| 200 | + <span className="text-ink-2">{it.label}</span> | |
| 201 | + )} | |
| 202 | + {it.meta && <span className="mono text-[10px] text-ink-3">{it.meta}</span>} | |
| 203 | + </li> | |
| 204 | + ))} | |
| 205 | + </ul> | |
| 206 | + ); | |
| 207 | + return ( | |
| 208 | + <div className={cn('flex items-center gap-3 border-y border-rule py-1.5', className)} aria-label={label} data-ticker> | |
| 209 | + <span className="eyebrow flex shrink-0 items-center gap-1.5"> | |
| 210 | + <span className={cn('dot', !paused && !reduced && 'pulse')} aria-hidden /> {label} | |
| 211 | + </span> | |
| 212 | + <div className={cn('ticker min-w-0 flex-1', reduced && 'no-scrollbar overflow-x-auto')} data-paused={paused || reduced}> | |
| 213 | + <div className={cn(reduced ? 'flex w-max gap-10' : 'ticker-track')} style={{ ['--ticker-duration' as string]: `${Math.max(20, speed * Math.max(1, items.length / 8))}s` }}> | |
| 214 | + {track(false)} | |
| 215 | + {!reduced && track(true)} | |
| 216 | + </div> | |
| 217 | + </div> | |
| 218 | + {!reduced && ( | |
| 219 | + <button type="button" onClick={() => setPaused((p) => !p)} className="flex size-8 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-pressed={paused} aria-label={paused ? 'Resume ticker' : 'Pause ticker'}> | |
| 220 | + {paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />} | |
| 221 | + </button> | |
| 222 | + )} | |
| 223 | + </div> | |
| 224 | + ); | |
| 225 | +} | |
| 226 | + | |
| 227 | +/* --------------------------------------------------------------------------------------------------------- SectionNav */ | |
| 228 | +/** Sticky in-page anchor nav; the section currently in view is highlighted (IntersectionObserver). */ | |
| 229 | +export function SectionNav({ items, className }: { items: { id: string; label: string }[]; className?: string }) { | |
| 230 | + const [activeId, setActiveId] = useState<string | null>(null); | |
| 231 | + const seen = useRef<Map<string, boolean>>(new Map()); | |
| 232 | + useEffect(() => { | |
| 233 | + const els = items.map((i) => document.getElementById(i.id)).filter((x): x is HTMLElement => !!x); | |
| 234 | + if (!els.length) return; | |
| 235 | + const io = new IntersectionObserver( | |
| 236 | + (entries) => { | |
| 237 | + for (const e of entries) seen.current.set(e.target.id, e.isIntersecting); | |
| 238 | + const first = items.find((i) => seen.current.get(i.id)); | |
| 239 | + if (first) setActiveId(first.id); | |
| 240 | + }, | |
| 241 | + { rootMargin: '-30% 0px -60% 0px', threshold: 0 }, | |
| 242 | + ); | |
| 243 | + els.forEach((el) => io.observe(el)); | |
| 244 | + return () => io.disconnect(); | |
| 245 | + }, [items]); | |
| 246 | + return ( | |
| 247 | + <nav aria-label="On this page" className={cn('no-scrollbar sticky top-[var(--header-h)] z-30 -mx-4 overflow-x-auto border-b border-rule bg-canvas/90 px-4 backdrop-blur-md md:mx-0 md:px-0', className)} data-section-nav> | |
| 248 | + <ul className="flex min-w-max gap-1"> | |
| 249 | + {items.map((i) => ( | |
| 250 | + <li key={i.id}> | |
| 251 | + <a href={`#${i.id}`} aria-current={activeId === i.id ? 'location' : undefined} className={cn('flex h-10 items-center border-b-2 px-2.5 text-[13px] whitespace-nowrap', activeId === i.id ? 'border-ink text-ink font-medium' : 'border-transparent text-ink-2 hover:text-ink')}> | |
| 252 | + {i.label} | |
| 253 | + </a> | |
| 254 | + </li> | |
| 255 | + ))} | |
| 256 | + </ul> | |
| 257 | + </nav> | |
| 258 | + ); | |
| 259 | +} | |
modified
apps/web/src/components/layout/theme.tsx
+7 −4
@@ -2,12 +2,15 @@ | ||
| 2 | 2 | import { Monitor, Moon, Sun } from 'lucide-react'; |
| 3 | 3 | import { useEffect, useState } from 'react'; |
| 4 | 4 | import { cn } from '@/lib/cn'; |
| 5 | +import { THEME_DARK, THEME_LIGHT } from '@/lib/site'; | |
| 6 | + | |
| 7 | +import { THEME_KEY } from '@/lib/prepaint'; | |
| 5 | 8 | |
| 6 | 9 | export type ThemePref = 'light' | 'dark' | 'system'; |
| 7 | −const KEY = 'aia-theme'; | |
| 10 | +const KEY = THEME_KEY; | |
| 8 | 11 | |
| 9 | −/** Inline in <head> before paint: applies the persisted preference (or the system scheme) to <html data-theme>. */ | |
| 10 | −export const THEME_SCRIPT = `(function(){try{var k='${KEY}',p=localStorage.getItem(k),m=window.matchMedia('(prefers-color-scheme: dark)'),t=(p==='light'||p==='dark')?p:(m.matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;}catch(e){}})();`; | |
| 12 | +/** Inline in <head> before paint — the string itself lives in `lib/prepaint.ts` (server-safe); import it from there in server code. */ | |
| 13 | +export { THEME_SCRIPT } from '@/lib/prepaint'; | |
| 11 | 14 | |
| 12 | 15 | function apply(pref: ThemePref) { |
| 13 | 16 | const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
@@ -15,7 +18,7 @@ function apply(pref: ThemePref) { | ||
| 15 | 18 | document.documentElement.setAttribute('data-theme', t); |
| 16 | 19 | document.documentElement.style.colorScheme = t; |
| 17 | 20 | const meta = document.querySelector('meta[name="theme-color"]'); |
| 18 | − if (meta) meta.setAttribute('content', t === 'dark' ? '#0b0d11' : '#f6f6f3'); | |
| 21 | + if (meta) meta.setAttribute('content', t === 'dark' ? THEME_DARK : THEME_LIGHT); | |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | export function useTheme(): [ThemePref, (p: ThemePref) => void] { |
added
apps/web/src/components/ui/hint.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { Info } from 'lucide-react'; | |
| 2 | +import type { ReactNode } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Pure-CSS tooltip (server-safe): an info glyph (or any trigger) that reveals a definition bubble on hover / focus. | |
| 7 | + * Used by `DataStrip` for "How counted" definitions. The bubble is also readable by screen readers through `aria-describedby`. | |
| 8 | + */ | |
| 9 | +export function Hint({ text, children, className, align = 'left', id }: { text: ReactNode; children?: ReactNode; className?: string; align?: 'left' | 'right'; id?: string }) { | |
| 10 | + const bubbleId = id ?? `hint-${hash(String(typeof text === 'string' ? text : 'hint'))}`; | |
| 11 | + return ( | |
| 12 | + <span className={cn('hint', align === 'right' && 'hint-right', className)}> | |
| 13 | + <button type="button" className="inline-flex size-6 items-center justify-center rounded-sm text-ink-3 hover:text-ink focus-visible:text-ink" aria-describedby={bubbleId} aria-label="Definition"> | |
| 14 | + {children ?? <Info className="size-3.5" aria-hidden />} | |
| 15 | + </button> | |
| 16 | + <span role="tooltip" id={bubbleId} className="hint-bubble"> | |
| 17 | + {text} | |
| 18 | + </span> | |
| 19 | + </span> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function hash(s: string): string { | |
| 24 | + let h = 0; | |
| 25 | + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; | |
| 26 | + return Math.abs(h).toString(36); | |
| 27 | +} | |
modified
apps/web/src/components/ui/key-value.tsx
+37 −5
@@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { ReactNode } from 'react'; |
| 2 | +import { Evidence } from '@/components/evidence/evidence'; | |
| 2 | 3 | import { cn } from '@/lib/cn'; |
| 3 | 4 | import { fmtValue } from '@/lib/format'; |
| 4 | 5 | import { propertyLabel, URL_KEYS } from '@/lib/site'; |
@@ -6,13 +7,31 @@ import type { Provenance } from '@/lib/types'; | ||
| 6 | 7 | import { ProvenanceInline } from './provenance'; |
| 7 | 8 | import { Missing } from './unavailable'; |
| 8 | 9 | |
| 9 | −export type KVRow = { key: string; label?: ReactNode; value?: ReactNode; raw?: unknown; hint?: ReactNode }; | |
| 10 | +export type KVRow = { key: string; label?: ReactNode; value?: ReactNode; raw?: unknown; hint?: ReactNode; unit?: string | null }; | |
| 10 | 11 | |
| 11 | 12 | /** |
| 12 | 13 | * Dense spec list (<dl>): label column + value + optional per-field provenance. Pass `provenance` (from EntityDetail) |
| 13 | 14 | * to render "Source · tier · observed" under each value when known. Values fall back to `fmtValue(raw, key)`. |
| 15 | + * Pass `slug` (+ optional `entity`) to make every value with provenance an **evidence trigger** that opens the drawer | |
| 16 | + * (`components/evidence`). Existing call sites without `slug` render exactly as before. | |
| 14 | 17 | */ |
| 15 | −export function KeyValue({ rows, provenance, className, dense = false, showMissing = false }: { rows: KVRow[]; provenance?: Provenance; className?: string; dense?: boolean; showMissing?: boolean }) { | |
| 18 | +export function KeyValue({ | |
| 19 | + rows, | |
| 20 | + provenance, | |
| 21 | + className, | |
| 22 | + dense = false, | |
| 23 | + showMissing = false, | |
| 24 | + slug, | |
| 25 | + entity, | |
| 26 | +}: { | |
| 27 | + rows: KVRow[]; | |
| 28 | + provenance?: Provenance; | |
| 29 | + className?: string; | |
| 30 | + dense?: boolean; | |
| 31 | + showMissing?: boolean; | |
| 32 | + slug?: string; | |
| 33 | + entity?: { name?: string; entity_type?: string } | null; | |
| 34 | +}) { | |
| 16 | 35 | const shown = rows.filter((r) => showMissing || r.value !== undefined || (r.raw !== undefined && r.raw !== null && r.raw !== '')); |
| 17 | 36 | if (shown.length === 0) return <p className="text-sm text-ink-3">No structured attributes yet.</p>; |
| 18 | 37 | return ( |
@@ -21,6 +40,7 @@ export function KeyValue({ rows, provenance, className, dense = false, showMissi | ||
| 21 | 40 | const p = provenance?.[r.key]; |
| 22 | 41 | const raw = r.raw; |
| 23 | 42 | let value: ReactNode = r.value; |
| 43 | + let display: string | undefined; | |
| 24 | 44 | if (value === undefined) { |
| 25 | 45 | if (raw === null || raw === undefined || raw === '') value = <Missing />; |
| 26 | 46 | else if (URL_KEYS.has(r.key) && typeof raw === 'string' && /^https?:\/\//.test(raw)) |
@@ -29,15 +49,27 @@ export function KeyValue({ rows, provenance, className, dense = false, showMissi | ||
| 29 | 49 | {raw.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')} |
| 30 | 50 | </a> |
| 31 | 51 | ); |
| 32 | − else value = <span className={typeof raw === 'number' ? 'tnum' : undefined}>{fmtValue(raw, r.key)}</span>; | |
| 52 | + else { | |
| 53 | + display = fmtValue(raw, r.key); | |
| 54 | + value = <span className={typeof raw === 'number' ? 'tnum' : undefined}>{display}</span>; | |
| 55 | + } | |
| 33 | 56 | } |
| 57 | + const canOpen = !!slug && !!p && raw !== null && raw !== undefined && raw !== '' && !(URL_KEYS.has(r.key) && typeof raw === 'string'); | |
| 34 | 58 | return ( |
| 35 | 59 | <div key={r.key}> |
| 36 | 60 | <dt>{r.label ?? propertyLabel(r.key)}</dt> |
| 37 | 61 | <dd> |
| 38 | − <div className="text-ink">{value}</div> | |
| 62 | + <div className="text-ink"> | |
| 63 | + {canOpen ? ( | |
| 64 | + <Evidence slug={slug as string} property={r.key} value={raw} display={display} unit={r.unit ?? p?.unit ?? null} fallback={p} entity={entity ?? null}> | |
| 65 | + {value} | |
| 66 | + </Evidence> | |
| 67 | + ) : ( | |
| 68 | + value | |
| 69 | + )} | |
| 70 | + </div> | |
| 39 | 71 | {r.hint && <div className="mt-0.5 text-xs text-ink-3">{r.hint}</div>} |
| 40 | − {p && <ProvenanceInline p={p} className="mt-0.5" />} | |
| 72 | + {p && <ProvenanceInline p={p} className="mt-0.5" slug={slug} property={r.key} value={raw} entity={entity ?? null} />} | |
| 41 | 73 | </dd> |
| 42 | 74 | </div> |
| 43 | 75 | ); |
modified
apps/web/src/components/ui/provenance.tsx
+30 −2
@@ -1,3 +1,4 @@ | ||
| 1 | +import { Evidence } from '@/components/evidence/evidence'; | |
| 1 | 2 | import { cn } from '@/lib/cn'; |
| 2 | 3 | import { fmtAgo } from '@/lib/format'; |
| 3 | 4 | import type { ProvenanceEntry } from '@/lib/types'; |
@@ -12,8 +13,27 @@ function domain(url: string | null | undefined): string | null { | ||
| 12 | 13 | } |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | −/** One-line provenance: "Source: docs.claude.com · T1 · observed 3 h ago · high". Server-safe. */ | |
| 16 | −export function ProvenanceInline({ p, className, showConfidence = true }: { p: ProvenanceEntry; className?: string; showConfidence?: boolean }) { | |
| 16 | +/** | |
| 17 | + * One-line provenance: "Source: docs.claude.com · T1 · observed 3 h ago · high". Server-safe. | |
| 18 | + * With `slug` + `property` it appends an "Evidence" trigger that opens the drawer (`components/evidence`). | |
| 19 | + */ | |
| 20 | +export function ProvenanceInline({ | |
| 21 | + p, | |
| 22 | + className, | |
| 23 | + showConfidence = true, | |
| 24 | + slug, | |
| 25 | + property, | |
| 26 | + value, | |
| 27 | + entity, | |
| 28 | +}: { | |
| 29 | + p: ProvenanceEntry; | |
| 30 | + className?: string; | |
| 31 | + showConfidence?: boolean; | |
| 32 | + slug?: string; | |
| 33 | + property?: string; | |
| 34 | + value?: unknown; | |
| 35 | + entity?: { name?: string; entity_type?: string } | null; | |
| 36 | +}) { | |
| 17 | 37 | const name = p.source_name ?? domain(p.url) ?? 'source'; |
| 18 | 38 | return ( |
| 19 | 39 | <p className={cn('flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-4 text-ink-3', className)}> |
@@ -41,6 +61,14 @@ export function ProvenanceInline({ p, className, showConfidence = true }: { p: P | ||
| 41 | 61 | <span title="Extracted by the local LLM factory">LLM-extracted</span> |
| 42 | 62 | </> |
| 43 | 63 | )} |
| 64 | + {slug && property && ( | |
| 65 | + <> | |
| 66 | + <span aria-hidden>·</span> | |
| 67 | + <Evidence slug={slug} property={property} value={value} unit={p.unit ?? null} fallback={p} entity={entity ?? null} className="text-accent"> | |
| 68 | + Evidence | |
| 69 | + </Evidence> | |
| 70 | + </> | |
| 71 | + )} | |
| 44 | 72 | </p> |
| 45 | 73 | ); |
| 46 | 74 | } |
modified
apps/web/src/components/ui/section.tsx
+1 −1
@@ -28,7 +28,7 @@ export function Section({ | ||
| 28 | 28 | hairline?: boolean; |
| 29 | 29 | }) { |
| 30 | 30 | return ( |
| 31 | − <section id={id} className={cn('scroll-mt-20 py-7 md:py-10', hairline && 'hairline', className)}> | |
| 31 | + <section id={id} className={cn('section-y scroll-mt-20', hairline && 'hairline', className)}> | |
| 32 | 32 | {(eyebrow || title) && ( |
| 33 | 33 | <div className="mb-4 flex items-end justify-between gap-4 md:mb-5"> |
| 34 | 34 | <div className="min-w-0"> |
added
apps/web/src/components/ui/sheet.tsx
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { X } from 'lucide-react'; | |
| 3 | +import { type ReactNode, useEffect, useRef } from 'react'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Sheet / drawer primitive shared by the evidence drawer, the terminal filter/inspector sheets and the mobile "More" menu. | |
| 8 | + * `side="auto"` (default): right-side panel ≥ lg, bottom sheet below. Focus trap, Esc, backdrop click, body scroll lock, | |
| 9 | + * `aria-modal`. Renders nothing when closed (server/client markup match: mount it always, drive `open`). | |
| 10 | + */ | |
| 11 | +export function Sheet({ | |
| 12 | + open, | |
| 13 | + onClose, | |
| 14 | + title, | |
| 15 | + eyebrow, | |
| 16 | + children, | |
| 17 | + side = 'auto', | |
| 18 | + width = 'lg:w-[26rem]', | |
| 19 | + className, | |
| 20 | + footer, | |
| 21 | + labelledBy, | |
| 22 | + id, | |
| 23 | +}: { | |
| 24 | + open: boolean; | |
| 25 | + onClose: () => void; | |
| 26 | + title?: ReactNode; | |
| 27 | + eyebrow?: ReactNode; | |
| 28 | + children: ReactNode; | |
| 29 | + side?: 'auto' | 'right' | 'bottom'; | |
| 30 | + /** Tailwind width class for the right-side panel (`lg:w-[26rem]` for `auto`, `w-[…]` when `side="right"`). */ | |
| 31 | + width?: string; | |
| 32 | + className?: string; | |
| 33 | + footer?: ReactNode; | |
| 34 | + labelledBy?: string; | |
| 35 | + id?: string; | |
| 36 | +}) { | |
| 37 | + const panel = useRef<HTMLDivElement>(null); | |
| 38 | + const closeBtn = useRef<HTMLButtonElement>(null); | |
| 39 | + const restore = useRef<HTMLElement | null>(null); | |
| 40 | + | |
| 41 | + useEffect(() => { | |
| 42 | + if (!open) return; | |
| 43 | + restore.current = document.activeElement as HTMLElement | null; | |
| 44 | + const prevOverflow = document.body.style.overflow; | |
| 45 | + document.body.style.overflow = 'hidden'; | |
| 46 | + const t = setTimeout(() => closeBtn.current?.focus(), 20); | |
| 47 | + const onKey = (e: KeyboardEvent) => { | |
| 48 | + if (e.key === 'Escape') { | |
| 49 | + e.stopPropagation(); | |
| 50 | + onClose(); | |
| 51 | + } else if (e.key === 'Tab' && panel.current) { | |
| 52 | + const nodes = panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'); | |
| 53 | + if (!nodes.length) return; | |
| 54 | + const first = nodes[0] as HTMLElement; | |
| 55 | + const last = nodes[nodes.length - 1] as HTMLElement; | |
| 56 | + if (e.shiftKey && document.activeElement === first) { | |
| 57 | + e.preventDefault(); | |
| 58 | + last.focus(); | |
| 59 | + } else if (!e.shiftKey && document.activeElement === last) { | |
| 60 | + e.preventDefault(); | |
| 61 | + first.focus(); | |
| 62 | + } | |
| 63 | + } | |
| 64 | + }; | |
| 65 | + document.addEventListener('keydown', onKey); | |
| 66 | + return () => { | |
| 67 | + clearTimeout(t); | |
| 68 | + document.removeEventListener('keydown', onKey); | |
| 69 | + document.body.style.overflow = prevOverflow; | |
| 70 | + restore.current?.focus?.(); | |
| 71 | + }; | |
| 72 | + }, [open, onClose]); | |
| 73 | + | |
| 74 | + if (!open) return null; | |
| 75 | + const right = side === 'right'; | |
| 76 | + const bottom = side === 'bottom'; | |
| 77 | + return ( | |
| 78 | + <div className="fixed inset-0 z-[90] flex bg-black/45 backdrop-blur-[1px]" onClick={onClose} role="presentation"> | |
| 79 | + <div | |
| 80 | + ref={panel} | |
| 81 | + id={id} | |
| 82 | + role="dialog" | |
| 83 | + aria-modal="true" | |
| 84 | + aria-labelledby={labelledBy} | |
| 85 | + onClick={(e) => e.stopPropagation()} | |
| 86 | + className={cn( | |
| 87 | + 'sheet-enter panel flex flex-col overflow-hidden shadow-2xl', | |
| 88 | + // bottom sheet (mobile / forced) | |
| 89 | + !right && 'absolute inset-x-0 bottom-0 max-h-[86dvh] rounded-b-none rounded-t-lg', | |
| 90 | + // right drawer ≥ lg (auto) or forced | |
| 91 | + side === 'auto' && `lg:inset-y-0 lg:right-0 lg:left-auto lg:h-full lg:max-h-none lg:rounded-none lg:border-y-0 lg:border-r-0 ${width} lg:max-w-[92vw]`, | |
| 92 | + right && `absolute inset-y-0 right-0 h-full max-w-[92vw] rounded-none border-y-0 border-r-0 ${width}`, | |
| 93 | + bottom && 'lg:max-h-[86dvh]', | |
| 94 | + className, | |
| 95 | + )} | |
| 96 | + > | |
| 97 | + <div className="flex items-start gap-3 border-b border-rule px-4 py-3"> | |
| 98 | + <div className="min-w-0 flex-1"> | |
| 99 | + {eyebrow && <p className="eyebrow">{eyebrow}</p>} | |
| 100 | + {title && <h2 className="mt-0.5 truncate text-[15px] font-semibold text-ink">{title}</h2>} | |
| 101 | + </div> | |
| 102 | + <button ref={closeBtn} type="button" onClick={onClose} className="-mr-1 flex size-11 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close"> | |
| 103 | + <X className="size-5" aria-hidden /> | |
| 104 | + </button> | |
| 105 | + </div> | |
| 106 | + <div className="scrollbar-thin flex-1 overflow-y-auto px-4 py-3">{children}</div> | |
| 107 | + {footer && <div className="safe-bottom border-t border-rule px-4 py-3">{footer}</div>} | |
| 108 | + </div> | |
| 109 | + </div> | |
| 110 | + ); | |
| 111 | +} | |
added
apps/web/src/components/watchlist/watch-button.tsx
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Star } from 'lucide-react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { toWatchItem, useWatchlist, WATCH_MAX } from '@/lib/watchlist'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Star toggle that adds/removes an entity from the local watchlist (`lib/watchlist.ts`). `aria-pressed` reflects the | |
| 8 | + * state; renders a neutral placeholder before hydration so server and client markup match. Sits next to `CompareButton`. | |
| 9 | + */ | |
| 10 | +export function WatchButton({ e, size = 'md', className, label = 'Watch' }: { e: { slug: string; name: string; entity_type: string; organization?: { name: string } | string | null }; size?: 'sm' | 'md'; className?: string; label?: string }) { | |
| 11 | + const wl = useWatchlist(); | |
| 12 | + const item = toWatchItem(e); | |
| 13 | + const on = wl.ready && wl.has(item.slug); | |
| 14 | + const full = wl.ready && wl.full && !on; | |
| 15 | + return ( | |
| 16 | + <button | |
| 17 | + type="button" | |
| 18 | + onClick={() => { | |
| 19 | + if (full) return; | |
| 20 | + wl.toggle(item); | |
| 21 | + }} | |
| 22 | + aria-pressed={on} | |
| 23 | + disabled={full} | |
| 24 | + title={on ? 'Remove from watchlist' : full ? `The watchlist holds at most ${WATCH_MAX} entities` : 'Add to watchlist (stored in this browser)'} | |
| 25 | + data-watch-button | |
| 26 | + className={cn( | |
| 27 | + 'inline-flex shrink-0 items-center gap-1 border text-xs font-medium whitespace-nowrap transition-colors disabled:cursor-not-allowed disabled:opacity-40', | |
| 28 | + size === 'sm' ? 'h-7 px-1.5' : 'h-9 px-2.5 text-sm', | |
| 29 | + on ? 'border-accent-2 bg-accent-2-soft text-accent-2' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink', | |
| 30 | + className, | |
| 31 | + )} | |
| 32 | + > | |
| 33 | + <Star className={cn(size === 'sm' ? 'size-3' : 'size-3.5', on && 'fill-current')} aria-hidden /> | |
| 34 | + <span>{on ? 'Watching' : label}</span> | |
| 35 | + </button> | |
| 36 | + ); | |
| 37 | +} | |
added
apps/web/src/components/watchlist/watchlist-view.tsx
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Star, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useMemo, useState } from 'react'; | |
| 5 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 6 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 7 | +import { EmptyState } from '@/components/ui/unavailable'; | |
| 8 | +import { clientApi } from '@/lib/client-api'; | |
| 9 | +import { routes } from '@/lib/site'; | |
| 10 | +import type { ChangeEvent } from '@/lib/types'; | |
| 11 | +import { useWatchlist, WATCH_EVENT_TYPES, WATCH_MAX } from '@/lib/watchlist'; | |
| 12 | + | |
| 13 | +type Feed = { status: 'idle' | 'loading' | 'ready'; events: ChangeEvent[]; failed: string[] }; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * `/watchlist` body: the watched entities (remove buttons) and their merged event feed — `/changes?entity=<slug>&limit=20` | |
| 17 | + * per item (≤ 12 items), merged, sorted by `effective_at ?? observed_at`, filtered to release / price / deprecation / | |
| 18 | + * benchmark / provider / licence event types. Nothing is stored server-side. | |
| 19 | + */ | |
| 20 | +export function WatchlistView() { | |
| 21 | + const wl = useWatchlist(); | |
| 22 | + const [feed, setFeed] = useState<Feed>({ status: 'idle', events: [], failed: [] }); | |
| 23 | + const slugs = useMemo(() => wl.items.map((i) => i.slug).slice(0, WATCH_MAX), [wl.items]); | |
| 24 | + const key = slugs.join(','); | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + if (!wl.ready) return; | |
| 28 | + if (!slugs.length) { | |
| 29 | + setFeed({ status: 'ready', events: [], failed: [] }); | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + const ctrl = new AbortController(); | |
| 33 | + setFeed((f) => ({ ...f, status: 'loading' })); | |
| 34 | + (async () => { | |
| 35 | + const results = await Promise.all(slugs.map((s) => clientApi.entityChanges(s, 20, ctrl.signal).then((p) => ({ s, items: p.items ?? [] })).catch((e) => ({ s, items: null as ChangeEvent[] | null, e })))); | |
| 36 | + if (ctrl.signal.aborted) return; | |
| 37 | + const seen = new Set<string>(); | |
| 38 | + const events: ChangeEvent[] = []; | |
| 39 | + const failed: string[] = []; | |
| 40 | + for (const r of results) { | |
| 41 | + if (!r.items) { | |
| 42 | + failed.push(r.s); | |
| 43 | + continue; | |
| 44 | + } | |
| 45 | + for (const e of r.items) { | |
| 46 | + if (!WATCH_EVENT_TYPES.has(e.event_type) || seen.has(e.id)) continue; | |
| 47 | + seen.add(e.id); | |
| 48 | + events.push(e); | |
| 49 | + } | |
| 50 | + } | |
| 51 | + events.sort((a, b) => new Date(b.effective_at ?? b.observed_at).getTime() - new Date(a.effective_at ?? a.observed_at).getTime()); | |
| 52 | + setFeed({ status: 'ready', events, failed }); | |
| 53 | + })(); | |
| 54 | + return () => ctrl.abort(); | |
| 55 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 56 | + }, [key, wl.ready]); | |
| 57 | + | |
| 58 | + if (!wl.ready) return <p className="py-8 text-sm text-ink-3">Loading your watchlist…</p>; | |
| 59 | + if (!wl.items.length) | |
| 60 | + return ( | |
| 61 | + <EmptyState title="Nothing watched yet"> | |
| 62 | + Press <span className="inline-flex items-center gap-1 border border-rule px-1.5 py-0.5 text-xs text-ink-2"><Star className="size-3" aria-hidden /> Watch</span> on any model, organization, provider or benchmark page. The list lives in this browser only — no account.{' '} | |
| 63 | + <Link href={routes.models()} className="link">Browse models →</Link> | |
| 64 | + </EmptyState> | |
| 65 | + ); | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <div className="grid gap-10 lg:grid-cols-[18rem_minmax(0,1fr)]"> | |
| 69 | + <div> | |
| 70 | + <div className="flex items-baseline justify-between"> | |
| 71 | + <p className="eyebrow">Watching · {wl.items.length}/{WATCH_MAX}</p> | |
| 72 | + <button type="button" onClick={wl.clear} className="text-xs text-ink-3 hover:text-danger"> | |
| 73 | + Clear all | |
| 74 | + </button> | |
| 75 | + </div> | |
| 76 | + <ul className="mt-2 border-t border-rule" data-watchlist-items> | |
| 77 | + {wl.items.map((i) => ( | |
| 78 | + <li key={i.slug} className="flex items-center gap-2 border-b border-rule py-2"> | |
| 79 | + <EntityBadge type={i.entity_type} small /> | |
| 80 | + <Link href={routes.entity(i)} className="min-w-0 flex-1 truncate text-sm font-medium text-ink hover:text-accent"> | |
| 81 | + {i.name} | |
| 82 | + </Link> | |
| 83 | + {i.org && <span className="hidden truncate text-xs text-ink-3 xl:block">{i.org}</span>} | |
| 84 | + <button type="button" onClick={() => wl.remove(i.slug)} className="flex size-9 shrink-0 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Stop watching ${i.name}`}> | |
| 85 | + <X className="size-4" aria-hidden /> | |
| 86 | + </button> | |
| 87 | + </li> | |
| 88 | + ))} | |
| 89 | + </ul> | |
| 90 | + {wl.items.length > 1 && ( | |
| 91 | + <p className="mt-3 text-xs"> | |
| 92 | + <Link href={routes.compare(wl.items.filter((i) => i.entity_type === wl.items[0]?.entity_type).slice(0, 6).map((i) => i.slug))} className="link"> | |
| 93 | + Compare the watched {wl.items[0]?.entity_type}s → | |
| 94 | + </Link> | |
| 95 | + </p> | |
| 96 | + )} | |
| 97 | + </div> | |
| 98 | + <div> | |
| 99 | + <p className="eyebrow">Relevant events · releases, prices, deprecations, benchmarks, providers, licences</p> | |
| 100 | + {feed.status === 'loading' && !feed.events.length && <p className="mt-3 text-sm text-ink-3">Loading events…</p>} | |
| 101 | + {feed.failed.length > 0 && <p className="mt-2 text-xs text-warning">Events unavailable for {feed.failed.length} {feed.failed.length === 1 ? 'entity' : 'entities'} (API did not answer).</p>} | |
| 102 | + {feed.status === 'ready' && !feed.events.length && feed.failed.length < slugs.length && ( | |
| 103 | + <p className="mt-3 border-y border-rule py-8 text-center text-sm text-ink-3">No release, price, deprecation, benchmark, provider or licence events recorded yet for the watched entities.</p> | |
| 104 | + )} | |
| 105 | + {feed.events.length > 0 && ( | |
| 106 | + <ul className="mt-2 border-t border-rule" data-watchlist-feed> | |
| 107 | + {feed.events.slice(0, 80).map((e) => ( | |
| 108 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 109 | + ))} | |
| 110 | + </ul> | |
| 111 | + )} | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + ); | |
| 115 | +} | |
modified
apps/web/src/lib/api.ts
+11 −0
@@ -13,7 +13,11 @@ import type { | ||
| 13 | 13 | EntityDetail, |
| 14 | 14 | EntitySummary, |
| 15 | 15 | ExploreType, |
| 16 | + FrontierPayload, | |
| 16 | 17 | GraphPayload, |
| 18 | + OpenModelsPayload, | |
| 19 | + ProvenanceDetail, | |
| 20 | + PulsePayload, | |
| 17 | 21 | HardwareFit, |
| 18 | 22 | Health, |
| 19 | 23 | Methodology, |
@@ -162,6 +166,13 @@ export const api = { | ||
| 162 | 166 | methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }), |
| 163 | 167 | trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }), |
| 164 | 168 | sitemap: (type?: string, limit = 5000, offset = 0) => request<SitemapPayload>('/sitemap', { type, limit, offset }, { revalidate: 3600 }), |
| 169 | + | |
| 170 | + // ---- 1.1 additions (endpoints may 404 until the API stream lands — always wrap in `safe()` and provide a fallback) | |
| 171 | + frontier: () => request<FrontierPayload>('/frontier', undefined, { revalidate: 300 }), | |
| 172 | + pulse: () => request<PulsePayload>('/pulse', undefined, { revalidate: 60 }), | |
| 173 | + open: (days = 30, limit = 12) => request<OpenModelsPayload>('/open', { days, limit }, { revalidate: 300 }), | |
| 174 | + provenance: (slug: string, property: string) => request<ProvenanceDetail>(`/entities/${enc(slug)}/provenance/${enc(property)}`, undefined, { revalidate: 300 }), | |
| 175 | + claim: (id: string) => request<Claim & { entity?: EntitySummary }>(`/claims/${enc(id)}`, undefined, { revalidate: 3600 }), | |
| 165 | 176 | }; |
| 166 | 177 | |
| 167 | 178 | /** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */ |
modified
apps/web/src/lib/client-api.ts
+21 −4
@@ -1,17 +1,34 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | /** Browser-side fetches: same origin `/api/v1/*` (Next rewrite → FastAPI). Never import server `api.ts` in client components. */ |
| 3 | −import type { ChangeEvent, Page, SearchPayload, Suggestion } from './types'; | |
| 3 | +import type { ChangeEvent, Claim, Page, ProvenanceDetail, SearchPayload, Suggestion } from './types'; | |
| 4 | + | |
| 5 | +export class ClientApiError extends Error { | |
| 6 | + readonly status: number; | |
| 7 | + constructor(status: number, path: string) { | |
| 8 | + super(`API ${status} on ${path}`); | |
| 9 | + this.name = 'ClientApiError'; | |
| 10 | + this.status = status; | |
| 11 | + } | |
| 12 | +} | |
| 4 | 13 | |
| 5 | 14 | async function get<T>(path: string, signal?: AbortSignal): Promise<T> { |
| 6 | 15 | const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal }); |
| 7 | − if (!res.ok) throw new Error(`API ${res.status} on ${path}`); | |
| 16 | + if (!res.ok) throw new ClientApiError(res.status, path); | |
| 8 | 17 | return (await res.json()) as T; |
| 9 | 18 | } |
| 10 | 19 | |
| 20 | +const enc = encodeURIComponent; | |
| 21 | + | |
| 11 | 22 | export const clientApi = { |
| 12 | − suggest: (q: string, signal?: AbortSignal) => get<{ items: Suggestion[] }>(`/search/suggest?q=${encodeURIComponent(q)}`, signal), | |
| 13 | − search: (q: string, limit = 10, signal?: AbortSignal) => get<SearchPayload>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal), | |
| 23 | + suggest: (q: string, signal?: AbortSignal) => get<{ items: Suggestion[] }>(`/search/suggest?q=${enc(q)}`, signal), | |
| 24 | + search: (q: string, limit = 10, signal?: AbortSignal) => get<SearchPayload>(`/search?q=${enc(q)}&limit=${limit}`, signal), | |
| 14 | 25 | changes: (qs: string, signal?: AbortSignal) => get<Page<ChangeEvent>>(`/changes?${qs}`, signal), |
| 26 | + /** Events of one entity (watchlist): `/changes?entity=<slug>&limit=`. */ | |
| 27 | + entityChanges: (slug: string, limit = 20, signal?: AbortSignal) => get<Page<ChangeEvent>>(`/changes?entity=${enc(slug)}&limit=${limit}`, signal), | |
| 28 | + /** 1.1 — full evidence behind one displayed value. 404 until the API stream lands → callers fall back to the inline provenance. */ | |
| 29 | + provenance: (slug: string, property: string, signal?: AbortSignal) => get<ProvenanceDetail>(`/entities/${enc(slug)}/provenance/${enc(property)}`, signal), | |
| 30 | + /** Claim history of one property (exists today): used by the evidence drawer for the claim id, validity and conflicts. */ | |
| 31 | + history: (slug: string, property: string, signal?: AbortSignal) => get<{ items: Claim[] }>(`/entities/${enc(slug)}/history?property=${enc(property)}`, signal), | |
| 15 | 32 | /** Page-view beacon (1 req/s/IP server-side). Fire-and-forget. */ |
| 16 | 33 | view: (path: string) => |
| 17 | 34 | fetch('/api/v1/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }), keepalive: true }).catch(() => undefined), |
added
apps/web/src/lib/density.ts
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +'use client'; | |
| 2 | +/** | |
| 3 | + * Display density — `comfortable` (default) · `compact` · `dense`. Persisted in `localStorage['aia-density']`, applied | |
| 4 | + * before paint by `DENSITY_SCRIPT` as `<html data-density>` and read by globals.css through CSS variables | |
| 5 | + * (`--d-cell-y` table cell padding, `--d-kv-y` key–value rows, `--d-section-y` section padding, `--d-base` font size). | |
| 6 | + * Same pattern as the theme (`components/layout/theme.tsx`). | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useState } from 'react'; | |
| 9 | +import { DENSITY_KEY } from './prepaint'; | |
| 10 | + | |
| 11 | +export type Density = 'comfortable' | 'compact' | 'dense'; | |
| 12 | +export { DENSITY_KEY, DENSITY_SCRIPT } from './prepaint'; | |
| 13 | +export const DENSITIES: Density[] = ['comfortable', 'compact', 'dense']; | |
| 14 | +export const DENSITY_LABELS: Record<Density, string> = { comfortable: 'Comfortable', compact: 'Compact', dense: 'Dense' }; | |
| 15 | +const EVENT = 'aia-density-change'; | |
| 16 | + | |
| 17 | +export function readDensity(): Density { | |
| 18 | + if (typeof window === 'undefined') return 'comfortable'; | |
| 19 | + const d = window.localStorage.getItem(DENSITY_KEY); | |
| 20 | + return d === 'compact' || d === 'dense' ? d : 'comfortable'; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function applyDensity(d: Density) { | |
| 24 | + if (typeof document === 'undefined') return; | |
| 25 | + if (d === 'comfortable') document.documentElement.removeAttribute('data-density'); | |
| 26 | + else document.documentElement.setAttribute('data-density', d); | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function setDensity(d: Density) { | |
| 30 | + try { | |
| 31 | + if (d === 'comfortable') window.localStorage.removeItem(DENSITY_KEY); | |
| 32 | + else window.localStorage.setItem(DENSITY_KEY, d); | |
| 33 | + } catch { | |
| 34 | + /* storage disabled */ | |
| 35 | + } | |
| 36 | + applyDensity(d); | |
| 37 | + window.dispatchEvent(new CustomEvent(EVENT)); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function nextDensity(d: Density): Density { | |
| 41 | + return DENSITIES[(DENSITIES.indexOf(d) + 1) % DENSITIES.length] as Density; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** React binding: `[density, set, cycle]`; `density` is `comfortable` during SSR/hydration. */ | |
| 45 | +export function useDensity(): [Density, (d: Density) => void, () => void] { | |
| 46 | + const [d, setD] = useState<Density>('comfortable'); | |
| 47 | + useEffect(() => { | |
| 48 | + const sync = () => setD(readDensity()); | |
| 49 | + sync(); | |
| 50 | + applyDensity(readDensity()); | |
| 51 | + window.addEventListener(EVENT, sync); | |
| 52 | + window.addEventListener('storage', sync); | |
| 53 | + return () => { | |
| 54 | + window.removeEventListener(EVENT, sync); | |
| 55 | + window.removeEventListener('storage', sync); | |
| 56 | + }; | |
| 57 | + }, []); | |
| 58 | + const set = useCallback((n: Density) => { | |
| 59 | + setDensity(n); | |
| 60 | + setD(n); | |
| 61 | + }, []); | |
| 62 | + const cycle = useCallback(() => set(nextDensity(readDensity())), [set]); | |
| 63 | + return [d, set, cycle]; | |
| 64 | +} | |
modified
apps/web/src/lib/format.ts
+25 −0
@@ -144,6 +144,31 @@ export function fmtDuration(seconds: Num | undefined | unknown): string { | ||
| 144 | 144 | return `${Math.round(n / 86400)} d`; |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | +/** Signed delta: +3.2% / −1.8% (relative change between two values). Null when either side is missing or the base is 0. */ | |
| 148 | +export function fmtDeltaPct(from: Num | undefined | unknown, to: Num | undefined | unknown, digits = 1): string | null { | |
| 149 | + const a = num(from); | |
| 150 | + const b = num(to); | |
| 151 | + if (a === null || b === null || a === 0) return null; | |
| 152 | + const d = ((b - a) / Math.abs(a)) * 100; | |
| 153 | + const sign = d > 0 ? '+' : d < 0 ? '−' : ''; | |
| 154 | + return `${sign}${Math.abs(d).toFixed(digits)}%`; | |
| 155 | +} | |
| 156 | +/** Signed integer: +12 / −3 / 0. */ | |
| 157 | +export function fmtSigned(v: Num | undefined | unknown): string { | |
| 158 | + const n = num(v); | |
| 159 | + if (n === null) return DASH; | |
| 160 | + return n > 0 ? `+${nf0.format(n)}` : n < 0 ? `−${nf0.format(Math.abs(n))}` : '0'; | |
| 161 | +} | |
| 162 | +/** Host of a URL without `www.` — for compact source labels. */ | |
| 163 | +export function hostOf(url: string | null | undefined): string | null { | |
| 164 | + if (!url) return null; | |
| 165 | + try { | |
| 166 | + return new URL(url).hostname.replace(/^www\./, ''); | |
| 167 | + } catch { | |
| 168 | + return null; | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 147 | 172 | export function titleCase(s: string | null | undefined): string { |
| 148 | 173 | if (!s) return DASH; |
| 149 | 174 | return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); |
added
apps/web/src/lib/prepaint.ts
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +/** | |
| 2 | + * Inline scripts run in <head> before paint (server-safe module: no 'use client', so the root layout can embed the strings). | |
| 3 | + * They apply the persisted theme and density to <html> so the first frame is right. Keys are shared with | |
| 4 | + * `components/layout/theme.tsx` and `lib/density.ts`. | |
| 5 | + */ | |
| 6 | +export const THEME_KEY = 'aia-theme'; | |
| 7 | +export const DENSITY_KEY = 'aia-density'; | |
| 8 | + | |
| 9 | +export const THEME_SCRIPT = `(function(){try{var k='${THEME_KEY}',p=localStorage.getItem(k),m=window.matchMedia('(prefers-color-scheme: dark)'),t=(p==='light'||p==='dark')?p:(m.matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;}catch(e){}})();`; | |
| 10 | + | |
| 11 | +export const DENSITY_SCRIPT = `(function(){try{var d=localStorage.getItem('${DENSITY_KEY}');if(d==='compact'||d==='dense'){document.documentElement.setAttribute('data-density',d);}}catch(e){}})();`; | |
| 12 | + | |
| 13 | +export const PREPAINT_SCRIPT = THEME_SCRIPT + DENSITY_SCRIPT; | |
modified
apps/web/src/lib/site.ts
+130 −15
@@ -2,10 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | export const SITE_NAME = 'AI Atlas'; |
| 4 | 4 | export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.ai-atlas.co').replace(/\/$/, ''); |
| 5 | −export const TAGLINE = 'Explore the entire AI ecosystem'; | |
| 5 | +export const TAGLINE = 'The temporal knowledge graph of the AI ecosystem'; | |
| 6 | 6 | export const DESCRIPTION = |
| 7 | − 'AI Atlas is a continuously updated, source-attributed map of the global AI ecosystem: models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — with full provenance and history.'; | |
| 7 | + 'AI Atlas is a continuously reconstructed, source-attributed, historical map of the global AI ecosystem: models, organizations, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — every number answers who said it, when, where, and how it was extracted.'; | |
| 8 | 8 | export const CONTACT_EMAIL = 'contact@spboucher.ai'; |
| 9 | +export const AUTHOR_NAME = 'Simon-Pierre Boucher'; | |
| 10 | +/** Theme tokens mirrored from globals.css (`--canvas`) for the manifest and `<meta theme-color>` — they cannot read CSS variables. */ | |
| 11 | +export const THEME_LIGHT = '#f6f6f3'; | |
| 12 | +export const THEME_DARK = '#0b0d11'; | |
| 13 | +export const HOST_NAME = 'MacLustr'; | |
| 14 | +export const HOST_URL = 'https://www.maclustr.io'; | |
| 9 | 15 | export const PUBLIC_API_BASE = 'https://www.ai-atlas.co/api/v1'; |
| 10 | 16 | export const BOT_UA = 'AIAtlasBot'; |
| 11 | 17 | |
@@ -29,6 +35,9 @@ export const TYPE_PATH: Record<string, string> = { | ||
| 29 | 35 | mcp_server: 'tools', |
| 30 | 36 | product: 'tools', |
| 31 | 37 | repository: 'repositories', |
| 38 | + model_family: 'families', | |
| 39 | + artifact: 'artifacts', | |
| 40 | + license: 'licenses', | |
| 32 | 41 | }; |
| 33 | 42 | /** URL path segment → API mount + canonical entity types accepted there. */ |
| 34 | 43 | export const PATH_TYPES: Record<string, { label: string; singular: string; types: string[]; api: string }> = { |
@@ -42,6 +51,9 @@ export const PATH_TYPES: Record<string, { label: string; singular: string; types | ||
| 42 | 51 | datasets: { label: 'Datasets', singular: 'Dataset', types: ['dataset'], api: 'datasets' }, |
| 43 | 52 | tools: { label: 'Tools', singular: 'Tool', types: ['tool', 'agent', 'mcp_server', 'product'], api: 'tools' }, |
| 44 | 53 | repositories: { label: 'Repositories', singular: 'Repository', types: ['repository'], api: 'entities' }, |
| 54 | + families: { label: 'Families', singular: 'Family', types: ['model_family'], api: 'families' }, | |
| 55 | + artifacts: { label: 'Artifacts', singular: 'Artifact', types: ['artifact'], api: 'models' }, | |
| 56 | + licenses: { label: 'Licenses', singular: 'License', types: ['license'], api: 'entities' }, | |
| 45 | 57 | }; |
| 46 | 58 | |
| 47 | 59 | export const TYPE_LABELS: Record<string, string> = { |
@@ -78,6 +90,8 @@ export const TYPE_LABELS: Record<string, string> = { | ||
| 78 | 90 | acquisition: 'Acquisition', |
| 79 | 91 | course: 'Course', |
| 80 | 92 | job: 'Job', |
| 93 | + model_family: 'Family', | |
| 94 | + artifact: 'Artifact', | |
| 81 | 95 | }; |
| 82 | 96 | export const TYPE_LABELS_PLURAL: Record<string, string> = { |
| 83 | 97 | model: 'Models', |
@@ -113,6 +127,8 @@ export const TYPE_LABELS_PLURAL: Record<string, string> = { | ||
| 113 | 127 | acquisition: 'Acquisitions', |
| 114 | 128 | course: 'Courses', |
| 115 | 129 | job: 'Jobs', |
| 130 | + model_family: 'Families', | |
| 131 | + artifact: 'Artifacts', | |
| 116 | 132 | }; |
| 117 | 133 | export function typeLabel(t: string, pluralForm = false): string { |
| 118 | 134 | return (pluralForm ? TYPE_LABELS_PLURAL[t] : TYPE_LABELS[t]) ?? t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); |
@@ -122,6 +138,9 @@ export function typeLabel(t: string, pluralForm = false): string { | ||
| 122 | 138 | export const TYPE_COLOR_KEY: Record<string, string> = { |
| 123 | 139 | model: 'model', |
| 124 | 140 | quantization: 'model', |
| 141 | + model_family: 'model', | |
| 142 | + artifact: 'model', | |
| 143 | + license: 'tool', | |
| 125 | 144 | company: 'company', |
| 126 | 145 | organization: 'company', |
| 127 | 146 | lab: 'company', |
@@ -191,9 +210,27 @@ export const routes = { | ||
| 191 | 210 | const s = p.toString(); |
| 192 | 211 | return s ? `/diff?${s}` : '/diff'; |
| 193 | 212 | }, |
| 194 | − graph: (slug: string, depth?: 1 | 2) => `/graph/${encodeURIComponent(slug)}${depth === 2 ? '?depth=2' : ''}`, | |
| 213 | + graph: (slug?: string, depth?: 1 | 2) => (slug ? `/graph/${encodeURIComponent(slug)}${depth === 2 ? '?depth=2' : ''}` : '/graph'), | |
| 195 | 214 | /** Entity page "as of" view (History tab). */ |
| 196 | 215 | entityAsOf: (e: { entity_type: string; slug: string }, date: string) => `${routes.entity(e)}?tab=history&asof=${encodeURIComponent(date)}`, |
| 216 | + /** Entity page History tab, optionally filtered to one property (deep link from the evidence drawer). */ | |
| 217 | + entityHistory: (e: { entity_type: string; slug: string }, property?: string) => `${routes.entity(e)}?tab=history${property ? `&property=${encodeURIComponent(property)}` : ''}`, | |
| 218 | + // ---- 1.1 routes (pages built by the D1–D3 streams; the shell links to them from here) | |
| 219 | + frontier: () => '/frontier', | |
| 220 | + pulse: () => '/pulse', | |
| 221 | + open: () => '/open', | |
| 222 | + families: () => '/families', | |
| 223 | + family: (slug: string) => `/families/${encodeURIComponent(slug)}`, | |
| 224 | + timeMachine: (date?: string) => (date ? `/time-machine?date=${encodeURIComponent(date)}` : '/time-machine'), | |
| 225 | + calculator: () => '/calculator', | |
| 226 | + runLocally: () => '/run-locally', | |
| 227 | + findAModel: () => '/find-a-model', | |
| 228 | + artifact: (slug: string) => `/artifacts/${encodeURIComponent(slug)}`, | |
| 229 | + agents: () => '/agents', | |
| 230 | + licenses: () => '/licenses', | |
| 231 | + claim: (id: string) => `/claims/${encodeURIComponent(id)}`, | |
| 232 | + watchlist: () => '/watchlist', | |
| 233 | + benchmarkMatrix: () => '/benchmarks?view=matrix', | |
| 197 | 234 | admin: (section?: string) => (section ? `/admin/${section}` : '/admin'), |
| 198 | 235 | explore: () => '/explore', |
| 199 | 236 | exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`, |
@@ -224,22 +261,100 @@ export const exploreNav = [ | ||
| 224 | 261 | { href: '/datasets', label: 'Datasets', type: 'dataset' }, |
| 225 | 262 | { href: '/tools', label: 'Tools', type: 'tool' }, |
| 226 | 263 | ]; |
| 264 | +/** Desktop header: `AI Atlas | Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾`. */ | |
| 227 | 265 | export const primaryNav = [ |
| 228 | − { href: '/changes', label: 'Changes' }, | |
| 229 | − { href: '/timeline', label: 'Timeline' }, | |
| 266 | + { href: '/models', label: 'Models' }, | |
| 267 | + { href: '/frontier', label: 'Frontier' }, | |
| 268 | + { href: '/benchmarks', label: 'Benchmarks' }, | |
| 230 | 269 | { href: '/prices', label: 'Prices' }, |
| 231 | − { href: '/compare', label: 'Compare' }, | |
| 232 | − { href: '/developers', label: 'Developers' }, | |
| 270 | + { href: '/papers', label: 'Research' }, | |
| 271 | + { href: '/graph', label: 'Graph' }, | |
| 272 | + { href: '/changes', label: 'Changes' }, | |
| 233 | 273 | ]; |
| 234 | −export const moreNav = [ | |
| 235 | − { href: '/about', label: 'About' }, | |
| 236 | − { href: '/methodology', label: 'Methodology' }, | |
| 237 | − { href: '/sources', label: 'Sources' }, | |
| 238 | − { href: '/diff', label: 'Diff two dates' }, | |
| 239 | − { href: '/hardware/fit', label: 'Hardware fit' }, | |
| 240 | − { href: '/developers', label: 'API & Developers' }, | |
| 241 | − { href: '/bot', label: 'AIAtlasBot' }, | |
| 274 | +export type NavItem = { href: string; label: string; hint?: string; type?: string }; | |
| 275 | +export type NavGroup = { label: string; items: NavItem[] }; | |
| 276 | +/** "More ▾" panel (desktop) and the mobile "More" sheet: the full site, grouped. */ | |
| 277 | +export const moreGroups: NavGroup[] = [ | |
| 278 | + { | |
| 279 | + label: 'Explore', | |
| 280 | + items: [ | |
| 281 | + { href: '/companies', label: 'Organizations', type: 'company' }, | |
| 282 | + { href: '/providers', label: 'Providers', type: 'provider' }, | |
| 283 | + { href: '/hardware', label: 'Hardware', type: 'hardware' }, | |
| 284 | + { href: '/datasets', label: 'Datasets', type: 'dataset' }, | |
| 285 | + { href: '/frameworks', label: 'Frameworks', type: 'framework' }, | |
| 286 | + { href: '/agents', label: 'Agents & Tools', type: 'tool' }, | |
| 287 | + { href: '/open', label: 'Open Models', type: 'model' }, | |
| 288 | + { href: '/families', label: 'Families', type: 'model' }, | |
| 289 | + ], | |
| 290 | + }, | |
| 291 | + { | |
| 292 | + label: 'Intelligence', | |
| 293 | + items: [ | |
| 294 | + { href: '/compare', label: 'Compare' }, | |
| 295 | + { href: '/calculator', label: 'Calculator', hint: 'Cost of a workload across providers' }, | |
| 296 | + { href: '/run-locally', label: 'Run locally', hint: 'Which models fit your hardware' }, | |
| 297 | + { href: '/find-a-model', label: 'Find a model' }, | |
| 298 | + { href: '/pulse', label: 'Pulse' }, | |
| 299 | + { href: '/watchlist', label: 'Watchlist' }, | |
| 300 | + ], | |
| 301 | + }, | |
| 302 | + { | |
| 303 | + label: 'Temporal', | |
| 304 | + items: [ | |
| 305 | + { href: '/timeline', label: 'Timeline' }, | |
| 306 | + { href: '/time-machine', label: 'Time Machine', hint: 'The atlas as of any date' }, | |
| 307 | + { href: '/diff', label: 'Diff', hint: 'What changed between two dates' }, | |
| 308 | + ], | |
| 309 | + }, | |
| 310 | + { | |
| 311 | + label: 'About', | |
| 312 | + items: [ | |
| 313 | + { href: '/sources', label: 'Sources' }, | |
| 314 | + { href: '/methodology', label: 'Methodology' }, | |
| 315 | + { href: '/developers', label: 'API' }, | |
| 316 | + { href: '/about', label: 'About' }, | |
| 317 | + ], | |
| 318 | + }, | |
| 242 | 319 | ]; |
| 320 | +/** Flat "More" list in the order required by the spec (desktop panel columns are built from `moreGroups`). */ | |
| 321 | +export const moreNav: NavItem[] = moreGroups.flatMap((g) => g.items); | |
| 322 | +/** Mobile bottom tab bar: Home · Models · Changes · Search · More. */ | |
| 323 | +export const mobileTabs = [ | |
| 324 | + { href: '/', label: 'Home' }, | |
| 325 | + { href: '/models', label: 'Models' }, | |
| 326 | + { href: '/changes', label: 'Changes' }, | |
| 327 | +]; | |
| 328 | +/** Footer columns. */ | |
| 329 | +export const footerGroups: NavGroup[] = [ | |
| 330 | + { label: 'Explore', items: [...exploreNav.map((n) => ({ href: n.href, label: n.label })), { href: '/open', label: 'Open models' }, { href: '/families', label: 'Families' }] }, | |
| 331 | + { label: 'Intelligence', items: [{ href: '/frontier', label: 'Frontier' }, { href: '/compare', label: 'Compare' }, { href: '/prices', label: 'Prices' }, { href: '/calculator', label: 'Calculator' }, { href: '/run-locally', label: 'Run locally' }, { href: '/find-a-model', label: 'Find a model' }, { href: '/graph', label: 'Knowledge graph' }, { href: '/pulse', label: 'Pulse' }] }, | |
| 332 | + { label: 'Temporal', items: [{ href: '/changes', label: 'Changes' }, { href: '/timeline', label: 'Timeline' }, { href: '/time-machine', label: 'Time machine' }, { href: '/diff', label: 'Diff two dates' }, { href: '/watchlist', label: 'Watchlist' }] }, | |
| 333 | + { label: 'About', items: [{ href: '/about', label: 'About' }, { href: '/methodology', label: 'Methodology' }, { href: '/sources', label: 'Sources' }, { href: '/developers', label: 'API / Developers' }, { href: '/bot', label: 'AIAtlasBot' }] }, | |
| 334 | +]; | |
| 335 | +/** "Signature products" strip on the homepage. */ | |
| 336 | +export const signatureProducts: NavItem[] = [ | |
| 337 | + { href: '/frontier', label: 'Frontier', hint: 'Who leads, on what, since when' }, | |
| 338 | + { href: '/graph', label: 'Knowledge graph', hint: 'Every entity, every relation' }, | |
| 339 | + { href: '/graph', label: 'Lineage', hint: 'Base → fine-tune → quantization' }, | |
| 340 | + { href: '/time-machine', label: 'Time machine', hint: 'The atlas as of any date' }, | |
| 341 | + { href: '/diff', label: 'Diff the AI world', hint: 'Two dates, every change' }, | |
| 342 | + { href: '/prices', label: 'Price intelligence', hint: 'USD per 1M tokens, over time' }, | |
| 343 | + { href: '/calculator', label: 'Cost vs performance', hint: 'Pareto view of models' }, | |
| 344 | + { href: '/benchmarks?view=matrix', label: 'Benchmark matrix', hint: 'Models × benchmarks' }, | |
| 345 | + { href: '/open', label: 'Open model frontier', hint: 'Best open weights, tracked' }, | |
| 346 | + { href: '/run-locally', label: 'Local model finder', hint: 'What fits your machine' }, | |
| 347 | + { href: '/methodology', label: 'Field-level provenance', hint: 'Who said it, when, where' }, | |
| 348 | + { href: '/changes', label: 'Change history', hint: 'Nothing is overwritten' }, | |
| 349 | +]; | |
| 350 | +/** Type prefixes understood by the command palette (`m:claude`). */ | |
| 351 | +export const PALETTE_PREFIXES: Record<string, { types: string[]; label: string }> = { | |
| 352 | + m: { types: ['model', 'quantization', 'artifact'], label: 'Models' }, | |
| 353 | + b: { types: ['benchmark'], label: 'Benchmarks' }, | |
| 354 | + o: { types: ['company', 'organization', 'lab', 'university'], label: 'Organizations' }, | |
| 355 | + p: { types: ['provider'], label: 'Providers' }, | |
| 356 | + h: { types: ['hardware'], label: 'Hardware' }, | |
| 357 | +}; | |
| 243 | 358 | |
| 244 | 359 | /** Change-event vocabulary (sdk/facts.py MATERIAL_PROPERTIES + writer): label, colour family. */ |
| 245 | 360 | export const EVENT_TYPE_LABELS: Record<string, string> = { |
modified
apps/web/src/lib/types.ts
+71 −1
@@ -205,6 +205,12 @@ export interface Stats { | ||
| 205 | 205 | first_entity_at: string | null; |
| 206 | 206 | archive: { raw_bytes: Num; raw_files: Num; text_bytes: Num; text_files: Num }; |
| 207 | 207 | computed_at: string; |
| 208 | + // ---- 1.1 additions (optional until the API stream lands) | |
| 209 | + /** Change events observed in the last 24 h excluding historical backfill. */ | |
| 210 | + change_events_live_24h?: Num; | |
| 211 | + /** How each counter is computed ("Models = canonical model releases; artifacts and folded variants excluded"). */ | |
| 212 | + definitions?: Record<string, string>; | |
| 213 | + [k: string]: unknown; | |
| 208 | 214 | } |
| 209 | 215 | |
| 210 | 216 | export interface StatsHistory { |
@@ -307,8 +313,63 @@ export interface ExploreType { | ||
| 307 | 313 | export interface DailyDigest { |
| 308 | 314 | date: string; |
| 309 | 315 | counts: Record<string, Num>; |
| 310 | − sections: { category: string; label: string; items: ChangeEvent[] }[]; | |
| 316 | + sections: { category: string; label: string; items: ChangeEvent[]; key?: string; total?: Num }[]; | |
| 311 | 317 | new_models: EntitySummary[]; |
| 318 | + total?: Num; | |
| 319 | + labels?: Record<string, string>; | |
| 320 | + previous_day?: string | null; | |
| 321 | + next_day?: string | null; | |
| 322 | + /** 2.0: number of historical backfill events left out of the digest (honesty line). */ | |
| 323 | + backfill_excluded?: Num; | |
| 324 | +} | |
| 325 | + | |
| 326 | +/** 1.1 — `GET /frontier`: current leaders per benchmark family + recent frontier movements. Loose shape; render what is present. */ | |
| 327 | +export interface FrontierPayload { | |
| 328 | + leaders?: { benchmark: EntitySummary; model: EntitySummary; score: Num; metric?: string | null; since?: string | null; trust_level?: string | null }[]; | |
| 329 | + recent_frontier_movements?: ChangeEvent[]; | |
| 330 | + computed_at?: string; | |
| 331 | + [k: string]: unknown; | |
| 332 | +} | |
| 333 | + | |
| 334 | +/** 1.1 — `GET /open?days=`: open-weight releases in the window. */ | |
| 335 | +export interface OpenModelsPayload { | |
| 336 | + items: EntitySummary[]; | |
| 337 | + total?: Num; | |
| 338 | + days?: number; | |
| 339 | + [k: string]: unknown; | |
| 340 | +} | |
| 341 | + | |
| 342 | +/** 1.1 — `GET /entities/{slug}/provenance/{property}`: the full evidence behind one displayed value. */ | |
| 343 | +export interface ProvenanceDetail { | |
| 344 | + slug?: string; | |
| 345 | + property: string; | |
| 346 | + value: unknown; | |
| 347 | + unit?: string | null; | |
| 348 | + claim_id?: string | null; | |
| 349 | + source_id?: string | null; | |
| 350 | + source_name?: string | null; | |
| 351 | + domain?: string | null; | |
| 352 | + url?: string | null; | |
| 353 | + tier: number | null; | |
| 354 | + confidence?: string | null; | |
| 355 | + extractor?: string | null; | |
| 356 | + observed_at?: string | null; | |
| 357 | + valid_from?: string | null; | |
| 358 | + valid_to?: string | null; | |
| 359 | + status?: string | null; | |
| 360 | + /** Other current/conflicting claims for the same property. */ | |
| 361 | + conflicts?: Claim[]; | |
| 362 | + history_count?: Num; | |
| 363 | + snapshot_id?: string | null; | |
| 364 | + [k: string]: unknown; | |
| 365 | +} | |
| 366 | + | |
| 367 | +/** 1.1 — `GET /pulse`: live activity summary (events/hour, connectors running…). Loose. */ | |
| 368 | +export interface PulsePayload { | |
| 369 | + events_24h?: Num; | |
| 370 | + events_live_24h?: Num; | |
| 371 | + per_hour?: { hour: string; count: Num }[]; | |
| 372 | + [k: string]: unknown; | |
| 312 | 373 | } |
| 313 | 374 | |
| 314 | 375 | export interface ChangeCategories { |
@@ -320,6 +381,15 @@ export interface TimelinePayload { | ||
| 320 | 381 | total?: number; |
| 321 | 382 | } |
| 322 | 383 | |
| 384 | +/** Local watchlist item (`lib/watchlist.ts`, localStorage `aia-watchlist`). */ | |
| 385 | +export interface WatchItem { | |
| 386 | + slug: string; | |
| 387 | + name: string; | |
| 388 | + entity_type: string; | |
| 389 | + org?: string | null; | |
| 390 | + added_at?: string; | |
| 391 | +} | |
| 392 | + | |
| 323 | 393 | export interface CompareDimension { |
| 324 | 394 | key: string; |
| 325 | 395 | label: string; |
added
apps/web/src/lib/watchlist.ts
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +'use client'; | |
| 2 | +/** | |
| 3 | + * Local watchlist (no account): `localStorage['aia-watchlist']` = `WatchItem[]` (slug, name, entity_type, org). | |
| 4 | + * Same-tab `aia-watchlist-change` event + cross-tab `storage`. Capped at WATCH_MAX items (the `/watchlist` page | |
| 5 | + * fetches events per item, so the cap keeps the page honest and fast). | |
| 6 | + */ | |
| 7 | +import { useCallback, useEffect, useState } from 'react'; | |
| 8 | +import type { WatchItem } from './types'; | |
| 9 | + | |
| 10 | +export const WATCH_KEY = 'aia-watchlist'; | |
| 11 | +export const WATCH_MAX = 12; | |
| 12 | +const EVENT = 'aia-watchlist-change'; | |
| 13 | + | |
| 14 | +/** Event types that matter for a watched entity (new version / price / deprecation / benchmark / provider / licence). */ | |
| 15 | +export const WATCH_EVENT_TYPES = new Set([ | |
| 16 | + 'NEW_MODEL', | |
| 17 | + 'RELEASE', | |
| 18 | + 'VERSION_RELEASED', | |
| 19 | + 'MODEL_UPDATED', | |
| 20 | + 'PRICE_CHANGED', | |
| 21 | + 'PROVIDER_LISTED', | |
| 22 | + 'DEPRECATION_ANNOUNCED', | |
| 23 | + 'RETIREMENT_ANNOUNCED', | |
| 24 | + 'STATUS_CHANGED', | |
| 25 | + 'BENCHMARK_RESULT', | |
| 26 | + 'BENCHMARK_UPDATED', | |
| 27 | + 'NEW_PROVIDER', | |
| 28 | + 'LICENSE_CHANGED', | |
| 29 | + 'OPENNESS_CHANGED', | |
| 30 | + 'CONTEXT_CHANGED', | |
| 31 | +]); | |
| 32 | + | |
| 33 | +function read(): WatchItem[] { | |
| 34 | + if (typeof window === 'undefined') return []; | |
| 35 | + try { | |
| 36 | + const raw = window.localStorage.getItem(WATCH_KEY); | |
| 37 | + if (!raw) return []; | |
| 38 | + const arr = JSON.parse(raw) as unknown; | |
| 39 | + if (!Array.isArray(arr)) return []; | |
| 40 | + return arr.filter((x): x is WatchItem => !!x && typeof x === 'object' && typeof (x as WatchItem).slug === 'string' && typeof (x as WatchItem).entity_type === 'string').slice(0, WATCH_MAX); | |
| 41 | + } catch { | |
| 42 | + return []; | |
| 43 | + } | |
| 44 | +} | |
| 45 | +function write(items: WatchItem[]) { | |
| 46 | + if (typeof window === 'undefined') return; | |
| 47 | + try { | |
| 48 | + window.localStorage.setItem(WATCH_KEY, JSON.stringify(items.slice(0, WATCH_MAX))); | |
| 49 | + } catch { | |
| 50 | + /* storage full / disabled */ | |
| 51 | + } | |
| 52 | + window.dispatchEvent(new CustomEvent(EVENT)); | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function readWatchlist(): WatchItem[] { | |
| 56 | + return read(); | |
| 57 | +} | |
| 58 | +export function isWatched(slug: string): boolean { | |
| 59 | + return read().some((i) => i.slug === slug); | |
| 60 | +} | |
| 61 | +export function addWatch(item: WatchItem): WatchItem[] { | |
| 62 | + const cur = read(); | |
| 63 | + if (cur.some((i) => i.slug === item.slug)) return cur; | |
| 64 | + const next = [{ ...item, added_at: new Date().toISOString() }, ...cur].slice(0, WATCH_MAX); | |
| 65 | + write(next); | |
| 66 | + return next; | |
| 67 | +} | |
| 68 | +export function removeWatch(slug: string): WatchItem[] { | |
| 69 | + const next = read().filter((i) => i.slug !== slug); | |
| 70 | + write(next); | |
| 71 | + return next; | |
| 72 | +} | |
| 73 | +export function toggleWatch(item: WatchItem): WatchItem[] { | |
| 74 | + return isWatched(item.slug) ? removeWatch(item.slug) : addWatch(item); | |
| 75 | +} | |
| 76 | +export function clearWatchlist() { | |
| 77 | + write([]); | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** Normalise an entity summary/detail into a WatchItem. */ | |
| 81 | +export function toWatchItem(e: { slug: string; name: string; entity_type: string; organization?: { name: string } | string | null }): WatchItem { | |
| 82 | + return { slug: e.slug, name: e.name, entity_type: e.entity_type, org: typeof e.organization === 'string' ? e.organization : e.organization?.name ?? null }; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** React binding; `ready` is false during SSR/hydration so server and client markup match. */ | |
| 86 | +export function useWatchlist() { | |
| 87 | + const [items, setItems] = useState<WatchItem[]>([]); | |
| 88 | + const [ready, setReady] = useState(false); | |
| 89 | + useEffect(() => { | |
| 90 | + const sync = () => setItems(read()); | |
| 91 | + sync(); | |
| 92 | + setReady(true); | |
| 93 | + window.addEventListener(EVENT, sync); | |
| 94 | + window.addEventListener('storage', sync); | |
| 95 | + return () => { | |
| 96 | + window.removeEventListener(EVENT, sync); | |
| 97 | + window.removeEventListener('storage', sync); | |
| 98 | + }; | |
| 99 | + }, []); | |
| 100 | + const add = useCallback((i: WatchItem) => setItems(addWatch(i)), []); | |
| 101 | + const remove = useCallback((slug: string) => setItems(removeWatch(slug)), []); | |
| 102 | + const toggle = useCallback((i: WatchItem) => setItems(toggleWatch(i)), []); | |
| 103 | + const clear = useCallback(() => { | |
| 104 | + clearWatchlist(); | |
| 105 | + setItems([]); | |
| 106 | + }, []); | |
| 107 | + const has = useCallback((slug: string) => items.some((i) => i.slug === slug), [items]); | |
| 108 | + return { items, ready, add, remove, toggle, clear, has, full: items.length >= WATCH_MAX }; | |
| 109 | +} | |
modified
docs/FRONTEND.md
+122 −10
@@ -40,14 +40,37 @@ system by default; `ThemeToggle` cycles system → light → dark). Tokens are C | ||
| 40 | 40 | | Entity types | `type-model` `type-company` `type-paper` `type-provider` `type-benchmark` `type-hardware` `type-framework` `type-dataset` `type-tool` | `EntityBadge` (also `var(--type-<key>)`) | |
| 41 | 41 | | Charts | `series-1..8` (`var(--series-n)`) | SVG charts | |
| 42 | 42 | |
| 43 | −Typography: **Geist Sans** for UI (`--font-sans`), **Geist Mono** for ids, numbers, telemetry (`.mono`). Base 15 px. | |
| 43 | +Typography: **Geist Sans** for UI (`--font-sans`), **Geist Mono** for ids, numbers, telemetry (`.mono`). Base 15 px | |
| 44 | +(`--d-base`; 14 px in `compact`, 13.5 px in `dense` — see density below). | |
| 44 | 45 | Utilities: `.eyebrow` (small caps label) · `.display` (headline) · `.mono` · `.tnum` (tabular numbers — use on every numeric cell) |
| 45 | 46 | · `.hairline` · `.link` · `.panel` (sparingly) · `.grid-bg` (hero) · `.scrollbar-thin` · `.no-scrollbar` · `.container-x` |
| 46 | 47 | · `.dot` / `.pulse` (live) · `.prose-atlas` · `.kv` (dense key–value grid, used by `KeyValue`) |
| 47 | 48 | · `.data-table` (+ `.stack` stacks rows < 768 px using `data-label`; `.compact`; `.num` right-aligned; `.primary` name cell; |
| 48 | − `.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper). | |
| 49 | −Radii are deliberately small (4 px; 8 px for panels). Brand: `public/logo.svg`, `src/app/icon.svg`, `apple-icon.tsx`, | |
| 50 | −`opengraph-image.tsx` (live counters when the API answers), `components/brand/logo.tsx` (`LogoMark`, `Wordmark`). | |
| 49 | + `.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper) | |
| 50 | +· `.section-y` (density-aware section padding, used by `Section`) · `.row-y` (density-aware feed-row padding) · `.evidence` | |
| 51 | + (dotted-underline evidence trigger) · `.hint` / `.hint-bubble` (pure-CSS tooltip) · `.ticker` / `.ticker-track` · `.heatmap` · `.terminal-rail` (sticky side rail). | |
| 52 | +Radii are deliberately small (4 px; 8 px for panels). | |
| 53 | + | |
| 54 | +**Density** (2026-09-12): `<html data-density="compact|dense">` (absent = comfortable), persisted in `localStorage['aia-density']`, | |
| 55 | +applied before paint by `DENSITY_SCRIPT` (`lib/prepaint.ts`, embedded with the theme script in `layout.tsx`). It only changes CSS | |
| 56 | +variables — `--d-base` (body font size) · `--d-cell-y` / `--d-cell-x` (`.data-table` cell padding) · `--d-kv-y` (`.kv` rows) · | |
| 57 | +`--d-row-y` (`.row-y`) · `--d-section-y` / `--d-section-y-md` (`.section-y`) · `--d-table-fs` — so any component built on those | |
| 58 | +classes is density-aware for free. Use `.row-y` instead of `py-2.5` on feed rows. `DensityToggle` (header) and the palette | |
| 59 | +command "Toggle density" cycle comfortable → compact → dense; `useDensity()` (`lib/density.ts`) → `[density, set, cycle]`. | |
| 60 | +**Pre-paint scripts live in `lib/prepaint.ts`** (server-safe). Never import a string export from a `'use client'` module into | |
| 61 | +a server component: Next hands the server a client-reference stub (this is why `THEME_SCRIPT` moved there). | |
| 62 | + | |
| 63 | +**Brand** (2026-09-12) — the *atlas plate*: a rounded-square plate with a fine coordinate grid, three nodes + two edges drawing the | |
| 64 | +letter A as a graph, a timeline baseline whose right-hand accent node marks "now". One geometry (`components/brand/mark.tsx`: | |
| 65 | +`MARK_GEOMETRY`, `MarkArt` for in-page SVG, `markSvgString`/`markDataUri`/`MarkImg` for ImageResponse — satori cannot rasterise | |
| 66 | +nested React SVG trees, so OG/icon routes embed a data-URI `<img>`). Tokens `--brand-plate/-ink/-grid/-accent` invert per theme | |
| 67 | +(dark plate in light mode, light plate in dark mode). Assets: `public/logo.svg` (mark), `public/logo-lockup.svg` (+ `-light`), | |
| 68 | +`src/app/icon.svg` (simplified 2-line grid, crisp at 16/32), `apple-icon.tsx`, `icon-192.png/` + `icon-512.png/` routes, | |
| 69 | +`manifest.ts` (theme colours from `THEME_LIGHT/THEME_DARK` in `lib/site.ts`), `opengraph-image.tsx` (root "wallpaper" with four live | |
| 70 | +counters). `components/brand/logo.tsx`: `LogoMark({ size, variant: 'plate' | 'mono', title })`, `Wordmark({ markSize, textClassName })` | |
| 71 | +("AI" bold, "Atlas" medium). `components/brand/og.tsx`: `Frame Eyebrow Title Facts Fallback` (unchanged API) + `Wallpaper({ eyebrow, | |
| 72 | +title, subtitle, counters, footer, markPx })`, `Counters({ items })`, `Grid`, `Lockup`, `Mark` — per-type OG images (D1–D3) should use | |
| 73 | +`Wallpaper` with their own eyebrow/title/counters. | |
| 51 | 74 | |
| 52 | 75 | ## 3. Data layer (`src/lib`) |
| 53 | 76 | |
@@ -85,17 +108,24 @@ in a route group `models/(list)/` for that reason). | ||
| 85 | 108 | `components/ui/unavailable.tsx` — `Unavailable(what, compact, reason)` · `Missing` · `EmptyState(title)`. |
| 86 | 109 | `components/ui/pagination.tsx` — `Pagination(total, limit, offset, makeHref)` · `withParams(base, current, patch)`. |
| 87 | 110 | `components/ui/tabs.tsx` (client) — `Tabs(tabs, mode='search'|'hash')` + `TabPanel(id)`; all panels are server-rendered, URL `?tab=` drives visibility. |
| 88 | −`components/ui/key-value.tsx` — `KeyValue(rows, provenance)`: dense spec `<dl>`; each row `{ key, raw | value, label?, hint? }`, URLs auto-linked, provenance line under each value. | |
| 89 | −`components/ui/provenance.tsx` — `ProvenanceInline(p)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted") · `SourceCell(url, tier, observedAt)`. | |
| 111 | +`components/ui/key-value.tsx` — `KeyValue(rows, provenance, slug?, entity?)`: dense spec `<dl>`; each row `{ key, raw | value, label?, hint?, unit? }`, URLs auto-linked, provenance line under each value. | |
| 112 | +With `slug` every value that has provenance becomes an **evidence trigger** (opens the drawer, §9); without it the output is unchanged. | |
| 113 | +`components/ui/provenance.tsx` — `ProvenanceInline(p, slug?, property?, value?, entity?)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted · Evidence") · `SourceCell(url, tier, observedAt)`. | |
| 114 | +`components/ui/sheet.tsx` (client) — `Sheet({ open, onClose, title, eyebrow, side: 'auto' | 'right' | 'bottom', width, footer, id })`: right drawer ≥ lg / bottom sheet below (`auto`), focus trap, Esc, scroll lock. Shared by the evidence drawer, terminal sheets and the mobile More menu. | |
| 115 | +`components/ui/hint.tsx` — `Hint({ text, align, children? })`: pure-CSS definition tooltip (server-safe), used for "How counted". | |
| 90 | 116 | `components/ui/data-table.tsx` — `DataTable(stack|scroll|compact)` · `Th(num)` · `Td(label, num, primary, wide, hideStack)` · `EmptyRow`. |
| 91 | 117 | `components/ui/entity.tsx` — `EntityLink` · `EntityRow` (badge · name · org · key attributes · quality) · `keyAttributes(e)` · `QualityMark` · `EntityInline`. |
| 92 | 118 | `components/ui/live.tsx` (client) — `Dot(pulse)` · `LiveAgo(at)` (re-renders every 30 s). |
| 93 | −`components/charts/charts.tsx` — pure SVG, theme-aware: `Sparkline` · `Bars` · `HBars` · `LineChart(series, yFormat, xTime)` · `Legend` · `stepPoints()` for price steps. | |
| 119 | +`components/charts/` (import from `@/components/charts`) — pure SVG, theme-aware: `Sparkline(values, variant: 'line' | 'trend', invert, format)` · `Bars` · `HBars` · | |
| 120 | +`LineChart(series, yFormat, xTime, yScale, step, children)` · `StepChart` · `Legend` · `Heatmap` · `stepPoints()` · `lineLayout()`; client: `InteractiveLineChart` · `ScatterChart` · `TimelineLanes` (see §9). | |
| 94 | 121 | `components/changes/` — `ChangeRow(e, dense, showDate, live)` · `Delta` · `groupByDay` · `LoadMore` (cursor `before=`). |
| 95 | 122 | `components/listing/` — `FilterBar` (GET form → URL params) · `Facets` · `ListingLayout` (facets aside / mobile `<details>`) · `ActiveFilters` · `GenericListing(type, fetch?)`. |
| 96 | −`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon) and `blocks.tsx`: | |
| 97 | −`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`. | |
| 98 | −`components/layout/` — header (Explore ▾, ⌘K search → `/search/suggest`, theme toggle), `MobileTabBar` (Home · Explore · Changes · Search · More), `SiteFooter`, `SearchDialog`, `ViewBeacon(path)`. | |
| 123 | +`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon; actions row = `CompareButton` · `WatchButton` · Explore graph) and `blocks.tsx`: | |
| 124 | +`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary` | |
| 125 | +(`SpecTable` and `Capabilities` pass `slug` to `KeyValue`, so their values open the evidence drawer). | |
| 126 | +`components/layout/` — `SiteHeader` (Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾ | Search ⌘K | theme | density), `MobileTabBar` | |
| 127 | +(Home · Models · Changes · Search · More → grouped bottom sheet), `SiteFooter` (4 columns + credits line), `SearchDialog` (command palette), `DensityToggle`, `terminal.tsx` primitives (§9), `ViewBeacon(path)`. | |
| 128 | +`components/evidence/`, `components/watchlist/` — see §9. | |
| 99 | 129 | `components/meta/sitemap-data.ts` — sitemap shards (`static`, `<type>-<n>` of 5 000 from `GET /sitemap`). |
| 100 | 130 | |
| 101 | 131 | Do not fork these; extend with props or add new components in your own folder. |
@@ -204,3 +234,85 @@ All numbers still come from the API; nothing below hardcodes counts, slugs or da | ||
| 204 | 234 | - `provenance[*]` currently lacks `source_name`; the UI falls back to the URL host. `extractor` is `llm` or `deterministic`. |
| 205 | 235 | - `/search` `query` is flat: `{ text, entity_type, openness, params_min, filters: { residual }, semantic }` — `understood()` in `search/page.tsx` renders it. |
| 206 | 236 | - `/methodology.event_types` items are `{ event_type, category, count, last_seen_at }` (no label/importance); `metrics` is `[]`. |
| 237 | + | |
| 238 | +## 9. Terminal shell (2026-09-12, stream D0) — contract for the page streams D1–D3 | |
| 239 | + | |
| 240 | +Everything below is built and QA'd (`qa/screens.mjs` shell sweep 320…1920 dark+light, `qa/shell.mjs` flows). Import paths are exact. | |
| 241 | +Pages must not fork these; extend with props. Every new route must exist in `lib/site.ts` (`routes.*`, nav arrays) — the shell already | |
| 242 | +links to `/frontier /prices /graph /open /families /families/[slug] /time-machine /diff /pulse /calculator /run-locally /find-a-model | |
| 243 | +/artifacts/[slug] /agents /licenses /claims/[id] /watchlist` (`routes.frontier() pulse() open() families() family(slug) timeMachine(date?) | |
| 244 | +calculator() runLocally() findAModel() artifact(slug) agents() licenses() claim(id) watchlist() benchmarkMatrix() entityHistory(e, property?) graph(slug?)`). | |
| 245 | +`TYPE_PATH` gained `model_family → families`, `artifact → artifacts`, `license → licenses` (+ `PATH_TYPES`, labels, colours). | |
| 246 | + | |
| 247 | +### 9.1 Navigation (`lib/site.ts`) | |
| 248 | +`primaryNav` (7 desktop items) · `moreGroups: NavGroup[]` (Explore / Intelligence / Temporal / About — desktop More panel and mobile sheet) · | |
| 249 | +`moreNav` (flat) · `mobileTabs` · `footerGroups` · `signatureProducts` (12 homepage links) · `PALETTE_PREFIXES` (`m: b: o: p: h:`). | |
| 250 | +Constants: `TAGLINE` = "The temporal knowledge graph of the AI ecosystem", `AUTHOR_NAME`, `HOST_NAME`, `HOST_URL`, `THEME_LIGHT`, `THEME_DARK`. | |
| 251 | +Header/menu QA hooks: `[data-more-button] [data-more-menu] [data-open-palette] [data-density-toggle] [data-mobile-more]`. | |
| 252 | + | |
| 253 | +### 9.2 Command palette (`components/layout/search-dialog.tsx`, opened by `useOpenSearch()` from `search-context.tsx`) | |
| 254 | +⌘K / Ctrl+K / `/`. Sections: Commands (fuzzy: prefix > word prefix > substring > subsequence) · Entities (`/search/suggest`, filtered | |
| 255 | +client-side by prefix type) · Recent (`localStorage['aia-recent']`, last 8) · "Search everything". `>` = commands only; `m:` `b:` `o:` `p:` `h:` | |
| 256 | +restrict entities. Commands with an argument ("Open benchmark …", "Find provider …", "Explore lineage of …" → graph) switch the input to the | |
| 257 | +prefix. To add a command: append to `COMMANDS` (`{ id, label, hint, keywords, href | prefix (+then) | action }`). Hooks: `[data-palette] | |
| 258 | +[data-palette-input] [data-palette-row="entity|command|recent|search"]`. | |
| 259 | + | |
| 260 | +### 9.3 Evidence drawer (`components/evidence`) | |
| 261 | +```tsx | |
| 262 | +import { Evidence, useEvidence, type EvidenceTarget } from '@/components/evidence'; | |
| 263 | +<Evidence slug={d.slug} property="context_length" value={raw} display="200K tokens" unit="tokens" fallback={d.provenance.context_length} entity={{ name: d.name, entity_type: d.entity_type }}> | |
| 264 | + {children /* rendered as a dotted-underline button, data-evidence="slug:property" */} | |
| 265 | +</Evidence> | |
| 266 | +const { open, close, target } = useEvidence(); // open({ slug, property, value?, display?, unit?, label?, fallback?, entity? }) | |
| 267 | +``` | |
| 268 | +`EvidenceProvider` + `EvidenceDrawer` are mounted once in `app/layout.tsx`; URL hash `#evidence=<slug>:<property>` opens the drawer on load. | |
| 269 | +Data order: `GET /entities/{slug}/provenance/{property}` (1.1, `clientApi.provenance`) → 404 → `GET /entities/{slug}/history?property=` | |
| 270 | +(`clientApi.history`: claim id, valid_from/to, conflicts) → the `fallback` `ProvenanceEntry`. Content: VALUE · SOURCE · TIER · OBSERVED · | |
| 271 | +EXTRACTOR · CONFIDENCE · VALID SINCE · conflicts · View source · View history (`routes.entityHistory(e, property)`) · View conflicts · claim id. | |
| 272 | +Cheapest integration for a page: pass `slug` (+ `entity`) to `KeyValue`, or `slug` + `property` to `ProvenanceInline`. For table cells wrap the | |
| 273 | +value in `<Evidence …>`. Types: `ProvenanceDetail` (`lib/types.ts`), `clientApi.provenance/history` (`lib/client-api.ts`, `ClientApiError.status`). | |
| 274 | + | |
| 275 | +### 9.4 Terminal layout primitives (`components/layout/terminal.tsx`, client) | |
| 276 | +- `TerminalLayout({ filters?, children, inspector?, filtersTitle, inspectorTitle, storageKey, filterCount, wide })` — desktop 3-pane | |
| 277 | + (filter rail 15rem sticky · main · inspector 22rem sticky, collapsible, state in `localStorage[storageKey]`); mobile: toolbar buttons | |
| 278 | + `[data-open-filters] [data-open-inspector]` open bottom sheets. Filters/inspector are server-rendered nodes passed as props (GET forms work). | |
| 279 | +- `FilterSheet({ open, onClose, title, children })` — the mobile filter sheet (Done button). | |
| 280 | +- `DataStrip({ items: StripItem[], dense })` — `StripItem { label, value, hint?, definition? (→ Hint tooltip), href?, delta?: { value, tone }, live? }`; | |
| 281 | + horizontal scroll < md, equal columns ≥ md. | |
| 282 | +- `Ticker({ items: TickerItem[], speed, label })` — `TickerItem { id, label, href?, tone: 'new'|'price'|'warn'|'danger'|'bench'|'neutral', meta? }`; | |
| 283 | + pause button, paused on hover, static + scrollable under `prefers-reduced-motion`. Hook `[data-ticker]`. | |
| 284 | +- `SectionNav({ items: { id, label }[] })` — sticky under the header, active section via IntersectionObserver (`aria-current="location"`). | |
| 285 | + | |
| 286 | +### 9.5 Charts (`@/components/charts`) | |
| 287 | +- `LineChart` gained `step?: boolean`, `children` (extra SVG layers) and `width`; `lineLayout(props)` exposes scales (`x y xInvert series ticks`). | |
| 288 | + `StepChart` = step mode. `Sparkline` gained `variant="trend"` (+ `invert` for lower-is-better, `format`, `title`). | |
| 289 | +- `InteractiveLineChart(props & { xFormat })` (client) — same SSR SVG + crosshair and nearest-point tooltip (pointer + touch). Hook `[data-interactive-chart]`. | |
| 290 | +- `ScatterChart({ points: ScatterPoint[], xScale, yScale: 'linear'|'log', xLabel, yLabel, xFormat, yFormat, frontier?: {x,y}[], highlight?: Set|string[], quadrant?: {x?,y?}, height, color, labelTop })` | |
| 291 | + (client) — `ScatterPoint { id, x, y, r?, label, sub?, href?, color?, group? }`; hover tooltip, sr-only link list. For `/calculator`, `/open`, `/frontier` Pareto views. | |
| 292 | +- `Heatmap({ rows, cols, cell(rowKey, colKey) → HeatCell | null, format, min, max, color, direction: 'higher'|'lower', caption, rowHeader })` (server-safe) — | |
| 293 | + `HeatCell { value, label?, href?, title? }`; sticky row/column headers inside `.table-scroll`, colour = `color-mix` of a token. For the benchmark matrix. | |
| 294 | +- `TimelineLanes({ lanes: Lane[], events: LaneEvent[], from?, to?, height?, brushable?, onRange?(from, to), laneWidth, title })` (client) — | |
| 295 | + `Lane { key, label, color? }`, `LaneEvent { id, lane, at, importance?, label, href?, sub? }`; dots sized by importance, hover tooltip, drag-brush. For Timeline 2.0. | |
| 296 | +All charts: CSS-variable colours, `tabular-nums`, `<title>`/`aria-label`, no animation. | |
| 297 | + | |
| 298 | +### 9.6 Watchlist (`lib/watchlist.ts`, `components/watchlist/`) | |
| 299 | +`localStorage['aia-watchlist']` = `WatchItem[] { slug, name, entity_type, org?, added_at? }` (≤ `WATCH_MAX` = 12). `useWatchlist()` → | |
| 300 | +`{ items, ready, add, remove, toggle, clear, has, full }`; helpers `readWatchlist isWatched addWatch removeWatch toggleWatch toWatchItem`, | |
| 301 | +`WATCH_EVENT_TYPES`. `WatchButton({ e, size: 'sm'|'md', label })` (star, `aria-pressed`, `[data-watch-button]`) sits next to `CompareButton` in | |
| 302 | +`EntityPage`; put it in listing rows the same way. `/watchlist` (`app/watchlist/page.tsx`, noindex) renders `WatchlistView` (client): items + | |
| 303 | +merged `/changes?entity=<slug>&limit=20` feed filtered to material event types (`[data-watchlist-items] [data-watchlist-feed]`). | |
| 304 | + | |
| 305 | +### 9.7 Homepage 3.0 (`app/page.tsx`, server, revalidate 60) | |
| 306 | +Compact hero → `DataStrip` from `/stats` (definitions from `stats.definitions` with local fallbacks) → `Ticker` (importance ≥ 2) → 2-column | |
| 307 | +editorial grid: Today in AI (`/changes/daily` sections, honesty line from `backfill_excluded` when present), Frontier moves (`/frontier | |
| 308 | +.recent_frontier_movements` else importance-3 events), Benchmark leaders (`/benchmarks` top + trust when present) | Price moves (`/prices/index` | |
| 309 | +movers + median trend sparkline when ≥ 2 points), Open weights (`/open?days=30` else `/models?openness=open-weights&sort=release`), Research | |
| 310 | +(`/papers?sort=published`) → `TimelineLanes` (12 months of NEW_MODEL/RELEASE by organization, from `/timeline?category=model`) → graph teaser → | |
| 311 | +12 signature products. Every fetch through `safe()`; every panel has an honest empty state. `api.frontier/pulse/open/provenance/claim` exist in | |
| 312 | +`lib/api.ts` (1.1) — always `safe()` them until the API stream ships. | |
| 313 | + | |
| 314 | +### 9.8 QA | |
| 315 | +`node qa/screens.mjs` now also sweeps `/`, `/about`, `/watchlist` and the open palette at 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 (dark + light, | |
| 316 | +overflow, console errors, touch targets ≥ 44 px on mobile widths). `node qa/shell.mjs` runs the flows: palette (`m:claude` → select, `>` commands → | |
| 317 | +density), More menu keyboard, density persistence, evidence drawer on the first model, watchlist add → `/watchlist`, favicon 16/32, brand routes. | |
| 318 | +Screenshots: `qa/screens/shell-*.png`, `qa/screens/shell/*.png`. | |
| 207 | 319 | |