Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ed5a731da | |||
| 10d79fc35d |
@@ -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
|
||||
|
||||
@@ -158,6 +158,32 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* The expanded sidebar owns its vertical scroll rather than the page. Keep
|
||||
that scrollbar inside the sidebar palette: the browser's native light
|
||||
track otherwise becomes a bright gutter in dark mode. */
|
||||
[data-sidebar='content'] {
|
||||
scrollbar-color: hsl(var(--sidebar-border)) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
[data-sidebar='content']::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-sidebar='content']::-webkit-scrollbar-track,
|
||||
[data-sidebar='content']::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-sidebar='content']::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--sidebar-border));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
[data-sidebar='content']::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted) / 0.55);
|
||||
}
|
||||
|
||||
/* Touch targets. 44px is the documented iOS minimum and the difference
|
||||
between a usable and a frustrating phone experience. */
|
||||
.tap {
|
||||
|
||||
@@ -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}.`);
|
||||