Shoot the README screenshots, and make them regenerable

The screenshot section had been a placeholder since the shell was rebuilt as
three panes, because there was no cheap way to re-shoot and a stale image is
worse than no image. So this ships the capture, not just the captures:
scripts/screenshots.mjs takes all ten pages at 1440x900 and 393x852, in light
and dark, and screenshots-encode.py halves and re-encodes them to WebP — 16MB
of PNG becomes 1.9MB in the tree.

Two things would silently ruin a run, and the script exists to encode both.
Seeding localStorage['pig.themeMode'] is not enough: the appearance preference
is authoritative server-side and adopted after hydration, so every dark capture
snapped back to light a beat after first paint. The /api/me/profile response is
rewritten instead. And pig.sidebarOpen / pig.piggyDockOpen are per-device, so
whatever the last human left behind would otherwise leak in. The run also fails
on a wrong theme, a horizontal scrollbar or a console error — three things a
screenshot cannot show you.

Piggy is not pictured mid-conversation. It is off by default and no inference
credential exists, so such an image would be a staged transcript rather than a
capture. The README says that rather than implying the feature is missing.

Correcting what the README asserted while shooting against the running code:
read authorisation IS enforced — createReadGuardRoutes is mounted ahead of the
feature routes, and routes/learn.ts and routes/activities.ts are mounted too,
so only the HubSpot pair is still unreachable. The remaining read gap is that a
grant cannot be narrowed, there being no row-level team filter in the query
layer. Counts refreshed against the tree: 275 tests, ~47k lines, 14 migrations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:15:45 -07:00
parent 9db53cb36f
commit 10d79fc35d
44 changed files with 563 additions and 33 deletions
+118 -33
View File
@@ -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.
<!-- TODO(screenshots): add docs/screenshots/{overview,margin,capacity-match,calendar}.png
— post-redesign, light and dark, desktop and 393px. Do not reuse the
pre-redesign pig-*.png audit captures. -->
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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/overview-desktop-dark.webp">
<img src="docs/screenshots/overview-desktop-light.webp" alt="PIG Overview: gross margin $675,871.37, 79.1% sold ratio, 1.3M idle GPU-hours, and a ranked list of capacity bought and unsold.">
</picture>
**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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/margin-desktop-dark.webp">
<img src="docs/screenshots/margin-desktop-light.webp" alt="PIG Margin: revenue $12.4M against $11.7M of full committed cost, broken down by commitment with sold ratio, cost per hour and break-even price.">
</picture>
**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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/capacity-match-desktop-dark.webp">
<img src="docs/screenshots/capacity-match-desktop-light.webp" alt="PIG capacity matcher: a requirement for 64 H100_80GB with high-speed interconnect, scored against two commitments at 64% and 58% fit with an Allocate this capacity action on each.">
</picture>
**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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/growth-desktop-dark.webp">
<img src="docs/screenshots/growth-desktop-light.webp" alt="PIG Growth: accounts ranked by attention score, each tagged deployed, expansion candidate, at risk or coverage gap, with the scoring signals listed underneath.">
</picture>
**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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/calendar-desktop-dark.webp">
<img src="docs/screenshots/calendar-desktop-light.webp" alt="PIG Calendar for 2026-Q3: weighted pipeline, deals closing, renewals, obligations due and authorisations expiring, above a quarter timeline with one lane per kind.">
</picture>
### 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:*
<img src="docs/screenshots/demand-desktop-light.webp" alt="PIG demand pipeline in light mode: ten stages from qualification through legal, scoping, proposal, procurement, POC and deployment, with deal cards showing ACV, product line and MSA/DPA badges.">
*Dark:*
<img src="docs/screenshots/demand-desktop-dark.webp" alt="The same demand pipeline in dark mode.">
Side by side at 393px, where both tunings have to survive a smaller surface:
| Supply pipeline · light | Supply pipeline · dark |
| --- | --- |
| <img src="docs/screenshots/supply-mobile-light.webp" alt="Supply pipeline at 393px in light mode"> | <img src="docs/screenshots/supply-mobile-dark.webp" alt="Supply pipeline at 393px in dark mode"> |
### 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 |
| --- | --- | --- |
| <img src="docs/screenshots/overview-mobile-light.webp" alt="PIG Overview at 393px in light mode, with a bottom tab bar"> | <img src="docs/screenshots/overview-mobile-dark.webp" alt="PIG Overview at 393px in dark mode"> | <img src="docs/screenshots/margin-mobile-dark.webp" alt="PIG Margin at 393px in dark mode, the commitment table reflowed into cards"> |
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
+225
View File
@@ -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**
<img src="screenshots/overview-desktop-light.webp" alt="Overview, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/overview-desktop-dark.webp" alt="Overview, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/overview-mobile-light.webp" alt="Overview, 393px, light"> | <img src="screenshots/overview-mobile-dark.webp" alt="Overview, 393px, dark"> |
## Margin
`/margin` — revenue against the full cost of every commitment, with break-even price per GPU-hour.
**Desktop · 1440px · light**
<img src="screenshots/margin-desktop-light.webp" alt="Margin, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/margin-desktop-dark.webp" alt="Margin, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/margin-mobile-light.webp" alt="Margin, 393px, light"> | <img src="screenshots/margin-mobile-dark.webp" alt="Margin, 393px, 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**
<img src="screenshots/capacity-desktop-light.webp" alt="Capacity · Availability, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/capacity-desktop-dark.webp" alt="Capacity · Availability, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/capacity-mobile-light.webp" alt="Capacity · Availability, 393px, light"> | <img src="screenshots/capacity-mobile-dark.webp" alt="Capacity · Availability, 393px, dark"> |
## Capacity · Match a requirement
`/capacity` — score a customer requirement against capacity already under commitment, then allocate against it.
**Desktop · 1440px · light**
<img src="screenshots/capacity-match-desktop-light.webp" alt="Capacity · Match a requirement, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/capacity-match-desktop-dark.webp" alt="Capacity · Match a requirement, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/capacity-match-mobile-light.webp" alt="Capacity · Match a requirement, 393px, light"> | <img src="screenshots/capacity-match-mobile-dark.webp" alt="Capacity · Match a requirement, 393px, dark"> |
## Growth
`/growth` — deterministic attention scores over paper, activity and sold or reserved capacity, with every signal explained.
**Desktop · 1440px · light**
<img src="screenshots/growth-desktop-light.webp" alt="Growth, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/growth-desktop-dark.webp" alt="Growth, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/growth-mobile-light.webp" alt="Growth, 393px, light"> | <img src="screenshots/growth-mobile-dark.webp" alt="Growth, 393px, dark"> |
## Calendar
`/calendar` — closes, renewals, obligations and expiring export authorisations on one quarter timeline.
**Desktop · 1440px · light**
<img src="screenshots/calendar-desktop-light.webp" alt="Calendar, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/calendar-desktop-dark.webp" alt="Calendar, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/calendar-mobile-light.webp" alt="Calendar, 393px, light"> | <img src="screenshots/calendar-mobile-dark.webp" alt="Calendar, 393px, 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**
<img src="screenshots/demand-desktop-light.webp" alt="Demand pipeline, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/demand-desktop-dark.webp" alt="Demand pipeline, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/demand-mobile-light.webp" alt="Demand pipeline, 393px, light"> | <img src="screenshots/demand-mobile-dark.webp" alt="Demand pipeline, 393px, dark"> |
## Supply pipeline
`/supply` — the buy side, from sourced through diligence and pricing to live and renewal.
**Desktop · 1440px · light**
<img src="screenshots/supply-desktop-light.webp" alt="Supply pipeline, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/supply-desktop-dark.webp" alt="Supply pipeline, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/supply-mobile-light.webp" alt="Supply pipeline, 393px, light"> | <img src="screenshots/supply-mobile-dark.webp" alt="Supply pipeline, 393px, dark"> |
## Accounts
`/accounts` — both sides of the market in one book, each record carrying its confidence grade and source.
**Desktop · 1440px · light**
<img src="screenshots/accounts-desktop-light.webp" alt="Accounts, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/accounts-desktop-dark.webp" alt="Accounts, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/accounts-mobile-light.webp" alt="Accounts, 393px, light"> | <img src="screenshots/accounts-mobile-dark.webp" alt="Accounts, 393px, dark"> |
## Contracts
`/contracts` — MSA, DPA, SLA, order form and capacity commitment, polymorphic over party and type.
**Desktop · 1440px · light**
<img src="screenshots/contracts-desktop-light.webp" alt="Contracts, desktop, light">
**Desktop · 1440px · dark**
<img src="screenshots/contracts-desktop-dark.webp" alt="Contracts, desktop, dark">
**Mobile · 393px**
| Light | Dark |
| --- | --- |
| <img src="screenshots/contracts-mobile-light.webp" alt="Contracts, 393px, light"> | <img src="screenshots/contracts-mobile-dark.webp" alt="Contracts, 393px, dark"> |
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+53
View File
@@ -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 <raw-png-dir> <out-dir>
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())
+167
View File
@@ -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}.`);