Files
pig/apps/web/src/components/RecordLink.tsx
T
claude 18d5f5bfc0
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped
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>
2026-08-14 18:22:15 -07:00

146 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}