Web core: Next 16 app (design system, shell, home, search, models, companies, entity pages, changes, explore, methodology, sources, developers, sitemap); provider slugs -api
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
81 changed files +8,142 −1
added
apps/web/.gitignore
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +.next/ | |
| 2 | +out/ | |
| 3 | +node_modules/ | |
| 4 | +next-env.d.ts | |
| 5 | +tsconfig.tsbuildinfo | |
| 6 | +qa/screens/ | |
| 7 | +.env*.local | |
added
apps/web/next.config.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +import type { NextConfig } from 'next'; | |
| 2 | +import { existsSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | + | |
| 5 | +// Monorepo: a single `.env` lives at the repository root; Next only reads the app directory. | |
| 6 | +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) { | |
| 7 | + if (existsSync(candidate)) { | |
| 8 | + try { | |
| 9 | + process.loadEnvFile(candidate); | |
| 10 | + } catch { | |
| 11 | + /* ignore malformed env */ | |
| 12 | + } | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +// Dev: FastAPI on :8331 (see repo .env). Production sets API_URL=http://127.0.0.1:8321 in the mld manifest. | |
| 17 | +const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, ''); | |
| 18 | + | |
| 19 | +const nextConfig: NextConfig = { | |
| 20 | + reactStrictMode: true, | |
| 21 | + poweredByHeader: false, | |
| 22 | + allowedDevOrigins: ['127.0.0.1', 'localhost'], | |
| 23 | + outputFileTracingRoot: path.resolve(__dirname, '../..'), | |
| 24 | + experimental: { | |
| 25 | + optimizePackageImports: ['lucide-react'], | |
| 26 | + }, | |
| 27 | + // Browser-side fetches go to the same origin; the FastAPI service is loopback-only. | |
| 28 | + async rewrites() { | |
| 29 | + return [ | |
| 30 | + { source: '/api/v1/:path*', destination: `${API_URL}/api/v1/:path*` }, | |
| 31 | + { source: '/health', destination: `${API_URL}/health` }, | |
| 32 | + ]; | |
| 33 | + }, | |
| 34 | + async headers() { | |
| 35 | + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=300, stale-while-revalidate=3600' }; | |
| 36 | + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' }; | |
| 37 | + return [ | |
| 38 | + { | |
| 39 | + source: '/(.*)', | |
| 40 | + headers: [ | |
| 41 | + { key: 'X-Content-Type-Options', value: 'nosniff' }, | |
| 42 | + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, | |
| 43 | + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, | |
| 44 | + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, | |
| 45 | + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, | |
| 46 | + ], | |
| 47 | + }, | |
| 48 | + { source: '/models/:path*', headers: [PUBLIC_CACHE] }, | |
| 49 | + { source: '/companies/:path*', headers: [PUBLIC_CACHE] }, | |
| 50 | + { source: '/papers/:path*', headers: [PUBLIC_CACHE] }, | |
| 51 | + { source: '/providers/:path*', headers: [PUBLIC_CACHE] }, | |
| 52 | + { source: '/benchmarks/:path*', headers: [PUBLIC_CACHE] }, | |
| 53 | + { source: '/hardware/:path*', headers: [PUBLIC_CACHE] }, | |
| 54 | + { source: '/api/:path*', headers: [NO_STORE] }, | |
| 55 | + { source: '/admin/:path*', headers: [NO_STORE] }, | |
| 56 | + ]; | |
| 57 | + }, | |
| 58 | +}; | |
| 59 | + | |
| 60 | +export default nextConfig; | |
added
apps/web/package.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@ai-atlas/web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "scripts": { | |
| 6 | + "dev": "next dev -p 8330", | |
| 7 | + "build": "next build", | |
| 8 | + "start": "next start -p 8320 -H 0.0.0.0", | |
| 9 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 10 | + "qa": "node qa/screens.mjs" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "d3-array": "^3.2.4", | |
| 14 | + "d3-scale": "^4.0.2", | |
| 15 | + "d3-shape": "^3.2.0", | |
| 16 | + "geist": "^1.5.1", | |
| 17 | + "lucide-react": "^1.0.0", | |
| 18 | + "next": "16.3.4", | |
| 19 | + "react": "19.2.8", | |
| 20 | + "react-dom": "19.2.8", | |
| 21 | + "server-only": "^0.0.1" | |
| 22 | + }, | |
| 23 | + "devDependencies": { | |
| 24 | + "@tailwindcss/postcss": "^4", | |
| 25 | + "@types/d3-array": "^3.2.1", | |
| 26 | + "@types/d3-scale": "^4.0.9", | |
| 27 | + "@types/d3-shape": "^3.1.7", | |
| 28 | + "@types/node": "^24.0.0", | |
| 29 | + "@types/react": "^19", | |
| 30 | + "@types/react-dom": "^19", | |
| 31 | + "tailwindcss": "^4", | |
| 32 | + "typescript": "^5.9.3" | |
| 33 | + } | |
| 34 | +} | |
added
apps/web/postcss.config.mjs
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +const config = { | |
| 2 | + plugins: { | |
| 3 | + '@tailwindcss/postcss': {}, | |
| 4 | + }, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +export default config; | |
added
apps/web/public/logo.svg
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> | |
| 2 | + <title>AI Atlas</title> | |
| 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"/> | |
| 13 | +</svg> | |
added
apps/web/qa/screens.mjs
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +/** | |
| 2 | + * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot. | |
| 3 | + * Also asserts that the homepage counters match GET /api/v1/stats. | |
| 4 | + * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331) | |
| 5 | + */ | |
| 6 | +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 7 | +import { mkdirSync } from 'node:fs'; | |
| 8 | + | |
| 9 | +const BASE = process.argv[2] ?? 'http://localhost:8330'; | |
| 10 | +const API = process.argv[3] ?? 'http://127.0.0.1:8331'; | |
| 11 | +const OUT = new URL('./screens/', import.meta.url).pathname; | |
| 12 | +mkdirSync(OUT, { recursive: true }); | |
| 13 | + | |
| 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']; | |
| 15 | +const WIDTHS = [390, 1440]; | |
| 16 | +const THEMES = ['dark', 'light']; | |
| 17 | +const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); | |
| 18 | + | |
| 19 | +const browser = await chromium.launch(); | |
| 20 | +let failures = 0; | |
| 21 | +for (const theme of THEMES) { | |
| 22 | + for (const width of WIDTHS) { | |
| 23 | + const mobile = width < 768; | |
| 24 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); | |
| 25 | + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); | |
| 26 | + const page = await ctx.newPage(); | |
| 27 | + for (const path of PAGES) { | |
| 28 | + if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic'].includes(path)) continue; | |
| 29 | + const errors = []; | |
| 30 | + const onErr = (e) => errors.push(String(e)); | |
| 31 | + const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; | |
| 32 | + page.on('pageerror', onErr); | |
| 33 | + page.on('console', onCon); | |
| 34 | + const t0 = Date.now(); | |
| 35 | + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); | |
| 36 | + await page.waitForTimeout(600); | |
| 37 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); | |
| 38 | + const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); | |
| 39 | + const expected = /does-not-exist/.test(path) ? 404 : 200; | |
| 40 | + 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))); | |
| 44 | + const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme; | |
| 45 | + if (!ok) failures++; | |
| 46 | + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 140) : ''}`); | |
| 47 | + await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined); | |
| 48 | + page.off('pageerror', onErr); | |
| 49 | + page.off('console', onCon); | |
| 50 | + } | |
| 51 | + await ctx.close(); | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +// Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be | |
| 56 | +// ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load): | |
| 57 | +// the tolerance is derived from the observed ingestion rate and is zero when the database is static. | |
| 58 | +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 }); | |
| 60 | + const t0 = Date.now(); | |
| 61 | + const before = pick(await (await fetch(`${API}/api/v1/stats`)).json()); | |
| 62 | + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); | |
| 63 | + const page = await ctx.newPage(); | |
| 64 | + await page.goto(BASE + '/', { waitUntil: 'networkidle' }); | |
| 65 | + const text = await page.evaluate(() => document.body.innerText); | |
| 66 | + await ctx.close(); | |
| 67 | + const after = pick(await (await fetch(`${API}/api/v1/stats`)).json()); | |
| 68 | + const elapsed = Math.max(1, (Date.now() - t0) / 1000); | |
| 69 | + const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, ''))); | |
| 70 | + for (const label of Object.keys(before)) { | |
| 71 | + const a = Number(before[label]); | |
| 72 | + const b = Number(after[label]); | |
| 73 | + if (!Number.isFinite(a) || !Number.isFinite(b)) continue; | |
| 74 | + const rate = Math.max(0, b - a) / elapsed; | |
| 75 | + const lo = a - Math.ceil(rate * 60); | |
| 76 | + const hit = nums.some((n) => n >= lo && n <= b); | |
| 77 | + if (!hit) failures++; | |
| 78 | + console.log(`${hit ? 'OK ' : 'FAIL'} counter ${label}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(b)}] (stats ${fmt0.format(a)} → ${fmt0.format(b)} in ${elapsed.toFixed(0)} s)${hit ? '' : ' — NOT found'}`); | |
| 79 | + } | |
| 80 | +} catch (e) { | |
| 81 | + console.log(`SKIP counters check: API unreachable (${e.message})`); | |
| 82 | +} | |
| 83 | + | |
| 84 | +await browser.close(); | |
| 85 | +console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK'); | |
| 86 | +process.exit(failures ? 1 : 0); | |
added
apps/web/src/app/[type]/[slug]/page.tsx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { entityMetadata, loadEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { PATH_TYPES, routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. | |
| 10 | + * (/models and /companies have their own segment folders; static segments win over this dynamic one.) | |
| 11 | + */ | |
| 12 | +type Params = { params: Promise<{ type: string; slug: string }> }; | |
| 13 | + | |
| 14 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 15 | + const { type, slug } = await params; | |
| 16 | + if (!PATH_TYPES[type]) return { title: 'Not found', robots: { index: false } }; | |
| 17 | + return entityMetadata(type, slug); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export default async function GenericEntityPage({ params }: Params) { | |
| 21 | + const { type, slug } = await params; | |
| 22 | + if (!PATH_TYPES[type]) notFound(); | |
| 23 | + const d = await loadEntity(type, slug); | |
| 24 | + const canonical = routes.entity(d); | |
| 25 | + if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); | |
| 26 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 27 | + return <EntityPage d={d} canonical={canonical} related={related?.items} />; | |
| 28 | +} | |
added
apps/web/src/app/about/page.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 4 | +import { api, safe } from '@/lib/api'; | |
| 5 | +import { fmtBytes, fmtDate, fmtInt } from '@/lib/format'; | |
| 6 | +import { CONTACT_EMAIL, routes } from '@/lib/site'; | |
| 7 | + | |
| 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 revalidate = 600; | |
| 10 | + | |
| 11 | +export default async function AboutPage() { | |
| 12 | + const stats = await safe(api.stats()); | |
| 13 | + return ( | |
| 14 | + <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." /> | |
| 16 | + <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> | |
| 20 | + </div> | |
| 21 | + {stats && ( | |
| 22 | + <Section eyebrow="Right now" title="The atlas in numbers" hairline={false}> | |
| 23 | + <StatGrid cols={5}> | |
| 24 | + <Stat label="Entities" value={fmtInt(stats.entities_total)} /> | |
| 25 | + <Stat label="Claims" value={fmtInt(stats.claims)} hint={`${fmtInt(stats.claims_current)} current`} /> | |
| 26 | + <Stat label="Relations" value={fmtInt(stats.relations)} /> | |
| 27 | + <Stat label="Snapshots archived" value={fmtInt(stats.snapshots)} hint={fmtBytes(stats.archive?.raw_bytes)} /> | |
| 28 | + <Stat label="Since" value={fmtDate(stats.first_entity_at)} /> | |
| 29 | + </StatGrid> | |
| 30 | + </Section> | |
| 31 | + )} | |
| 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> | |
| 36 | + </Section> | |
| 37 | + </Container> | |
| 38 | + ); | |
| 39 | +} | |
added
apps/web/src/app/apple-icon.tsx
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | + | |
| 3 | +/** 180×180 PNG generated from the AI Atlas mark (globe + relation graph) on the dark canvas. */ | |
| 4 | +export const size = { width: 180, height: 180 }; | |
| 5 | +export const contentType = 'image/png'; | |
| 6 | + | |
| 7 | +export default function AppleIcon() { | |
| 8 | + return new ImageResponse( | |
| 9 | + ( | |
| 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> | |
| 21 | + </div> | |
| 22 | + ), | |
| 23 | + { ...size }, | |
| 24 | + ); | |
| 25 | +} | |
added
apps/web/src/app/benchmarks/page.tsx
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 3 | +import { EntityLink } from '@/components/ui/entity'; | |
| 4 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 5 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtInt, fmtScore, num } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } }; | |
| 10 | +export const revalidate = 300; | |
| 11 | + | |
| 12 | +export default async function BenchmarksPage() { | |
| 13 | + const res = await safe(api.benchmarks()); | |
| 14 | + const items = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0)); | |
| 15 | + return ( | |
| 16 | + <Container> | |
| 17 | + <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} benchmarks</p> : undefined} /> | |
| 18 | + <div className="pb-16"> | |
| 19 | + {!res ? ( | |
| 20 | + <Unavailable what="Benchmarks" /> | |
| 21 | + ) : ( | |
| 22 | + <> | |
| 23 | + <DataTable caption="Benchmarks"> | |
| 24 | + <thead> | |
| 25 | + <tr> | |
| 26 | + <Th>Benchmark</Th> | |
| 27 | + <Th>Category</Th> | |
| 28 | + <Th>Metric</Th> | |
| 29 | + <Th num>Results</Th> | |
| 30 | + <Th num>Models</Th> | |
| 31 | + <Th>Current leader</Th> | |
| 32 | + </tr> | |
| 33 | + </thead> | |
| 34 | + <tbody> | |
| 35 | + {items.length === 0 && <EmptyRow cols={6}>No benchmarks recorded yet.</EmptyRow>} | |
| 36 | + {items.map((b) => { | |
| 37 | + const a = b.attributes ?? {}; | |
| 38 | + return ( | |
| 39 | + <tr key={b.id}> | |
| 40 | + <Td primary> | |
| 41 | + <EntityLink e={b} /> | |
| 42 | + {b.description && <span className="block max-w-md truncate text-xs text-ink-3">{b.description}</span>} | |
| 43 | + </Td> | |
| 44 | + <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? a.category : <span className="text-ink-3">—</span>}</Td> | |
| 45 | + <Td label="Metric" className="text-ink-2">{typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}</Td> | |
| 46 | + <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td> | |
| 47 | + <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td> | |
| 48 | + <Td label="Current leader"> | |
| 49 | + {b.top ? ( | |
| 50 | + <span> | |
| 51 | + <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}</span> | |
| 52 | + </span> | |
| 53 | + ) : ( | |
| 54 | + <span className="text-ink-3">—</span> | |
| 55 | + )} | |
| 56 | + </Td> | |
| 57 | + </tr> | |
| 58 | + ); | |
| 59 | + })} | |
| 60 | + </tbody> | |
| 61 | + </DataTable> | |
| 62 | + <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open the benchmark to see them.</Note> | |
| 63 | + </> | |
| 64 | + )} | |
| 65 | + </div> | |
| 66 | + </Container> | |
| 67 | + ); | |
| 68 | +} | |
added
apps/web/src/app/bot/page.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container, PageHeader, Section } from '@/components/ui/section'; | |
| 4 | +import { BOT_UA, CONTACT_EMAIL, routes } from '@/lib/site'; | |
| 5 | + | |
| 6 | +export const metadata: Metadata = { title: 'AIAtlasBot — crawler policy', description: 'About AIAtlasBot, the crawler that reads public AI documentation for AI Atlas: purpose, robots.txt compliance, rate limits and how to contact us or opt out.', alternates: { canonical: '/bot' } }; | |
| 7 | + | |
| 8 | +export default function BotPage() { | |
| 9 | + return ( | |
| 10 | + <Container> | |
| 11 | + <PageHeader eyebrow="Crawler" title={<span className="mono">{BOT_UA}</span>} lede="The crawler behind AI Atlas. It reads public documentation, pricing pages, model cards, papers, feeds and repositories to keep the atlas current and sourced." /> | |
| 12 | + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 13 | + <p> | |
| 14 | + User-Agent: <span className="mono text-ink">{BOT_UA}/0.1 (+https://www.ai-atlas.co/bot; {CONTACT_EMAIL})</span> | |
| 15 | + </p> | |
| 16 | + </div> | |
| 17 | + <Section eyebrow="Purpose" title="What it collects, and why" hairline={false}> | |
| 18 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">AIAtlasBot fetches publicly accessible pages that describe AI models, organizations, providers, prices, benchmarks, hardware, frameworks and datasets. Pages are archived and turned into attributed facts; each fact on AI Atlas links back to the page it came from, giving publishers credit and traffic. It does not collect personal data, does not log in, and does not fetch content behind paywalls or access controls.</p> | |
| 19 | + </Section> | |
| 20 | + <Section eyebrow="Behaviour" title="How it behaves"> | |
| 21 | + <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> | |
| 22 | + <li>Honours <span className="mono">robots.txt</span> (including <span className="mono">Crawl-delay</span>) for <span className="mono">{BOT_UA}</span> and <span className="mono">*</span>.</li> | |
| 23 | + <li>Per-domain rate limits (typically a few requests per minute; arXiv ≤ 4/min, Hugging Face ≤ 30/min, GitHub ≤ 20/min).</li> | |
| 24 | + <li>Conditional requests (<span className="mono">ETag</span> / <span className="mono">If-Modified-Since</span>) and content hashing so unchanged pages cost nothing.</li> | |
| 25 | + <li>Adaptive intervals: pages that rarely change are visited less often; failures back off with a circuit breaker.</li> | |
| 26 | + <li>Never bypasses CAPTCHAs or bot walls. If a site blocks us, the document is marked blocked and left alone.</li> | |
| 27 | + </ul> | |
| 28 | + </Section> | |
| 29 | + <Section eyebrow="Opt out" title="Blocking or contacting us"> | |
| 30 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">To exclude your site, add to your robots.txt:</p> | |
| 31 | + <pre className="mt-3 max-w-3xl border border-rule bg-surface p-4 text-[13px] text-ink"><code>{`User-agent: ${BOT_UA}\nDisallow: /`}</code></pre> | |
| 32 | + <p className="mt-3 max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 33 | + Changes are picked up on the next visit. For questions, corrections or removal requests write to <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>. The full list of sources is public on <Link href={routes.sources()} className="link">/sources</Link>. | |
| 34 | + </p> | |
| 35 | + </Section> | |
| 36 | + </Container> | |
| 37 | + ); | |
| 38 | +} | |
added
apps/web/src/app/changes/[date]/page.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { EntityLink } from '@/components/ui/entity'; | |
| 6 | +import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 7 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 8 | +import { api, safe } from '@/lib/api'; | |
| 9 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 10 | +import { categoryLabel, routes, SITE_NAME } from '@/lib/site'; | |
| 11 | + | |
| 12 | +type Params = { params: Promise<{ date: string }> }; | |
| 13 | +const VALID = /^\d{4}-\d{2}-\d{2}$/; | |
| 14 | + | |
| 15 | +function shift(date: string, days: number): string { | |
| 16 | + const d = new Date(`${date}T00:00:00Z`); | |
| 17 | + d.setUTCDate(d.getUTCDate() + days); | |
| 18 | + return d.toISOString().slice(0, 10); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 22 | + const { date } = await params; | |
| 23 | + if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } }; | |
| 24 | + const title = `What changed in AI on ${fmtDate(date)}`; | |
| 25 | + return { title, description: `Daily digest of AI ecosystem changes for ${fmtDate(date)}: new models, prices, benchmarks, deprecations and announcements — generated from the ${SITE_NAME} database.`, alternates: { canonical: routes.changesDay(date) } }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export default async function DailyPage({ params }: Params) { | |
| 29 | + const { date } = await params; | |
| 30 | + if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound(); | |
| 31 | + const today = new Date().toISOString().slice(0, 10); | |
| 32 | + if (date > today) notFound(); | |
| 33 | + const d = await safe(api.changesDaily(date)); | |
| 34 | + const total = d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0; | |
| 35 | + const cats = d ? Object.entries(d.counts ?? {}).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0)) : []; | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <Container> | |
| 39 | + <PageHeader | |
| 40 | + eyebrow={<><Link href={routes.changes()} className="hover:text-ink">Changes</Link><span aria-hidden>/</span><span>Daily digest</span></>} | |
| 41 | + title={<>What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span></>} | |
| 42 | + lede={d ? `${fmtInt(total)} events across ${cats.length} categories, generated from the database — nothing editorial.` : undefined} | |
| 43 | + aside={ | |
| 44 | + <nav className="flex items-center gap-2 text-sm" aria-label="Day navigation"> | |
| 45 | + <Link href={routes.changesDay(shift(date, -1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev">‹ {fmtDate(shift(date, -1))}</Link> | |
| 46 | + {date < today && <Link href={routes.changesDay(shift(date, 1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next">{fmtDate(shift(date, 1))} ›</Link>} | |
| 47 | + </nav> | |
| 48 | + } | |
| 49 | + /> | |
| 50 | + <div className="pb-16"> | |
| 51 | + {!d ? ( | |
| 52 | + <Unavailable what="Daily digest" /> | |
| 53 | + ) : total === 0 && !d.new_models?.length ? ( | |
| 54 | + <EmptyState title="Nothing recorded on this day">Connectors may not have observed changes, or the day predates the archive.</EmptyState> | |
| 55 | + ) : ( | |
| 56 | + <> | |
| 57 | + {cats.length > 0 && ( | |
| 58 | + <StatGrid cols={cats.length >= 6 ? 6 : cats.length >= 4 ? 4 : 3}> | |
| 59 | + {cats.slice(0, 6).map(([c, n]) => ( | |
| 60 | + <Stat key={c} label={categoryLabel(c)} value={fmtInt(n)} href={`/changes?category=${encodeURIComponent(c)}&since=${date}`} /> | |
| 61 | + ))} | |
| 62 | + </StatGrid> | |
| 63 | + )} | |
| 64 | + {d.new_models?.length > 0 && ( | |
| 65 | + <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false}> | |
| 66 | + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 67 | + {d.new_models.map((m) => ( | |
| 68 | + <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm"> | |
| 69 | + <EntityLink e={m} className="font-medium" /> | |
| 70 | + <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span> | |
| 71 | + </li> | |
| 72 | + ))} | |
| 73 | + </ul> | |
| 74 | + </Section> | |
| 75 | + )} | |
| 76 | + {d.sections | |
| 77 | + .filter((s) => s.items.length) | |
| 78 | + .map((s) => ( | |
| 79 | + <Section key={s.category} eyebrow={categoryLabel(s.category)} title={s.label} action={{ href: `/changes?category=${encodeURIComponent(s.category)}`, label: 'All' }}> | |
| 80 | + <ul className="border-t border-rule"> | |
| 81 | + {s.items.map((e) => ( | |
| 82 | + <ChangeRow key={e.id} e={e} live={false} /> | |
| 83 | + ))} | |
| 84 | + </ul> | |
| 85 | + </Section> | |
| 86 | + ))} | |
| 87 | + </> | |
| 88 | + )} | |
| 89 | + </div> | |
| 90 | + </Container> | |
| 91 | + ); | |
| 92 | +} | |
added
apps/web/src/app/changes/page.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ChangeRow, groupByDay } from '@/components/changes/change-row'; | |
| 4 | +import { LoadMore } from '@/components/changes/load-more'; | |
| 5 | +import { ActiveFilters, FilterBar } from '@/components/listing/filters'; | |
| 6 | +import { withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 11 | +import { categoryLabel, eventLabel, IMPORTANCE_LABELS, routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { | |
| 14 | + title: 'Changes — what changed in AI, as it happens', | |
| 15 | + description: 'A live, source-attributed feed of changes in the AI ecosystem: new models, price moves, context changes, deprecations, benchmark results, announcements.', | |
| 16 | + alternates: { canonical: '/changes' }, | |
| 17 | +}; | |
| 18 | +export const revalidate = 60; | |
| 19 | + | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | +const KEYS = ['category', 'type', 'entity_type', 'importance_min', 'since', 'until', 'q'] as const; | |
| 22 | +const LIMIT = 50; | |
| 23 | + | |
| 24 | +export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 25 | + const sp = await searchParams; | |
| 26 | + const current: Record<string, string | undefined> = {}; | |
| 27 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 28 | + const [page, cats] = await Promise.all([safe(api.changes({ ...current, limit: LIMIT })), safe(api.changesCategories(7))]); | |
| 29 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/changes', current, patch); | |
| 30 | + const qs = new URLSearchParams(Object.entries(current).filter(([, v]) => v) as [string, string][]).toString(); | |
| 31 | + const groups = groupByDay(page?.items ?? []); | |
| 32 | + const last = page?.items[page.items.length - 1]; | |
| 33 | + const cursor = page && page.items.length >= LIMIT && last ? last.observed_at : null; | |
| 34 | + const catCounts = new Map<string, number>(); | |
| 35 | + const typeCounts = new Map<string, number>(); | |
| 36 | + for (const c of cats?.items ?? []) { | |
| 37 | + catCounts.set(c.category, (catCounts.get(c.category) ?? 0) + (num(c.count) ?? 0)); | |
| 38 | + typeCounts.set(c.event_type, (typeCounts.get(c.event_type) ?? 0) + (num(c.count) ?? 0)); | |
| 39 | + } | |
| 40 | + const catOptions = [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${categoryLabel(value)} (${fmtInt(n)})` })); | |
| 41 | + const typeOptions = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${eventLabel(value)} (${fmtInt(n)})` })); | |
| 42 | + const today = new Date().toISOString().slice(0, 10); | |
| 43 | + | |
| 44 | + return ( | |
| 45 | + <Container> | |
| 46 | + <PageHeader eyebrow="Changes" title="What changed in AI" lede="Every material change the connectors observe becomes an event with a source. Filter by category, event type and importance; older events load with a cursor." aside={<Link href={routes.changesDay(today)} className="link text-sm">Today's digest →</Link>}> | |
| 47 | + <FilterBar | |
| 48 | + action="/changes" | |
| 49 | + className="mt-6" | |
| 50 | + resetHref={routes.changes()} | |
| 51 | + fields={[ | |
| 52 | + { kind: 'select', name: 'category', label: 'Category', value: current.category, options: catOptions }, | |
| 53 | + { kind: 'select', name: 'type', label: 'Event type', value: current.type, options: typeOptions }, | |
| 54 | + { kind: 'select', name: 'importance_min', label: 'Min importance', value: current.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) }, | |
| 55 | + { kind: 'text', name: 'since', label: 'Since', value: current.since, placeholder: 'YYYY-MM-DD' }, | |
| 56 | + { kind: 'text', name: 'q', label: 'Text', value: current.q, placeholder: 'in summary' }, | |
| 57 | + ]} | |
| 58 | + /> | |
| 59 | + <ActiveFilters current={current} labels={{ category: 'category', type: 'type', importance_min: 'importance ≥', since: 'since', until: 'until', q: 'text', entity_type: 'entity' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 60 | + {cats && catOptions.length > 0 && ( | |
| 61 | + <p className="mt-4 text-xs text-ink-3"> | |
| 62 | + Last 7 days: {[...catCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([c, n], i) => ( | |
| 63 | + <span key={c}> | |
| 64 | + {i > 0 && ' · '} | |
| 65 | + <Link href={href({ category: c })} className="hover:text-ink">{categoryLabel(c)} <span className="tnum text-ink-2">{fmtInt(n)}</span></Link> | |
| 66 | + </span> | |
| 67 | + ))} | |
| 68 | + </p> | |
| 69 | + )} | |
| 70 | + </PageHeader> | |
| 71 | + | |
| 72 | + <div className="pb-16"> | |
| 73 | + {!page ? ( | |
| 74 | + <Unavailable what="Change feed" /> | |
| 75 | + ) : page.items.length === 0 ? ( | |
| 76 | + <EmptyState title="No events match these filters">The change engine only emits events when a source states a material change — try widening the filters.</EmptyState> | |
| 77 | + ) : ( | |
| 78 | + <> | |
| 79 | + <p className="tnum text-xs text-ink-3">{fmtInt(page.total)} events{current.since ? ` since ${fmtDate(current.since)}` : ''}</p> | |
| 80 | + {groups.map((g) => ( | |
| 81 | + <section key={g.day} className="mt-6"> | |
| 82 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 83 | + <Link href={routes.changesDay(g.day)} className="hover:text-ink">{fmtDate(g.day)}</Link> <span className="tnum text-ink-3">{g.items.length}</span> | |
| 84 | + </h2> | |
| 85 | + <ul className="border-t border-rule"> | |
| 86 | + {g.items.map((e) => ( | |
| 87 | + <ChangeRow key={e.id} e={e} /> | |
| 88 | + ))} | |
| 89 | + </ul> | |
| 90 | + </section> | |
| 91 | + ))} | |
| 92 | + <LoadMore qs={qs} initialCursor={cursor} lastDay={groups[groups.length - 1]?.day ?? null} /> | |
| 93 | + </> | |
| 94 | + )} | |
| 95 | + </div> | |
| 96 | + </Container> | |
| 97 | + ); | |
| 98 | +} | |
added
apps/web/src/app/companies/[slug]/page.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { entityMetadata, loadEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +type Params = { params: Promise<{ slug: string }> }; | |
| 9 | + | |
| 10 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 11 | + const { slug } = await params; | |
| 12 | + return entityMetadata('companies', slug); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export default async function CompanyPage({ params }: Params) { | |
| 16 | + const { slug } = await params; | |
| 17 | + const d = await loadEntity('companies', slug); | |
| 18 | + if (d.slug !== slug) permanentRedirect(routes.entity(d)); | |
| 19 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 20 | + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; | |
| 21 | +} | |
added
apps/web/src/app/companies/page.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtInt, num, titleCase } from '@/lib/format'; | |
| 11 | +import { routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { | |
| 14 | + title: 'AI companies, labs and organizations', | |
| 15 | + description: 'Companies, research labs and organizations building AI: country, kind, models and papers in the atlas, with sources for every attribute.', | |
| 16 | + alternates: { canonical: '/companies' }, | |
| 17 | +}; | |
| 18 | +export const revalidate = 300; | |
| 19 | + | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | +const LIMIT = 50; | |
| 22 | +const KEYS = ['q', 'country', 'kind', 'sort', 'order', 'offset'] as const; | |
| 23 | +const SORTS = [ | |
| 24 | + { value: 'models', label: 'Most models' }, | |
| 25 | + { value: 'name', label: 'Name' }, | |
| 26 | + { value: 'updated', label: 'Recently updated' }, | |
| 27 | + { value: 'quality', label: 'Data quality' }, | |
| 28 | +]; | |
| 29 | + | |
| 30 | +export default async function CompaniesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 31 | + const sp = await searchParams; | |
| 32 | + const current: Record<string, string | undefined> = {}; | |
| 33 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 34 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 35 | + const sort = current.sort ?? 'models'; | |
| 36 | + const page = await safe(api.companies({ ...current, sort, limit: LIMIT, offset, facets: 1 })); | |
| 37 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/companies', current, patch); | |
| 38 | + const facetGroups: FacetGroup[] = [ | |
| 39 | + { key: 'country', label: 'Country', items: (page?.facets?.countries ?? []).map((x) => ({ value: x.value, count: x.count })) }, | |
| 40 | + { key: 'kind', label: 'Kind', items: (page?.facets?.kinds ?? []).map((x) => ({ value: x.value, label: titleCase(x.value), count: x.count })) }, | |
| 41 | + ]; | |
| 42 | + const sortHref = (s: string) => href({ sort: s, order: sort === s && current.order !== 'asc' ? 'asc' : undefined, offset: undefined }); | |
| 43 | + const SortTh = ({ s, children, num: n }: { s: string; children: React.ReactNode; num?: boolean }) => ( | |
| 44 | + <Th num={n} aria-sort={sort === s ? (current.order === 'asc' ? 'ascending' : 'descending') : undefined}> | |
| 45 | + <Link href={sortHref(s)} className={sort === s ? 'text-ink' : 'hover:text-ink'}> | |
| 46 | + {children} | |
| 47 | + {sort === s && <span aria-hidden> {current.order === 'asc' ? '↑' : '↓'}</span>} | |
| 48 | + </Link> | |
| 49 | + </Th> | |
| 50 | + ); | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <Container wide> | |
| 54 | + <PageHeader eyebrow="Companies" title="Companies, labs & organizations" lede="Who builds, serves and studies AI. Model and paper counts are live from the graph." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} organizations</p> : undefined}> | |
| 55 | + <FilterBar | |
| 56 | + action="/companies" | |
| 57 | + className="mt-6" | |
| 58 | + resetHref={routes.companies()} | |
| 59 | + fields={[ | |
| 60 | + { kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'e.g. Anthropic' }, | |
| 61 | + { kind: 'text', name: 'country', label: 'Country (ISO-2)', value: current.country, placeholder: 'US, FR, CN…' }, | |
| 62 | + ...(current.kind ? [{ kind: 'hidden' as const, name: 'kind', value: current.kind }] : []), | |
| 63 | + ]} | |
| 64 | + sort={{ value: sort, options: SORTS }} | |
| 65 | + /> | |
| 66 | + <ActiveFilters current={current} labels={{ q: 'query', country: 'country', kind: 'kind' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 67 | + </PageHeader> | |
| 68 | + <div className="pb-16"> | |
| 69 | + <ListingLayout facets={<Facets groups={facetGroups} current={current} makeHref={(p) => href(p)} />}> | |
| 70 | + {!page ? ( | |
| 71 | + <Unavailable what="Companies" /> | |
| 72 | + ) : ( | |
| 73 | + <> | |
| 74 | + <DataTable caption="Companies"> | |
| 75 | + <thead> | |
| 76 | + <tr> | |
| 77 | + <SortTh s="name">Organization</SortTh> | |
| 78 | + <Th>Country</Th> | |
| 79 | + <Th>Kind</Th> | |
| 80 | + <Th>Founded</Th> | |
| 81 | + <SortTh s="models" num>Models</SortTh> | |
| 82 | + <Th num>Papers</Th> | |
| 83 | + <SortTh s="quality" num>Quality</SortTh> | |
| 84 | + </tr> | |
| 85 | + </thead> | |
| 86 | + <tbody> | |
| 87 | + {page.items.length === 0 && <EmptyRow cols={7}>No organizations match these filters.</EmptyRow>} | |
| 88 | + {page.items.map((c) => { | |
| 89 | + const a = c.attributes ?? {}; | |
| 90 | + return ( | |
| 91 | + <tr key={c.id}> | |
| 92 | + <Td primary> | |
| 93 | + <EntityLink e={c} /> | |
| 94 | + {c.description && <span className="block max-w-md truncate text-xs text-ink-3">{c.description}</span>} | |
| 95 | + </Td> | |
| 96 | + <Td label="Country" className="mono text-ink-2">{typeof a.country === 'string' ? a.country : <span className="text-ink-3">—</span>}</Td> | |
| 97 | + <Td label="Kind" className="text-ink-2">{typeof a.org_kind === 'string' ? titleCase(a.org_kind) : <span className="text-ink-3">—</span>}</Td> | |
| 98 | + <Td label="Founded" className="tnum text-ink-2">{a.founded ? String(a.founded).slice(0, 4) : <span className="text-ink-3">—</span>}</Td> | |
| 99 | + <Td num label="Models" className="tnum">{num(c.model_count) ? <Link href={`/models?org=${encodeURIComponent(c.slug)}`} className="hover:text-accent">{fmtInt(c.model_count)}</Link> : <span className="text-ink-3">0</span>}</Td> | |
| 100 | + <Td num label="Papers" className="tnum">{num(c.paper_count) ? fmtInt(c.paper_count) : <span className="text-ink-3">0</span>}</Td> | |
| 101 | + <Td num label="Quality"><QualityMark q={c.quality?.score} /></Td> | |
| 102 | + </tr> | |
| 103 | + ); | |
| 104 | + })} | |
| 105 | + </tbody> | |
| 106 | + </DataTable> | |
| 107 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 108 | + </> | |
| 109 | + )} | |
| 110 | + </ListingLayout> | |
| 111 | + </div> | |
| 112 | + </Container> | |
| 113 | + ); | |
| 114 | +} | |
added
apps/web/src/app/compare/page.tsx
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 4 | +import { DataTable, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { EntityLink } from '@/components/ui/entity'; | |
| 6 | +import { ProvenanceInline } from '@/components/ui/provenance'; | |
| 7 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtDate, fmtValue, num } from '@/lib/format'; | |
| 11 | +import { routes, typeLabel } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical: '/compare' } }; | |
| 14 | +export const revalidate = 300; | |
| 15 | + | |
| 16 | +export default async function ComparePage({ searchParams }: { searchParams: Promise<{ ids?: string }> }) { | |
| 17 | + const { ids: raw } = await searchParams; | |
| 18 | + const ids = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6); | |
| 19 | + const res = ids.length >= 2 ? await safe(api.compare(ids)) : null; | |
| 20 | + return ( | |
| 21 | + <Container wide> | |
| 22 | + <PageHeader eyebrow="Compare" title={res ? `Comparing ${res.items.length} ${typeLabel(res.entity_type, true).toLowerCase()}` : 'Compare side by side'} lede="Two to six entities of one type. Each cell shows the recorded value and, on hover, where it came from."> | |
| 23 | + <form action="/compare" method="get" className="mt-6 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 24 | + <label htmlFor="ids" className="sr-only">Slugs, comma separated</label> | |
| 25 | + <input id="ids" name="ids" defaultValue={ids.join(',')} placeholder="slugs separated by commas, e.g. claude-opus-5,gpt-5,gemini-3-pro" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} /> | |
| 26 | + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Compare</button> | |
| 27 | + </form> | |
| 28 | + <Note className="mt-2">Find slugs in any entity URL, or use “Add to a comparison” on entity pages. Picker UI is coming.</Note> | |
| 29 | + </PageHeader> | |
| 30 | + <div className="pb-16"> | |
| 31 | + {ids.length < 2 ? ( | |
| 32 | + <EmptyState title="Enter at least two slugs"> | |
| 33 | + Example: <Link href={routes.compare(['claude-opus-5', 'gpt-5'])} className="link">claude-opus-5 vs gpt-5</Link> | |
| 34 | + </EmptyState> | |
| 35 | + ) : !res ? ( | |
| 36 | + <Unavailable what="Comparison" reason="One of the slugs may not exist, or the entities are of different types." /> | |
| 37 | + ) : ( | |
| 38 | + <> | |
| 39 | + <DataTable scroll stack={false} caption="Comparison"> | |
| 40 | + <thead> | |
| 41 | + <tr> | |
| 42 | + <Th>Dimension</Th> | |
| 43 | + {res.items.map((it) => ( | |
| 44 | + <Th key={it.entity.id}> | |
| 45 | + <span className="flex flex-col items-start gap-0.5 normal-case tracking-normal"> | |
| 46 | + <EntityBadge type={it.entity.entity_type} small /> | |
| 47 | + <EntityLink e={it.entity} className="text-sm font-semibold" /> | |
| 48 | + {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>} | |
| 49 | + </span> | |
| 50 | + </Th> | |
| 51 | + ))} | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + {res.dimensions.map((dim) => { | |
| 56 | + const nums = res.items.map((it) => num(it.values[dim.key])); | |
| 57 | + const best = dim.kind === 'number' && nums.some((n) => n !== null) ? Math.max(...(nums.filter((n): n is number => n !== null))) : null; | |
| 58 | + return ( | |
| 59 | + <tr key={dim.key}> | |
| 60 | + <Td className="text-ink-2"> | |
| 61 | + {dim.label} | |
| 62 | + {dim.unit && <span className="text-ink-3"> · {dim.unit}</span>} | |
| 63 | + </Td> | |
| 64 | + {res.items.map((it) => { | |
| 65 | + const v = it.values[dim.key]; | |
| 66 | + const p = it.provenance?.[dim.key]; | |
| 67 | + const n = num(v); | |
| 68 | + return ( | |
| 69 | + <Td key={it.entity.id} className={dim.kind === 'number' ? 'tnum' : undefined}> | |
| 70 | + <span className={best !== null && n === best && nums.filter((x) => x !== null).length > 1 ? 'font-semibold text-ink' : undefined} title={p ? `${p.source_name ?? p.url ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}> | |
| 71 | + {v === null || v === undefined || v === '' ? <span className="text-ink-3">Unavailable</span> : dim.kind === 'date' ? fmtDate(String(v)) : fmtValue(v, dim.key)} | |
| 72 | + </span> | |
| 73 | + {p && <ProvenanceInline p={p} showConfidence={false} className="hidden md:flex" />} | |
| 74 | + </Td> | |
| 75 | + ); | |
| 76 | + })} | |
| 77 | + </tr> | |
| 78 | + ); | |
| 79 | + })} | |
| 80 | + </tbody> | |
| 81 | + </DataTable> | |
| 82 | + <Note className="mt-3">Bold marks the highest number in a row; it is not a verdict. Benchmark rows only appear when all compared entities have a result under a comparable configuration.</Note> | |
| 83 | + </> | |
| 84 | + )} | |
| 85 | + </div> | |
| 86 | + </Container> | |
| 87 | + ); | |
| 88 | +} | |
added
apps/web/src/app/datasets/page.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | + | |
| 4 | +export const metadata: Metadata = { title: 'AI datasets', description: 'Training and evaluation datasets with modality, size, license and publisher, linked to the models trained on them.', alternates: { canonical: '/datasets' } }; | |
| 5 | +export const revalidate = 300; | |
| 6 | + | |
| 7 | +export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 8 | + const sp = await searchParams; | |
| 9 | + return <GenericListing type="dataset" basePath="/datasets" eyebrow="Datasets" title="Datasets" lede="Training and evaluation corpora as documented on Hugging Face and publisher pages." searchParams={sp} />; | |
| 10 | +} | |
added
apps/web/src/app/developers/page.tsx
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { DataTable, Td, Th } from '@/components/ui/data-table'; | |
| 4 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { PUBLIC_API_BASE, routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, examples with curl.', alternates: { canonical: '/developers' } }; | |
| 9 | +export const revalidate = 3600; | |
| 10 | + | |
| 11 | +const ENDPOINTS: { path: string; returns: string }[] = [ | |
| 12 | + { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' }, | |
| 13 | + { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size' }, | |
| 14 | + { path: 'GET /stats/history?days=90', returns: 'Daily counts' }, | |
| 15 | + { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search; returns the compiled filters and ranked entities' }, | |
| 16 | + { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions' }, | |
| 17 | + { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance, relations, sources, timeline and type-specific blocks (prices, results, lineage, hardware_fit, models, papers)' }, | |
| 18 | + { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' }, | |
| 19 | + { path: 'GET /entities/{slug}/timeline · /history?property= · /asof?date= · /graph?depth= · /sources · /related', returns: 'Events, full claim history, state as of a date, neighbourhood graph, documents, related entities' }, | |
| 20 | + { path: 'GET /models?q=&org=&openness=&modality=&status=&min_params=&min_context=&year_from=&license=&sort=&facets=1', returns: 'Paged models with facets' }, | |
| 21 | + { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model and paper counts' }, | |
| 22 | + { path: 'GET /papers?q=&category=&org=&since=&until=', returns: 'Paged papers' }, | |
| 23 | + { path: 'GET /providers · GET /prices?model=&provider=¤t=1 · GET /prices/history · GET /prices/index?days=', returns: 'Providers, current prices, full price history, median/min price index and movers' }, | |
| 24 | + { path: 'GET /benchmarks · GET /benchmarks/{slug}/results · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks, leaderboards (with config), result history' }, | |
| 25 | + { path: 'GET /hardware · GET /hardware/fit?memory_gb=&quant=&context=', returns: 'Hardware listing and the estimated fit tool' }, | |
| 26 | + { path: 'GET /explore/types · GET /explore/{type}', returns: 'Entity types with counts; generic listing for any type' }, | |
| 27 | + { path: 'GET /changes?category=&type=&importance_min=&since=&before= · GET /changes/daily?date= · GET /changes/categories', returns: 'Change-event feed (cursor paged), daily digest, category counts' }, | |
| 28 | + { path: 'GET /timeline?entity=&year=&category=', returns: 'Events grouped by month' }, | |
| 29 | + { path: 'GET /compare?ids=a,b,c', returns: 'Side-by-side dimensions with provenance (2–6 entities of one type)' }, | |
| 30 | + { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=', returns: 'What changed between two dates' }, | |
| 31 | + { path: 'GET /sources · GET /methodology · GET /trending · GET /sitemap', returns: 'Transparency, vocabularies, most viewed, sitemap feed' }, | |
| 32 | +]; | |
| 33 | + | |
| 34 | +export default async function DevelopersPage() { | |
| 35 | + const health = await safe(api.health()); | |
| 36 | + return ( | |
| 37 | + <Container> | |
| 38 | + <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} /> | |
| 39 | + | |
| 40 | + <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}> | |
| 41 | + <p className="text-sm text-ink-2"> | |
| 42 | + Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503. | |
| 43 | + </p> | |
| 44 | + <pre className="scrollbar-thin mt-4 overflow-x-auto border border-rule bg-surface p-4 text-[13px] leading-relaxed text-ink"><code>{`# Live counters | |
| 45 | +curl -s ${PUBLIC_API_BASE}/stats | jq '.entities' | |
| 46 | + | |
| 47 | +# Natural-language search → compiled filters + ranked results | |
| 48 | +curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]' | |
| 49 | + | |
| 50 | +# A model with provenance, prices, results, lineage | |
| 51 | +curl -s ${PUBLIC_API_BASE}/models/claude-opus-5 | jq '{name, attributes, provenance: (.provenance | keys)}' | |
| 52 | + | |
| 53 | +# Price history for one model across providers | |
| 54 | +curl -s "${PUBLIC_API_BASE}/prices/history?model=claude-opus-5" | jq '.items[] | {provider: .provider.name, input_per_mtok, valid_from, valid_to}' | |
| 55 | + | |
| 56 | +# What changed today | |
| 57 | +curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</code></pre> | |
| 58 | + </Section> | |
| 59 | + | |
| 60 | + <Section eyebrow="Endpoints" title="Public routes"> | |
| 61 | + <DataTable caption="Public endpoints" compact> | |
| 62 | + <thead><tr><Th>Route</Th><Th>Returns</Th></tr></thead> | |
| 63 | + <tbody> | |
| 64 | + {ENDPOINTS.map((e) => ( | |
| 65 | + <tr key={e.path}> | |
| 66 | + <Td primary className="mono text-[12.5px] break-words">{e.path}</Td> | |
| 67 | + <Td label="Returns" wide className="text-ink-2">{e.returns}</Td> | |
| 68 | + </tr> | |
| 69 | + ))} | |
| 70 | + </tbody> | |
| 71 | + </DataTable> | |
| 72 | + <Note className="mt-3">Shapes are documented in the OpenAPI schema. Postgres aggregates may arrive as strings; treat numeric fields as <span className="mono">number | string | null</span>.</Note> | |
| 73 | + </Section> | |
| 74 | + | |
| 75 | + <Section eyebrow="Terms" title="Fair use"> | |
| 76 | + <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> | |
| 77 | + <li>Public routes are free to use without a key. Please cache responses and identify your client with a User-Agent that includes a contact address.</li> | |
| 78 | + <li>Search is rate-limited per IP (HTTP 429 when exceeded). Higher limits and developer keys (<span className="mono">x-api-key</span>) will be available on request — see <Link href={routes.about()} className="link">contact</Link>.</li> | |
| 79 | + <li>Attribution: “Data: AI Atlas (www.ai-atlas.co)” with a link. Every record carries its own upstream sources; please keep them when you redistribute.</li> | |
| 80 | + <li>Never treat a missing field as zero. <span className="mono">null</span> means the sources did not state it.</li> | |
| 81 | + </ul> | |
| 82 | + </Section> | |
| 83 | + </Container> | |
| 84 | + ); | |
| 85 | +} | |
added
apps/web/src/app/error.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container } from '@/components/ui/section'; | |
| 4 | + | |
| 5 | +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { | |
| 6 | + return ( | |
| 7 | + <Container className="py-20 md:py-28"> | |
| 8 | + <p className="eyebrow">Error</p> | |
| 9 | + <h1 className="display mt-2 text-3xl md:text-5xl">Something went wrong.</h1> | |
| 10 | + <p className="mt-4 max-w-xl text-ink-2">The page could not be rendered. The dataset itself is fine — this is usually the API being briefly unavailable.</p> | |
| 11 | + {error.digest && <p className="mono mt-2 text-xs text-ink-3">ref {error.digest}</p>} | |
| 12 | + <div className="mt-6 flex gap-3 text-sm"> | |
| 13 | + <button type="button" onClick={reset} className="bg-ink px-3 py-2 text-canvas hover:opacity-90"> | |
| 14 | + Try again | |
| 15 | + </button> | |
| 16 | + <Link href="/" className="border border-rule px-3 py-2 text-ink-2 hover:text-ink"> | |
| 17 | + Home | |
| 18 | + </Link> | |
| 19 | + </div> | |
| 20 | + </Container> | |
| 21 | + ); | |
| 22 | +} | |
added
apps/web/src/app/explore/[type]/[slug]/page.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { buildMetadata, loadAnyEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { routes, TYPE_PATH } from '@/lib/site'; | |
| 7 | + | |
| 8 | +/** Fallback detail page for entity types without a dedicated path (regulation, incident, researcher, …). */ | |
| 9 | +type Params = { params: Promise<{ type: string; slug: string }> }; | |
| 10 | + | |
| 11 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 12 | + const { type, slug } = await params; | |
| 13 | + const d = await safe(api.entity(slug)); | |
| 14 | + if (!d || d.entity_type !== type) return { title: 'Not found', robots: { index: false } }; | |
| 15 | + return buildMetadata(d); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default async function ExploreEntityPage({ params }: Params) { | |
| 19 | + const { type, slug } = await params; | |
| 20 | + const d = await loadAnyEntity(type, slug); | |
| 21 | + if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d)); | |
| 22 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 23 | + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; | |
| 24 | +} | |
added
apps/web/src/app/explore/[type]/page.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 3 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 4 | +import { TYPE_LABELS, TYPE_PATH, typeLabel } from '@/lib/site'; | |
| 5 | + | |
| 6 | +type Params = { params: Promise<{ type: string }>; searchParams: Promise<Record<string, string | undefined>> }; | |
| 7 | + | |
| 8 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 9 | + const { type } = await params; | |
| 10 | + if (!/^[a-z_]+$/.test(type)) return { title: 'Not found', robots: { index: false } }; | |
| 11 | + return { title: `${typeLabel(type, true)} — explore`, description: `All ${typeLabel(type, true).toLowerCase()} in AI Atlas, with sources and history.`, alternates: { canonical: `/explore/${type}` } }; | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** Generic listing for any type. Types with a dedicated section redirect there (one canonical listing per type). */ | |
| 15 | +export default async function ExploreTypePage({ params, searchParams }: Params) { | |
| 16 | + const { type } = await params; | |
| 17 | + if (!/^[a-z_]+$/.test(type) || type.length > 32) notFound(); | |
| 18 | + if (TYPE_PATH[type]) permanentRedirect(`/${TYPE_PATH[type]}`); | |
| 19 | + if (!TYPE_LABELS[type]) notFound(); | |
| 20 | + const sp = await searchParams; | |
| 21 | + return <GenericListing type={type} basePath={`/explore/${type}`} eyebrow="Explore" title={typeLabel(type, true)} searchParams={sp} />; | |
| 22 | +} | |
added
apps/web/src/app/explore/page.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 4 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 5 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtInt, num } from '@/lib/format'; | |
| 8 | +import { routes, typeLabel } from '@/lib/site'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: 'Explore — every entity type with live counts', description: 'Browse the AI Atlas graph by entity type: models, companies, papers, providers, benchmarks, hardware, frameworks, datasets, tools and more.', alternates: { canonical: '/explore' } }; | |
| 11 | +export const revalidate = 300; | |
| 12 | + | |
| 13 | +const BLURB: Record<string, string> = { | |
| 14 | + model: 'Foundation and fine-tuned models: parameters, context, openness, prices, benchmarks, lineage.', | |
| 15 | + company: 'Labs and companies that develop, serve or study AI.', | |
| 16 | + paper: 'Research papers linked to the models and benchmarks they describe.', | |
| 17 | + provider: 'Inference providers and their published prices per 1M tokens.', | |
| 18 | + benchmark: 'Evaluation suites with results and configurations.', | |
| 19 | + hardware: 'GPUs, accelerators and devices with memory and bandwidth specs.', | |
| 20 | + framework: 'Training, inference and agent frameworks.', | |
| 21 | + dataset: 'Training and evaluation datasets.', | |
| 22 | + tool: 'Developer tools, agents and MCP servers.', | |
| 23 | + repository: 'Code repositories linked to models and frameworks.', | |
| 24 | + regulation: 'Laws, standards and policy instruments affecting AI.', | |
| 25 | + incident: 'Reported incidents and safety events.', | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export default async function ExplorePage() { | |
| 29 | + const res = await safe(api.exploreTypes()); | |
| 30 | + const items = (res?.items ?? []).slice().sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)); | |
| 31 | + const total = items.reduce((n, t) => n + (num(t.count) ?? 0), 0); | |
| 32 | + return ( | |
| 33 | + <Container> | |
| 34 | + <PageHeader eyebrow="Explore" title="The atlas by entity type" lede="Counts are live from the graph. Types without a dedicated section open a generic listing." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} entities</p> : undefined} /> | |
| 35 | + <div className="pb-16"> | |
| 36 | + {!res ? ( | |
| 37 | + <Unavailable what="Entity types" /> | |
| 38 | + ) : ( | |
| 39 | + <ul className="grid border-l border-t border-rule sm:grid-cols-2 lg:grid-cols-3"> | |
| 40 | + {items.map((t) => ( | |
| 41 | + <li key={t.entity_type} className="border-b border-r border-rule"> | |
| 42 | + <Link href={routes.listing(t.entity_type)} className="flex h-full flex-col px-5 py-5 hover:bg-surface-2"> | |
| 43 | + <div className="flex items-center justify-between"> | |
| 44 | + <EntityBadge type={t.entity_type} /> | |
| 45 | + <span className="tnum text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</span> | |
| 46 | + </div> | |
| 47 | + <p className="mt-3 font-medium">{t.label || typeLabel(t.entity_type, true)}</p> | |
| 48 | + <p className="mt-1 text-sm text-ink-2">{BLURB[t.entity_type] ?? `All ${typeLabel(t.entity_type, true).toLowerCase()} in the graph.`}</p> | |
| 49 | + </Link> | |
| 50 | + </li> | |
| 51 | + ))} | |
| 52 | + </ul> | |
| 53 | + )} | |
| 54 | + </div> | |
| 55 | + </Container> | |
| 56 | + ); | |
| 57 | +} | |
added
apps/web/src/app/frameworks/page.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | + | |
| 4 | +export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes', description: 'Training, inference and agent frameworks with versions, licenses and repository metrics from their official sources.', alternates: { canonical: '/frameworks' } }; | |
| 5 | +export const revalidate = 300; | |
| 6 | + | |
| 7 | +export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 8 | + const sp = await searchParams; | |
| 9 | + return <GenericListing type="framework" basePath="/frameworks" eyebrow="Frameworks" title="Frameworks & runtimes" lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages." searchParams={sp} />; | |
| 10 | +} | |
added
apps/web/src/app/globals.css
+495 −0
@@ -0,0 +1,495 @@ | ||
| 1 | +@import 'tailwindcss'; | |
| 2 | + | |
| 3 | +/* | |
| 4 | + AI Atlas design tokens — institutional, dense, readable. Two themes on <html data-theme="light|dark">. | |
| 5 | + Light: warm paper canvas, near-black ink, atlas blue accent. Dark: graphite canvas, off-white ink, brighter blue. | |
| 6 | + Amber (accent-2) is reserved for money and change; positive/warning/danger are semantic only. | |
| 7 | + Tier colours grade source quality (1 official → 4 unverified); entity-type colours identify badges everywhere. | |
| 8 | +*/ | |
| 9 | + | |
| 10 | +:root, | |
| 11 | +[data-theme='light'] { | |
| 12 | + color-scheme: light; | |
| 13 | + --canvas: #f6f6f3; | |
| 14 | + --surface: #ffffff; | |
| 15 | + --surface-2: #eeeeea; | |
| 16 | + --surface-3: #e3e4de; | |
| 17 | + --ink: #101216; | |
| 18 | + --ink-2: #4b5161; | |
| 19 | + --ink-3: #7c8293; | |
| 20 | + --rule: rgba(16, 18, 22, 0.1); | |
| 21 | + --rule-strong: rgba(16, 18, 22, 0.22); | |
| 22 | + --accent: #1f4fd8; | |
| 23 | + --accent-ink: #ffffff; | |
| 24 | + --accent-soft: rgba(31, 79, 216, 0.1); | |
| 25 | + --accent-2: #a8600a; | |
| 26 | + --accent-2-soft: rgba(212, 130, 22, 0.14); | |
| 27 | + --positive: #1d7a4a; | |
| 28 | + --positive-soft: rgba(29, 122, 74, 0.12); | |
| 29 | + --warning: #b4700e; | |
| 30 | + --warning-soft: rgba(212, 130, 22, 0.14); | |
| 31 | + --danger: #c0333d; | |
| 32 | + --danger-soft: rgba(192, 51, 61, 0.12); | |
| 33 | + | |
| 34 | + --tier-1: #1d7a4a; | |
| 35 | + --tier-2: #1f4fd8; | |
| 36 | + --tier-3: #b4700e; | |
| 37 | + --tier-4: #7c8293; | |
| 38 | + | |
| 39 | + --type-model: #1f4fd8; | |
| 40 | + --type-company: #6d3fc7; | |
| 41 | + --type-paper: #b6337e; | |
| 42 | + --type-provider: #0d7c86; | |
| 43 | + --type-benchmark: #c2560f; | |
| 44 | + --type-hardware: #3d7a1d; | |
| 45 | + --type-framework: #0f6fa8; | |
| 46 | + --type-dataset: #8a3fb0; | |
| 47 | + --type-tool: #5b6478; | |
| 48 | + | |
| 49 | + --series-1: #1f4fd8; | |
| 50 | + --series-2: #c2560f; | |
| 51 | + --series-3: #1d7a4a; | |
| 52 | + --series-4: #6d3fc7; | |
| 53 | + --series-5: #0d7c86; | |
| 54 | + --series-6: #b6337e; | |
| 55 | + --series-7: #7c8293; | |
| 56 | + --series-8: #c0333d; | |
| 57 | + | |
| 58 | + --radius: 4px; | |
| 59 | + --radius-lg: 8px; | |
| 60 | + --header-h: 56px; | |
| 61 | + --tabbar-h: 58px; | |
| 62 | +} | |
| 63 | + | |
| 64 | +[data-theme='dark'] { | |
| 65 | + color-scheme: dark; | |
| 66 | + --canvas: #0b0d11; | |
| 67 | + --surface: #12151b; | |
| 68 | + --surface-2: #191d25; | |
| 69 | + --surface-3: #222732; | |
| 70 | + --ink: #e9ebf0; | |
| 71 | + --ink-2: #a3a9b8; | |
| 72 | + --ink-3: #6f7688; | |
| 73 | + --rule: rgba(190, 200, 225, 0.11); | |
| 74 | + --rule-strong: rgba(190, 200, 225, 0.26); | |
| 75 | + --accent: #6d95ff; | |
| 76 | + --accent-ink: #061024; | |
| 77 | + --accent-soft: rgba(109, 149, 255, 0.14); | |
| 78 | + --accent-2: #f2b04a; | |
| 79 | + --accent-2-soft: rgba(242, 176, 74, 0.14); | |
| 80 | + --positive: #45c47f; | |
| 81 | + --positive-soft: rgba(69, 196, 127, 0.14); | |
| 82 | + --warning: #f2b04a; | |
| 83 | + --warning-soft: rgba(242, 176, 74, 0.14); | |
| 84 | + --danger: #ff6b74; | |
| 85 | + --danger-soft: rgba(255, 107, 116, 0.14); | |
| 86 | + | |
| 87 | + --tier-1: #45c47f; | |
| 88 | + --tier-2: #6d95ff; | |
| 89 | + --tier-3: #f2b04a; | |
| 90 | + --tier-4: #8b92a5; | |
| 91 | + | |
| 92 | + --type-model: #6d95ff; | |
| 93 | + --type-company: #b08cff; | |
| 94 | + --type-paper: #ff7ab6; | |
| 95 | + --type-provider: #3fc3cf; | |
| 96 | + --type-benchmark: #ff9a4d; | |
| 97 | + --type-hardware: #8fd35b; | |
| 98 | + --type-framework: #57b7f0; | |
| 99 | + --type-dataset: #d38cff; | |
| 100 | + --type-tool: #9aa3b8; | |
| 101 | + | |
| 102 | + --series-1: #6d95ff; | |
| 103 | + --series-2: #ff9a4d; | |
| 104 | + --series-3: #45c47f; | |
| 105 | + --series-4: #b08cff; | |
| 106 | + --series-5: #3fc3cf; | |
| 107 | + --series-6: #ff7ab6; | |
| 108 | + --series-7: #9aa3b8; | |
| 109 | + --series-8: #ff6b74; | |
| 110 | +} | |
| 111 | + | |
| 112 | +@theme inline { | |
| 113 | + --color-canvas: var(--canvas); | |
| 114 | + --color-surface: var(--surface); | |
| 115 | + --color-surface-2: var(--surface-2); | |
| 116 | + --color-surface-3: var(--surface-3); | |
| 117 | + --color-ink: var(--ink); | |
| 118 | + --color-ink-2: var(--ink-2); | |
| 119 | + --color-ink-3: var(--ink-3); | |
| 120 | + --color-rule: var(--rule); | |
| 121 | + --color-rule-strong: var(--rule-strong); | |
| 122 | + --color-accent: var(--accent); | |
| 123 | + --color-accent-ink: var(--accent-ink); | |
| 124 | + --color-accent-soft: var(--accent-soft); | |
| 125 | + --color-accent-2: var(--accent-2); | |
| 126 | + --color-accent-2-soft: var(--accent-2-soft); | |
| 127 | + --color-positive: var(--positive); | |
| 128 | + --color-positive-soft: var(--positive-soft); | |
| 129 | + --color-warning: var(--warning); | |
| 130 | + --color-warning-soft: var(--warning-soft); | |
| 131 | + --color-danger: var(--danger); | |
| 132 | + --color-danger-soft: var(--danger-soft); | |
| 133 | + --color-tier-1: var(--tier-1); | |
| 134 | + --color-tier-2: var(--tier-2); | |
| 135 | + --color-tier-3: var(--tier-3); | |
| 136 | + --color-tier-4: var(--tier-4); | |
| 137 | + --color-type-model: var(--type-model); | |
| 138 | + --color-type-company: var(--type-company); | |
| 139 | + --color-type-paper: var(--type-paper); | |
| 140 | + --color-type-provider: var(--type-provider); | |
| 141 | + --color-type-benchmark: var(--type-benchmark); | |
| 142 | + --color-type-hardware: var(--type-hardware); | |
| 143 | + --color-type-framework: var(--type-framework); | |
| 144 | + --color-type-dataset: var(--type-dataset); | |
| 145 | + --color-type-tool: var(--type-tool); | |
| 146 | + --color-series-1: var(--series-1); | |
| 147 | + --color-series-2: var(--series-2); | |
| 148 | + --color-series-3: var(--series-3); | |
| 149 | + --color-series-4: var(--series-4); | |
| 150 | + --color-series-5: var(--series-5); | |
| 151 | + --color-series-6: var(--series-6); | |
| 152 | + --color-series-7: var(--series-7); | |
| 153 | + --color-series-8: var(--series-8); | |
| 154 | + | |
| 155 | + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; | |
| 156 | + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 157 | + | |
| 158 | + --radius-sm: var(--radius); | |
| 159 | + --radius-md: var(--radius); | |
| 160 | + --radius-lg: var(--radius-lg); | |
| 161 | + --radius-xl: 12px; | |
| 162 | + | |
| 163 | + --text-2xs: 0.6875rem; | |
| 164 | + --text-2xs--line-height: 1rem; | |
| 165 | +} | |
| 166 | + | |
| 167 | +@layer base { | |
| 168 | + html { | |
| 169 | + background: var(--canvas); | |
| 170 | + color: var(--ink); | |
| 171 | + -webkit-text-size-adjust: 100%; | |
| 172 | + text-rendering: optimizeLegibility; | |
| 173 | + font-feature-settings: 'cv11', 'ss01'; | |
| 174 | + scroll-padding-top: calc(var(--header-h) + 12px); | |
| 175 | + } | |
| 176 | + body { | |
| 177 | + font-family: var(--font-sans); | |
| 178 | + font-size: 15px; | |
| 179 | + line-height: 1.5; | |
| 180 | + background: var(--canvas); | |
| 181 | + color: var(--ink); | |
| 182 | + } | |
| 183 | + ::selection { | |
| 184 | + background: var(--accent-soft); | |
| 185 | + } | |
| 186 | + :focus-visible { | |
| 187 | + outline: 2px solid var(--accent); | |
| 188 | + outline-offset: 2px; | |
| 189 | + } | |
| 190 | + h1, | |
| 191 | + h2, | |
| 192 | + h3, | |
| 193 | + h4 { | |
| 194 | + letter-spacing: -0.01em; | |
| 195 | + text-wrap: balance; | |
| 196 | + } | |
| 197 | + code, | |
| 198 | + kbd, | |
| 199 | + pre, | |
| 200 | + samp { | |
| 201 | + font-family: var(--font-mono); | |
| 202 | + } | |
| 203 | + table { | |
| 204 | + font-variant-numeric: tabular-nums; | |
| 205 | + } | |
| 206 | + a { | |
| 207 | + text-underline-offset: 3px; | |
| 208 | + } | |
| 209 | + button { | |
| 210 | + cursor: pointer; | |
| 211 | + } | |
| 212 | + input, | |
| 213 | + select, | |
| 214 | + textarea { | |
| 215 | + font: inherit; | |
| 216 | + color: inherit; | |
| 217 | + } | |
| 218 | + input::placeholder { | |
| 219 | + color: var(--ink-3); | |
| 220 | + } | |
| 221 | + input[type='search']::-webkit-search-decoration, | |
| 222 | + input[type='search']::-webkit-search-cancel-button { | |
| 223 | + -webkit-appearance: none; | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/* ---------------------------------------------------------------------------------------------------------- utilities */ | |
| 228 | +.container-x { | |
| 229 | + padding-left: max(1rem, env(safe-area-inset-left)); | |
| 230 | + padding-right: max(1rem, env(safe-area-inset-right)); | |
| 231 | +} | |
| 232 | +@media (min-width: 768px) { | |
| 233 | + .container-x { | |
| 234 | + padding-left: 2rem; | |
| 235 | + padding-right: 2rem; | |
| 236 | + } | |
| 237 | +} | |
| 238 | + | |
| 239 | +.eyebrow { | |
| 240 | + font-size: 0.6875rem; | |
| 241 | + line-height: 1rem; | |
| 242 | + letter-spacing: 0.1em; | |
| 243 | + text-transform: uppercase; | |
| 244 | + font-weight: 600; | |
| 245 | + color: var(--ink-3); | |
| 246 | +} | |
| 247 | +.display { | |
| 248 | + font-weight: 600; | |
| 249 | + letter-spacing: -0.03em; | |
| 250 | + line-height: 1.02; | |
| 251 | + text-wrap: balance; | |
| 252 | +} | |
| 253 | +.mono { | |
| 254 | + font-family: var(--font-mono); | |
| 255 | + font-feature-settings: 'tnum', 'zero'; | |
| 256 | +} | |
| 257 | +.tnum { | |
| 258 | + font-variant-numeric: tabular-nums; | |
| 259 | + font-feature-settings: 'tnum'; | |
| 260 | +} | |
| 261 | +.hairline { | |
| 262 | + border-top: 1px solid var(--rule); | |
| 263 | +} | |
| 264 | +.hairline-strong { | |
| 265 | + border-top: 1px solid var(--rule-strong); | |
| 266 | +} | |
| 267 | +.link { | |
| 268 | + color: var(--accent); | |
| 269 | + text-decoration: none; | |
| 270 | +} | |
| 271 | +.link:hover { | |
| 272 | + text-decoration: underline; | |
| 273 | +} | |
| 274 | +/* Bordered surface — use sparingly (dialogs, sheets, the odd callout). Prefer hairlines and spatial composition. */ | |
| 275 | +.panel { | |
| 276 | + background: var(--surface); | |
| 277 | + border: 1px solid var(--rule); | |
| 278 | + border-radius: var(--radius-lg); | |
| 279 | +} | |
| 280 | +.grid-bg { | |
| 281 | + background-image: linear-gradient(to right, var(--rule) 1px, transparent 1px), linear-gradient(to bottom, var(--rule) 1px, transparent 1px); | |
| 282 | + background-size: 48px 48px; | |
| 283 | + mask-image: radial-gradient(ellipse at 50% 0%, rgba(0, 0, 0, 0.9), transparent 75%); | |
| 284 | +} | |
| 285 | +.scrollbar-thin { | |
| 286 | + scrollbar-width: thin; | |
| 287 | + scrollbar-color: var(--rule-strong) transparent; | |
| 288 | +} | |
| 289 | +.scrollbar-thin::-webkit-scrollbar { | |
| 290 | + height: 6px; | |
| 291 | + width: 6px; | |
| 292 | +} | |
| 293 | +.scrollbar-thin::-webkit-scrollbar-thumb { | |
| 294 | + background: var(--rule-strong); | |
| 295 | + border-radius: 3px; | |
| 296 | +} | |
| 297 | +.no-scrollbar { | |
| 298 | + scrollbar-width: none; | |
| 299 | +} | |
| 300 | +.no-scrollbar::-webkit-scrollbar { | |
| 301 | + display: none; | |
| 302 | +} | |
| 303 | +.safe-bottom { | |
| 304 | + padding-bottom: env(safe-area-inset-bottom, 0px); | |
| 305 | +} | |
| 306 | +.prose-atlas p + p { | |
| 307 | + margin-top: 0.75rem; | |
| 308 | +} | |
| 309 | +.prose-atlas a { | |
| 310 | + color: var(--accent); | |
| 311 | +} | |
| 312 | +.prose-atlas a:hover { | |
| 313 | + text-decoration: underline; | |
| 314 | +} | |
| 315 | + | |
| 316 | +/* Live indicator */ | |
| 317 | +.dot { | |
| 318 | + display: inline-block; | |
| 319 | + width: 7px; | |
| 320 | + height: 7px; | |
| 321 | + border-radius: 999px; | |
| 322 | + background: var(--positive); | |
| 323 | + vertical-align: middle; | |
| 324 | +} | |
| 325 | +.pulse { | |
| 326 | + position: relative; | |
| 327 | +} | |
| 328 | +.pulse::after { | |
| 329 | + content: ''; | |
| 330 | + position: absolute; | |
| 331 | + inset: -3px; | |
| 332 | + border-radius: 999px; | |
| 333 | + border: 1px solid var(--positive); | |
| 334 | + animation: pulse-ring 1.8s ease-out infinite; | |
| 335 | +} | |
| 336 | +@keyframes pulse-ring { | |
| 337 | + 0% { | |
| 338 | + transform: scale(0.6); | |
| 339 | + opacity: 0.9; | |
| 340 | + } | |
| 341 | + 100% { | |
| 342 | + transform: scale(1.8); | |
| 343 | + opacity: 0; | |
| 344 | + } | |
| 345 | +} | |
| 346 | + | |
| 347 | +/* ---------------------------------------------------------------------------------------------------------- data tables */ | |
| 348 | +.data-table { | |
| 349 | + width: 100%; | |
| 350 | + border-collapse: collapse; | |
| 351 | + font-variant-numeric: tabular-nums; | |
| 352 | + font-size: 0.875rem; | |
| 353 | +} | |
| 354 | +.data-table th { | |
| 355 | + text-align: left; | |
| 356 | + font-weight: 600; | |
| 357 | + font-size: 0.6875rem; | |
| 358 | + letter-spacing: 0.08em; | |
| 359 | + text-transform: uppercase; | |
| 360 | + color: var(--ink-3); | |
| 361 | + padding: 0.5rem 0.75rem; | |
| 362 | + border-bottom: 1px solid var(--rule-strong); | |
| 363 | + white-space: nowrap; | |
| 364 | + vertical-align: bottom; | |
| 365 | +} | |
| 366 | +.data-table th:first-child, | |
| 367 | +.data-table td:first-child { | |
| 368 | + padding-left: 0; | |
| 369 | +} | |
| 370 | +.data-table th:last-child, | |
| 371 | +.data-table td:last-child { | |
| 372 | + padding-right: 0; | |
| 373 | +} | |
| 374 | +.data-table td { | |
| 375 | + padding: 0.55rem 0.75rem; | |
| 376 | + border-bottom: 1px solid var(--rule); | |
| 377 | + vertical-align: middle; | |
| 378 | +} | |
| 379 | +.data-table tbody tr:hover { | |
| 380 | + background: var(--surface-2); | |
| 381 | +} | |
| 382 | +.data-table .num { | |
| 383 | + text-align: right; | |
| 384 | + font-variant-numeric: tabular-nums; | |
| 385 | +} | |
| 386 | +.data-table th.num { | |
| 387 | + text-align: right; | |
| 388 | +} | |
| 389 | +.data-table .primary { | |
| 390 | + font-weight: 500; | |
| 391 | + color: var(--ink); | |
| 392 | +} | |
| 393 | +.data-table.compact td { | |
| 394 | + padding-top: 0.35rem; | |
| 395 | + padding-bottom: 0.35rem; | |
| 396 | +} | |
| 397 | +.data-table a.row-link { | |
| 398 | + color: inherit; | |
| 399 | + text-decoration: none; | |
| 400 | +} | |
| 401 | +.data-table a.row-link:hover { | |
| 402 | + color: var(--accent); | |
| 403 | +} | |
| 404 | +@media (max-width: 767px) { | |
| 405 | + .data-table.stack thead { | |
| 406 | + display: none; | |
| 407 | + } | |
| 408 | + .data-table.stack tr { | |
| 409 | + display: grid; | |
| 410 | + grid-template-columns: 1fr 1fr; | |
| 411 | + gap: 0.25rem 0.75rem; | |
| 412 | + padding: 0.75rem 0; | |
| 413 | + border-bottom: 1px solid var(--rule); | |
| 414 | + } | |
| 415 | + .data-table.stack tbody tr:hover { | |
| 416 | + background: transparent; | |
| 417 | + } | |
| 418 | + .data-table.stack td { | |
| 419 | + display: block; | |
| 420 | + border: 0; | |
| 421 | + padding: 0; | |
| 422 | + min-width: 0; | |
| 423 | + overflow-wrap: anywhere; | |
| 424 | + } | |
| 425 | + .data-table.stack td[data-label]::before { | |
| 426 | + content: attr(data-label); | |
| 427 | + display: block; | |
| 428 | + font-size: 0.625rem; | |
| 429 | + letter-spacing: 0.08em; | |
| 430 | + text-transform: uppercase; | |
| 431 | + color: var(--ink-3); | |
| 432 | + font-weight: 600; | |
| 433 | + margin-bottom: 0.1rem; | |
| 434 | + } | |
| 435 | + .data-table.stack td.primary { | |
| 436 | + grid-column: 1 / -1; | |
| 437 | + font-size: 0.9375rem; | |
| 438 | + } | |
| 439 | + .data-table.stack td.num { | |
| 440 | + text-align: left; | |
| 441 | + } | |
| 442 | + .data-table.stack td.wide { | |
| 443 | + grid-column: 1 / -1; | |
| 444 | + } | |
| 445 | + .data-table.stack td.hide-stack { | |
| 446 | + display: none; | |
| 447 | + } | |
| 448 | +} | |
| 449 | +/* Horizontal scroll wrapper for wide tables that must not stack. */ | |
| 450 | +.table-scroll { | |
| 451 | + overflow-x: auto; | |
| 452 | + -webkit-overflow-scrolling: touch; | |
| 453 | + scrollbar-width: thin; | |
| 454 | +} | |
| 455 | +.table-scroll .data-table th, | |
| 456 | +.table-scroll .data-table td { | |
| 457 | + white-space: nowrap; | |
| 458 | +} | |
| 459 | + | |
| 460 | +/* ---------------------------------------------------------------------------------------------------------- key–value */ | |
| 461 | +.kv { | |
| 462 | + display: grid; | |
| 463 | + grid-template-columns: minmax(0, 1fr); | |
| 464 | + font-size: 0.875rem; | |
| 465 | +} | |
| 466 | +.kv > div { | |
| 467 | + display: grid; | |
| 468 | + grid-template-columns: 9.5rem minmax(0, 1fr); | |
| 469 | + gap: 0.25rem 1rem; | |
| 470 | + padding: 0.5rem 0; | |
| 471 | + border-bottom: 1px solid var(--rule); | |
| 472 | + align-items: baseline; | |
| 473 | +} | |
| 474 | +.kv > div > dt { | |
| 475 | + color: var(--ink-3); | |
| 476 | + font-size: 0.8125rem; | |
| 477 | +} | |
| 478 | +.kv > div > dd { | |
| 479 | + min-width: 0; | |
| 480 | + overflow-wrap: anywhere; | |
| 481 | +} | |
| 482 | +@media (max-width: 480px) { | |
| 483 | + .kv > div { | |
| 484 | + grid-template-columns: 7.5rem minmax(0, 1fr); | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 488 | +@media (prefers-reduced-motion: reduce) { | |
| 489 | + *, | |
| 490 | + *::before, | |
| 491 | + *::after { | |
| 492 | + animation-duration: 0.01ms !important; | |
| 493 | + transition-duration: 0.01ms !important; | |
| 494 | + } | |
| 495 | +} | |
added
apps/web/src/app/hardware/page.tsx
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/icon.svg
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/layout.tsx
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/methodology/page.tsx
+146 −0
Large diff (146 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/models/(list)/loading.tsx
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/models/(list)/page.tsx
+161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/models/[slug]/page.tsx
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/not-found.tsx
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/opengraph-image.tsx
+78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/page.tsx
+202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/papers/page.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/providers/page.tsx
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/robots.ts
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/search/page.tsx
+128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sitemap.xml/route.ts
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sitemap/[shard]/route.ts
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sources/page.tsx
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/timeline/page.tsx
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/tools/page.tsx
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/brand/logo.tsx
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/changes/change-row.tsx
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/changes/load-more.tsx
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/charts.tsx
+179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/entity/blocks.tsx
+498 −0
Large diff (498 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/entity/entity-page.tsx
+474 −0
Large diff (474 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/entity/load.ts
+63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/mobile-tab-bar.tsx
+85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-context.tsx
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-dialog.tsx
+165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-footer.tsx
+63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-header.tsx
+139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/theme.tsx
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/view-beacon.tsx
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/listing/filters.tsx
+130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/listing/generic-listing.tsx
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/meta/sitemap-data.ts
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/badges.tsx
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/data-table.tsx
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/entity.tsx
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/key-value.tsx
+47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/live.tsx
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/pagination.tsx
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/provenance.tsx
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/section.tsx
+99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/tabs.tsx
+112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/unavailable.tsx
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api.ts
+174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/cn.ts
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.ts
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/format.ts
+181 −0
Large diff (181 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/site.ts
+438 −0
Large diff (438 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/types.ts
+380 −0
Large diff (380 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/tsconfig.json
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
docs/FRONTEND.md
+140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-lock.yaml
+1169 −0
Large diff (1,169 lines) is not loaded by default to keep the page fast.Load diff
modified
src/aiatlas/registry/__init__.py
+3 −1
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff