SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
2 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
14.4 KB

# 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) = constrained main + Footer; (map) = full-viewport main (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 from NAV_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-endpoint revalidate (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 via formatPartialDate), fmtRelative, fmtValue (event values), flagEmoji, countryName, placeLine, bestMw.
  • labels.ts — all enum → label/tone maps.
  • og-template.tsx — renderOg({ eyebrow, title, subtitle, stats }) shared by opengraph-image.tsx/twitter-image.tsx and 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/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).
  • 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/.
  • 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)

  1. Server-render content; lazy-load maps/charts with next/dynamic (ssr: false).
  2. Fetch through api.*; render OfflineNotice + skeleton/empty states when !ok. Never throw on API failure.
  3. Reuse PageHeader → MetricRow → Sections; tables via Table with data-labels; filters via FacilityFilterBar.
  4. Every figure shows its unit; unknown = “—” (never 0); estimates get EstimateMark; per-field provenance via ProvenancePopover.
  5. Verify at 360, 390, 768, 1280 (Playwright screenshots in apps/web/qa/). No horizontal overflow, 44 px touch targets, focus visible.