diff --git a/README.md b/README.md
index 5c09cb3..0684540 100644
--- a/README.md
+++ b/README.md
@@ -60,18 +60,102 @@ for, not because they are universal.
## Screenshots
-> **Placeholder — fresh captures needed.** The application shell was rebuilt as
-> a three-pane layout (header, collapsible sidebar rail, docked Piggy) and
-> every screenshot taken before that redesign now misrepresents the product.
-> Rather than ship misleading images, this section is deliberately empty until
-> the new shell is re-shot at desktop and at 393px, in light and dark.
->
-> Pages to capture: `/` Overview, `/margin`, `/capacity` (the matcher),
-> `/calendar`, `/piggy` docked beside a record.
+Captured against the current shell — header, collapsible sidebar rail, docked
+Piggy — running locally on the seed plus demo book (`db:seed` and `db:demo`), so
+every number below is computed by the code in this repository rather than drawn.
+Records prefixed `DEMO —` are fictional; the rest are the sourced, cited seed.
-
+Each image follows your own system theme. Both themes are shown explicitly
+further down, and [the full gallery](docs/screenshots.md) has all ten pages at
+1440px and 393px, in light and dark. Desktop captures are the 1440×900 viewport
+rather than the full scroll height — what you see is what fits above the fold.
+`node scripts/screenshots.mjs` re-shoots the set.
+
+**Overview** — margin, sold ratio and idle capacity across the book, with the
+commitments you are paying for and not selling ranked by cost exposure.
+
+
+
+
+
+
+**Margin** — revenue from what was sold against the *full* cost of what was
+bought, per commitment. `Cost covered` and a break-even price are the two states
+that matter; charging only the allocated share of cost would report a healthy
+margin on a block that is losing money.
+
+
+
+
+
+
+**Capacity → Match a requirement** — the matcher. Ask what a customer needs and
+PIG scores it against capacity already under commitment, saying why each block
+fits, and hands you straight to the allocation that records the sale.
+
+
+
+
+
+
+**Growth** — deterministic attention scores over customer paper, deal activity
+and sold or reserved capacity. Every point is an explained signal with its
+sources named; nothing here is a model's guess at a win probability.
+
+
+
+
+
+
+**Calendar** — what closes, what renews, what expires and when capacity lands,
+projected from the records that already carry the dates. Export authorisations
+expire on this timeline too, because an expired one converts lawful business
+into unlawful business.
+
+
+
+
+
+
+### Light and dark
+
+Theme is a stored preference that follows a person between devices, resolved
+before first paint by an inline script so dark-mode users never get a white
+flash. Both tunings of the accent palette are defined in `@pig/core` and applied
+as CSS variables at runtime, so there is one definition of each colour.
+
+The demand pipeline, in both. Legal sits second rather than last, because MSA
+and DPA execution gates delivery rather than closing the deal — most CRMs put
+contracts at the end of the funnel and are wrong about it for this market.
+
+*Light:*
+
+
+
+*Dark:*
+
+
+
+Side by side at 393px, where both tunings have to survive a smaller surface:
+
+| Supply pipeline · light | Supply pipeline · dark |
+| --- | --- |
+| | |
+
+### Mobile
+
+PIG is responsive to 393px — the sidebar becomes a bottom tab bar, tables become
+cards, and the safe-area insets are handled. It is not a native app.
+
+| Overview · light | Overview · dark | Margin · dark |
+| --- | --- | --- |
+| | | |
+
+Piggy is deliberately not pictured mid-conversation. It is off by default
+(`PIGGY_ENABLED=false`, and the Compose service sits behind a profile), and
+showing it answering would mean staging a transcript rather than capturing one.
+What it may and may not do is described under [the agent
+surface](#the-agent-surface).
## The two-sided data model
@@ -245,7 +329,7 @@ half-working.
## Architecture
-A pnpm monorepo. Around 45k lines of TypeScript including tests, 261 tests
+A pnpm monorepo. Around 47k lines of TypeScript including tests, 275 tests
across five packages, green CI.
```
@@ -257,9 +341,9 @@ apps/
cli/ `pig`, the HTTP surface for scripts and agent kernels
packages/
core/ Ontology, permissions, margin arithmetic, palette — no I/O
- db/ Drizzle schema (47 tables), 13 migrations, seed and demo data
+ db/ Drizzle schema (47 tables), 14 migrations, seed and demo data
prime/ Typed client for the Prime Intellect compute API
-docs/ ontology.md, build-plan.md, agents.md, seed-data.md
+docs/ ontology.md, screenshots.md, build-plan.md, agents.md, seed-data.md
deploy/ Caddyfile example, autodeploy units, deployment notes
```
@@ -322,11 +406,15 @@ required to hold it is what separates the roster from the cost book.
`economics:read` is the one that matters — supplier cost per GPU-hour and
break-even price *are* the business.
-⚠️ **The read half is written and tested but not yet enforced.** The policy
-table (`apps/api/src/routes/read-guards.ts`) and its middleware exist, and
-`read-governance.test.ts` fails when a GET appears that no rule covers — but
-`createReadGuardRoutes` is not mounted in `app.ts`, so today every
-authenticated member can read the whole book including cost. See
+The read half is enforced. The policy table lives in
+`apps/api/src/routes/read-guards.ts` and `createReadGuardRoutes` is mounted in
+`app.ts` **before** the feature routes — Hono runs matched handlers in
+registration order, so a guard registered after its route would return 200 while
+looking correct. `read-governance.test.ts` pins that ordering in both
+directions, and fails when a GET appears that no rule covers, so a new read
+endpoint cannot ship ungoverned by accident.
+
+What it still cannot do is filter *within* a grant: see
[limitations](#what-is-not-built-yet).
## The agent surface
@@ -399,7 +487,7 @@ see [docs/agents.md](./docs/agents.md).
CI is Gitea Actions, one sequence, about two minutes. It typechecks every
package, applies the migration chain **twice** to a real empty Postgres, asserts
-the seed is idempotent, runs 261 unit tests and the critical-path E2E, boots the
+the seed is idempotent, runs 275 unit tests and the critical-path E2E, boots the
server and curls it, builds the front end, checks the inline theme script still
hashes to the value the proxy's CSP allows, and builds the Docker image.
@@ -428,20 +516,16 @@ one thing an on-call needs at 04:00.
Said plainly, because you are going to grep the repo anyway.
-**Read authorisation is not enforced.** As above: the policy, the middleware
-and the governance test all exist; the router line that mounts them does not.
-Any authenticated member can currently read cost and margin regardless of team
-or role. This is the most significant gap in the product and it is one line in
-`app.ts`.
+**Read authorisation is enforced, but only at the grant.** A capability is held
+or it is not. Once `economics:read` is held, it returns every commitment's cost
+— there is no filter that narrows it to one team's book, because no row-level
+team filter exists anywhere in the query layer. That is the gap to close before
+PIG serves a company where "supply can see supply's costs" is a requirement.
-**Four route modules are written, tested and never mounted.**
-`routes/read-guards.ts`, `routes/learn.ts`, `routes/hubspot.ts` and
-`routes/hubspot-webhook.ts` are all absent from `app.ts`. Consequences: the
-**Learn** page is in the navigation and its API answers 404, and the HubSpot
-integration — OAuth, connections, sync jobs, webhook verification, seven
-`hubspot_*` tables — is unreachable. `routes/activities.ts` is likewise
-unmounted, but harmlessly: an older inline `POST /api/activities` in `app.ts`
-still serves it.
+**The HubSpot integration is written, tested and never mounted.**
+`routes/hubspot.ts` and `routes/hubspot-webhook.ts` are both absent from
+`app.ts`, so OAuth, connections, sync jobs, webhook verification and seven
+`hubspot_*` tables are all unreachable from the running server.
**Six of the eight declared agent task kinds are never enqueued.** The worker
is complete and generic, but only `enrich_account` and `enrich_contact` are
@@ -478,6 +562,7 @@ responsive to 393px; it is not a native app.
- **[AGENTS.md](./AGENTS.md) — start here if you are joining this codebase.**
Architecture rules, the traps that have already bitten, and conventions.
+- [Screenshots](./docs/screenshots.md) — every page, at 1440px and 393px, light and dark
- [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way
- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
diff --git a/docs/screenshots.md b/docs/screenshots.md
new file mode 100644
index 0000000..0f96e9b
--- /dev/null
+++ b/docs/screenshots.md
@@ -0,0 +1,225 @@
+# Screenshots
+
+Every page of PIG, at 1440px and at 393px, in light and in dark.
+
+Captured from a local instance running the seed plus demo book — `pnpm run
+db:seed` then `pnpm run db:demo`. Records prefixed `DEMO —` are fictional and
+exist to make the arithmetic legible; everything else is the sourced, cited seed
+described in [seed-data.md](seed-data.md). Every figure shown is computed by the
+code in this repository, not drawn.
+
+Piggy is absent deliberately. It is off by default (`PIGGY_ENABLED=false`, and
+the Compose service sits behind a profile), and it needs an inference credential
+to answer at all — so any screenshot of it mid-conversation would be a staged
+transcript rather than a capture.
+
+Desktop captures are the 1440×900 viewport, not the full scroll height: what you
+see is what fits above the fold.
+
+**Regenerating.** Start the API and the web dev server against a seeded
+database, then run the capture:
+
+```
+pnpm run dev:api
+pnpm run dev:web
+node scripts/screenshots.mjs
+```
+
+It forces the theme, the sidebar and the dock so nothing a previous session left
+in `localStorage` leaks into a capture, fails the run if any page reports a
+horizontal scrollbar, a console error or the wrong theme, and re-encodes
+everything to WebP. Do not hand-edit these files — re-shoot them.
+
+Back to the [README](../README.md).
+
+**Jump to:** [Overview](#overview) · [Margin](#margin) · [Capacity · Availability](#capacity--availability) · [Capacity · Match a requirement](#capacity--match-a-requirement) · [Growth](#growth) · [Calendar](#calendar) · [Demand pipeline](#demand-pipeline) · [Supply pipeline](#supply-pipeline) · [Accounts](#accounts) · [Contracts](#contracts)
+
+
+## Overview
+
+`/` — margin, sold ratio, idle capacity and the ranked idle-cost exposure across the whole book.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Margin
+
+`/margin` — revenue against the full cost of every commitment, with break-even price per GPU-hour.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Capacity · Availability
+
+`/capacity` — what is held, what is sold, what is still sellable, and what is merely reserved by a live hold.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Capacity · Match a requirement
+
+`/capacity` — score a customer requirement against capacity already under commitment, then allocate against it.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Growth
+
+`/growth` — deterministic attention scores over paper, activity and sold or reserved capacity, with every signal explained.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Calendar
+
+`/calendar` — closes, renewals, obligations and expiring export authorisations on one quarter timeline.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Demand pipeline
+
+`/demand` — the sell side. Legal sits second, because MSA and DPA execution gates delivery rather than closing the deal.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Supply pipeline
+
+`/supply` — the buy side, from sourced through diligence and pricing to live and renewal.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Accounts
+
+`/accounts` — both sides of the market in one book, each record carrying its confidence grade and source.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
+
+
+## Contracts
+
+`/contracts` — MSA, DPA, SLA, order form and capacity commitment, polymorphic over party and type.
+
+**Desktop · 1440px · light**
+
+
+
+**Desktop · 1440px · dark**
+
+
+
+**Mobile · 393px**
+
+| Light | Dark |
+| --- | --- |
+| | |
diff --git a/docs/screenshots/accounts-desktop-dark.webp b/docs/screenshots/accounts-desktop-dark.webp
new file mode 100644
index 0000000..333e111
Binary files /dev/null and b/docs/screenshots/accounts-desktop-dark.webp differ
diff --git a/docs/screenshots/accounts-desktop-light.webp b/docs/screenshots/accounts-desktop-light.webp
new file mode 100644
index 0000000..7fdb041
Binary files /dev/null and b/docs/screenshots/accounts-desktop-light.webp differ
diff --git a/docs/screenshots/accounts-mobile-dark.webp b/docs/screenshots/accounts-mobile-dark.webp
new file mode 100644
index 0000000..3ec9d18
Binary files /dev/null and b/docs/screenshots/accounts-mobile-dark.webp differ
diff --git a/docs/screenshots/accounts-mobile-light.webp b/docs/screenshots/accounts-mobile-light.webp
new file mode 100644
index 0000000..067ef58
Binary files /dev/null and b/docs/screenshots/accounts-mobile-light.webp differ
diff --git a/docs/screenshots/calendar-desktop-dark.webp b/docs/screenshots/calendar-desktop-dark.webp
new file mode 100644
index 0000000..c98add7
Binary files /dev/null and b/docs/screenshots/calendar-desktop-dark.webp differ
diff --git a/docs/screenshots/calendar-desktop-light.webp b/docs/screenshots/calendar-desktop-light.webp
new file mode 100644
index 0000000..2cbad8b
Binary files /dev/null and b/docs/screenshots/calendar-desktop-light.webp differ
diff --git a/docs/screenshots/calendar-mobile-dark.webp b/docs/screenshots/calendar-mobile-dark.webp
new file mode 100644
index 0000000..135d633
Binary files /dev/null and b/docs/screenshots/calendar-mobile-dark.webp differ
diff --git a/docs/screenshots/calendar-mobile-light.webp b/docs/screenshots/calendar-mobile-light.webp
new file mode 100644
index 0000000..d400464
Binary files /dev/null and b/docs/screenshots/calendar-mobile-light.webp differ
diff --git a/docs/screenshots/capacity-desktop-dark.webp b/docs/screenshots/capacity-desktop-dark.webp
new file mode 100644
index 0000000..21cdf54
Binary files /dev/null and b/docs/screenshots/capacity-desktop-dark.webp differ
diff --git a/docs/screenshots/capacity-desktop-light.webp b/docs/screenshots/capacity-desktop-light.webp
new file mode 100644
index 0000000..f45312d
Binary files /dev/null and b/docs/screenshots/capacity-desktop-light.webp differ
diff --git a/docs/screenshots/capacity-match-desktop-dark.webp b/docs/screenshots/capacity-match-desktop-dark.webp
new file mode 100644
index 0000000..da072ed
Binary files /dev/null and b/docs/screenshots/capacity-match-desktop-dark.webp differ
diff --git a/docs/screenshots/capacity-match-desktop-light.webp b/docs/screenshots/capacity-match-desktop-light.webp
new file mode 100644
index 0000000..8342e04
Binary files /dev/null and b/docs/screenshots/capacity-match-desktop-light.webp differ
diff --git a/docs/screenshots/capacity-match-mobile-dark.webp b/docs/screenshots/capacity-match-mobile-dark.webp
new file mode 100644
index 0000000..ebb360e
Binary files /dev/null and b/docs/screenshots/capacity-match-mobile-dark.webp differ
diff --git a/docs/screenshots/capacity-match-mobile-light.webp b/docs/screenshots/capacity-match-mobile-light.webp
new file mode 100644
index 0000000..28fef55
Binary files /dev/null and b/docs/screenshots/capacity-match-mobile-light.webp differ
diff --git a/docs/screenshots/capacity-mobile-dark.webp b/docs/screenshots/capacity-mobile-dark.webp
new file mode 100644
index 0000000..9b1a3c5
Binary files /dev/null and b/docs/screenshots/capacity-mobile-dark.webp differ
diff --git a/docs/screenshots/capacity-mobile-light.webp b/docs/screenshots/capacity-mobile-light.webp
new file mode 100644
index 0000000..2cf36e2
Binary files /dev/null and b/docs/screenshots/capacity-mobile-light.webp differ
diff --git a/docs/screenshots/contracts-desktop-dark.webp b/docs/screenshots/contracts-desktop-dark.webp
new file mode 100644
index 0000000..583f094
Binary files /dev/null and b/docs/screenshots/contracts-desktop-dark.webp differ
diff --git a/docs/screenshots/contracts-desktop-light.webp b/docs/screenshots/contracts-desktop-light.webp
new file mode 100644
index 0000000..df1210b
Binary files /dev/null and b/docs/screenshots/contracts-desktop-light.webp differ
diff --git a/docs/screenshots/contracts-mobile-dark.webp b/docs/screenshots/contracts-mobile-dark.webp
new file mode 100644
index 0000000..3ebf51b
Binary files /dev/null and b/docs/screenshots/contracts-mobile-dark.webp differ
diff --git a/docs/screenshots/contracts-mobile-light.webp b/docs/screenshots/contracts-mobile-light.webp
new file mode 100644
index 0000000..2ebaa52
Binary files /dev/null and b/docs/screenshots/contracts-mobile-light.webp differ
diff --git a/docs/screenshots/demand-desktop-dark.webp b/docs/screenshots/demand-desktop-dark.webp
new file mode 100644
index 0000000..806d207
Binary files /dev/null and b/docs/screenshots/demand-desktop-dark.webp differ
diff --git a/docs/screenshots/demand-desktop-light.webp b/docs/screenshots/demand-desktop-light.webp
new file mode 100644
index 0000000..a89b522
Binary files /dev/null and b/docs/screenshots/demand-desktop-light.webp differ
diff --git a/docs/screenshots/demand-mobile-dark.webp b/docs/screenshots/demand-mobile-dark.webp
new file mode 100644
index 0000000..a1e476c
Binary files /dev/null and b/docs/screenshots/demand-mobile-dark.webp differ
diff --git a/docs/screenshots/demand-mobile-light.webp b/docs/screenshots/demand-mobile-light.webp
new file mode 100644
index 0000000..eec84ae
Binary files /dev/null and b/docs/screenshots/demand-mobile-light.webp differ
diff --git a/docs/screenshots/growth-desktop-dark.webp b/docs/screenshots/growth-desktop-dark.webp
new file mode 100644
index 0000000..2900e29
Binary files /dev/null and b/docs/screenshots/growth-desktop-dark.webp differ
diff --git a/docs/screenshots/growth-desktop-light.webp b/docs/screenshots/growth-desktop-light.webp
new file mode 100644
index 0000000..8ff7f3d
Binary files /dev/null and b/docs/screenshots/growth-desktop-light.webp differ
diff --git a/docs/screenshots/growth-mobile-dark.webp b/docs/screenshots/growth-mobile-dark.webp
new file mode 100644
index 0000000..bfdf49a
Binary files /dev/null and b/docs/screenshots/growth-mobile-dark.webp differ
diff --git a/docs/screenshots/growth-mobile-light.webp b/docs/screenshots/growth-mobile-light.webp
new file mode 100644
index 0000000..9b42ce4
Binary files /dev/null and b/docs/screenshots/growth-mobile-light.webp differ
diff --git a/docs/screenshots/margin-desktop-dark.webp b/docs/screenshots/margin-desktop-dark.webp
new file mode 100644
index 0000000..294cb15
Binary files /dev/null and b/docs/screenshots/margin-desktop-dark.webp differ
diff --git a/docs/screenshots/margin-desktop-light.webp b/docs/screenshots/margin-desktop-light.webp
new file mode 100644
index 0000000..093dad2
Binary files /dev/null and b/docs/screenshots/margin-desktop-light.webp differ
diff --git a/docs/screenshots/margin-mobile-dark.webp b/docs/screenshots/margin-mobile-dark.webp
new file mode 100644
index 0000000..61e2925
Binary files /dev/null and b/docs/screenshots/margin-mobile-dark.webp differ
diff --git a/docs/screenshots/margin-mobile-light.webp b/docs/screenshots/margin-mobile-light.webp
new file mode 100644
index 0000000..0a0dfab
Binary files /dev/null and b/docs/screenshots/margin-mobile-light.webp differ
diff --git a/docs/screenshots/overview-desktop-dark.webp b/docs/screenshots/overview-desktop-dark.webp
new file mode 100644
index 0000000..b965fee
Binary files /dev/null and b/docs/screenshots/overview-desktop-dark.webp differ
diff --git a/docs/screenshots/overview-desktop-light.webp b/docs/screenshots/overview-desktop-light.webp
new file mode 100644
index 0000000..a1a7b99
Binary files /dev/null and b/docs/screenshots/overview-desktop-light.webp differ
diff --git a/docs/screenshots/overview-mobile-dark.webp b/docs/screenshots/overview-mobile-dark.webp
new file mode 100644
index 0000000..7a19f34
Binary files /dev/null and b/docs/screenshots/overview-mobile-dark.webp differ
diff --git a/docs/screenshots/overview-mobile-light.webp b/docs/screenshots/overview-mobile-light.webp
new file mode 100644
index 0000000..2dad713
Binary files /dev/null and b/docs/screenshots/overview-mobile-light.webp differ
diff --git a/docs/screenshots/supply-desktop-dark.webp b/docs/screenshots/supply-desktop-dark.webp
new file mode 100644
index 0000000..42db628
Binary files /dev/null and b/docs/screenshots/supply-desktop-dark.webp differ
diff --git a/docs/screenshots/supply-desktop-light.webp b/docs/screenshots/supply-desktop-light.webp
new file mode 100644
index 0000000..cce5618
Binary files /dev/null and b/docs/screenshots/supply-desktop-light.webp differ
diff --git a/docs/screenshots/supply-mobile-dark.webp b/docs/screenshots/supply-mobile-dark.webp
new file mode 100644
index 0000000..a311bc8
Binary files /dev/null and b/docs/screenshots/supply-mobile-dark.webp differ
diff --git a/docs/screenshots/supply-mobile-light.webp b/docs/screenshots/supply-mobile-light.webp
new file mode 100644
index 0000000..4a261bc
Binary files /dev/null and b/docs/screenshots/supply-mobile-light.webp differ
diff --git a/scripts/screenshots-encode.py b/scripts/screenshots-encode.py
new file mode 100644
index 0000000..d5ccb2d
--- /dev/null
+++ b/scripts/screenshots-encode.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Downscale and re-encode the raw Playwright captures as WebP.
+
+Called by scripts/screenshots.mjs; not useful on its own.
+
+ python3 scripts/screenshots-encode.py
+
+The captures are taken at 2x (desktop) and 3x (mobile) so text is sampled
+cleanly, then halved here. Halving rather than keeping the full capture is
+deliberate: a README renders images at roughly 850px wide, so a 2880px-wide PNG
+buys nothing but repository weight. WebP at q86 takes the forty-image set from
+about 16MB to under 2MB.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import sys
+
+from PIL import Image
+
+QUALITY = 86
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print(__doc__, file=sys.stderr)
+ return 2
+
+ src = pathlib.Path(sys.argv[1])
+ dst = pathlib.Path(sys.argv[2])
+ dst.mkdir(parents=True, exist_ok=True)
+
+ captures = sorted(src.glob("*.png"))
+ if not captures:
+ print(f"no PNG captures in {src}", file=sys.stderr)
+ return 1
+
+ before = after = 0
+ for png in captures:
+ image = Image.open(png).convert("RGB")
+ image = image.resize((image.width // 2, image.height // 2), Image.LANCZOS)
+ out = dst / f"{png.stem}.webp"
+ image.save(out, "WEBP", quality=QUALITY, method=6)
+ before += png.stat().st_size
+ after += out.stat().st_size
+
+ print(f"encoded {len(captures)} images: {before / 1e6:.1f}MB PNG -> {after / 1e6:.1f}MB WebP")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs
new file mode 100644
index 0000000..e5c228e
--- /dev/null
+++ b/scripts/screenshots.mjs
@@ -0,0 +1,167 @@
+/*
+ * Re-shoot the documentation screenshots.
+ *
+ * pnpm run dev:api # :8920, against a seeded database
+ * pnpm run dev:web # :5173, proxies /api to the above
+ * node scripts/screenshots.mjs # writes docs/screenshots/*.webp
+ *
+ * Every page, at 1440×900 and at 393×852, in light and in dark — forty images.
+ * The README section that these feed went stale once already, when the shell
+ * was rebuilt and nobody could cheaply re-shoot; that is why this is a script
+ * in the tree rather than a note about what to capture.
+ *
+ * Three things are forced rather than clicked:
+ *
+ * - The theme. Seeding localStorage is not enough on its own: the appearance
+ * preference is stored server-side and adopted after hydration (see
+ * apps/web/src/lib/theme.tsx), so every dark shot would snap back to light
+ * a beat after first paint. The response is rewritten instead.
+ * - The sidebar, expanded, and the Piggy dock, closed — both are per-device
+ * states in localStorage, so whatever the last human left behind would
+ * otherwise leak into the captures.
+ * - The capacity matcher's results, by submitting the form. An empty form is
+ * not a picture of a matcher.
+ *
+ * Captures are the viewport rather than the full scroll height: a README image
+ * three thousand pixels tall is unreadable at the width a README renders in.
+ *
+ * The PNGs Playwright produces are downscaled from their 2×/3× capture and
+ * re-encoded as WebP by scripts/screenshots-encode.py, which runs last. Forty
+ * PNGs at capture scale are 16MB; the WebP set is under 2MB, which is the
+ * difference between a repository people clone and one they do not.
+ */
+import { chromium } from 'playwright';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const BASE = process.env.PIG_WEB_URL ?? 'http://127.0.0.1:5173';
+const OUT = process.env.PIG_SHOTS_OUT ?? 'docs/screenshots';
+const raw = mkdtempSync(join(tmpdir(), 'pig-shots-'));
+
+const PAGES = [
+ { slug: 'overview', path: '/' },
+ { slug: 'margin', path: '/margin' },
+ { slug: 'capacity', path: '/capacity' },
+ {
+ slug: 'capacity-match',
+ path: '/capacity',
+ tab: 'Match a requirement',
+ submit: 'Find capacity',
+ },
+ { slug: 'growth', path: '/growth' },
+ { slug: 'calendar', path: '/calendar' },
+ { slug: 'demand', path: '/demand' },
+ { slug: 'supply', path: '/supply' },
+ { slug: 'accounts', path: '/accounts' },
+ { slug: 'contracts', path: '/contracts' },
+];
+
+const VIEWPORTS = [
+ { name: 'desktop', width: 1440, height: 900, scale: 2 },
+ { name: 'mobile', width: 393, height: 852, scale: 3 },
+];
+
+// channel:'chrome' reuses the system browser rather than downloading one.
+const browser = await chromium.launch({ channel: 'chrome' });
+let failures = 0;
+
+for (const theme of ['light', 'dark']) {
+ for (const vp of VIEWPORTS) {
+ const ctx = await browser.newContext({
+ viewport: { width: vp.width, height: vp.height },
+ deviceScaleFactor: vp.scale,
+ colorScheme: theme,
+ isMobile: vp.name === 'mobile',
+ hasTouch: vp.name === 'mobile',
+ });
+
+ await ctx.addInitScript(`
+ try {
+ localStorage.setItem('pig.themeMode', ${JSON.stringify(theme)});
+ localStorage.setItem('pig.accent', 'pig');
+ localStorage.setItem('pig.sidebarOpen', 'true');
+ localStorage.setItem('pig.piggyDockOpen', 'false');
+ } catch {}
+ `);
+
+ await ctx.route('**/api/me/profile', async (route) => {
+ const response = await route.fetch();
+ let body = {};
+ try {
+ body = await response.json();
+ } catch {
+ /* An error body is fine to discard; the theme fields are what matter. */
+ }
+ await route.fulfill({ response, json: { ...body, themeMode: theme, accentColor: 'pig' } });
+ });
+
+ const page = await ctx.newPage();
+ const problems = [];
+ page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
+ page.on('console', (m) => {
+ if (m.type() === 'error') problems.push(`console: ${m.text().slice(0, 140)}`);
+ });
+
+ for (const p of PAGES) {
+ await page.goto(BASE + p.path, { waitUntil: 'networkidle' });
+
+ if (p.tab) {
+ await page
+ .getByRole('tab', { name: p.tab })
+ .or(page.getByText(p.tab, { exact: true }))
+ .first()
+ .click();
+ await page.waitForTimeout(600);
+ }
+ if (p.submit) {
+ await page.getByRole('button', { name: p.submit }).first().click();
+ await page.waitForLoadState('networkidle').catch(() => {});
+ await page.waitForTimeout(900);
+ }
+ await page.waitForTimeout(1200);
+
+ await page.screenshot({ path: `${raw}/${p.slug}-${vp.name}-${theme}.png` });
+
+ /*
+ * Two checks a status code cannot make: a horizontal scrollbar (the
+ * 393px failure mode), and the theme actually landing. Both would
+ * otherwise be discovered by a human squinting at forty images.
+ */
+ const [overflow, applied] = await page.evaluate(() => [
+ document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ document.documentElement.dataset.theme,
+ ]);
+ const ok = applied === theme && overflow === 0;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? ' ' : '!!'} ${p.slug.padEnd(15)} ${vp.name.padEnd(7)} ` +
+ `want=${theme.padEnd(5)} got=${applied} overflow=${overflow}`,
+ );
+ }
+
+ if (problems.length) {
+ failures += 1;
+ console.log(' !! ' + [...new Set(problems)].slice(0, 4).join('\n !! '));
+ }
+ await ctx.close();
+ }
+}
+
+await browser.close();
+
+const encode = spawnSync('python3', ['scripts/screenshots-encode.py', raw, OUT], {
+ stdio: 'inherit',
+});
+rmSync(raw, { recursive: true, force: true });
+
+if (encode.status !== 0) {
+ console.error('encoding failed — is Pillow installed? (python3 -m pip install Pillow)');
+ process.exit(1);
+}
+if (failures) {
+ console.error(`\n${failures} capture(s) had a theme, overflow or console problem — see above.`);
+ process.exit(1);
+}
+console.log(`\nWrote ${PAGES.length * 4} images to ${OUT}.`);