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
@@ -1,11 +1,21 @@
/**
* The first thing anyone sees after signing in.
* The blank transcript, on every Piggy surface.
*
* There used to be two of these. The workspace had this one; the dock, the
* sheet and the phone drawer had a second, narrower one that offered three
* read openers, showed a Sparkles glyph and never once mentioned that Piggy
* can write — the product's headline capability, missing from the surface most
* people keep open all day. One agent gets one front door, so this is now it,
* and `narrow` is what the 22rem column asks for instead of a second file.
*
* It has one job that the old blank transcript did not have: Piggy can write
* now, and nobody will discover that by typing into a box. So the openers are
* in two columns — what it can find out, and what it can get done — and the
* second column says plainly that a change is proposed and waits for a person.
*
* Every sentence here comes from `lib/piggy-copy`. It is written once because
* it was written three times and had already drifted.
*
* The read openers come from `piggySuggestions`, which picks them by the one
* read tool this context resolves to, so every line is one Piggy can ground.
* The write openers are held here because there is no equivalent table for them
@@ -23,9 +33,10 @@
*/
import { ArrowRight, PenLine, Search } from 'lucide-react';
import type { PiggyChatContext, PiggyMode } from '@pig/core';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { piggySuggestions } from '@/lib/piggy-suggestions';
import { PiggyMark } from '@/components/PiggyMark';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
/**
* Openers that end in a change to the book.
@@ -69,7 +80,11 @@ export function PiggyWorkspaceStarters({
* the write tools still withheld.
*/
onAskWithChange: (text: string) => void;
/** The middle column is under ~40rem: stack the two groups. */
/**
* The surface is under ~26rem — the dock, the phone drawer, the workspace's
* middle column on a phone. Stacks the two groups, halves the openers and
* takes the short form of every sentence.
*/
narrow?: boolean;
}) {
/*
@@ -89,41 +104,53 @@ export function PiggyWorkspaceStarters({
return (
// `flex-1` rather than `h-full`: the conversation viewport's content element
// is sized by its children, so a percentage height resolves to nothing.
// Centred where there is room to spare, and tight where there is not: at
// 393x852 the six-line version needs every one of these 40 pixels to land
// whole above the composer.
// Centred where there is room to spare, and airless where there is not: on
// a narrow surface this front door has to land whole above the composer at
// 393x852 and in the dock's 22rem column, and it is measured to.
<div
className={cn(
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
narrow ? 'gap-4 py-1' : 'gap-6 py-6',
narrow ? 'gap-3' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
<PiggyMark className={cn('text-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
{/* The agent's mark in the agent's colour. Accent is identity in this
product and never meaning, and this is the one place on the front
door where the identity is the subject. */}
<PiggyMark className={cn('text-accent-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
<h2
className={cn(
'font-semibold tracking-tight',
narrow ? 'mt-2' : 'mt-3',
narrow ? 'text-base' : 'text-lg sm:text-xl',
// Sentence case at the section-heading step in the dock, at the
// page-title step on the full workspace. Nothing between the two:
// the 18px it used to sit at belongs to no step in the scale.
narrow ? 'text-base' : 'text-xl',
)}
>
Ask across the book and now, act on the answer.
{piggyLine(piggyCopy.headline, narrow)}
</h2>
<p className={cn('mt-1.5 max-w-xl text-muted', narrow ? 'text-xs leading-5' : 'text-sm leading-6')}>
{/* Narrow keeps the boundary and drops the mechanism: the headline has
already said a change waits for approval, and the note under the
write openers says it again where it is about to matter. Repeating
it a third time in a 22rem column costs three lines the openers
need to land above the composer. */}
<p className={cn('max-w-xl text-sm text-muted', narrow ? 'mt-1 leading-5' : 'mt-1.5 leading-6')}>
{narrow
? 'Piggy reads your PIG records through scoped tools. Switched to Ask first, it drafts changes for you to approve.'
: 'Piggy reads your PIG records through scoped tools, with no shell, filesystem or browser. Switched to Ask first, it also drafts changes: each one arrives as a card you read and approve, and nothing reaches the book until you do.'}
? piggyLine(piggyCopy.capability, true)
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
</p>
</div>
<div className={cn('grid gap-4', narrow ? 'grid-cols-1' : 'sm:grid-cols-2')}>
<div className={cn('grid', narrow ? 'grid-cols-1 gap-3' : 'gap-4 sm:grid-cols-2')}>
<StarterGroup
narrow={narrow}
icon={<Search aria-hidden className="size-3.5" />}
title="Look something up"
title={piggyLine(piggyCopy.readGroupTitle, narrow)}
/* Dropped on a phone, where the two columns are stacked and every
line costs: the hero above has just said the same thing, and the
note that has to survive is the one about writing. */
note={narrow ? null : 'Answered from your records, with the rows it read attached.'}
note={narrow ? null : piggyLine(piggyCopy.readGroupNote, narrow)}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
@@ -133,15 +160,17 @@ export function PiggyWorkspaceStarters({
</StarterGroup>
<StarterGroup
narrow={narrow}
icon={<PenLine aria-hidden className="size-3.5" />}
title="Get something done"
note={
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
note={piggyLine(
canWrite
? mode === 'read_only'
? 'These switch Piggy to Ask first: it proposes the change, you press Apply. It asks which record if your line does not say.'
: 'Piggy shows you exactly what it would write, and asks which record if your line does not say.'
: 'Your access does not allow changing records, so Piggy can only read.'
}
? piggyCopy.writeGroupNote.readOnly
: piggyCopy.writeGroupNote.askFirst
: piggyCopy.writeGroupNote.noAccess,
narrow,
)}
>
{writes.map((suggestion) => (
<StarterButton
@@ -162,21 +191,31 @@ function StarterGroup({
icon,
title,
note,
narrow,
children,
}: {
icon: React.ReactNode;
title: string;
note: string | null;
/** Measured, not guessed: see the note on the drawer's height below. */
narrow: boolean;
children: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
/*
* The gaps close on a narrow surface rather than the content thinning.
* Measured at 393x852 the phone drawer gives this front door 458px and it
* wanted 498, and the 40px it was over came out of white space rather than
* out of an opener — a column of two questions is the whole point of the
* second column, and one of them is not a choice.
*/
<section className={cn('flex min-w-0 flex-col', narrow ? 'gap-1.5' : 'gap-2')}>
<Label as="h3" className="flex items-center gap-1.5">
{icon}
{title}
</h3>
<div className="flex flex-col gap-1.5">{children}</div>
{note ? <p className="text-[11px] leading-4 text-muted">{note}</p> : null}
</Label>
<div className={cn('flex flex-col', narrow ? 'gap-1' : 'gap-1.5')}>{children}</div>
{note ? <p className="text-xs leading-4 text-muted">{note}</p> : null}
</section>
);
}