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>
@@ -60,18 +60,102 @@ for, not because they are universal.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
> **Placeholder — fresh captures needed.** The application shell was rebuilt as
|
Captured against the current shell — header, collapsible sidebar rail, docked
|
||||||
> a three-pane layout (header, collapsible sidebar rail, docked Piggy) and
|
Piggy — running locally on the seed plus demo book (`db:seed` and `db:demo`), so
|
||||||
> every screenshot taken before that redesign now misrepresents the product.
|
every number below is computed by the code in this repository rather than drawn.
|
||||||
> Rather than ship misleading images, this section is deliberately empty until
|
Records prefixed `DEMO —` are fictional; the rest are the sourced, cited seed.
|
||||||
> 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.
|
|
||||||
|
|
||||||
<!-- TODO(screenshots): add docs/screenshots/{overview,margin,capacity-match,calendar}.png
|
Each image follows your own system theme. Both themes are shown explicitly
|
||||||
— post-redesign, light and dark, desktop and 393px. Do not reuse the
|
further down, and [the full gallery](docs/screenshots.md) has all ten pages at
|
||||||
pre-redesign pig-*.png audit captures. -->
|
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
|
## The two-sided data model
|
||||||
|
|
||||||
@@ -245,7 +329,7 @@ half-working.
|
|||||||
|
|
||||||
## Architecture
|
## 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.
|
across five packages, green CI.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -257,9 +341,9 @@ apps/
|
|||||||
cli/ `pig`, the HTTP surface for scripts and agent kernels
|
cli/ `pig`, the HTTP surface for scripts and agent kernels
|
||||||
packages/
|
packages/
|
||||||
core/ Ontology, permissions, margin arithmetic, palette — no I/O
|
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
|
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
|
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
|
`economics:read` is the one that matters — supplier cost per GPU-hour and
|
||||||
break-even price *are* the business.
|
break-even price *are* the business.
|
||||||
|
|
||||||
⚠️ **The read half is written and tested but not yet enforced.** The policy
|
The read half is enforced. The policy table lives in
|
||||||
table (`apps/api/src/routes/read-guards.ts`) and its middleware exist, and
|
`apps/api/src/routes/read-guards.ts` and `createReadGuardRoutes` is mounted in
|
||||||
`read-governance.test.ts` fails when a GET appears that no rule covers — but
|
`app.ts` **before** the feature routes — Hono runs matched handlers in
|
||||||
`createReadGuardRoutes` is not mounted in `app.ts`, so today every
|
registration order, so a guard registered after its route would return 200 while
|
||||||
authenticated member can read the whole book including cost. See
|
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).
|
[limitations](#what-is-not-built-yet).
|
||||||
|
|
||||||
## The agent surface
|
## 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
|
CI is Gitea Actions, one sequence, about two minutes. It typechecks every
|
||||||
package, applies the migration chain **twice** to a real empty Postgres, asserts
|
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
|
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.
|
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.
|
Said plainly, because you are going to grep the repo anyway.
|
||||||
|
|
||||||
**Read authorisation is not enforced.** As above: the policy, the middleware
|
**Read authorisation is enforced, but only at the grant.** A capability is held
|
||||||
and the governance test all exist; the router line that mounts them does not.
|
or it is not. Once `economics:read` is held, it returns every commitment's cost
|
||||||
Any authenticated member can currently read cost and margin regardless of team
|
— there is no filter that narrows it to one team's book, because no row-level
|
||||||
or role. This is the most significant gap in the product and it is one line in
|
team filter exists anywhere in the query layer. That is the gap to close before
|
||||||
`app.ts`.
|
PIG serves a company where "supply can see supply's costs" is a requirement.
|
||||||
|
|
||||||
**Four route modules are written, tested and never mounted.**
|
**The HubSpot integration is written, tested and never mounted.**
|
||||||
`routes/read-guards.ts`, `routes/learn.ts`, `routes/hubspot.ts` and
|
`routes/hubspot.ts` and `routes/hubspot-webhook.ts` are both absent from
|
||||||
`routes/hubspot-webhook.ts` are all absent from `app.ts`. Consequences: the
|
`app.ts`, so OAuth, connections, sync jobs, webhook verification and seven
|
||||||
**Learn** page is in the navigation and its API answers 404, and the HubSpot
|
`hubspot_*` tables are all unreachable from the running server.
|
||||||
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.
|
|
||||||
|
|
||||||
**Six of the eight declared agent task kinds are never enqueued.** The worker
|
**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
|
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.**
|
- **[AGENTS.md](./AGENTS.md) — start here if you are joining this codebase.**
|
||||||
Architecture rules, the traps that have already bitten, and conventions.
|
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
|
- [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
|
- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
|
||||||
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
|
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
|
||||||
|
|||||||
@@ -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"> |
|
||||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -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())
|
||||||
@@ -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}.`);
|
||||||