# Company Atlas — web app (`apps/web`) 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`. ## Run ```bash pnpm install # repo root node apps/web/qa/mock-api.mjs # contract-shaped mock API on :8371 (dev/QA only; admin token dev-admin-token) pnpm dev:web # http://localhost:8370 (reads ../../.env; API_URL defaults to :8371) pnpm -r typecheck && pnpm build # apps/web/.next (NEXT_DIST_DIR overrides the output dir) pnpm --filter @company-atlas/web start # :8360, expects API_URL # QA (Playwright from ~/Desktop/uqo-eval): screenshots in apps/web/qa/screens/ (git-ignored) node apps/web/qa/screens.mjs [BASE] [API] # every route × 390/1440 × dark/light; console errors, 404/5xx, overflow, tap targets, counters, SSE node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company → timeline filter → compare → watchlist → ⌘K → admin ``` ## Routes | Route | Rendering | Data | |---|---|---| | `/` | ISR 60 s + client live counters/feed | `/pulse`, `/live`, `/index`, `/signals`, `/stats` | | `/live` (`?event_type&min_importance&min_confidence&country&industry`) | dynamic + SSE `/live/stream` (polling fallback + 45 s watchdog) | `/live`, `/countries`, `/industries` | | `/companies` (filters, sort, search, pagination) | ISR 120 s | `/companies?sparkline=1` | | `/company/[slug]` (`?tab=overview|timeline|signals|jobs|products|pricing|locations|leadership|sources|history`, `&filter=`, `&status=&ai=1`) | ISR 120 s; only the active tab's data is fetched | `/companies/{slug}` (card + `profile` + `facts` + `relationships`) + per-tab endpoints | | `/company/compare?companies=a,b,c` | ISR 120 s + client picker (`/search/suggest`) | `/companies/compare` | | `/events`, `/events/[id]` | ISR 60/120 s | `/events`, `/events/summary`, `/events/{id}` (+ `/companies/{slug}/events`) | | `/change/[id]`, `/sensor/[id]`, `/snapshot/[id]`, `/snapshot/[id]/diff/[other]` | ISR (noindex) | provenance endpoints | | `/rankings?kind&window&country&industry` | ISR 120 s | `/rankings` | | `/industry`, `/industry/[slug]`, `/country`, `/country/[code]` | ISR 300 s | atlases + `/map` | | `/search?q=` (+ `/ask` panel when the query reads like a question) | dynamic | `/search`, `/ask` | | `/watchlist` | client (owner token) | `/watchlist`, `/alerts`, `/alerts/deliveries` | | `/system`, `/methodology`, `/about`, `/api`, `/bot` | ISR / static | `/system`, `/stats`, `/stats/history`, `/methodology` | | `/admin`, `/admin/[module]` (overview, connectors, sensors, companies, failures, queue, llm, reviews, quality, costs) | client, token in localStorage → `X-CA-Admin-Token` | `/admin/*` | | `robots.txt`, `sitemap.xml` (index) + `/sitemaps/sitemap/.xml` (shards via `generateSitemaps`), `manifest.webmanifest`, `opengraph-image` + `twitter-image` (root, `/company/[slug]`), `opengraph-image` (`/industry/[slug]`, `/country/[code]`), `icon.svg` — see **Brand** below | metadata routes | `/sitemap?kind&page`, `/stats`, `/companies/{slug}`, `/industries/{slug}`, `/countries/{code}` | 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). ## Code map ``` src/lib types.ts (docs/API.md mirror) · api.ts (server fetch, ApiError, safe/orNull) · client-api.ts (same-origin, owner/admin) format.ts (incl. fmtMoney compact currency) · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording) countries.ts (ISO) · profile.ts (enrichment profile helpers: source labels, description attribution, logo candidates, monogram, socials, people merge/ranking, corporate-structure grouping) · owner.ts · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, footer) · ui/ (section/stat/empty, badges, tabs, sheet, pagination, key-value, live dot/ago, skeleton) · charts/ (Sparkline, LineChart, Bars/Columns, Heatmap, WorldMap) events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters) company/ (header + density strip, company-logo (client, error → monogram), profile-panels (SourceChip, DescriptionAttribution, KeyFactsPanel, CorporateStructurePanel, WikidataProducts), metric tiles, table/cards/mini list, tab panels, compare picker, watch button, watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (geometry — single source of the mark, mark, logo) src/lib/og.tsx share-image renderers (site / company / topic) used by the opengraph-image + twitter-image routes scripts/ build-icons.mjs (favicon set, PNG icons, standalone logo SVGs — `pnpm icons`) public/ favicon.ico · icon-192/512(.png) · icon-512-maskable.png · apple-touch-icon.png · logo*.svg (all generated, committed) qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored) ``` 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. ## Brand (2026-09-13) **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 `` 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) | generated | | `public/icon-512-maskable.png`, `apple-touch-icon.png` (180) | 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. ## Careful language, enforced in the UI `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 …". ## Company pages — enrichment profile (2026-09-13) 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 ``, `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. `; employees = sourced count with year when known, else the stated band; industries; attributed description (`DescriptionAttribution`: Wikipedia → "Source: Wikipedia · " 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`, 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 ", 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 `
` 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 · ` (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/.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 `` src at runtime (images fall through to the icon candidate, then to monograms). `pnpm -r typecheck` clean; `pnpm build` green. ## QA results (2026-09-12, mock API) `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). ## Contract notes / questions for the API team 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. 2. `/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. 3. `/companies/{slug}/jobs` — the summary is expected in `meta.summary` (the page also accepts a top-level `summary`). 4. `/live` — the client accepts both `{ items: Event[] }` and a bare `Event[]`; please return `{ items }`. 5. `/admin/queue` and `/admin/reviews` — documented as unpaginated `{ items }`; the client also accepts a `Page`. 6. `Event.importance` is treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock. 7. `Event.tags` should be de-duplicated server-side (type + surface collide, e.g. `pricing`/`pricing`). 8. `/watchlist` GET for a fresh token should return `200 { items: [], events: [] }` rather than 404 so first-visit renders cleanly (the mock does). 9. Sitemap: the web asks `/sitemap?kind=companies&page=N` and expects `pages` on every response; only `indexed = true` companies should be listed. 10. `MapBucket.country` is expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click). 11. `CompanyDetail.coverage.historical_coverage` is displayed as a percentage (0–100), consistent with the `historical_coverage` metric. ## Known gaps - 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.