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. + + + + 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. + + +**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. + + + + 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. + + +**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. + + + + 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. + + +**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. + + + + PIG Growth: accounts ranked by attention score, each tagged deployed, expansion candidate, at risk or coverage gap, with the scoring signals listed underneath. + + +**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. + + + + PIG Calendar for 2026-Q3: weighted pipeline, deals closing, renewals, obligations due and authorisations expiring, above a quarter timeline with one lane per kind. + + +### 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:* + +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:* + +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 | +| --- | --- | +| Supply pipeline at 393px in light mode | 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 | +| --- | --- | --- | +| PIG Overview at 393px in light mode, with a bottom tab bar | PIG Overview at 393px in dark mode | 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 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** + +Overview, desktop, light + +**Desktop · 1440px · dark** + +Overview, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Overview, 393px, light | Overview, 393px, dark | + + +## Margin + +`/margin` — revenue against the full cost of every commitment, with break-even price per GPU-hour. + +**Desktop · 1440px · light** + +Margin, desktop, light + +**Desktop · 1440px · dark** + +Margin, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Margin, 393px, light | 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** + +Capacity · Availability, desktop, light + +**Desktop · 1440px · dark** + +Capacity · Availability, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Capacity · Availability, 393px, light | 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** + +Capacity · Match a requirement, desktop, light + +**Desktop · 1440px · dark** + +Capacity · Match a requirement, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Capacity · Match a requirement, 393px, light | 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** + +Growth, desktop, light + +**Desktop · 1440px · dark** + +Growth, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Growth, 393px, light | Growth, 393px, dark | + + +## Calendar + +`/calendar` — closes, renewals, obligations and expiring export authorisations on one quarter timeline. + +**Desktop · 1440px · light** + +Calendar, desktop, light + +**Desktop · 1440px · dark** + +Calendar, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Calendar, 393px, light | 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** + +Demand pipeline, desktop, light + +**Desktop · 1440px · dark** + +Demand pipeline, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Demand pipeline, 393px, light | Demand pipeline, 393px, dark | + + +## Supply pipeline + +`/supply` — the buy side, from sourced through diligence and pricing to live and renewal. + +**Desktop · 1440px · light** + +Supply pipeline, desktop, light + +**Desktop · 1440px · dark** + +Supply pipeline, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Supply pipeline, 393px, light | 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** + +Accounts, desktop, light + +**Desktop · 1440px · dark** + +Accounts, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Accounts, 393px, light | Accounts, 393px, dark | + + +## Contracts + +`/contracts` — MSA, DPA, SLA, order form and capacity commitment, polymorphic over party and type. + +**Desktop · 1440px · light** + +Contracts, desktop, light + +**Desktop · 1440px · dark** + +Contracts, desktop, dark + +**Mobile · 393px** + +| Light | Dark | +| --- | --- | +| Contracts, 393px, light | Contracts, 393px, 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}.`);