spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1# Company Atlas — web app (`apps/web`)23Next 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4, Geist self-hosted. SSR everywhere it makes sense4(ISR 60–3600 s for public aggregates, `no-store` for live/owner/admin), client components only for the live pieces5(SSE feed, counters, ⌘K search, drawer, watchlist, admin). Ports: dev **8370** → API **8371**; prod **8360** → API6`127.0.0.1:8361` (`API_URL`). The browser never talks to FastAPI directly: `/api/v1/*` and `/health` are rewritten by7`next.config.ts`.89## Run1011```bash12pnpm install # repo root13node apps/web/qa/mock-api.mjs # contract-shaped mock API on :8371 (dev/QA only; admin token dev-admin-token)14pnpm dev:web # http://localhost:8370 (reads ../../.env; API_URL defaults to :8371)15pnpm -r typecheck && pnpm build # apps/web/.next (NEXT_DIST_DIR overrides the output dir)16pnpm --filter @company-atlas/web start # :8360, expects API_URL17# QA (Playwright from ~/Desktop/uqo-eval): screenshots in apps/web/qa/screens/ (git-ignored)18node apps/web/qa/screens.mjs [BASE] [API] # every route × 390/1440 × dark/light; console errors, 404/5xx, overflow, tap targets, counters, SSE19node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company → timeline filter → compare → watchlist → ⌘K → admin20```2122## Routes2324| Route | Rendering | Data |25|---|---|---|26| `/` | ISR 60 s + client live counters/feed | `/pulse`, `/live`, `/index`, `/signals`, `/stats` |27| `/live` (`?event_type&min_importance&min_confidence&country&industry`) | dynamic + SSE `/live/stream` (polling fallback + 45 s watchdog) | `/live`, `/countries`, `/industries` |28| `/companies` (filters, sort, search, pagination) | ISR 120 s | `/companies?sparkline=1` |29| `/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 |30| `/company/compare?companies=a,b,c` | ISR 120 s + client picker (`/search/suggest`) | `/companies/compare` |31| `/events`, `/events/[id]` | ISR 60/120 s | `/events`, `/events/summary`, `/events/{id}` (+ `/companies/{slug}/events`) |32| `/change/[id]`, `/sensor/[id]`, `/snapshot/[id]`, `/snapshot/[id]/diff/[other]` | ISR (noindex) | provenance endpoints |33| `/rankings?kind&window&country&industry` | ISR 120 s | `/rankings` |34| `/industry`, `/industry/[slug]`, `/country`, `/country/[code]` | ISR 300 s | atlases + `/map` |35| `/search?q=` (+ `/ask` panel when the query reads like a question) | dynamic | `/search`, `/ask` |36| `/watchlist` | client (owner token) | `/watchlist`, `/alerts`, `/alerts/deliveries` |37| `/system`, `/methodology`, `/about`, `/api`, `/bot` | ISR / static | `/system`, `/stats`, `/stats/history`, `/methodology` |38| `/admin`, `/admin/[module]` (overview, connectors, sensors, companies, failures, queue, llm, reviews, quality, costs) | client, token in localStorage → `X-CA-Admin-Token` | `/admin/*` |39| `robots.txt`, `sitemap.xml` (index) + `/sitemaps/sitemap/<id>.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}` |4041Not-found: `not-found.tsx` (dynamic segments check existence in their `layout.tsx`/page and call `notFound()`); `error.tsx`42re-applies the theme. No root `loading.tsx` (Next 16 soft-404 gotcha).4344## Code map4546```47src/lib types.ts (docs/API.md mirror) · api.ts (server fetch, ApiError, safe/orNull) · client-api.ts (same-origin, owner/admin)48 format.ts (incl. fmtMoney compact currency) · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording)49 countries.ts (ISO) · profile.ts (enrichment profile helpers: source labels, description attribution, logo candidates,50 monogram, socials, people merge/ranking, corporate-structure grouping) · owner.ts · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts51src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, footer) · ui/ (section/stat/empty, badges, tabs, sheet,52 pagination, key-value, live dot/ago, skeleton) · charts/ (Sparkline, LineChart, Bars/Columns, Heatmap, WorldMap)53 events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters)54 company/ (header + density strip, company-logo (client, error → monogram), profile-panels (SourceChip, DescriptionAttribution,55 KeyFactsPanel, CorporateStructurePanel, WikidataProducts), metric tiles, table/cards/mini list, tab panels, compare picker,56 watch button, watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (geometry — single source of the mark, mark, logo)57src/lib/og.tsx share-image renderers (site / company / topic) used by the opengraph-image + twitter-image routes58scripts/ build-icons.mjs (favicon set, PNG icons, standalone logo SVGs — `pnpm icons`)59public/ favicon.ico · icon-192/512(.png) · icon-512-maskable.png · apple-touch-icon.png · logo*.svg (all generated, committed)60qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored)61```6263Design tokens live at the top of `src/app/globals.css` (canvas/surface/ink/rule/accent/positive/warning/danger/live,64per-event-type hues `--ev-*`, sensor tiers, series, map, brand plate, density variables) and are exposed to Tailwind through65`@theme inline`. Both themes are hand-tuned; default follows the system and `lib/prepaint.ts` sets `data-theme` before paint.6667## Brand (2026-09-13)6869**Mark — the "atlas plate".** A rounded square plate carrying a globe drawn as a sparse grid (ring, one meridian ellipse, two70parallels) crossed by a strong equator — the timeline — that ends on a bright "now" node sitting on the horizon, with a short71pulse trail behind it. One 64 × 64 geometry, three detail levels chosen by rendered size so nothing becomes a sub-pixel72hairline: `tiny` ≤ 20 px (favicon 16: no parallels / halo, 5.2-unit strokes), `small` ≤ 40 px (favicon 32, header), `full`73above (bevel hairline on the plate, halo ring). Colourways: dark plate `#0f1419` / ink `#f7f7f4` / live `#3fd07a` for light74surfaces; light plate `#e8ebf1` / ink `#0a0d12` / live `#16a34a` for dark surfaces. In the UI the plate follows the theme through75the `--brand-plate / --brand-ink / --brand-grid / --brand-accent` tokens in `globals.css` (dark plate on the light UI, light76plate on the dark UI). Wordmark: Geist, "Company" regular in `--ink-2` + "Atlas" semibold in `--ink`, tracking −0.02 em.7778**Single source.** `src/components/brand/geometry.ts` (pure TS, no JSX) owns the path data, weights, palettes and79`markShapes()` / `markSvgString()`. `mark.tsx` turns the shape list into React (`MarkArt`) or a data-URI `<img>` for Satori80(`MarkImg`); `logo.tsx` exposes `LogoMark` (`plate` | `mono`), `WordmarkText`, `Wordmark` / `Logo`. `scripts/build-icons.mjs`81imports the same `geometry.ts` (Node type stripping) so every exported file is generated from that one description.8283| File | What | How it is made |84|---|---|---|85| `src/app/icon.svg` → `/icon.svg` | favicon for SVG-capable browsers (tiny level, hard-coded colours) | generated |86| `public/favicon.ico` | real ICO container, 16 (tiny) / 32 (small) / 48 (full) PNG-encoded entries, packed by hand | generated |87| `public/icon-192.png`, `icon-512.png` | PWA icons (`purpose: any`, rounded plate on transparent) | generated |88| `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 |89| `public/logo-mark.svg` | the mark alone, dark plate | generated |90| `public/logo-light.svg` / `logo-dark.svg` | lockup for light / dark backgrounds, transparent, **vector text** (Geist outlines, no font needed) | generated |91| `public/logo.svg` | lockup on its own dark rounded card — safe on any background (press, READMEs) | generated |9293`layout.tsx` lists `icons.icon = [/favicon.ico (sizes any, image/x-icon), /icon.svg]` and `icons.apple = /apple-touch-icon.png`;94`manifest.ts` references the PNGs. The former `apple-icon.tsx` and `icon-512/route.tsx` ImageResponse routes were removed.9596**Regenerate** (after touching `geometry.ts` or the wordmark): `pnpm icons` from `apps/web` (= `node scripts/build-icons.mjs`;97`PLAYWRIGHT_MODULE` overrides the Playwright import, default `~/Desktop/uqo-eval/node_modules/playwright/index.mjs`). The script98reads Geist-Regular / Geist-SemiBold TTFs from the `geist` package, parses `glyf` outlines itself, measures glyph positions99(advances + kerning) in Chromium with the same font files, then renders each PNG at 1× with Playwright and packs the ICO100(6-byte header, 16-byte directory entries, PNG payloads). Commit the outputs.101102**Share images** (`src/lib/og.tsx`, Satori via `next/og`, 1200 × 630, Geist TTFs loaded from the `geist` package at first use):103- `renderSiteOg(stats)` — root `opengraph-image.tsx` / `twitter-image.tsx`: lockup, "Continuous corporate observation network",104 headline *The Live Atlas of Global Companies*, live counters (companies · sensors · observations · events from `/stats`,105 omitted when the API is down), URL bottom-left, "Hosted on MacLustr" bottom-right, orthographic globe (world-atlas land +106 15° graticule through d3-geo) on the right.107- `renderCompanyOg(detail)` — `/company/[slug]/opengraph-image.tsx` + `twitter-image.tsx`: company logo fetched **server-side**108 as a buffer (`fetchLogoDataUri`: candidates `profile.logo_url → icon_url → logo_url`, http(s) only, no IP literals / localhost109 except the API host, 2.5 s timeout, ≤ 1.5 MB, png/jpeg/svg only, 24 h data cache) with a monogram plate fallback; name110 (auto-sized), domain · country · industry chips, key facts (founded, employees, HQ — only what is sourced), three metric111 tiles (Activity Score, Hiring Momentum 30d, AI Adoption — "—" + "not enough monitored evidence" when missing), sensors ·112 events · changes, URL. Unknown slug → site image.113- `renderTopicOg()` — `/industry/[slug]` and `/country/[code]` `opengraph-image.tsx`: eyebrow, name, description / region,114 up to eight monitored company chips, four counters.115- Satori rules: every box `display:flex`; never several text children in one div (wrap in spans); images as data URIs; no116 `runtime` re-export between sibling routes (thin route files only export `alt` / `size` / `contentType` / default).117118**Credits.** Footer bottom line on every page (13 px, `--ink-2`): "Built by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on119MacLustr — www.maclustr.io" (`mailto:`, `https://www.maclustr.io` with `rel="noopener noreferrer"`), constants in `lib/site.ts`120(`AUTHOR_NAME`, `CONTACT_EMAIL`, `HOST_NAME`, `HOST_URL`). `/about#who-builds-it` — "Who builds Company Atlas": built by, contact,121hosting on the MacLustr cluster (link), data links, and the SVG logo files for press.122123## Careful language, enforced in the UI124125`no_longer_listed` → "no longer listed"; job decreases say "listings are no longer visible … not evidence of layoffs";126products "no longer listed in the public catalog"; leadership "no longer listed on the monitored leadership page"; signals127carry a `signal` chip and confidence; every event shows `confidence_label`, `origin` (llm/hybrid show model + prompt version),128sources with detection times and a link to `/change/[id]`; retracted events stay visible, struck through, and are excluded129from counts. Missing metrics render as a dash with "not enough monitored evidence" — never 0. Empty states: "No monitored130evidence available yet." / "Last successfully checked …".131132## Company pages — enrichment profile (2026-09-13)133134Built against the additive API 1.1 shape (`CompanyCard.profile`, `CompanyDetail.facts`, `relationships[].provenance` +135`company.logo_url`, `people[].source`); every field may be null and the UI hides what is missing instead of rendering dashes.136137- **Header** (`company-header.tsx`): logo via `CompanyLogo` (candidates `profile.logo_url → profile.icon_url → logo_url`, plain lazy138 `<img referrerPolicy="no-referrer">`, `onError` walks to the next candidate, then a monogram plate — `data-logo="image|monogram"`);139 name · legal name · legal form; domain chip + listing chip (`ticker · exchange`, only when `public_company`); ISO country + HQ city/140 region; `est. <founded_year>`; employees = sourced count with year when known, else the stated band; industries; attributed141 description (`DescriptionAttribution`: Wikipedia → "Source: Wikipedia · <license, default CC BY-SA 4.0>" link; Wikidata → "Source:142 Wikidata · CC0"; homepage → "From the company's website"; llm → warning chip "Generated from the company's public pages" + one-line143 caveat; unattributed registry description → no line); socials row (text chips with ↗, `rel="noopener nofollow noreferrer"`;144 lucide 1.x has no brand icons, so no fake glyphs). `generateMetadata` uses the profile description (truncated at 220 chars).145- **Key facts** (`KeyFactsPanel`, first block of the Overview main column, `data-key-facts`): founded · headquarters (city, region,146 country name + "map" link to `?tab=locations`) · employees (year) · revenue / net income / total assets (`fmtMoney` compact in the147 stated currency + `FY<year>`, never converted) · legal form · listing · ISIN · LEI (→ GLEIF record) · SEC CIK (→ EDGAR browse) ·148 Industry (Wikidata) · phone · references (Wikipedia / Wikidata). Each row ends with a `SourceChip` (label from `SOURCE_LABELS`,149 `title` = "<source> · retrieved <retrieved_at>", links to the source URL when given); provenance comes from `profile.sources[]`150 by field with `facts[]` as fallback, and any `facts[]` key not already rendered is appended as a generic row. Empty →151 "No sourced facts yet — enrichment pending." Footer note: figures quoted as retrieved.152- **Corporate structure** (`CorporateStructurePanel`, Overview aside, replaces the flat Relationships list): grouped Parent153 (`SUBSIDIARY_OF`) · Owners (`OWNED_BY`) · Acquired by · Subsidiaries (`PARENT_OF`) · Owns (`OWNER_OF`) · Acquisitions (`ACQUIRED`) ·154 Other relationships (kind chip); each row = 20 px logo/monogram, link to the atlas company or plain `to_name`, "former" chip when155 `valid_to`, validity (`since … / … – …`), confidence %, provenance chip. More than 8 rows → collapsed `<details>` whose summary156 counts the groups. Current relationships sort before ended ones.157- **Leadership** (`PeoplePanel`): `mergePeople` joins page-observed and Wikidata rows by normalised name (both chips kept, page158 title wins), ordered CEO → chair → founders → other C-level → VP → rest (`roleRank`). Chips "Leadership page" / "Wikidata";159 Wikidata-only rows read "recorded on Wikidata · retrieved <date> · statement ↗". The "No longer listed" group stays separate:160 page rows "no longer listed since …", Wikidata rows "no longer recorded as current on Wikidata · <date>". Heading counts both161 sources. Rows carry `data-person-source="page|wikidata|page+wikidata"`.162- **Products**: when the observed catalogue is empty and `profile.products` is not, the tab shows "Products & services listed on163 Wikidata" chips (`WikidataProducts`, provenance = `sources[products]` else the Wikidata entity) with a note that they carry no164 first-seen / last-seen dates, above the usual empty state.165- **Directory / lists**: `CompanyTable` desktop rows show a 28 px logo and `domain · <one-line description>` (truncated, full text in166 `title`; `showDescription={false}` to opt out); mobile cards and `CompanyMiniList` show 32 / 24 px logos; compare column headers167 20 px. Rankings, industry, country, search and watchlist inherit through `CompanyTable`.168- **Mock** (`qa/mock-api.mjs`): hand-written profiles for 20 companies (complete: nvidia, apple, toyota, sony, roche, sap, spotify,169 adyen, arm, tsmc, siemens, samsung-electronics; partial: stripe, shopify, cohere (llm description, icon only), anthropic (wikidata170 description), mistral-ai, nubank, revolut, klarna), null-filled profiles (cabify, careem, flutterwave, zerodha, discovery), no171 `profile` key at all (compass, bitso, wiz), auto-derived minimal profiles for the rest; `facts[]`, v1.1 relationships172 (Sony has 13 → collapsed; Arm has parent + owner; SAP/Sony have former relationships), Wikidata executives (one per company also173 on the page → merge), `anthropic` / `mistral-ai` without an observed catalogue. Sample logos are served by the mock itself174 (`/api/v1/_mock/logo|icon/<slug>.svg`) so QA never hits the network. Figures are approximate public values for layout only.175176## QA results (2026-09-13, company pages upgrade, mock API)177178`qa/screens.mjs`: 206 checks (50 routes × 390/1440 × dark/light — new routes `/company/nvidia`, `/company/sony`,179`/company/sony?tab=leadership`, `/company/cohere`, `/company/cabify`, `/company/anthropic?tab=products`, `/company/compass` — plus180counters + SSE): all OK, zero console errors, zero 404/5xx sub-requests, zero horizontal overflow, zero sub-40 px controls on181mobile. `qa/flows.mjs`: 30/30 (15 steps × 390/1440). Element screenshots of the header (complete / Wikidata-described /182LLM-described / no-profile), key facts (full and empty), corporate structure (grouped, parent+owner, 13-row collapsed → open),183merged leadership, Wikidata products fallback, directory rows and compare headers reviewed by eye in both themes; the logo184fallback was exercised by breaking every `<img>` src at runtime (images fall through to the icon candidate, then to monograms).185`pnpm -r typecheck` clean; `pnpm build` green.186187## QA results (2026-09-12, mock API)188189`qa/screens.mjs`: 178 checks (44 routes × 390/1440 × dark/light + counters + SSE) — all 200/404 as expected, zero console190errors, zero 404/5xx sub-requests, zero horizontal overflow, zero interactive controls under 40 px on mobile, homepage191counters within the `/stats` window, SSE prepends a new row on `/live`. `qa/flows.mjs`: 15/15 steps pass at 390 and 1440192(live feed → drawer → company → timeline filter → compare picker → watch → watchlist + alert rule → ⌘K → admin).193`pnpm -r typecheck` clean; `pnpm build` green (27 static pages + dynamic routes, sitemap shards prerendered). Screenshots reviewed by eye (home, live, company tabs,194compare, events, event detail, change diff, sensor, snapshot diff, rankings, industry/country, search + ask, watchlist,195system, admin, 404) in both themes.196197Fixed during QA: grid items letting scroll containers widen the page on mobile (`[class*='grid-cols'] > * { min-width: 0 }`);198sub-40 px `.btn-sm`/chips/compare-remove buttons on touch; duplicate React keys (world-atlas features with repeated ids,199duplicated event tags); OG image satori flex rule; `/sitemap.xml` conflict with `generateSitemaps` (shards moved under200`/sitemaps/`); `ADMIN_MODULES` imported from a `'use client'` file into a server page; a `valueOf` prop name colliding with201`Object.prototype`; hiring-momentum value wrapping in the narrow mobile metric tile; `useSearchParams` in the homepage live feed without a Suspense202boundary (build-time prerender error); SSE frames buffered by gzip behind the Next proxy (see contract note 1).203204## Contract notes / questions for the API team2052061. **SSE must not be compressed.** Behind the Next rewrite proxy (dev and `next start`) `text/event-stream` is gzip-buffered207 unless the response carries `Cache-Control: no-store, no-transform`. Without it the browser receives nothing until the208 connection closes. The mock sets it; FastAPI's `/live/stream` must too (and any reverse proxy must keep209 `X-Accel-Buffering: no`). The web feed has a 45 s watchdog that falls back to polling `/live?since=` just in case.2102. `/industries/{slug}` and `/countries/{code}` return `companies: CompanyCard[]` while the list rows use `companies: number`.211 The client handles both (`IndustryDetailRaw`/`CountryDetailRaw`); a distinct field name (e.g. `companies_list`) would be212 cleaner.2133. `/companies/{slug}/jobs` — the summary is expected in `meta.summary` (the page also accepts a top-level `summary`).2144. `/live` — the client accepts both `{ items: Event[] }` and a bare `Event[]`; please return `{ items }`.2155. `/admin/queue` and `/admin/reviews` — documented as unpaginated `{ items }`; the client also accepts a `Page`.2166. `Event.importance` is treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock.2177. `Event.tags` should be de-duplicated server-side (type + surface collide, e.g. `pricing`/`pricing`).2188. `/watchlist` GET for a fresh token should return `200 { items: [], events: [] }` rather than 404 so first-visit renders219 cleanly (the mock does).2209. Sitemap: the web asks `/sitemap?kind=companies&page=N` and expects `pages` on every response; only `indexed = true`221 companies should be listed.22210. `MapBucket.country` is expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click).22311. `CompanyDetail.coverage.historical_coverage` is displayed as a percentage (0–100), consistent with the `historical_coverage`224 metric.225226## Known gaps227228- No Technology tab yet (spec §40 lists it; the API has no technology-signals endpoint) — technology signals surface through229 `TECHNOLOGY` events and the developer/documentation surfaces.230- Alerts are created/deleted but not edited; deliveries are read-only.231- Admin sensor actions use `prompt()` for `set_interval`; `set_connector` has no UI yet.232- `/api` documents rate limits as designed in the spec (120 req/min anonymous); confirm the real values once `ratelimit.py` lands.233- The world map uses headquarters clusters from `/map`; per-office expansions will appear once buckets include them.234