Make Piggy part of the product rather than a guest in it
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:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* A named record, one click from wherever it was named.
|
||||
*
|
||||
* Extracted from the approval card, where it was the escape hatch on a pending
|
||||
* write: before deciding, open the account and check the note is not already
|
||||
* there. It is here because Piggy's answers need the same thing and do not have
|
||||
* it — an answer naming "DEMO — 128× H100 reserved, 6 months" and "$399,972.42"
|
||||
* is inert text, while the Overview renders that same record as a row with
|
||||
* "Match →". Closing that gap is what turns the transcript from an island that
|
||||
* knows your data into a surface you can act from.
|
||||
*
|
||||
* Two honesty rules, both load-bearing:
|
||||
*
|
||||
* The title never promises a record the link cannot open. `/accounts/:id` is
|
||||
* the only per-record route PIG has, so everything else lands on the list
|
||||
* that contains the row. "Open the list containing Northwind Robotics" is a
|
||||
* worse sentence than "Open Northwind Robotics" and a true one.
|
||||
*
|
||||
* A record with no route renders as plain text rather than as a dead link.
|
||||
* Losing the name would be worse — an answer would silently stop mentioning
|
||||
* the thing it reasoned about — and a chip that goes nowhere is the failure
|
||||
* `tool.tsx` already names: proving nothing is worse than claiming nothing.
|
||||
*
|
||||
* `newTab` exists because the approval card's own escape hatch was destroying
|
||||
* the proposal it existed to help verify: a same-tab navigation unmounts the
|
||||
* transcript, and the pending card with it.
|
||||
*/
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* Where a record of each kind can be opened.
|
||||
*
|
||||
* When the other detail routes land, each of these becomes a one-line edit and
|
||||
* `opensRecord` grows an entry; this is the only place a record id becomes a
|
||||
* URL. The keys are wider than `PiggyRecordType` on purpose — a proposed change
|
||||
* carries `record.type` as free text, and `allocation` and `task` are both
|
||||
* things a write tool can produce.
|
||||
*/
|
||||
export const RECORD_ROUTES: Record<string, string> = {
|
||||
account: '/accounts',
|
||||
contact: '/accounts',
|
||||
demand_deal: '/demand',
|
||||
supply_deal: '/supply',
|
||||
contract: '/contracts',
|
||||
commitment: '/capacity',
|
||||
allocation: '/capacity',
|
||||
task: '/calendar',
|
||||
};
|
||||
|
||||
/** The URL for a record, or null when PIG has nowhere to send the reader. */
|
||||
export function recordHref(type: string, id: string): string | null {
|
||||
const base = RECORD_ROUTES[type];
|
||||
if (!base) return null;
|
||||
return type === 'account' ? `${base}/${id}` : base;
|
||||
}
|
||||
|
||||
/** Whether the link opens the record itself or merely the list holding it. */
|
||||
export function opensRecord(type: string): boolean {
|
||||
return type === 'account';
|
||||
}
|
||||
|
||||
export interface RecordLinkProps {
|
||||
/** Record type as the payload reported it — free text, not a closed union. */
|
||||
type: string;
|
||||
id: string;
|
||||
/** The record's name. Falls back to a neutral noun rather than showing a uuid. */
|
||||
label?: string;
|
||||
/**
|
||||
* Open in a new tab. Off by default: this is the extracted behaviour, and a
|
||||
* link inside a page that steals the tab is normal. Turn it on where leaving
|
||||
* would destroy unsubmitted state — the pending approval card, and any link
|
||||
* inside a streaming transcript.
|
||||
*/
|
||||
newTab?: boolean;
|
||||
/**
|
||||
* A word before the name: "Check", "Open". Omitted by default so a link
|
||||
* inside a sentence reads as the record's name and nothing else.
|
||||
*/
|
||||
verb?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecordLink({ type, id, label, newTab = false, verb, className }: RecordLinkProps) {
|
||||
const href = recordHref(type, id);
|
||||
const name = label?.trim() || 'the record';
|
||||
|
||||
/*
|
||||
* `min-h-11` even though this is a text link: it sits in the approval card's
|
||||
* footer beside two 44px buttons and inside transcript prose, and both are
|
||||
* places a thumb lands. The 44px floor is the primitive set's oldest rule and
|
||||
* an inline link is not an exemption from it.
|
||||
*
|
||||
* `text-xs` is the approval footer's size and the default here; a caller
|
||||
* inside 14px prose passes `className="text-sm"` and `cn()` resolves it,
|
||||
* rather than this growing a size prop for two values.
|
||||
*/
|
||||
const shared =
|
||||
'inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-lg text-xs ' +
|
||||
'text-muted underline-offset-4 transition-colors duration-1 ease-enter ' +
|
||||
'hover:text-fg hover:underline focus-visible:text-fg focus-visible:outline-none ' +
|
||||
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 ' +
|
||||
'focus-visible:ring-offset-bg';
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<span className={cn('inline-flex max-w-full items-center text-xs text-muted', className)}>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const destination = opensRecord(type)
|
||||
? `Open ${name}`
|
||||
: `Open the list containing ${name}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(shared, className)}
|
||||
title={newTab ? `${destination} in a new tab` : destination}
|
||||
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
<span className="truncate">
|
||||
{verb ? `${verb} ` : ''}
|
||||
{name}
|
||||
</span>
|
||||
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
|
||||
{/* The title attribute is not announced reliably, and where this link
|
||||
lands is the one thing a screen-reader user must be told before they
|
||||
follow it out of a pending approval: that it opens a new tab, and —
|
||||
for every kind but `account`, which is the only per-record route PIG
|
||||
has — that it opens the list rather than the row. The honest wording
|
||||
existed already but lived only in `title`, which is neither visible
|
||||
nor announced. */}
|
||||
{opensRecord(type) && !newTab ? null : (
|
||||
<span className="sr-only">
|
||||
{opensRecord(type) ? '' : ' (opens the list containing it)'}
|
||||
{newTab ? ' (opens in a new tab)' : ''}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user