Make Piggy part of the product rather than a guest in it
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped

Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 18:22:15 -07:00
parent f0173440e4
commit 18d5f5bfc0
89 changed files with 8523 additions and 2447 deletions
+35 -35
View File
@@ -15,7 +15,9 @@ import type { PermissionGrant } from '@pig/core';
import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
import { get, money, relativeTime, unitPrice } from '@/lib/api';
import { PiggyAskButton } from '@/components/PiggyChat';
import { Badge, Button, Card, EmptyState, Input, Skeleton, cn } from '@/components/ui';
import { Badge, Button, Card, EmptyState, Input, Label, Skeleton, Stat, cn } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { can } from '@/lib/permissions';
@@ -108,20 +110,20 @@ function PipelineBoard<T extends { id: string; name: string; stage: string; upda
: 'Are we lining up more capacity than the demand side can absorb?')}
/>;
if (boardQuery.isLoading) return <div className="space-y-5">{header}<Skeleton className="h-96" /></div>;
if (boardQuery.isError) return <div className="space-y-5">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5">{header}<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." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
if (boardQuery.isLoading) return <div className="space-y-6">{header}<Skeleton className="h-96" /></div>;
if (boardQuery.isError) return <div className="space-y-6">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-6">{header}<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." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id));
return <div className="space-y-5">
return <div className="space-y-6">
{header}
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
<PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
<Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
</section>
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
<div className="lg:hidden"><FormField label="Focus stage"><select id={`${team}-stage`} className="h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select></FormField><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-xs text-muted">{index + 1}</span><Label as="h2" id={`${team}-${stage}`} className="truncate">{STAGE_LABELS[stage] ?? stage}</Label></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
{sheetNode}
</div>;
}
@@ -136,45 +138,43 @@ function PipelineBoard<T extends { id: string; name: string; stage: string; upda
* matters more, reading the deal or asking about it.
*/
function DealCard<T extends { id: string; name: string; updatedAt: string }>({ row, renderCard, writable, focused, onEdit, onFocus }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; focused: boolean; onEdit(): void; onFocus(): void }) {
// `ring-brand`, not `ring-accent`: in this Tailwind config `accent` is
// shadcn's subtle surface, so a ring drawn in it is invisible against the
// card. The brand is the monochrome that inverts with the theme.
return <article className={cn('card relative min-w-0 p-3 pr-12 shadow-sm', focused && 'ring-2 ring-brand')}>
// The focus ring is the brand across the product, so a selected card is
// marked the same way rather than in a colour that means something else.
return <article className={cn('card relative min-w-0 p-3 pr-12', focused && 'ring-2 ring-brand')}>
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
<button
type="button"
aria-pressed={focused}
onClick={onFocus}
title={focused ? 'Stop pointing Piggy at this deal' : 'Point Piggy at this deal'}
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors duration-1 ease-enter hover:bg-surface-2"
>
<span className="block truncate font-medium">{row.deal.name}</span>
<span className="block truncate text-xs text-muted">{row.accountName ?? 'No account'}</span>
<span className="sr-only">{focused ? 'Piggy is looking at this deal' : 'Point Piggy at this deal'}</span>
</button>
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
<p className="mt-2 text-xs text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
</article>;
}
function PipelineStat({ label, value }: { label: string; value: string }) { return <div className="min-w-[7rem] rounded-lg bg-surface px-3 py-2"><p className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className="nums mt-0.5 truncate text-sm font-semibold">{value}</p></div>; }
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return <p className={compact ? 'rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted' : 'py-10 text-center text-sm text-muted'}>{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}</p>; }
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
{/*
Reversed above `sm` rather than reordered: stacked on a phone the primary
action has to come first, and on a wide header the same button belongs at
the right edge where it has always been.
*/}
<div className="flex flex-col gap-2 sm:shrink-0 sm:flex-row-reverse">
<Button className="min-h-11" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>
{/*
No `context` prop on purpose: this asks about whatever the board has
published, which is the focused deal when there is one. Passing the page
here would pin it to the board and quietly ignore the card the user just
put in focus.
*/}
<PiggyAskButton label={askLabel} prompt={askPrompt} />
</div>
</header>;
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) {
return <EmptyState
size={compact ? 'inline' : 'panel'}
className={compact ? 'rounded-lg border border-dashed border-border' : undefined}
title={filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}
/>;
}
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
return <PageHeader
title={<span className="flex flex-wrap items-center gap-2">{title}<Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></span>}
description={subtitle}
actions={<Button variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
/*
No `context` prop on purpose: this asks about whatever the board has
published, which is the focused deal when there is one. Passing the page
here would pin it to the board and quietly ignore the card the user just
put in focus.
*/
ask={<PiggyAskButton label={askLabel} prompt={askPrompt} />}
/>;
}