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
+481
View File
@@ -0,0 +1,481 @@
/**
* Every status a PIG record can be in, drawn once.
*
* The audit found this file's contents scattered across five others: account
* side and relationship state in `Account.tsx` and again in `Growth.tsx` with
* different tones for the same value, contract status in `Contracts.tsx` and
* again in `Account.tsx` disagreeing about whether "expired" is red, run and
* task state in `activity-panel.tsx`, renewal urgency in `Contracts.tsx`. Two
* implementations of the same badge do not merely duplicate; they contradict,
* and a reader who learns that green means good on one page and nothing on the
* next has learned that colour here is decoration.
*
* So the tones follow one rule, taken from the design direction, and the rule
* is stricter than what any of the five did:
*
* positive a business figure that is good — capacity actually running, a
* deal won, an expansion open
* warning something needs a person NOW — a notice window already open
* danger a loss, or a failure a person must resolve — an account gone, a
* run that failed, an authorisation that has lapsed
* info a neutral time or system fact — running, queued, out for signature
* neutral done, fine, nothing to do
*
* The consequence, and it is the point: **process outcomes get no colour.**
* "Executed", "Succeeded", "Closed lost", "Terminated" are all neutral. A
* ledger where every success is green is a ledger nobody scans, and the one
* row that needs a person is invisible in a column of colour.
*
* Colour is never the only signal. Every badge here carries a word, and the
* states that need a person or record a failure carry a mark as well, so the
* scan works without hue: a **circle** for "act on this", a **triangle** for
* "this went wrong". Two different shapes, not two colours of the same one —
* lucide's `AlertTriangle` is an alias of `TriangleAlert`, so the obvious pair
* drew the identical glyph twice and the distinction existed only in the hue it
* was supposed to be independent of. Nothing else gets an icon: a badge set
* where every chip has a glyph is a badge set with no emphasis left to spend.
*/
import { CircleAlert, TriangleAlert } from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
SUPPLY_STAGE_LABELS,
type AccountSide,
type ContractStatus,
type CustomerRelationshipState,
type DemandStage,
type GrowthFacet,
type SupplyStage,
} from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { shortDate } from '@/lib/api';
/** The tones `Badge` understands. `accent` is deliberately absent: the accent
* is PIG's identity colour and never carries meaning. */
export type StatusTone = 'neutral' | 'positive' | 'warning' | 'danger' | 'info';
/**
* The two marks, sized to the 12px badge text.
*
* Lucide ships icons at 24px and `Badge` does not size its children, so an
* unsized glyph in a badge renders twice the height of the word beside it —
* which is what `Contracts.tsx` and `Growth.tsx` were both doing.
*/
function ActMark() {
return <CircleAlert className="size-3.5" aria-hidden />;
}
function FailMark() {
return <TriangleAlert className="size-3.5" aria-hidden />;
}
/** Underscored enum value to a readable word, for the values with no label map. */
function humanise(value: string): string {
const words = value.replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : value;
}
// --------------------------------------------------------------- account side
/**
* Which side of the book an account sits on.
*
* All three are neutral. Side is identity, not status — nothing about being a
* supplier is good or bad or needs anybody — and the words already tell them
* apart. `Account.tsx` and `Accounts.tsx` were both painting supply blue and
* both sides accent, which spent two of the five tones on a fact that changes
* nothing a reader would do.
*/
export const SIDE_TONES: Record<AccountSide, StatusTone> = {
supply: 'neutral',
demand: 'neutral',
both: 'neutral',
};
export const SIDE_LABELS: Record<AccountSide, string> = {
supply: 'Buy-side',
demand: 'Sell-side',
both: 'Both sides',
};
export function SideBadge({ side, className }: { side: AccountSide; className?: string }) {
return (
<Badge tone={SIDE_TONES[side]} className={cn('shrink-0', className)}>
{SIDE_LABELS[side]}
</Badge>
);
}
// -------------------------------------------------------- customer lifecycle
/**
* Where a customer relationship stands.
*
* `deployed` is the only good one: capacity is actually running, which is the
* figure the business is built on. `former_customer` is a loss and says so.
* `prospect` and `contracted` are stages on the way, and a stage is not news.
*/
export const RELATIONSHIP_TONES: Record<CustomerRelationshipState, StatusTone> = {
prospect: 'neutral',
contracted: 'neutral',
deployed: 'positive',
former_customer: 'danger',
};
export const RELATIONSHIP_LABELS: Record<CustomerRelationshipState, string> = {
prospect: 'Prospect',
contracted: 'Contracted',
deployed: 'Deployed',
former_customer: 'Former customer',
};
export function RelationshipBadge({
state,
className,
}: {
state: CustomerRelationshipState;
className?: string;
}) {
return (
<Badge tone={RELATIONSHIP_TONES[state]} className={className}>
{RELATIONSHIP_LABELS[state]}
</Badge>
);
}
// ---------------------------------------------------------------- growth facet
/**
* Why an account is on the Growth page.
*
* The old version coloured all six, which made the page a mosaic. Here a
* deadline and a risk are the only things that get a person's attention, and
* the hygiene facets — a coverage gap, stale data — are grey. Stale data was
* `warning` before, competing for the eye with `at_risk` on the same row.
*/
export const FACET_TONES: Record<GrowthFacet, StatusTone> = {
expansion_candidate: 'positive',
renewal_due: 'warning',
at_risk: 'danger',
idle_supply_match: 'info',
coverage_gap: 'neutral',
data_stale: 'neutral',
};
export const FACET_LABELS: Record<GrowthFacet, string> = {
expansion_candidate: 'Expansion candidate',
renewal_due: 'Renewal due',
at_risk: 'At risk',
idle_supply_match: 'Idle supply match',
coverage_gap: 'Coverage gap',
data_stale: 'Data stale',
};
export function FacetBadge({ facet, className }: { facet: GrowthFacet; className?: string }) {
return (
<Badge tone={FACET_TONES[facet]} className={className}>
{facet === 'renewal_due' ? <ActMark /> : facet === 'at_risk' ? <FailMark /> : null}
{FACET_LABELS[facet]}
</Badge>
);
}
// -------------------------------------------------------------------- deals
/**
* Pipeline stage, both sides.
*
* Won and live are business-good; everything in flight is process and stays
* grey, which is the change from the accent-coloured pipeline the pages drew
* before. `churned` is red where `closed_lost` is not: losing a relationship
* is a loss worth marking, losing one deal out of a pipeline of them is the
* ordinary shape of the job.
*/
export const DEMAND_STAGE_TONES: Record<DemandStage, StatusTone> = {
qualification: 'neutral',
legal: 'neutral',
scoping: 'neutral',
proposal: 'neutral',
procurement: 'neutral',
poc: 'neutral',
deployment: 'neutral',
expansion: 'neutral',
closed_won: 'positive',
closed_lost: 'neutral',
};
export const SUPPLY_STAGE_TONES: Record<SupplyStage, StatusTone> = {
sourced: 'neutral',
qualifying: 'neutral',
technical_diligence: 'neutral',
financial_diligence: 'neutral',
pricing: 'neutral',
contracting: 'neutral',
onboarding: 'neutral',
live: 'positive',
renewal: 'warning',
churned: 'danger',
rejected: 'neutral',
};
export type DealStageBadgeProps =
| { side: 'demand'; stage: DemandStage; className?: string }
| { side: 'supply'; stage: SupplyStage; className?: string };
/**
* Not named in the direction's component table, but the audit counted deal
* stage among the domains drawn several ways, and `Account.tsx` holds two
* private tone functions for it. It belongs with the rest of the vocabulary.
*/
export function DealStageBadge(props: DealStageBadgeProps) {
const { tone, label } =
props.side === 'demand'
? { tone: DEMAND_STAGE_TONES[props.stage], label: DEMAND_STAGE_LABELS[props.stage] }
: { tone: SUPPLY_STAGE_TONES[props.stage], label: SUPPLY_STAGE_LABELS[props.stage] };
return (
<Badge tone={tone} className={props.className}>
{props.side === 'supply' && props.stage === 'renewal' ? <ActMark /> : null}
{label}
</Badge>
);
}
// ---------------------------------------------------------------- contracts
/**
* Paper state.
*
* `executed` is neutral, which is the tone change most likely to be questioned.
* It is a process outcome — the paper is signed, nothing follows from it today
* — and on the contracts list most rows are executed, so colouring it green
* paints the whole table and leaves the expiring one indistinguishable.
* `out_for_signature` is `info` rather than `warning` because it is waiting on
* the counterparty, not on us.
*/
export const CONTRACT_STATUS_TONES: Record<ContractStatus, StatusTone> = {
draft: 'neutral',
in_review: 'neutral',
in_negotiation: 'neutral',
out_for_signature: 'info',
executed: 'neutral',
expired: 'danger',
terminated: 'neutral',
};
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
draft: 'Draft',
in_review: 'In review',
in_negotiation: 'Negotiating',
out_for_signature: 'For signature',
executed: 'Executed',
expired: 'Expired',
terminated: 'Terminated',
};
export function ContractStatusBadge({
status,
className,
}: {
status: ContractStatus;
className?: string;
}) {
return (
<Badge tone={CONTRACT_STATUS_TONES[status]} className={className}>
{status === 'expired' ? <FailMark /> : null}
{CONTRACT_STATUS_LABELS[status]}
</Badge>
);
}
// ------------------------------------------------------------------ renewals
/** Mirrors `RenewalState` in `apps/api/src/services/contracts.ts`, which the
* web app cannot import. Expiry minus notice days against today. */
export type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired';
export const RENEWAL_TONES: Record<RenewalState, StatusTone> = {
not_applicable: 'neutral',
scheduled: 'neutral',
due: 'warning',
expired: 'danger',
};
/**
* Renewal urgency, which is the one status in the product that is worth money
* on a deadline: a notice window that quietly opened last week is the most
* expensive thing in this book to miss.
*
* Only the two states that need a person are drawn as badges. A contract with
* no alarm, or one whose notice is months away, is fine print — this appears in
* a column, and forty grey chips down a table are forty things to look past
* before finding the two amber ones. That restraint is `Contracts.tsx`'s
* original design and it is kept deliberately rather than regularised away.
*/
export function RenewalBadge({
state,
noticeAt,
className,
}: {
state: RenewalState;
/** The computed notice date, shown when it is still ahead. */
noticeAt?: string | Date | null;
className?: string;
}) {
if (state === 'due') {
return (
<Badge tone="warning" className={className}>
<ActMark />
Notice due
</Badge>
);
}
if (state === 'expired') {
return (
<Badge tone="danger" className={className}>
<FailMark />
Expired
</Badge>
);
}
return (
<span className={cn('text-xs text-muted', className)}>
{state === 'not_applicable'
? 'No alarm'
: noticeAt
? `Notice ${shortDate(noticeAt)}`
: 'Notice scheduled'}
</span>
);
}
// ------------------------------------------------------------ agent activity
/**
* A run's status is free text on the wire — `agent_runs.status` is a `text`
* column — so an unrecognised value is shown as it arrived, in neutral, rather
* than forced into one of the four we know. A status this panel has never heard
* of is information, not an error.
*
* `aborted` reads "Stopped by you" because that is what it means: the reader
* pressed Stop, or navigated away. "Aborted" describes the process; the person
* wants to know whether it was them.
*/
export const RUN_STATUS_TONES: Record<string, StatusTone> = {
running: 'info',
awaiting_approval: 'warning',
succeeded: 'neutral',
aborted: 'neutral',
failed: 'danger',
};
export const RUN_STATUS_LABELS: Record<string, string> = {
running: 'Running',
awaiting_approval: 'Needs you',
succeeded: 'Succeeded',
aborted: 'Stopped by you',
failed: 'Failed',
};
export function RunStatusBadge({ status, className }: { status: string; className?: string }) {
const tone = RUN_STATUS_TONES[status] ?? 'neutral';
return (
<Badge tone={tone} className={className}>
{status === 'failed' ? <FailMark /> : status === 'awaiting_approval' ? <ActMark /> : null}
{RUN_STATUS_LABELS[status] ?? humanise(status)}
</Badge>
);
}
/** Mirrors `PiggyTaskSummary['state']`: the three live states the queue derives
* plus `AGENT_TASK_OUTCOMES`. */
export type TaskState =
| 'running'
| 'queued'
| 'scheduled'
| 'succeeded'
| 'failed'
| 'skipped'
| 'cancelled';
export const TASK_STATE_TONES: Record<TaskState, StatusTone> = {
running: 'info',
queued: 'info',
scheduled: 'info',
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
export const TASK_STATE_LABELS: Record<TaskState, string> = {
running: 'Running',
queued: 'Queued',
scheduled: 'Scheduled',
succeeded: 'Succeeded',
failed: 'Failed',
skipped: 'Skipped',
cancelled: 'Cancelled',
};
export function TaskStateBadge({ state, className }: { state: TaskState; className?: string }) {
return (
<Badge tone={TASK_STATE_TONES[state]} className={className}>
{state === 'failed' ? <FailMark /> : null}
{TASK_STATE_LABELS[state]}
</Badge>
);
}
// ------------------------------------------------------------------ approval
/** The five states of a proposed write. Structurally identical to
* `PiggyApprovalState` in `piggy/approval-card.tsx`, which owns the
* transitions; declared here so this module does not depend on the card. */
export type ApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
/**
* The decided table from the design direction.
*
* `applied` is neutral, not green — the confirmation the reader wants is a
* single positive check beside the record link in the card's status line, not a
* green block in a transcript where every approved write would then be green.
* `pending` is the only warning, and it is warning because it is the only state
* in the product where the agent has stopped and is waiting for a person.
*/
export const APPROVAL_STATE_TONES: Record<ApprovalState, StatusTone> = {
pending: 'warning',
submitting: 'neutral',
applied: 'neutral',
rejected: 'neutral',
failed: 'danger',
};
export function ApprovalStateBadge({
state,
/** Which way the person answered, so `submitting` can say which. */
decision,
className,
}: {
state: ApprovalState;
decision?: 'apply' | 'reject' | null;
className?: string;
}) {
const label =
state === 'pending'
? 'Needs you'
: state === 'submitting'
? decision === 'reject'
? 'Rejecting'
: 'Applying'
: state === 'applied'
? 'Applied'
: state === 'rejected'
? 'Rejected'
: 'Not applied';
return (
<Badge tone={APPROVAL_STATE_TONES[state]} className={cn('shrink-0', className)}>
{state === 'pending' ? <ActMark /> : state === 'failed' ? <FailMark /> : null}
{label}
</Badge>
);
}