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:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Piggy — the mark.
|
||||
*
|
||||
* A front-facing pig's head reduced to the four shapes that survive at 16px:
|
||||
* two triangular ears, a rounded head, a snout with two nostrils, two eyes.
|
||||
* Drawn with `currentColor` so it inherits whatever it sits on and works in
|
||||
* both themes without a second asset, and on any accent the user picks.
|
||||
*
|
||||
* This is a placeholder of reasonable quality, not final brand art — good
|
||||
* enough to ship and to judge layout against, and easy to swap for
|
||||
* commissioned artwork later without touching any layout code.
|
||||
*/
|
||||
export function PiggyMark({
|
||||
className = 'h-6 w-6',
|
||||
title,
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
// Decorative by default: when there is no title the mark sits beside the
|
||||
// wordmark, and announcing it twice is noise for a screen reader.
|
||||
role={title ? 'img' : undefined}
|
||||
aria-hidden={title ? undefined : true}
|
||||
aria-label={title}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
|
||||
{/* Ears. Drawn before the head so the head's fill overlaps their base. */}
|
||||
<path
|
||||
d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
{/* Head. Slightly wider than tall — reads as a pig rather than a bear. */}
|
||||
<path
|
||||
d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
{/* Eyes, knocked out of the head so the mark stays a single colour. */}
|
||||
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
|
||||
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
|
||||
|
||||
{/* Snout, also knocked out, with two nostrils punched back in. */}
|
||||
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" className="fill-[hsl(var(--bg))]" />
|
||||
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
|
||||
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full lockup: mark plus wordmark.
|
||||
*
|
||||
* Lowercase, because "pig" set in lowercase reads as a friendly product name
|
||||
* while "PIG" reads as an acronym being shouted. The expansion is available
|
||||
* to assistive technology without cluttering the interface.
|
||||
*/
|
||||
export function PiggyLogo({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<PiggyMark className="h-7 w-7 shrink-0 text-accent-fg" />
|
||||
<span className="text-lg font-semibold lowercase tracking-tight">
|
||||
pig
|
||||
<span className="sr-only"> — Prime Intellect Growth</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The application shell.
|
||||
*
|
||||
* Two navigation treatments rather than one responsive compromise:
|
||||
*
|
||||
* Phone — a bottom tab bar, because the top of a large phone is out of
|
||||
* thumb reach, and iOS users expect primary navigation there.
|
||||
* Desktop — a persistent sidebar, because the horizontal room exists and
|
||||
* hiding navigation behind a hamburger on a 27-inch display wastes
|
||||
* it.
|
||||
*
|
||||
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
|
||||
* — it has the width, and the bottom bar looks lost across a tablet.
|
||||
*/
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Boxes,
|
||||
Building2,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
||||
import { cn } from './ui';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText },
|
||||
{ to: '/team', label: 'Team', icon: Users },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const current = NAV.find((item) =>
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg">
|
||||
{/* ------------------------------------------------- desktop sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface lg:flex',
|
||||
// Respect the safe area on notched displays in landscape.
|
||||
'pl-[var(--safe-left)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<PiggyLogo />
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-subtle text-accent-fg'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border px-5 py-3 text-xs text-muted">
|
||||
Prime Intellect Growth
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---------------------------------------------------- mobile header */}
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
|
||||
// A translucent bar with a blur reads as native on iOS; the opaque
|
||||
// fallback keeps text legible where backdrop-filter is unsupported.
|
||||
'bg-surface/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden',
|
||||
'pt-[var(--safe-top)]',
|
||||
)}
|
||||
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
|
||||
>
|
||||
<PiggyMark className="h-6 w-6 text-accent-fg" />
|
||||
<span className="font-semibold lowercase tracking-tight">
|
||||
{current?.label ?? 'pig'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ---------------------------------------------------------- content */}
|
||||
<main
|
||||
className={cn(
|
||||
'lg:pl-60',
|
||||
// Bottom padding clears the tab bar and the home indicator beneath
|
||||
// it. Without this the last row of any list is unreachable.
|
||||
'pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0',
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ------------------------------------------------- mobile tab bar */}
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/90 backdrop-blur-md lg:hidden',
|
||||
'supports-[backdrop-filter]:bg-surface/80',
|
||||
)}
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="mx-auto flex max-w-lg items-stretch justify-around">
|
||||
{NAV.filter((item) => item.primary).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'tap flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium',
|
||||
isActive ? 'text-accent-fg' : 'text-muted',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user