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>
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
/**
|
|
* The left navigation pane.
|
|
*
|
|
* One component for both treatments the sidebar primitive provides: the
|
|
* collapsible desktop rail and the phone Sheet. Deliberately not two, because
|
|
* the previous shell had the desktop list and the tab bar as separate JSX and
|
|
* they had already drifted — the tab bar's active pill and the sidebar's
|
|
* active row used different tokens.
|
|
*/
|
|
import { Fragment } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
|
import { useIdentity } from '@/lib/identity';
|
|
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
|
import { AccountSwitcher } from './AccountSwitcher';
|
|
import { Button, Label } from './ui';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
SidebarSeparator,
|
|
useSidebar,
|
|
} from './ui/sidebar';
|
|
|
|
export function AppSidebar() {
|
|
const identity = useIdentity();
|
|
const items = visibleNav(identity);
|
|
const { isMobile, setOpenMobile } = useSidebar();
|
|
|
|
return (
|
|
<Sidebar collapsible="icon">
|
|
<SidebarHeader>
|
|
{/* The Sheet's own close button is suppressed because it lands on top
|
|
of the account switcher. This one replaces it — Escape and the
|
|
overlay work, but a visible close is not optional on a touch
|
|
device where neither is discoverable. */}
|
|
{isMobile ? (
|
|
<div className="flex items-center justify-between pl-2">
|
|
{/* Same spec as every other group heading in the rail — this one
|
|
had its own copy of the retired 10px/600/0.16em values. */}
|
|
<Label>Navigate</Label>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted"
|
|
aria-label="Close navigation"
|
|
onClick={() => setOpenMobile(false)}
|
|
>
|
|
<X className="size-5" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<AccountSwitcher />
|
|
</SidebarHeader>
|
|
<SidebarSeparator />
|
|
<SidebarContent>
|
|
{NAV_GROUPS.map((group) => {
|
|
const groupItems = items.filter((item) => item.group === group);
|
|
// A heading over nothing is worse than a missing section: it reads
|
|
// as a section that failed to load rather than one you cannot use.
|
|
if (!groupItems.length) return null;
|
|
const heading = NAV_GROUP_HEADING[group];
|
|
return (
|
|
<Fragment key={group}>
|
|
<SidebarGroup>
|
|
{heading ? <SidebarGroupLabel>{heading}</SidebarGroupLabel> : null}
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{groupItems.map((item) => (
|
|
<NavItemRow key={item.to} item={item} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
{/*
|
|
An unlabelled group has no heading to separate it from the next
|
|
one, so it gets a rule instead. This is also the only separation
|
|
that survives collapse: at icon width every heading is pulled up
|
|
and faded out, so without the rule the front door would be just
|
|
one more glyph in an undifferentiated stack of them.
|
|
*/}
|
|
{heading === null ? <SidebarSeparator /> : null}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</SidebarContent>
|
|
{/*
|
|
No footer. The old shell ended with "Prime Intellect Growth / Compute
|
|
revenue system", which the account switcher at the top now says
|
|
better — and at 900px the fourteen nav rows do not all fit, so a
|
|
restatement of the workspace name was costing two of them.
|
|
*/}
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|
|
|
|
function NavItemRow({ item }: { item: NavItem }) {
|
|
const { setOpenMobile, isMobile } = useSidebar();
|
|
// `asChild` renders the row *as* the link rather than wrapping one, so there
|
|
// is a single focusable element per row. Active state is asked of the router
|
|
// instead of compared against a pathname, so `/demand/abc` still lights
|
|
// Demand and `/` does not light everything.
|
|
const resolved = useResolvedPath(item.to);
|
|
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
|
|
|
|
return (
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton asChild isActive={isActive} tooltip={item.label}>
|
|
<Link
|
|
to={item.to}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
onClick={() => {
|
|
if (isMobile) setOpenMobile(false);
|
|
}}
|
|
>
|
|
<item.icon aria-hidden />
|
|
<span>{item.label}</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|