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
+227
View File
@@ -0,0 +1,227 @@
/**
* UI primitives, in the shadcn idiom — copied-in components you own rather
* than a dependency you configure. Kept in one file because there are few
* enough that a directory of six-line modules would be worse.
*
* Every interactive element clears a 44px touch target, which is the
* documented iOS minimum and the practical difference between a control that
* works on a phone and one that is merely present on it.
*/
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';
import {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from 'react';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// ------------------------------------------------------------------- button
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
// touch-manipulation removes the 300ms tap delay that older mobile Safari
// applies while waiting to see whether a tap is a double-tap zoom.
'touch-manipulation select-none whitespace-nowrap',
{
variants: {
variant: {
primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80',
secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
outline: 'border border-border bg-transparent hover:bg-surface-2',
ghost: 'bg-transparent hover:bg-surface-2',
danger: 'bg-danger text-white hover:opacity-90',
},
size: {
// min-h keeps the target tappable even when the label is short.
sm: 'h-9 min-h-[36px] px-3 text-xs',
md: 'h-11 min-h-[44px] px-4',
lg: 'h-12 min-h-[48px] px-6 text-base',
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
),
);
Button.displayName = 'Button';
// -------------------------------------------------------------------- input
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'h-11 w-full rounded-lg border border-border bg-surface px-3 text-fg',
'placeholder:text-muted focus-visible:border-accent',
// The base stylesheet enforces a 16px minimum so Safari does not zoom
// the viewport on focus; this must not override it downward.
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';
// --------------------------------------------------------------------- card
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('card', className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col gap-1 p-4 sm:p-5', className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('font-semibold leading-tight', className)} {...props} />;
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
}
// -------------------------------------------------------------------- badge
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
{
variants: {
tone: {
neutral: 'bg-surface-2 text-muted',
accent: 'bg-accent-subtle text-accent-fg',
positive: 'bg-positive/10 text-positive',
warning: 'bg-warning/10 text-warning',
danger: 'bg-danger/10 text-danger',
info: 'bg-info/10 text-info',
},
},
defaultVariants: { tone: 'neutral' },
},
);
export function Badge({
className,
tone,
...props
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
// --------------------------------------------------------------------- stat
/**
* A single headline number.
*
* `nums` applies tabular figures so a value does not jitter horizontally as it
* updates — which it does, on a dashboard that refreshes while someone is
* looking at it.
*/
export function Stat({
label,
value,
hint,
tone,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
tone?: 'positive' | 'warning' | 'danger' | 'default';
}) {
const toneClass =
tone === 'positive'
? 'text-positive'
: tone === 'warning'
? 'text-warning'
: tone === 'danger'
? 'text-danger'
: 'text-fg';
return (
<div className="card p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
</div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div>
);
}
// ------------------------------------------------------------------ skeleton
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-surface-2', className)} />;
}
// --------------------------------------------------------------- empty state
export function EmptyState({
icon,
title,
description,
action,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
{icon ? <div className="text-muted">{icon}</div> : null}
<div>
<p className="font-medium">{title}</p>
{description ? (
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
) : null}
</div>
{action}
</div>
);
}
// -------------------------------------------------------- confidence marker
/**
* Provenance, shown rather than hidden.
*
* PIG holds records about real people assembled from public sources, and some
* rest on a single weak citation. Presenting those with the same visual weight
* as a corroborated record is how a CRM quietly becomes misinformation — so
* anything short of `confirmed` is labelled wherever it appears.
*/
export function ConfidenceBadge({ confidence }: { confidence: string }) {
if (confidence === 'confirmed') return null;
const tone =
confidence === 'probable' ? 'info' : confidence === 'disputed' ? 'danger' : 'warning';
const label =
confidence === 'probable'
? 'Probable'
: confidence === 'disputed'
? 'Disputed'
: 'Unverified';
return (
<Badge tone={tone} title="How well-sourced this record is">
{label}
</Badge>
);
}