SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
21.8 KB

# 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
/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/<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}

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

text
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 <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) 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 <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 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.