Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit.
The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
The monorepo was on npm workspaces. pnpm gives it a content-addressed store
shared between the eight packages, a lockfile that records the whole graph
rather than a flattened view of it, and — the reason this mattered in practice —
`workspace:*`, which makes an internal dependency unambiguous instead of a
version range that npm may satisfy from the registry.
Mechanics:
- `packageManager: pnpm@11.21.0` pins the version; corepack installs it in CI
and in the image, so all three environments resolve identically.
- The npm `workspaces` array is replaced by `pnpm-workspace.yaml`. pnpm
ignores the former, and keeping both would leave two sources of truth.
- All six internal dependencies moved to `workspace:*`.
- Root scripts use `pnpm -r --if-present` and `pnpm -F <pkg>`.
Two findings worth recording, both from running it rather than reading it:
`tsx` was a devDependency, but the server runs TypeScript directly in
production — the container's command is `pnpm exec tsx apps/api/src/server.ts`.
Under npm this was concealed by the runtime stage re-installing tsx by hand
after pruning dev dependencies. Under `pnpm install --prod` that sleight of
hand stops working and the image simply fails to start. tsx is now declared in
`dependencies`, which is what it has always actually been.
The first image build failed with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
That is not a pnpm bug: it had decided the modules directory was stale and
wanted confirmation before deleting it, which a non-interactive build cannot
give. The trigger was the host's `node_modules` reaching the build context —
there was no `.dockerignore` at all. pnpm's tree is symlinks into a
content-addressed store, so copying it into an image produces dangling links
and a directory pnpm rightly considers corrupt. Fixed by adding
`.dockerignore` and setting `CI=true`, which is required in any non-interactive
pnpm build.
`esbuild` is denied install scripts via `allowBuilds`. Its platform binary
arrives through the optional dependency `@esbuild/linux-x64` and the postinstall
only verifies it; confirmed by running the binary directly, which reports
0.25.12.
Verified under pnpm: typecheck clean, 150 tests / 0 failures, e2e passes, web
builds. The image was built and booted against a real Postgres — health ok,
`/api/dashboard` 401 with an issuer configured, `/` and `/capacity` serve the
SPA, `/og.png` serves as image/png, and the migrator runs from the pruned
runtime stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Work in progress from the Codex session, committed so nothing sits undeployed.
Verified before committing: typecheck clean across all packages, 139 unit tests
and the e2e suite green, migrations apply to an empty Postgres.
Adds the HubSpot integration boundary (OAuth, client, contracts, webhook
signature verification, sync), a growth route, customer-lifecycle service,
Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types.
Two things are deliberately incomplete and should not be mistaken for finished:
`packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it
is inert — no tables, no migration. That is the correct order (the shape can
settle before it becomes a migration), but it does mean the HubSpot routes have
no persistence behind them yet.
`pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The
workspace file contains a literal unanswered placeholder — "esbuild: set this
to true or false" — and this repository installs with npm, which is also what
CI runs. Committing a second package manager's lockfile would make the install
ambiguous. If the move to pnpm is intended it should be a deliberate change
that updates CI and the Dockerfile together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two demo gaps, both of which made working features look like they were not
there.
**Toasts fired into nothing.** RecordSheets already called toast.success on
every save, but <Toaster /> was never mounted, so nothing appeared. It could
not be mounted, either: the shadcn original imports next-themes, which PIG does
not use — it has its own provider so a chosen theme is persisted server-side
and follows a user between devices. Rewired to PIG's useTheme, mounted inside
ThemeProvider, and offset clear of the phone tab bar and the home indicator.
Feedback added where the interface otherwise gives none: allocation and hold
report the GPU-hours actually written, because the sheet closes on success and
the only other evidence is a number moving off-screen; releasing a hold says
the capacity is sellable again; fact decisions say what the decision meant, and
that approving evidence is not the same as writing it to a record; the profile
form confirms rather than just clearing itself, which otherwise reads as the
input being discarded.
**The fact table was empty**, so the review queue and every provenance tooltip
had nothing to show — the mechanism that makes an agent-written CRM
trustworthy, invisible. Six agent-derived facts seeded with a deliberate mix:
two applied, showing what a confident agent writes unprompted, and four
proposed, including one weak claim that a reviewer should reject, so the queue
is not a row of obvious approvals. Each carries a score, a band, evidence and
where available a source. Idempotent on subject+field+value; verified over two
runs.
Verified: toast confirmed firing in a real browser on a 393px viewport, 135
unit tests and e2e green, typecheck clean, CSP hash unchanged, 0px horizontal
overflow across 12 routes at both breakpoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command
row selection, ghost and outline button hover, the dialog close affordance. The
brand colour in shadcn is `primary`.
PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That
inverted the meaning, so every shadcn hover and selection state painted a
full-strength brand block. With the monochrome "pig" palette in dark mode the
brand is near-white, so a selected command row rendered as a white slab against
a near-black sheet. Measured before the change: selected row rgb(250,250,250)
on a rgb(9,9,11) body.
`accent` now aliases `--accent-subtle` and `accent-foreground` aliases
`--accent-fg`, which is what those tokens were created for. The eleven places
where PIG's own components wanted a solid brand fill — filled chips, selected
card borders, progress bars — move to `primary`, which still resolves to
`--accent`. A `brand` alias is added for clarity.
After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a
subtle tint above the body; the pipeline's active stage chip stays a solid
rgb(250,250,250) fill, unchanged.
Found by opening overlays, which earlier screenshot sweeps never did — every
route had been checked, but a dropdown or a command palette only misbehaves
once it is open. Worth remembering: page-level sweeps do not exercise portals.
Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px
horizontal overflow across 12 routes at 393px and 1440px.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seam existed with only a Supabase implementation, so an on-prem deployment
had no way to authenticate. A customer running PIG inside their own network
already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to
stand up a second identity system is a serious adoption tax and in a regulated
environment usually refused outright.
Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is
discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips
discovery entirely for an air-gapped network. OIDC takes precedence over
Supabase so an on-prem install can leave the hosted values in its environment
file without them quietly taking over.
Three decisions worth stating:
Discovery is resolved lazily and the FAILURE is not cached. Doing it per
request would put the customer's identity provider on the critical path of
every API call; doing it eagerly at boot would mean their IdP rebooting takes
the CRM down with it. So it happens on first use and retries on the next
request.
The audience check is optional but warned about loudly. Without it, a token the
provider issued for ANY other application in the same tenant verifies here — a
token minted for an unrelated internal tool would be accepted as a PIG session.
It cannot be mandatory because some providers legitimately issue
single-audience tokens.
Email falls back through email, preferred_username and upn, because providers
disagree, but a preferred_username without an "@" is ignored — PIG keys
membership on the address, and a bare username must never become an account
identity.
Also fixed a warning that claimed "authentication is DISABLED" on a correctly
configured OIDC deployment. That is worse than silence: an operator who reads
it on a secure install learns to ignore the warnings. The dev bypass itself was
already correct — it keys on the resolved provider rather than on Supabase.
18 new tests, most of them about what the provider must REFUSE: a foreign
signing key, a foreign issuer, a token for a different application, an expired
token, a token with no subject, and a discovery outage that must not become
permanent. Keys are generated per test and the JWKS is served locally, so they
run offline.
Verified: production refuses to start with neither provider, starts with OIDC
alone, enforces 401 on an unauthenticated request, and warns only about the
genuinely missing admin list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An authenticated GET to any unrecognised API route — a typo, a renamed
endpoint, an older client — fell through to the SPA fallback and returned
200 text/html containing the app shell.
This is close to the worst failure shape for an API consumer. `response.ok` is
true, so nothing treats it as an error; the caller then dies on `JSON.parse`
with "Unexpected token '<'" far from the actual cause. The MCP server, the CLI
and Piggy all consume this API and would all have hit it. It was masked from
casual testing because unauthenticated requests are rejected earlier by the
auth middleware, so it only appears once you hold a valid token.
Found by probing production with Scott's token: GET /api/keys (the real path is
/api/api-keys) returned 200 text/html.
The static-file middleware already carried this guard — added for the same
reason when og.png was being served as HTML — but the SPA fallback beneath it
did not. Same guard, one place missing.
Verified: unknown API paths now return 404 application/json, real API paths
still answer, client-side routes still receive the shell, and static assets
still serve with their own content types. Typecheck clean, 124 unit tests and
the e2e suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Overview page overflowed 80px at 393px wide. Traced to the "The book" card:
the grid column was a correct 361px, the card inside it was 457px and refused
to shrink. Confirmed by forcing `min-width: 0` on grid children in the live
page, which took the overflow to 0.
Fixed on the Card base class rather than at the call site, because this is the
third time the same trap has been fixed individually — grid and flex children
default to `min-width: auto` and cards routinely hold something unshrinkable, a
tabular-nums figure or a nowrap badge. `min-width: 0` is inert for a
block-level card outside a flex or grid parent, so applying it always costs
nothing and removes the whole class of bug.
Verified by running the stack locally against the demo data: 0px overflow
across all 12 routes at both 393px and 1440px.
AGENTS.md updated to say any NEW container primitive needs the same, with the
one-line browser check to confirm it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same fix already applied to the capacity cards, missed here. A zero break-even
means the block's cost is fully recovered and any further sale is upside;
printing "$0.00" is technically true and reads like a rendering bug. The demo
book has three such blocks, so it was visible on every screenshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invite gate was unreachable. PIG shares its identity provider with another
application, and self-registration there is deliberately switched off — so
somebody with a personal address and a valid invite code could never obtain a
token, and therefore could never reach the endpoint that accepts the code. The
gate was real and nothing could ever arrive at it.
Opening self-registration on the provider would have opened it for the
neighbouring application too, which is precisely why it was closed. So PIG now
mints the account itself through the provider's admin API, and only after the
invite validates. The provider stays shut; the invite becomes the actual gate.
Order is deliberate: validate the invite, create the auth account, create the
profile, consume the invite. If the profile write fails the auth account is
deleted again — otherwise someone could sign in with no profile and no way to
obtain one, because their invite would look spent.
An administrator's address does NOT bypass this. The bypass in /api/signup
exists to bootstrap the first admin from an account that already exists; here
an account is created from nothing, and an ungated version of that is simply
an open registration endpoint.
Registration signs the person in on success rather than returning them to a
login form to retype the password they entered ten seconds earlier. An address
that already exists in the provider but has no PIG profile is detected and
pointed at sign-in, since /api/signup handles that case properly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The accent swatches always previewed the light-mode value, which made the
near-black "Pig" swatch effectively invisible on a dark card — the one place
the preview needed to be accurate. Each accent is tuned twice because a colour
that reads well on white is usually too dark on near-black; the swatch now
shows the value for the theme actually in effect.
Team and the placeholder routes also set document titles, so every route in
the app is now distinguishable in history and in a tab strip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server mounted static files at /assets only, so every top-level file fell
through to the SPA catch-all. og.png, apple-touch-icon.png, icon-192.png and
manifest.webmanifest each returned `200 text/html` containing the app shell.
This is a nearly invisible failure. The app works. The tab shows an icon,
because browsers cache the SVG. Nothing errors anywhere. But a link shared to
iMessage, Slack or X fetches og.png, receives HTML, and renders a card with no
image — which is the entire point of having made one.
Now the whole dist directory is served, with /api excluded so routes are never
answered from disk, and the SPA fallback still catching client-side routes that
have no file behind them.
Found by fetching the asset from an outside host instead of trusting that
adding the file was enough. Verified: og.png is image/png at 53KB, the icons
and manifest carry their real types, /margin and /capacity still receive the
shell, and the API is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no sign-out control anywhere. Easy to miss when you develop
permanently signed in, and a stranded feeling for anyone on a shared machine.
Clearing the identity provider's session is sufficient — PIG holds no session
of its own — and the auth listener returns to sign-in without a reload. A hard
redirect follows regardless, so a failed provider call cannot leave a
half-signed-out interface.
Every route now sets its own document title. A single static title makes
browser history and a wall of tabs useless: every entry reads "pig" and nobody
can tell the margin view from the pipeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
z.coerce.boolean() calls Boolean(value), so the string "false" is true. So are
"0", "no" and "off". Every feature flag set to false was silently on.
Caught by reading a startup warning that should not have been there: the
deployment logged "PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset" while
the .env plainly said false. Had the key been present, PIG would have started
polling a third-party API nobody asked it to poll.
Replaced with an explicit parser accepting 1/true/yes/on, treating an empty or
absent value as the default. Demonstrated both behaviours side by side before
committing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons,
generated from an HTML template by a script so the mark, wordmark and tagline
cannot drift from the product. The apple-touch-icon referenced in index.html
was a 404 until now. Icons are drawn on an opaque plate with inset because iOS
rounds corners and Android may apply a circle — an edge-to-edge mark loses its
ears to that crop. og:image is absolute, which is the single most common
reason a card unfurls blank.
PIG_INVITE_CODE was a lie. Signup validates against the invites table, so
setting the variable only flipped a label in the UI — an operator would set it,
hand the code to a colleague, and watch them be rejected. It is now reconciled
into a real invite row at boot: setting it issues, changing it rotates and
revokes the predecessor, and removing it revokes. Verified all three, plus that
a restart with an unchanged code does not duplicate.
Two bugs found while doing that:
- `uses_remaining` was jsonb, so the SQL decrement could never have worked.
Now integer. The generated migration failed because Postgres has no implicit
jsonb->integer cast, so the USING clause is hand-written.
- Redemption keyed off `redeemedAt`, which would have made every reusable
invite single-use — a confusing way to lock a team out. Availability now
comes from `usesRemaining`, and redemption records who used it most recently
without consuming it.
Sign-in gains a password option alongside the magic link, defaulting to
password since that is the daily path. PIG stores neither; both are handled by
the identity provider and PIG only ever sees the resulting token.
autocomplete is set so password managers and iOS can fill.
Verified: full migration chain applies to a fresh Postgres, the CSP hash for
the inline theme script is unchanged by the rebuild.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying and then trying to actually use it surfaced a dead end: /api/signup
was exempted from auth but never implemented, so a real person could sign in,
receive 403 needs_profile, and have nowhere to go. Authentication worked;
joining did not.
The route is mounted before the auth middleware, because requiring membership
to reach the route that grants membership is circular. It verifies the token
itself and then requires one of two things:
- A valid invite code. Stored hashed, optionally pinned to an address,
optionally expiring, consumed on redemption with the redeemer recorded.
- Presence in PIG_ADMIN_EMAILS. The bootstrap path, which exists because on a
fresh deployment nobody can issue an invite since nobody can sign in to
issue one.
The bootstrap path is narrow by construction: the address must be listed in
server-side configuration AND match the verified email claim on the token.
Admin rights are never read from the request body, so a crafted payload
cannot grant them.
Two behaviours worth noting. A row that was invited but never signed into is
claimed rather than rejected, binding it to the identity that just proved
ownership of the address. And a resubmitted form returns the existing user
instead of erroring, because a double-tap should be harmless.
The front end now treats needs_profile as a step in the flow rather than an
error, showing a team picker. Sending someone back to a login screen they have
already completed is a loop with no exit.
Also: the seed no longer creates the dev@localhost admin row under
NODE_ENV=production. It was unreachable (no auth subject, so nobody can sign
in as it), but an admin-flagged placeholder in a real deployment is a trap.
Verified: rejects a missing token, rejects an invalid body, claims a pre-existing
row, and is idempotent on resubmission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a
first-class target, not an afterthought:
- Two navigation treatments rather than one compromise. A bottom tab bar on
phones, because the top of a large phone is out of thumb reach; a persistent
sidebar from lg upward, so an iPad in portrait gets it too.
- Safe-area insets throughout, so the tab bar clears the home indicator and the
last row of a list is actually reachable.
- Inputs are pinned to a 16px minimum, which is the correct fix for Safari
zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for
everyone and recent iOS ignores it anyway.
- The pipeline board becomes a stage picker on phones. An eight-column board
scrolling horizontally on a 390px screen is technically responsive and
practically useless.
Theming: users pick an accent and the whole interface re-tints. Accent values
live once, in @pig/core, and are written onto the root element at runtime —
there is no CSS copy to drift from the TypeScript. Preferences are stored
server-side so they follow a person between laptop and phone, mirrored into
localStorage only so the pre-paint script can avoid a white flash. Status
colours stay fixed regardless of accent: if "at risk" re-tinted to whatever
someone picked, the signal would be gone.
Seed data is public research, every record carrying a confidence grade and a
source URL. No email addresses are seeded or inferred — none are published, and
guessing them from a name and a domain is unreliable and rude. Authorship is
not promoted to employment: contributors, residency participants and alumni are
recorded as what the evidence actually shows, and a name that could not be
sourced at all is listed as unresolved rather than invented.
Three defects found and fixed by actually running it rather than assuming:
1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op
without a matching unique constraint, so a second run duplicated 27
contacts. There is deliberately no unique index on (account, name) — two
people at one company can share a name — so idempotency is enforced in the
seed instead of by bending the schema.
2. /capacity scrolled sideways on a phone. Grid items default to
min-width:auto and `truncate` sets nowrap, so a long title became
unshrinkable content and widened the track. Fixed with min-w-0 on every
truncating grid child.
3. The idle-capacity alert silently failed to fire at exactly 80% utilisation,
losing a float comparison against a 0.2 threshold. Moved to 0.15, which is
also a more sensible line for "worth attention".
The worked example is tuned to teach rather than to flatter: 70% sold at a 53%
markup lands at +6.7% margin with 20% still idle, so both the healthy number
and the alert are visible. Drop the sold share to 55% and the same block goes
underwater — that sensitivity is the argument for the product.
Verified in a real browser at 393px and 1440px, light and dark: zero horizontal
overflow on every route, zero console errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
packages/prime — a hand-written typed client, because the first-party SDK is
Python only. Deliberately narrow: PIG reads availability and nothing else, and
the key it holds should be scoped so it could not provision even if the code
tried. Rate limits are undocumented upstream, so it backs off empirically with
full jitter and honours Retry-After. Unknown fields survive in `raw` rather
than being dropped.
apps/api — Hono, with authentication and authorization kept firmly apart. A
verified JWT proves someone has an account in the identity project, which may
be shared with other applications; it does NOT prove they belong here. Access
requires a row in PIG's own users table, and a token without one gets 403
needs_profile rather than entry.
The capacity service is the business logic: availability counts sold and held
separately, so a live hold removes inventory from everyone else's availability
without inflating utilisation. Expired holds are ignored at read time, so the
numbers stay right even when the sweeper is behind. Matching treats
interconnect as a hard filter and excludes Unknown as well as Ethernet —
unverified is not the same as adequate.
apps/mcp — nine tools over stdio, so a team member drives PIG from Claude
Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the
same HTTP API the browser does, with no database credentials, so an agent can
never reach further than the person it acts for. Results are formatted as
prose rather than raw JSON.
Theme preferences live in the database rather than localStorage, so a chosen
accent follows someone from laptop to phone. Status colours stay independent
of the accent: if "at risk" re-tinted to whatever a user picked, the signal
would be gone.
Note on the SDK import: its package exports use a `./*` wildcard whose types
entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The
runtime specifier must keep the .js suffix, so the types are mapped via
tsconfig paths rather than by writing an import that would fail at runtime.
Verified: all five packages typecheck; the MCP server constructs and registers
its tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>