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,238 @@
|
||||
/**
|
||||
* Settings — appearance, profile, and connecting an agent.
|
||||
*
|
||||
* The appearance section is where the user picks the accent that re-tints the
|
||||
* whole product. It is saved server-side, so the choice follows them between
|
||||
* devices rather than being a per-browser quirk.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, Monitor, Moon, Sun, Terminal } from 'lucide-react';
|
||||
import { get, patch } from '@/lib/api';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isPlatformAdmin: boolean;
|
||||
teams: { team: string; role: string }[];
|
||||
via: string;
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
|
||||
</header>
|
||||
|
||||
<Appearance />
|
||||
<Profile me={me} />
|
||||
<ConnectAgent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Appearance() {
|
||||
const { mode, accent, setMode, setAccent, accents } = useTheme();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Appearance</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Saved to your account, so it follows you between your laptop and your phone.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Theme</p>
|
||||
<div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">
|
||||
{THEME_MODES.map((value) => {
|
||||
const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setMode(value as ThemeMode)}
|
||||
aria-pressed={mode === value}
|
||||
className={[
|
||||
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors sm:flex-none',
|
||||
mode === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Accent</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{accents.map((option) => {
|
||||
const selected = option.key === accent;
|
||||
const definition = getAccent(option.key);
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => setAccent(option.key)}
|
||||
aria-pressed={selected}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
className={[
|
||||
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||||
selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{/*
|
||||
The swatch previews the light-mode value while the app is in
|
||||
light mode and the dark value in dark mode, because the two
|
||||
are tuned separately and a single preview would misrepresent
|
||||
one of them.
|
||||
*/}
|
||||
<span
|
||||
className="h-4 w-4 rounded-full border border-black/10"
|
||||
style={{ backgroundColor: `hsl(${definition.light.accent})` }}
|
||||
aria-hidden
|
||||
/>
|
||||
{option.label}
|
||||
{selected ? <Check className="h-3.5 w-3.5" aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Status colours — positive, warning, danger — stay fixed regardless of your accent,
|
||||
so a warning always looks like a warning.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile({ me }: { me: Me | undefined }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
patch('/api/me/preferences', {
|
||||
...(name ? { name } : {}),
|
||||
...(title ? { title } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setName('');
|
||||
setTitle('');
|
||||
},
|
||||
});
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs text-muted">Name</dt>
|
||||
<dd>{me.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted">Email</dt>
|
||||
<dd className="break-all">{me.email}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-xs text-muted">Teams</dt>
|
||||
<dd className="mt-1 flex flex-wrap gap-1.5">
|
||||
{me.teams.length === 0 ? (
|
||||
<span className="text-muted">No team membership</span>
|
||||
) : (
|
||||
me.teams.map((t) => (
|
||||
<Badge key={t.team} tone="accent">
|
||||
{t.team} · {t.role}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{me.isPlatformAdmin ? <Badge tone="warning">Platform admin</Badge> : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form
|
||||
className="grid gap-3 sm:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-muted">Display name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Head of Compute"
|
||||
/>
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={save.isPending || (!name && !title)}
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectAgent() {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-pig-host';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-accent-fg" aria-hidden />
|
||||
<CardTitle className="text-base">Connect your agent</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
PIG speaks MCP, so Claude Code, Codex, prime-agent and Buzz agents can all work with
|
||||
your pipeline directly from the terminal.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="scroll-x rounded-lg bg-surface-2 p-3">
|
||||
<pre className="text-xs leading-relaxed">
|
||||
<code>{`export PIG_URL=${origin}
|
||||
export PIG_API_KEY=pig_... # create one below
|
||||
|
||||
claude mcp add pig -- npx -y @pig/mcp`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
The agent authenticates as its own principal, separately revocable from your own
|
||||
session, and can never reach further than you can.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user