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
+58 -36
View File
@@ -16,7 +16,8 @@ import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark';
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
import { Badge, Button, Card, EmptyState, Section, Skeleton, Stat } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title';
@@ -323,10 +324,16 @@ function RoutePage({ children }: { children: React.ReactNode }) {
* inset's own tab-bar clearance does not apply to it, and without this the
* composer would sit underneath the phone tab bar — the exact control a phone
* user came here to reach. `lg` matches where the tab bar gives way.
*
* Under 500px tall the reserve is given back. A phone in landscape, or a phone
* with the keyboard up, is spending 72px of a 390px viewport on a bar it can
* reach again by turning the handset back — while the transcript, which is why
* the page exists, is measured at 40px. The tab bar itself stands down at the
* same height (Shell.tsx), so nothing lands underneath it.
*/
function WorkspaceRoute({ children }: { children: React.ReactNode }) {
return (
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0">
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0">
{/* `flex-1` on the fallback, or the spinner for a pane this tall sits up
against the header while the rest of it stays empty. */}
<Suspense fallback={<div className="flex flex-1 items-center justify-center"><RouteLoading /></div>}>
@@ -379,7 +386,7 @@ function Placeholder({ title }: { title: string }) {
function Team() {
usePageTitle('Team');
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['team'],
queryFn: () =>
get<
@@ -397,39 +404,55 @@ function Team() {
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
return (
/*
* The one page that never got a design pass, because it never had an owner:
* it lives inline in App.tsx rather than in `pages/`, so the wave that swept
* all thirteen routes swept past it. Measured against the rest of the
* product it carried a 30px `<h1>` where every other route is 24px, an
* accent-coloured "ACCESS MAP" eyebrow of exactly the kind the direction
* deleted from Growth (identity colour used as decoration, on a page with
* no agent in it), hand-rolled 10px/24px stat tiles instead of `Stat`, and
* a 14px section heading floating on the canvas. It is now the same three
* primitives every other page is built from and nothing else changed.
*/
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
See who can operate each side of the compute business and where ownership is thin.
</p>
</div>
<Link
to="/settings"
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
>
Manage access in Settings
</Link>
</header>
<PageHeader
title="Team"
description="See who can operate each side of the compute business and where ownership is thin."
actions={
<Link
to="/settings"
className="tap inline-flex items-center rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
>
Manage access in Settings
</Link>
}
/>
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
{[
['People', data?.length ?? 0],
['Teams', representedTeams],
['Assignments', assignments],
].map(([label, value]) => (
<Card key={label} className="p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
</Card>
))}
{/* `grid-cols-2 gap-3 xl:grid-cols-*`, the same KPI row Overview and
Margin use. This carried `grid-cols-3 sm:max-w-xl`, which made Team
the one page whose headline figures were a different size and whose
row stopped halfway across the page. */}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
<Stat label="People" value={data?.length ?? 0} />
<Stat label="Teams" value={representedTeams} />
<Stat label="Assignments" value={assignments} />
</div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
{/* Three routes rendered an honest error and then offered nothing to
do about it. A transient 500 on a page with no Try again is a page
a person has to know to reload. */}
<EmptyState
title="Team unavailable"
description={error instanceof Error ? error.message : 'Could not load team access.'}
action={
<Button type="button" variant="outline" onClick={() => void refetch()}>
Try again
</Button>
}
/>
</Card>
) : null}
@@ -446,11 +469,10 @@ function Team() {
) : null}
{!isLoading && !error && data?.length ? (
<section aria-labelledby="team-members-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
<span className="text-xs text-muted">Roles are enforced server-side</span>
</div>
<Section
title="People and permissions"
description="Roles are enforced server-side."
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((person) => {
const initials = person.name
@@ -486,7 +508,7 @@ function Team() {
);
})}
</div>
</section>
</Section>
) : null}
</div>
);