Next 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4, Geist self-hosted. SSR everywhere it makes sense
(ISR 60–3600 s for public aggregates, no-store for live/owner/admin), client components only for the live pieces
(SSE feed, counters, ⌘K search, drawer, watchlist, admin). Ports: dev 8370 → API 8371; prod 8360 → API
127.0.0.1:8361 (API_URL). The browser never talks to FastAPI directly: /api/v1/* and /health are rewritten by
next.config.ts.
Not-found: not-found.tsx (dynamic segments check existence in their layout.tsx/page and call notFound()); error.tsx
re-applies the theme. No root loading.tsx (Next 16 soft-404 gotcha).
Design tokens live at the top of src/app/globals.css (canvas/surface/ink/rule/accent/positive/warning/danger/live,
per-event-type hues --ev-*, sensor tiers, series, map, brand plate, density variables) and are exposed to Tailwind through
@theme inline. Both themes are hand-tuned; default follows the system and lib/prepaint.ts sets data-theme before paint.
Mark — the "atlas plate". A rounded square plate carrying a globe drawn as a sparse grid (ring, one meridian ellipse, two
parallels) crossed by a strong equator — the timeline — that ends on a bright "now" node sitting on the horizon, with a short
pulse trail behind it. One 64 × 64 geometry, three detail levels chosen by rendered size so nothing becomes a sub-pixel
hairline: tiny ≤ 20 px (favicon 16: no parallels / halo, 5.2-unit strokes), small ≤ 40 px (favicon 32, header), full
above (bevel hairline on the plate, halo ring). Colourways: dark plate #0f1419 / ink #f7f7f4 / live #3fd07a for light
surfaces; light plate #e8ebf1 / ink #0a0d12 / live #16a34a for dark surfaces. In the UI the plate follows the theme through
the --brand-plate / --brand-ink / --brand-grid / --brand-accent tokens in globals.css (dark plate on the light UI, light
plate on the dark UI). Wordmark: Geist, "Company" regular in --ink-2 + "Atlas" semibold in --ink, tracking −0.02 em.
Single source.src/components/brand/geometry.ts (pure TS, no JSX) owns the path data, weights, palettes and
markShapes() / markSvgString(). mark.tsx turns the shape list into React (MarkArt) or a data-URI <img> for Satori
(MarkImg); logo.tsx exposes LogoMark (plate | mono), WordmarkText, Wordmark / Logo. scripts/build-icons.mjs
imports the same geometry.ts (Node type stripping) so every exported file is generated from that one description.
File
What
How it is made
src/app/icon.svg → /icon.svg
favicon for SVG-capable browsers (tiny level, hard-coded colours)
generated
public/favicon.ico
real ICO container, 16 (tiny) / 32 (small) / 48 (full) PNG-encoded entries, packed by hand
generated
public/icon-192.png, icon-512.png
PWA icons (purpose: any, rounded plate on transparent)
full-bleed plate (radius: 0), the OS applies its own mask; globe stays inside the safe zone
generated
public/logo-mark.svg
the mark alone, dark plate
generated
public/logo-light.svg / logo-dark.svg
lockup for light / dark backgrounds, transparent, vector text (Geist outlines, no font needed)
generated
public/logo.svg
lockup on its own dark rounded card — safe on any background (press, READMEs)
generated
layout.tsx lists icons.icon = [/favicon.ico (sizes any, image/x-icon), /icon.svg] and icons.apple = /apple-touch-icon.png;
manifest.ts references the PNGs. The former apple-icon.tsx and icon-512/route.tsx ImageResponse routes were removed.
Regenerate (after touching geometry.ts or the wordmark): pnpm icons from apps/web (= node scripts/build-icons.mjs;
PLAYWRIGHT_MODULE overrides the Playwright import, default ~/Desktop/uqo-eval/node_modules/playwright/index.mjs). The script
reads Geist-Regular / Geist-SemiBold TTFs from the geist package, parses glyf outlines itself, measures glyph positions
(advances + kerning) in Chromium with the same font files, then renders each PNG at 1× with Playwright and packs the ICO
(6-byte header, 16-byte directory entries, PNG payloads). Commit the outputs.
Share images (src/lib/og.tsx, Satori via next/og, 1200 × 630, Geist TTFs loaded from the geist package at first use):
renderSiteOg(stats) — root opengraph-image.tsx / twitter-image.tsx: lockup, "Continuous corporate observation network",
headline The Live Atlas of Global Companies, live counters (companies · sensors · observations · events from /stats,
omitted when the API is down), URL bottom-left, "Hosted on MacLustr" bottom-right, orthographic globe (world-atlas land +
15° graticule through d3-geo) on the right.
renderCompanyOg(detail) — /company/[slug]/opengraph-image.tsx + twitter-image.tsx: company logo fetched server-side
as a buffer (fetchLogoDataUri: candidates profile.logo_url → icon_url → logo_url, http(s) only, no IP literals / localhost
except the API host, 2.5 s timeout, ≤ 1.5 MB, png/jpeg/svg only, 24 h data cache) with a monogram plate fallback; name
(auto-sized), domain · country · industry chips, key facts (founded, employees, HQ — only what is sourced), three metric
tiles (Activity Score, Hiring Momentum 30d, AI Adoption — "—" + "not enough monitored evidence" when missing), sensors ·
events · changes, URL. Unknown slug → site image.
renderTopicOg() — /industry/[slug] and /country/[code]opengraph-image.tsx: eyebrow, name, description / region,
up to eight monitored company chips, four counters.
Satori rules: every box display:flex; never several text children in one div (wrap in spans); images as data URIs; no
runtime re-export between sibling routes (thin route files only export alt / size / contentType / default).
Credits. Footer bottom line on every page (13 px, --ink-2): "Built by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on
MacLustr — www.maclustr.io" (mailto:, https://www.maclustr.io with rel="noopener noreferrer"), constants in lib/site.ts
(AUTHOR_NAME, CONTACT_EMAIL, HOST_NAME, HOST_URL). /about#who-builds-it — "Who builds Company Atlas": built by, contact,
hosting on the MacLustr cluster (link), data links, and the SVG logo files for press.
no_longer_listed → "no longer listed"; job decreases say "listings are no longer visible … not evidence of layoffs";
products "no longer listed in the public catalog"; leadership "no longer listed on the monitored leadership page"; signals
carry a signal chip and confidence; every event shows confidence_label, origin (llm/hybrid show model + prompt version),
sources with detection times and a link to /change/[id]; retracted events stay visible, struck through, and are excluded
from counts. Missing metrics render as a dash with "not enough monitored evidence" — never 0. Empty states: "No monitored
evidence available yet." / "Last successfully checked …".
Built against the additive API 1.1 shape (CompanyCard.profile, CompanyDetail.facts, relationships[].provenance +
company.logo_url, people[].source); every field may be null and the UI hides what is missing instead of rendering dashes.
Header (company-header.tsx): logo via CompanyLogo (candidates profile.logo_url → profile.icon_url → logo_url, plain lazy
<img referrerPolicy="no-referrer">, onError walks to the next candidate, then a monogram plate — data-logo="image|monogram");
name · legal name · legal form; domain chip + listing chip (ticker · exchange, only when public_company); ISO country + HQ city/
region; est. <founded_year>; employees = sourced count with year when known, else the stated band; industries; attributed
description (DescriptionAttribution: Wikipedia → "Source: Wikipedia · <license, default CC BY-SA 4.0>" link; Wikidata → "Source:
Wikidata · CC0"; homepage → "From the company's website"; llm → warning chip "Generated from the company's public pages" + one-line
caveat; unattributed registry description → no line); socials row (text chips with ↗, rel="noopener nofollow noreferrer";
lucide 1.x has no brand icons, so no fake glyphs). generateMetadata uses the profile description (truncated at 220 chars).
Key facts (KeyFactsPanel, first block of the Overview main column, data-key-facts): founded · headquarters (city, region,
country name + "map" link to ?tab=locations) · employees (year) · revenue / net income / total assets (fmtMoney compact in the
stated currency + FY<year>, never converted) · legal form · listing · ISIN · LEI (→ GLEIF record) · SEC CIK (→ EDGAR browse) ·
Industry (Wikidata) · phone · references (Wikipedia / Wikidata). Each row ends with a SourceChip (label from SOURCE_LABELS,
title = " · retrieved <retrieved_at>", links to the source URL when given); provenance comes from profile.sources[]
by field with facts[] as fallback, and any facts[] key not already rendered is appended as a generic row. Empty →
"No sourced facts yet — enrichment pending." Footer note: figures quoted as retrieved.
Corporate structure (CorporateStructurePanel, Overview aside, replaces the flat Relationships list): grouped Parent
(SUBSIDIARY_OF) · Owners (OWNED_BY) · Acquired by · Subsidiaries (PARENT_OF) · Owns (OWNER_OF) · Acquisitions (ACQUIRED) ·
Other relationships (kind chip); each row = 20 px logo/monogram, link to the atlas company or plain to_name, "former" chip when
valid_to, validity (since … / … – …), confidence %, provenance chip. More than 8 rows → collapsed <details> whose summary
counts the groups. Current relationships sort before ended ones.
Leadership (PeoplePanel): mergePeople joins page-observed and Wikidata rows by normalised name (both chips kept, page
title wins), ordered CEO → chair → founders → other C-level → VP → rest (roleRank). Chips "Leadership page" / "Wikidata";
Wikidata-only rows read "recorded on Wikidata · retrieved · statement ↗". The "No longer listed" group stays separate:
page rows "no longer listed since …", Wikidata rows "no longer recorded as current on Wikidata · ". Heading counts both
sources. Rows carry data-person-source="page|wikidata|page+wikidata".
Products: when the observed catalogue is empty and profile.products is not, the tab shows "Products & services listed on
Wikidata" chips (WikidataProducts, provenance = sources[products] else the Wikidata entity) with a note that they carry no
first-seen / last-seen dates, above the usual empty state.
Directory / lists: CompanyTable desktop rows show a 28 px logo and domain · <one-line description> (truncated, full text in
title; showDescription={false} to opt out); mobile cards and CompanyMiniList show 32 / 24 px logos; compare column headers
20 px. Rankings, industry, country, search and watchlist inherit through CompanyTable.
Mock (qa/mock-api.mjs): hand-written profiles for 20 companies (complete: nvidia, apple, toyota, sony, roche, sap, spotify,
adyen, arm, tsmc, siemens, samsung-electronics; partial: stripe, shopify, cohere (llm description, icon only), anthropic (wikidata
description), mistral-ai, nubank, revolut, klarna), null-filled profiles (cabify, careem, flutterwave, zerodha, discovery), no
profile key at all (compass, bitso, wiz), auto-derived minimal profiles for the rest; facts[], v1.1 relationships
(Sony has 13 → collapsed; Arm has parent + owner; SAP/Sony have former relationships), Wikidata executives (one per company also
on the page → merge), anthropic / mistral-ai without an observed catalogue. Sample logos are served by the mock itself
(/api/v1/_mock/logo|icon/<slug>.svg) so QA never hits the network. Figures are approximate public values for layout only.
# QA results (2026-09-13, company pages upgrade, mock API)
qa/screens.mjs: 206 checks (50 routes × 390/1440 × dark/light — new routes /company/nvidia, /company/sony,
/company/sony?tab=leadership, /company/cohere, /company/cabify, /company/anthropic?tab=products, /company/compass — plus
counters + SSE): all OK, zero console errors, zero 404/5xx sub-requests, zero horizontal overflow, zero sub-40 px controls on
mobile. qa/flows.mjs: 30/30 (15 steps × 390/1440). Element screenshots of the header (complete / Wikidata-described /
LLM-described / no-profile), key facts (full and empty), corporate structure (grouped, parent+owner, 13-row collapsed → open),
merged leadership, Wikidata products fallback, directory rows and compare headers reviewed by eye in both themes; the logo
fallback was exercised by breaking every <img> src at runtime (images fall through to the icon candidate, then to monograms).
pnpm -r typecheck clean; pnpm build green.
qa/screens.mjs: 178 checks (44 routes × 390/1440 × dark/light + counters + SSE) — all 200/404 as expected, zero console
errors, zero 404/5xx sub-requests, zero horizontal overflow, zero interactive controls under 40 px on mobile, homepage
counters within the /stats window, SSE prepends a new row on /live. qa/flows.mjs: 15/15 steps pass at 390 and 1440
(live feed → drawer → company → timeline filter → compare picker → watch → watchlist + alert rule → ⌘K → admin).
pnpm -r typecheck clean; pnpm build green (27 static pages + dynamic routes, sitemap shards prerendered). Screenshots reviewed by eye (home, live, company tabs,
compare, events, event detail, change diff, sensor, snapshot diff, rankings, industry/country, search + ask, watchlist,
system, admin, 404) in both themes.
Fixed during QA: grid items letting scroll containers widen the page on mobile ([class*='grid-cols'] > * { min-width: 0 });
sub-40 px .btn-sm/chips/compare-remove buttons on touch; duplicate React keys (world-atlas features with repeated ids,
duplicated event tags); OG image satori flex rule; /sitemap.xml conflict with generateSitemaps (shards moved under
/sitemaps/); ADMIN_MODULES imported from a 'use client' file into a server page; a valueOf prop name colliding with
Object.prototype; hiring-momentum value wrapping in the narrow mobile metric tile; useSearchParams in the homepage live feed without a Suspense
boundary (build-time prerender error); SSE frames buffered by gzip behind the Next proxy (see contract note 1).
SSE must not be compressed. Behind the Next rewrite proxy (dev and next start) text/event-stream is gzip-buffered
unless the response carries Cache-Control: no-store, no-transform. Without it the browser receives nothing until the
connection closes. The mock sets it; FastAPI's /live/stream must too (and any reverse proxy must keep
X-Accel-Buffering: no). The web feed has a 45 s watchdog that falls back to polling /live?since= just in case.
/industries/{slug} and /countries/{code} return companies: CompanyCard[] while the list rows use companies: number.
The client handles both (IndustryDetailRaw/CountryDetailRaw); a distinct field name (e.g. companies_list) would be
cleaner.
/companies/{slug}/jobs — the summary is expected in meta.summary (the page also accepts a top-level summary).
/live — the client accepts both { items: Event[] } and a bare Event[]; please return { items }.
/admin/queue and /admin/reviews — documented as unpaginated { items }; the client also accepts a Page.
Event.importance is treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock.
Event.tags should be de-duplicated server-side (type + surface collide, e.g. pricing/pricing).
/watchlist GET for a fresh token should return 200 { items: [], events: [] } rather than 404 so first-visit renders
cleanly (the mock does).
Sitemap: the web asks /sitemap?kind=companies&page=N and expects pages on every response; only indexed = true
companies should be listed.
MapBucket.country is expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click).
CompanyDetail.coverage.historical_coverage is displayed as a percentage (0–100), consistent with the historical_coverage
metric.
No Technology tab yet (spec §40 lists it; the API has no technology-signals endpoint) — technology signals surface through
TECHNOLOGY events and the developer/documentation surfaces.
Alerts are created/deleted but not edited; deliveries are read-only.
Admin sensor actions use prompt() for set_interval; set_connector has no UI yet.
/api documents rate limits as designed in the spec (120 req/min anonymous); confirm the real values once ratelimit.py lands.
The world map uses headquarters clusters from /map; per-office expansions will appear once buckets include them.