DataCenterIndex — Design system (apps/web)
Industrial, 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).
What we avoid: generic SaaS cards, neon/cyberpunk, fake terminals, heavy gradients, oversized heroes, giant rounded rectangles, card soup, dual-axis charts, rainbow palettes.
Tokens (apps/web/src/app/globals.css, Tailwind v4 @theme)
| Token | Light | Dark | Use |
|---|---|---|---|
--paper / bg-paper |
#f5f4f1 |
#0b0f14 |
page background |
--surface / bg-surface |
#ffffff |
#10161d |
panels, table header, sheets |
--surface-2, --surface-3 |
#efeeea, #e6e4df |
#161d25, #1d252e |
hover rows, skeletons, bar tracks |
--ink … --ink-4 |
#0b0f14 → #9a9fa5 |
#eceae5 → #5b6168 |
text hierarchy (ink, ink-2 body, ink-3 meta, ink-4 hint) |
--hair, --hair-2 |
#e3e1dc, #c9c6bf |
#1f272f, #2f3941 |
1 px borders (never shadows) |
--accent, --accent-ink, --accent-soft |
#c8541a |
#e8763a |
THE accent: pipeline, emphasis, primary CTA, live dot |
Status scale (validated with the dataviz six-checks: lightness-separated blue/violet; every colour ships with a label):
| Tone | Statuses | Light | Dark |
|---|---|---|---|
st-operational |
operational, partially_operational, expansion | #1f6b3a |
#3fa465 |
st-construction |
under_construction | #b87a0a |
#d4962a |
st-planned |
announced, proposed | #5f8fc4 |
#7fb0e6 |
st-permitting |
permitting, approved | #5b3f8f |
#8a6bc9 |
st-delayed |
delayed | #a94a2a |
#d9704a |
st-closed |
cancelled, closed | #858a90 |
#6f757c |
st-rumored |
rumored (drawn dotted) | #8f95a0 |
#7b828c |
st-unknown |
unknown | #b3b6ba |
#4d545b |
Mapping lives in lib/labels.ts (STATUS_TONE, STATUS_LABEL, statusVar()); the map reads the CSS variables at runtime so it follows the theme.
Claim-first visual language (2026-09-12, §76–78)
One vocabulary for every figure, shared by badges, tables, the evidence drawer, the map legend and the OG images:
| Dimension | Values (label) | Rendering | Component |
|---|---|---|---|
| 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 |
| 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 |
| 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) |
| 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 |
| 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() |
| Authority tier | A–E for this field (never a universal source rank) | mono letter in a hairline box, tooltip with the tier meaning | TierMark |
| Significance | Major (accent) · Medium · Minor | 3 bars; only major uses the accent | SignificanceBadge, SignificanceMark |
| AI evidence | confirmed · likely · associated | accent diamond + “AI” (+ level when not confirmed) | AiMark |
| Winner | “shown” | tiny accent-soft chip on the observation / claim backing the displayed value | provenance table, claims table, drawer |
| Extraction method | Structured data · Parser · Sentence extraction · Human-reviewed · Model-assisted · Method not recorded | derived from method / parserName via extractionKind() |
evidence drawer |
Honesty 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 ≈.
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).
Map 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.
Typography: 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.
Spacing 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.
Textures: .graticule (thin 48 px grid with a fade mask) on the home hero only.
Layout & shell
app/layout.tsx: fonts, metadata (title template%s · DataCenterIndex, OG/Twitter images generated), viewport themeColor, JSON-LD Organization + WebSite,Header,MobileNav,CommandPalette.- Route groups:
(site)= constrainedmain+Footer;(map)= full-viewportmain(header stays relative). - 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 fromNAV_MORE) · search trigger (⌘K, ≥ md) · theme toggle · burger (< lg) opening the full menu. - 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]). - 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.
- Sticky filter bars on list pages sit under the header (
top-12 sm:top-14).
Component kit (apps/web/src/components/ui, import from @/components/ui)
| Component | Notes |
|---|---|
Button (primary/secondary/ghost/accent/danger, xs–lg, href), IconButton (44 px lg), MoreLink |
links render as next/link |
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 |
ScopeBadge, SemanticsLabel, SignificanceBadge, AiMark, StageBadge, TierMark |
claim-first vocabulary (see above) |
SourceBadge (client) |
provenance kind with hover details (source, published, retrieved, archive) |
SegmentedControl |
view switchers / radius / density — link version (href) for URL state, onChange for local state |
StatusDot |
dotted for rumored, hollow for imprecise |
Metric, MetricRow, MetricCell |
row scrolls horizontally on mobile, grid on ≥ sm |
Card, CardHeader, CardBody, Section, Page, PageHeader |
flat, bordered |
Table, THead, TBody, Tr, Th (sortable via sortHref), Td (label, primary, numeric), RowLink |
sticky header; stacked cards < 640 px via data-label |
Tabs/TabList/Tab/TabPanel, LinkTabs |
arrow-key navigation; link version for URL state |
Sheet |
bottom sheet (drag handle: down closes, up expands) on mobile, side drawer on ≥ md; focus trap, ESC, modal={false} for map quick profile |
Popover, Tooltip, ProvenancePopover |
portal-positioned, outside-click / ESC |
Skeleton, SkeletonText, SkeletonRows, SkeletonBlock |
|
EmptyState, ErrorState, OfflineNotice |
every page falls back to these — never crash |
Pagination |
“1–50 of 1,234”, numbered on ≥ sm |
FilterChips, ToggleChip |
active chips (href or onRemove) + multi-select chips |
SearchInput |
clear button, trailing slot |
Kbd |
|
Sparkline, BarList, StackedBar, ColumnChart, LineChart (step series for observed values), AreaChart, Donut |
SVG, ResizeObserver, hover tooltip, one axis, legend when ≥ 2 series |
TimelineList, SourceHistory |
source history rail |
CountryFlag |
emoji from iso2 + accessible name |
RelativeTime |
SSR renders UTC date; client switches to “3 min ago” (suppressHydrationWarning) |
Higher-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.
Libraries (apps/web/src/lib)
api.ts—apiGet<T>()never throws ({ ok, data, error }), 8 s timeout, per-endpointrevalidate(dashboard 60, lists 120, details 300, live 30, map 0);api.*typed helpers;clientApiBase()for hooks.routes.ts— every internal href (routes.facility(slug),entityHref(type, slug)),NAV_PRIMARY,SITE_URL.query.ts—parseFilters,filtersToQuery,describeFilters,removeFilter,countActiveFilters(URL ↔FacilityFilters).format.ts—fmtMw(GW ≥ 1000, 0–1 decimals),splitMw,fmtInt/Compact/Usd/Pct/Delta,fmtDate(UTC, partial dates viaformatPartialDate),fmtRelative,fmtValue(event values),flagEmoji,countryName,placeLine,bestMw.labels.ts— all enum → label/tone maps.og-template.tsx—renderOg({ eyebrow, title, subtitle, stats })shared byopengraph-image.tsx/twitter-image.tsxand future entity OG images.hooks.ts—useDebounced,useMediaQuery,useLocalStorage,useInterval.
Brand
Mark: 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).
Build notes (apps/web)
@dci/coreis consumed from TypeScript source and uses NodeNext./x.jsspecifiers. Turbopack cannot map them to.ts, so the scripts run webpack (next dev --webpack,next build --webpack) withresolve.extensionAliasinnext.config.ts. Client bundles resolve@dci/corethrough thebrowserexport condition (packages/core/src/index.browser.ts, no Node built-ins);tsconfig.jsonmirrors it withcustomConditions: ["browser"]— in client components only import what that barrel exports (types, enums, normalize, api-types).- QA:
node apps/web/qa/shoot.mjs <suffix>(Playwright from~/Desktop/uqo-eval; envBASE,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) intoqa/shots/. - 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.
Rules for new pages (entity pages, admin)
- Server-render content; lazy-load maps/charts with
next/dynamic(ssr: false). - Fetch through
api.*; renderOfflineNotice+ skeleton/empty states when!ok. Never throw on API failure. - Reuse
PageHeader→MetricRow→Sections; tables viaTablewithdata-labels; filters viaFacilityFilterBar. - Every figure shows its unit; unknown = “—” (never 0); estimates get
EstimateMark; per-field provenance viaProvenancePopover. - Verify at 360, 390, 768, 1280 (Playwright screenshots in
apps/web/qa/). No horizontal overflow, 44 px touch targets, focus visible.