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:
+58
-36
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user