18d5f5bfc0
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>
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
/**
|
|
* 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
|
|
* yet, and they are written against the same constraint: each one is answerable
|
|
* with the tools a `/piggy` turn is actually given — the workspace summary, the
|
|
* record lookups, the renewals list — and none of them names a record that only
|
|
* exists in the demo book.
|
|
*
|
|
* Pressing a write opener while Piggy is in Read only moves it to Ask first.
|
|
* That is a change to a permission, so it is never silent: the card says so
|
|
* before it is pressed, and the mode control in the header changes with it. Ask
|
|
* first cannot write unattended — it proposes, and the Apply button is the
|
|
* person — so the escalation this performs is from "no tools" to "a proposal
|
|
* you must approve", which is the thing the user just asked for by pressing it.
|
|
*/
|
|
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 { Label, cn } from '@/components/ui';
|
|
|
|
/**
|
|
* Openers that end in a change to the book.
|
|
*
|
|
* Every write tool takes a record id, and none of the tools a `/piggy` turn is
|
|
* given returns one from the page context alone — so each of these is a lookup
|
|
* followed by a write, and none of them names a record. Naming one would make
|
|
* them land beautifully on the seeded demo book and fail on the first real
|
|
* deployment, which is the opposite of the trade this file should make.
|
|
*
|
|
* The consequence is stated to the user rather than hidden: where the sentence
|
|
* does not identify the record, Piggy asks which one instead of choosing. That
|
|
* is the behaviour a CRM should have, and it is measurably what the default
|
|
* model does — see the note under the column.
|
|
*/
|
|
const WRITE_STARTERS = [
|
|
'Find the block furthest from break-even and log a note on its account.',
|
|
'Look up the contract renewing soonest and log a call about extending it.',
|
|
'Add a task to chase the account we have not spoken to in a month.',
|
|
];
|
|
|
|
const READ_STARTERS_SHOWN = 3;
|
|
|
|
export function PiggyWorkspaceStarters({
|
|
context,
|
|
mode,
|
|
canWrite,
|
|
onAsk,
|
|
onAskWithChange,
|
|
narrow = false,
|
|
}: {
|
|
context?: PiggyChatContext;
|
|
/** Only to word the note. The escalation itself belongs to the thread. */
|
|
mode: PiggyMode;
|
|
canWrite: boolean;
|
|
onAsk: (text: string) => void;
|
|
/**
|
|
* An opener that ends in a write. The thread raises the mode first and sends
|
|
* once the conversation is holding the new one — `send` reads the mode out of
|
|
* the conversation, so sending in the same tick would ask for a change with
|
|
* the write tools still withheld.
|
|
*/
|
|
onAskWithChange: (text: string) => void;
|
|
/**
|
|
* 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;
|
|
}) {
|
|
/*
|
|
* Two openers each on a phone, three on a desktop.
|
|
*
|
|
* Not a taste decision: the transcript sticks to the bottom of its
|
|
* scrollport, so anything taller than the viewport opens with its own
|
|
* heading scrolled off the top. Measured at 393x852 the six-opener version
|
|
* overran by about 180px, which put the pig, the headline and the first
|
|
* column header above the fold on the screen that is supposed to introduce
|
|
* the product.
|
|
*/
|
|
const perGroup = narrow ? 2 : READ_STARTERS_SHOWN;
|
|
const reads = piggySuggestions(context).slice(0, perGroup);
|
|
const writes = WRITE_STARTERS.slice(0, perGroup);
|
|
|
|
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 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-3' : 'gap-6 py-6',
|
|
)}
|
|
>
|
|
<div className="flex flex-col items-center text-center">
|
|
{/* 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',
|
|
// 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',
|
|
)}
|
|
>
|
|
{piggyLine(piggyCopy.headline, narrow)}
|
|
</h2>
|
|
{/* 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
|
|
? piggyLine(piggyCopy.capability, true)
|
|
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
|
|
</p>
|
|
</div>
|
|
|
|
<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={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 : piggyLine(piggyCopy.readGroupNote, narrow)}
|
|
>
|
|
{reads.map((suggestion) => (
|
|
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
|
|
{suggestion}
|
|
</StarterButton>
|
|
))}
|
|
</StarterGroup>
|
|
|
|
<StarterGroup
|
|
narrow={narrow}
|
|
icon={<PenLine aria-hidden className="size-3.5" />}
|
|
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
|
|
note={piggyLine(
|
|
canWrite
|
|
? mode === 'read_only'
|
|
? piggyCopy.writeGroupNote.readOnly
|
|
: piggyCopy.writeGroupNote.askFirst
|
|
: piggyCopy.writeGroupNote.noAccess,
|
|
narrow,
|
|
)}
|
|
>
|
|
{writes.map((suggestion) => (
|
|
<StarterButton
|
|
key={suggestion}
|
|
disabled={!canWrite}
|
|
onClick={() => onAskWithChange(suggestion)}
|
|
>
|
|
{suggestion}
|
|
</StarterButton>
|
|
))}
|
|
</StarterGroup>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
/*
|
|
* 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}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function StarterButton({
|
|
children,
|
|
onClick,
|
|
disabled = false,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={cn(
|
|
'group flex min-h-11 w-full items-center gap-2 rounded-lg border border-border bg-surface',
|
|
'px-3 py-2 text-left text-sm leading-5 transition-colors',
|
|
'hover:border-fg/20 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface',
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1">{children}</span>
|
|
<ArrowRight
|
|
aria-hidden
|
|
className="size-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
</button>
|
|
);
|
|
}
|