spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1# DataCenterIndex — Design system (apps/web)23Industrial, precise, authoritative. Our World in Data × a terminal for digital infrastructure × a global infrastructure map. The UI is **image-free** (SVG only), **mobile-first** (320–430 px first), light by default with a true dark theme (`next-themes`, class strategy).45What we avoid: generic SaaS cards, neon/cyberpunk, fake terminals, heavy gradients, oversized heroes, giant rounded rectangles, card soup, dual-axis charts, rainbow palettes.67## Tokens (`apps/web/src/app/globals.css`, Tailwind v4 `@theme`)89| Token | Light | Dark | Use |10|---|---|---|---|11| `--paper` / `bg-paper` | `#f5f4f1` | `#0b0f14` | page background |12| `--surface` / `bg-surface` | `#ffffff` | `#10161d` | panels, table header, sheets |13| `--surface-2`, `--surface-3` | `#efeeea`, `#e6e4df` | `#161d25`, `#1d252e` | hover rows, skeletons, bar tracks |14| `--ink` … `--ink-4` | `#0b0f14 → #9a9fa5` | `#eceae5 → #5b6168` | text hierarchy (ink, ink-2 body, ink-3 meta, ink-4 hint) |15| `--hair`, `--hair-2` | `#e3e1dc`, `#c9c6bf` | `#1f272f`, `#2f3941` | 1 px borders (never shadows) |16| `--accent`, `--accent-ink`, `--accent-soft` | `#c8541a` | `#e8763a` | THE accent: pipeline, emphasis, primary CTA, live dot |1718Status scale (validated with the dataviz six-checks: lightness-separated blue/violet; every colour ships with a label):1920| Tone | Statuses | Light | Dark |21|---|---|---|---|22| `st-operational` | operational, partially_operational, expansion | `#1f6b3a` | `#3fa465` |23| `st-construction` | under_construction | `#b87a0a` | `#d4962a` |24| `st-planned` | announced, proposed | `#5f8fc4` | `#7fb0e6` |25| `st-permitting` | permitting, approved | `#5b3f8f` | `#8a6bc9` |26| `st-delayed` | delayed | `#a94a2a` | `#d9704a` |27| `st-closed` | cancelled, closed | `#858a90` | `#6f757c` |28| `st-rumored` | rumored (drawn **dotted**) | `#8f95a0` | `#7b828c` |29| `st-unknown` | unknown | `#b3b6ba` | `#4d545b` |3031Mapping lives in `lib/labels.ts` (`STATUS_TONE`, `STATUS_LABEL`, `statusVar()`); the map reads the CSS variables at runtime so it follows the theme.3233### Claim-first visual language (2026-09-12, §76–78)3435One vocabulary for every figure, shared by badges, tables, the evidence drawer, the map legend and the OG images:3637| Dimension | Values (label) | Rendering | Component |38|---|---|---|---|39| Status / stage | Operational · Partially operational · Under construction · Approved · Permitting · Announced · Proposed · Rumored (dotted) · Delayed · Cancelled · Closed | dot in the status tone + exact label; the same tones colour project stages (`StageBadge`), funnel bars and map markers | `StatusBadge`, `StageBadge`, `StatusDot` |40| Confidence | Verified · High · Moderate · Estimated · Unverified | 4-dot meter in ink (never red/amber): **Moderate must not look alarming** — only *Estimated* uses the warn dot colour | `ConfidenceBadge` |41| Source / provenance kind | OPERATOR · GOVERNMENT · UTILITY · FILING · REGISTRY · CLOUD (primary, filled dot) — DATASET · NEWS · SECONDARY · COMMUNITY (secondary, hollow dot) | upper-case tracked label; hover / focus tooltip shows source name, published, retrieved and whether an archived copy exists | `SourceBadge` (client) |42| Scope | Building · Facility · Campus (site scopes, filled square) — Metro · Country · Portfolio · Company-wide · Scope unknown (hollow dashed square) | non-site scopes are never summed into a record; they appear under “Figures not assigned to this site” | `ScopeBadge` |43| Semantics | IT capacity · Critical power · Total power · Planned · Grid connection · Utility · Ultimate build-out · Phase capacity (+ investment predicates) | small caps label under every MW figure so “300 MW” always says what it measures | `SemanticsLabel`, `semanticsLabel()` |44| Authority tier | A–E **for this field** (never a universal source rank) | mono letter in a hairline box, tooltip with the tier meaning | `TierMark` |45| Significance | Major (accent) · Medium · Minor | 3 bars; only *major* uses the accent | `SignificanceBadge`, `SignificanceMark` |46| AI evidence | confirmed · likely · associated | accent diamond + “AI” (+ level when not confirmed) | `AiMark` |47| Winner | “shown” | tiny accent-soft chip on the observation / claim backing the displayed value | provenance table, claims table, drawer |48| Extraction method | Structured data · Parser · Sentence extraction · Human-reviewed · Model-assisted · Method not recorded | derived from `method` / `parserName` via `extractionKind()` | evidence drawer |4950Honesty states: an unknown figure is `—` with the reason underneath (“Not published by any source”, “No expansion figure published”, “date not published”), never `0`; empty API layers render the API `note`; totals carry their coverage caveat (“MW published for 6 % of facilities — lower bound”); planned figures and estimates carry `≈`.5152**Evidence drawer** (`components/evidence`, §24/§80): wrap any important figure in `<Evidence field="plannedPowerMw">` (dotted underline, real `<button>`, `aria-haspopup="dialog"`); the page provides `<EvidenceProvider data={{ claims, provenance, history }}>`. The drawer is a right `Sheet` on ≥ md and a bottom sheet on mobile: value + semantics + scope, source badge, document host / link, published, retrieved, confidence, authority tier, extraction method, the supporting sentence with the figure highlighted, VIEW SOURCE, and a HISTORY tab (date, old → new, source, WHY? → the claim or observation behind the change). When a field has no evidence the children render untouched (nothing pretends to be sourced).5354Map marks (§18): facility circles sized by known MW (sqrt, 4–16 px at zoom 10), filled for exact / parcel / street, hollow ring for city / metro / approximate, rumored lighter; AI = accent diamond; project = square in the stage tone (ring underneath when imprecise); IXP = ink triangle; cloud region = hollow accent circle; power layers = amber triangle; density = graduated grid cells (ink for counts, accent for MW) with value labels; time machine = `year` filter + “opening date known for X %” caption.5556Typography: Inter (UI) + JetBrains Mono for figures (`.figure` = mono + tabular nums + −0.02 em). Body 14 px / 1.45. Big numbers tight tracking (`Metric`). `.label-caps` = 10.5 px uppercase 0.08 em for labels/headers.5758Spacing on a 4/8 px grid. Radii: 2 px (`rounded-xs`), 3 px (`rounded-sm`), 4 px (`rounded-md`). Hairline borders everywhere, no shadows. Focus ring: 2 px accent. Reduced motion respected globally.5960Textures: `.graticule` (thin 48 px grid with a fade mask) on the home hero only.6162## Layout & shell6364- `app/layout.tsx`: fonts, metadata (title template `%s · DataCenterIndex`, OG/Twitter images generated), viewport themeColor, JSON-LD Organization + WebSite, `Header`, `MobileNav`, `CommandPalette`.65- Route groups: `(site)` = constrained `main` + `Footer`; `(map)` = full-viewport `main` (header stays relative).66- Header (§101–102): sticky, 48 px mobile / 56 px desktop. Wordmark + `NAV_PRIMARY` (≥ lg): Map · Facilities · Markets · Operators · Projects · AI · Live, then a **More** menu (three groups Index / Intelligence / Reference built from `NAV_MORE`) · search trigger (⌘K, ≥ md) · theme toggle · burger (< lg) opening the full menu.67- Mobile bottom nav (§72): fixed, 5 items — Map · Live · Projects · Rank · Search — safe-area padding, hidden while a map sheet is open (`body[data-hide-mobile-nav]`).68- Footer: Index / Intelligence / Reference link groups covering every route, credits line (“Built by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on MacLustr”), data attribution line.69- Sticky filter bars on list pages sit under the header (`top-12 sm:top-14`).7071## Component kit (`apps/web/src/components/ui`, import from `@/components/ui`)7273| Component | Notes |74|---|---|75| `Button` (`primary/secondary/ghost/accent/danger`, `xs–lg`, `href`), `IconButton` (44 px `lg`), `MoreLink` | links render as `next/link` |76| `Badge` (`neutral/outline/accent/ok/warn/danger/info`), `StatusBadge`, `TypeBadge`, `ConfidenceBadge` (4-dot meter), `PrecisionBadge` (hollow dot = city/metro), `EventBadge`, `FlagBadges` (AI/HS), `EstimateMark` (≈) | exact labels from `lib/labels.ts` |77| `ScopeBadge`, `SemanticsLabel`, `SignificanceBadge`, `AiMark`, `StageBadge`, `TierMark` | claim-first vocabulary (see above) |78| `SourceBadge` (client) | provenance kind with hover details (source, published, retrieved, archive) |79| `SegmentedControl` | view switchers / radius / density — link version (`href`) for URL state, `onChange` for local state |80| `StatusDot` | dotted for rumored, `hollow` for imprecise |81| `Metric`, `MetricRow`, `MetricCell` | row scrolls horizontally on mobile, grid on ≥ sm |82| `Card`, `CardHeader`, `CardBody`, `Section`, `Page`, `PageHeader` | flat, bordered |83| `Table`, `THead`, `TBody`, `Tr`, `Th` (sortable via `sortHref`), `Td` (`label`, `primary`, `numeric`), `RowLink` | sticky header; stacked cards < 640 px via `data-label` |84| `Tabs/TabList/Tab/TabPanel`, `LinkTabs` | arrow-key navigation; link version for URL state |85| `Sheet` | bottom sheet (drag handle: down closes, up expands) on mobile, side drawer on ≥ md; focus trap, ESC, `modal={false}` for map quick profile |86| `Popover`, `Tooltip`, `ProvenancePopover` | portal-positioned, outside-click / ESC |87| `Skeleton`, `SkeletonText`, `SkeletonRows`, `SkeletonBlock` | |88| `EmptyState`, `ErrorState`, `OfflineNotice` | every page falls back to these — never crash |89| `Pagination` | “1–50 of 1,234”, numbered on ≥ sm |90| `FilterChips`, `ToggleChip` | active chips (href or onRemove) + multi-select chips |91| `SearchInput` | clear button, `trailing` slot |92| `Kbd` | |93| `Sparkline`, `BarList`, `StackedBar`, `ColumnChart`, `LineChart` (`step` series for observed values), `AreaChart`, `Donut` | SVG, ResizeObserver, hover tooltip, one axis, legend when ≥ 2 series |94| `TimelineList`, `SourceHistory` | source history rail |95| `CountryFlag` | emoji from iso2 + accessible name |96| `RelativeTime` | SSR renders UTC date; client switches to “3 min ago” (`suppressHydrationWarning`) |9798Higher-level shared components: `components/facility-table.tsx`, `components/facility-filters.tsx` (`FacilityFilterFields` controls + `FacilityFilterBar` URL-synced bar & sheet + `useUrlFilters`), `components/event-list.tsx` (`EventRow`, `ValueChange` old→new, `SignificanceMark`, significance band, AI mark, “also reported by N sources”), `components/live-feed.tsx` (polling), `components/project-list.tsx`, `components/project-stages.tsx` (lifecycle strip + velocity), `components/evidence/*` (`EvidenceProvider`, `Evidence`, `EvidenceDrawer`, `ClaimsTable`, `CapacityHistoryChart`, `DataQualityPanel`, `NearbyInfrastructurePanel`), `components/home/*` (`GlobalTicker`, `PulseBlock`, `PipelineFunnel`, `MapPreview`), `components/map/*` (`MapCanvas` with `layer` / `density` / `year` / `extraQuery`, `ModesRail`, `DensityToggle`, `TimeMachineControl`, `MapPage` = left rail · map · right inspector on desktop, full-viewport map + bottom sheets on mobile), `components/entity/section-nav.tsx` (client, IntersectionObserver active section), `components/brand/logo.tsx`.99100## Libraries (`apps/web/src/lib`)101102- `api.ts` — `apiGet<T>()` never throws (`{ ok, data, error }`), 8 s timeout, per-endpoint `revalidate` (dashboard 60, lists 120, details 300, live 30, map 0); `api.*` typed helpers; `clientApiBase()` for hooks.103- `routes.ts` — every internal href (`routes.facility(slug)`, `entityHref(type, slug)`), `NAV_PRIMARY`, `SITE_URL`.104- `query.ts` — `parseFilters`, `filtersToQuery`, `describeFilters`, `removeFilter`, `countActiveFilters` (URL ↔ `FacilityFilters`).105- `format.ts` — `fmtMw` (GW ≥ 1000, 0–1 decimals), `splitMw`, `fmtInt/Compact/Usd/Pct/Delta`, `fmtDate` (UTC, partial dates via `formatPartialDate`), `fmtRelative`, `fmtValue` (event values), `flagEmoji`, `countryName`, `placeLine`, `bestMw`.106- `labels.ts` — all enum → label/tone maps.107- `og-template.tsx` — `renderOg({ eyebrow, title, subtitle, stats })` shared by `opengraph-image.tsx`/`twitter-image.tsx` and future entity OG images.108- `hooks.ts` — `useDebounced`, `useMediaQuery`, `useLocalStorage`, `useInterval`.109110## Brand111112Mark: a globe (circle + meridian) crossed by three rack lines; the middle line is the accent “index tick” running past the globe. `public/brand/logo.svg`, `logo-mark.svg`, `logo-dark.svg`; React `LogoMark`, `Wordmark`, `Logo`. Favicons: `app/icon.svg`, `icon.png` 512, `apple-icon.png` 180, `public/icons/icon-192/512(+maskable).png` (regenerate with `SHARP_PATH=<sharp> node apps/web/scripts/render-icons.mjs`).113114## Build notes (apps/web)115116- `@dci/core` is consumed from TypeScript source and uses NodeNext `./x.js` specifiers. Turbopack cannot map them to `.ts`, so the scripts run **webpack** (`next dev --webpack`, `next build --webpack`) with `resolve.extensionAlias` in `next.config.ts`. Client bundles resolve `@dci/core` through the `browser` export condition (`packages/core/src/index.browser.ts`, no Node built-ins); `tsconfig.json` mirrors it with `customConditions: ["browser"]` — in client components only import what that barrel exports (types, enums, normalize, api-types).117- QA: `node apps/web/qa/shoot.mjs <suffix>` (Playwright from `~/Desktop/uqo-eval`; env `BASE`, `WIDTHS`, `THEME=dark`, `PAGES`) screenshots every core page at 320–1280 and reports horizontal overflow. `node apps/web/qa/sweep.mjs` (`PAGES=…`, `QUICK=1`) audits overflow, tap targets, contrast, headings and interactions; `node apps/web/qa/shot.mjs <path> <name> [--w=390] [--dark] [--scroll=#id] [--click=selector]` takes one viewport screenshot (with an interaction) into `qa/shots/`.118- Dev-only quirk observed: Next 16 dev + webpack can throw a transient `SyntaxError: Unexpected end of JSON input` (manifest race) when pages are hit while compiling; production (`next start`) is unaffected.119120## Rules for new pages (entity pages, admin)1211221. Server-render content; lazy-load maps/charts with `next/dynamic` (`ssr: false`).1232. Fetch through `api.*`; render `OfflineNotice` + skeleton/empty states when `!ok`. Never throw on API failure.1243. Reuse `PageHeader` → `MetricRow` → `Section`s; tables via `Table` with `data-label`s; filters via `FacilityFilterBar`.1254. Every figure shows its unit; unknown = “—” (never 0); estimates get `EstimateMark`; per-field provenance via `ProvenancePopover`.1265. Verify at 360, 390, 768, 1280 (Playwright screenshots in `apps/web/qa/`). No horizontal overflow, 44 px touch targets, focus visible.127