Add the web app, seed data, and user-selectable theming

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>
This commit is contained in:
2026-08-12 19:16:43 -07:00
parent 7aeec0c632
commit de33a03524
30 changed files with 12765 additions and 1 deletions
+250
View File
@@ -0,0 +1,250 @@
/**
* The pipeline boards, for both sides of the market.
*
* A column-per-stage board on desktop; on a phone, a stage picker and a single
* column. A horizontally scrolling eight-column board on a 390px screen is
* technically responsive and practically unusable — you cannot see where a
* card is going, which is the entire point of a board.
*/
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { get, money, relativeTime } from '@/lib/api';
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
interface DemandDeal {
id: string;
name: string;
stage: string;
productLine: string;
acvCents: number | null;
msaExecuted: boolean;
dpaExecuted: boolean;
updatedAt: string;
}
interface SupplyDeal {
id: string;
name: string;
stage: string;
gpuType: string | null;
gpuCount: number | null;
targetCostPerGpuHourCents: number | null;
updatedAt: string;
}
interface Board<T> {
stages: string[];
deals: { deal: T; accountName: string | null }[];
}
const STAGE_LABELS: Record<string, string> = {
qualification: 'Qualification',
legal: 'Legal',
scoping: 'Scoping',
proposal: 'Proposal',
procurement: 'Procurement',
poc: 'POC',
deployment: 'Deployment',
expansion: 'Expansion',
closed_won: 'Closed won',
closed_lost: 'Closed lost',
sourced: 'Sourced',
qualifying: 'Qualifying',
technical_diligence: 'Technical diligence',
financial_diligence: 'Financial diligence',
pricing: 'Pricing',
contracting: 'Contracting',
onboarding: 'Onboarding',
live: 'Live',
renewal: 'Renewal',
churned: 'Churned',
rejected: 'Rejected',
};
export function DemandPipeline() {
return (
<PipelineBoard<DemandDeal>
title="Demand"
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
endpoint="/api/deals/demand"
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.acvCents ? (
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
) : null}
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
{/* Contract state is surfaced on the card because shipping capacity
without executed paper is the mistake this pipeline prevents. */}
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
</div>
</>
)}
/>
);
}
export function SupplyPipeline() {
return (
<PipelineBoard<SupplyDeal>
title="Supply"
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
endpoint="/api/deals/supply"
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.gpuCount && deal.gpuType ? (
<Badge tone="accent">
{deal.gpuCount}× {deal.gpuType}
</Badge>
) : null}
{deal.targetCostPerGpuHourCents ? (
<span className="nums text-xs text-muted">
{money(deal.targetCostPerGpuHourCents)}/hr target
</span>
) : null}
</div>
</>
)}
/>
);
}
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
title,
subtitle,
endpoint,
renderCard,
}: {
title: string;
subtitle: string;
endpoint: string;
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
}) {
const { data, isLoading } = useQuery({
queryKey: [endpoint],
queryFn: () => get<Board<T>>(endpoint),
});
const [activeStage, setActiveStage] = useState<string | null>(null);
const byStage = useMemo(() => {
const map = new Map<string, { deal: T; accountName: string | null }[]>();
for (const stage of data?.stages ?? []) map.set(stage, []);
for (const row of data?.deals ?? []) {
map.get(row.deal.stage)?.push(row);
}
return map;
}, [data]);
if (isLoading) return <Skeleton className="h-96" />;
if (!data || data.deals.length === 0) {
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} />
<Card>
<EmptyState
title={`No ${title.toLowerCase()} deals yet`}
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
/>
</Card>
</div>
);
}
const stages = data.stages;
const currentStage = activeStage ?? stages[0]!;
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} />
{/* Phone: pick one stage. The chips scroll; the board does not. */}
<div className="lg:hidden">
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
{stages.map((stage) => {
const count = byStage.get(stage)?.length ?? 0;
return (
<button
key={stage}
onClick={() => setActiveStage(stage)}
className={[
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
stage === currentStage
? 'bg-accent text-accent-on'
: 'bg-surface-2 text-muted',
].join(' ')}
>
{STAGE_LABELS[stage] ?? stage}
<span className="ml-1.5 opacity-70">{count}</span>
</button>
);
})}
</div>
<div className="mt-3 space-y-2">
{(byStage.get(currentStage) ?? []).map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
))}
{(byStage.get(currentStage) ?? []).length === 0 ? (
<p className="py-8 text-center text-sm text-muted">
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
</p>
) : null}
</div>
</div>
{/* Desktop: the full board, scrolling horizontally within its own pane
so the page itself never scrolls sideways. */}
<div className="scroll-x hidden lg:block">
<div className="flex gap-3 pb-2">
{stages.map((stage) => {
const rows = byStage.get(stage) ?? [];
return (
<section key={stage} className="w-72 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
<span className="nums text-xs text-muted">{rows.length}</span>
</div>
<div className="space-y-2">
{rows.map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
))}
</div>
</section>
);
})}
</div>
</div>
</div>
);
}
function DealCard<T extends { id: string; updatedAt: string }>({
row,
renderCard,
}: {
row: { deal: T; accountName: string | null };
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
}) {
return (
<article className="card p-3">
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
</article>
);
}
function Header({ title, subtitle }: { title: string; subtitle: string }) {
return (
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p>
</header>
);
}