Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* The account tile at the top of the sidebar.
*
* It carries the Piggy mark in the user's own accent, because that accent is
* the one piece of the interface they chose and the workspace identity is
* where they will look for it. The swatch row in the menu is the same
* `setAccent` the Settings page calls — not a copy of the palette, and not a
* second place a colour could be defined.
*
* PIG is single-workspace today, so this is a switcher with one entry. It is
* still a menu rather than a label: it is where identity, appearance and
* sign-out belong, and the shape does not have to change when a second
* workspace appears.
*/
import { ChevronsUpDown, Check, LogOut, Monitor, Moon, Settings2, Sun } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ThemeMode } from '@pig/core';
import { getSupabase } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { useTheme } from '@/lib/theme';
import { PiggyMark } from './PiggyMark';
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { cn } from './ui';
const WORKSPACE_NAME = 'Prime Intellect Growth';
const MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
];
export function AccountSwitcher() {
const identity = useIdentity();
const { isMobile, setOpenMobile } = useSidebar();
const { accent, accents, setAccent, mode, setMode, resolved } = useTheme();
async function signOut() {
try {
await getSupabase()?.auth.signOut();
} finally {
// Belt and braces, matching Settings: if the provider call fails a
// reload still lands on sign-in rather than a half-signed-out interface.
window.location.href = '/';
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent"
aria-label={`${WORKSPACE_NAME} — account and appearance`}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<span className="flex min-w-0 flex-1 flex-col text-left leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold text-fg">{WORKSPACE_NAME}</span>
<span className="truncate text-xs font-normal text-muted">{identity.name}</span>
</span>
<ChevronsUpDown className="ml-auto size-4 shrink-0 text-muted group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-64"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={8}
>
<DropdownMenuLabel className="flex min-w-0 items-center gap-2 py-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">{identity.name}</span>
<span className="truncate text-xs font-normal text-muted">{identity.email}</span>
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Accent
</DropdownMenuLabel>
<div className="flex flex-wrap gap-1.5 px-2 pb-2">
{accents.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setAccent(option.key)}
aria-label={option.label}
aria-pressed={accent === option.key}
title={option.label}
className={cn(
'grid size-7 place-items-center rounded-full border transition-transform hover:scale-110',
accent === option.key ? 'border-fg' : 'border-border',
)}
// The dark tuning of each accent is a different colour, not a
// dimmed one. A swatch showing the light value in dark mode
// is a swatch showing a colour the user will not get.
style={{
background: `hsl(${resolved === 'dark' ? option.dark.accent : option.light.accent})`,
}}
>
{accent === option.key ? (
<Check className="size-3.5 text-white mix-blend-difference" aria-hidden />
) : null}
</button>
))}
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Appearance
</DropdownMenuLabel>
{MODES.map((option) => (
<DropdownMenuItem
key={option.value}
className="min-h-11"
onSelect={() => setMode(option.value)}
>
<option.icon aria-hidden />
{option.label}
{mode === option.value ? <Check className="ml-auto size-4" aria-hidden /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="min-h-11">
<Link to="/settings" onClick={() => setOpenMobile(false)}>
<Settings2 aria-hidden />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="min-h-11" onSelect={() => void signOut()}>
<LogOut aria-hidden />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* The application header: logo, where you are, search, Piggy.
*
* Search reads as a field because that is what people look for, but it is a
* BUTTON, not an input. It was briefly a real `<input>` that opened the
* palette on focus, and that is a keyboard trap: Tab moved into the field,
* the modal took over, Escape left focus on `<body>`, and Tab from there ran
* the same three elements and reopened the dialog — so no keyboard user could
* ever reach the nav, the Piggy toggle or the page. A field that cannot be
* focused without being replaced is not a field.
*
* The alternative — a real input filtering inline and escalating on Enter —
* was rejected because the palette is the thing that answers, and an inline
* filter would be a second search that ranks differently from the one ⌘K
* opens. One search, one ranking; the control that opens it says so honestly.
* (This is also what shadcn's own examples do.)
*
* Full width above both side panes rather than inset between them, so the
* logo has somewhere to live and the panes have a fixed edge to hang from.
*/
import { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { activeNavItem, visibleNav } from '@/lib/nav';
import { CommandPalette } from './CommandPalette';
import { PiggyLogo } from './PiggyMark';
import { PiggyDockToggle } from './PiggyDock';
import { Button, cn } from './ui';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
import { SidebarTrigger } from './ui/sidebar';
export function AppHeader() {
const identity = useIdentity();
const { pathname } = useLocation();
const items = visibleNav(identity);
const current = activeNavItem(items, pathname);
const [commandOpen, setCommandOpen] = useState(false);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
setCommandOpen((open) => !open);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
return (
<header
className={cn(
'sticky top-0 z-40 flex w-full shrink-0 items-center gap-2 border-b border-border',
// Translucent with a blur reads as native on iOS; the opaque fallback
// keeps text legible where backdrop-filter is unsupported.
'bg-surface/90 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75',
'pr-[max(0.75rem,var(--safe-right))] lg:pr-[max(1rem,var(--safe-right))]',
'pl-[max(0.5rem,var(--safe-left))] lg:pl-[max(0.75rem,var(--safe-left))]',
)}
style={{ height: 'var(--app-header-h)', paddingTop: 'var(--safe-top)' }}
>
<SidebarTrigger />
<Link to="/" className="tap flex shrink-0 items-center rounded-lg px-1" aria-label="PIG home">
<PiggyLogo />
</Link>
{current ? (
<Breadcrumb className="ml-2 hidden min-w-0 lg:block">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="text-muted">{current.group}</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{current.label}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
) : null}
{/* min-w-0 on the search wrapper: without it the 393px header refuses to
shrink below the field's intrinsic width and the page scrolls. */}
<div className="ml-auto flex min-w-0 items-center gap-1">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={commandOpen}
className={cn(
'hidden h-9 min-w-0 items-center gap-2 rounded-md border border-input bg-surface-2 px-2.5',
'text-left text-sm text-muted shadow-sm transition-colors hover:text-fg md:flex md:w-56 lg:w-72',
)}
onClick={() => setCommandOpen(true)}
>
<Search className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">Search pages and workflows</span>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
K
</kbd>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted md:hidden"
aria-label="Search and navigate"
onClick={() => setCommandOpen(true)}
>
<Search className="size-5" aria-hidden />
</Button>
<PiggyDockToggle />
</div>
<CommandPalette destinations={items} open={commandOpen} onOpenChange={setCommandOpen} />
</header>
);
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 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 { X } from 'lucide-react';
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { AccountSwitcher } from './AccountSwitcher';
import { Button } 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">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Navigate
</span>
<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;
return (
<SidebarGroup key={group}>
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{groupItems.map((item) => (
<NavItemRow key={item.to} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</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>
);
}
+44 -3
View File
@@ -1,4 +1,4 @@
import { Fragment } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import {
@@ -30,11 +30,52 @@ export function CommandPalette({
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
// Clear on close rather than on open: reopening must not present yesterday's
// query over a list it is already silently filtering. Keyed off `open` and
// not the close handler because ⌘K and a selected item both close the dialog
// by setting the prop directly.
useEffect(() => {
if (!open) setQuery('');
}, [open]);
// Where focus came from, so it can go back there. This used to be
// `onCloseAutoFocus: preventDefault` — necessary while the header's search
// control was an input that opened the palette on focus, because restoring
// focus reopened the dialog. That control is a button now, and suppressing
// restoration left focus on `<body>`: Tab then restarted at the top of the
// document, which is a keyboard trap of its own. Radix's own restoration
// does not survive this dialog either (measured: focus lands on `<body>`),
// so the opener is captured and refocused explicitly.
const opener = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) opener.current = document.activeElement as HTMLElement | null;
}, [open]);
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search pages and workflows…" aria-label="Search pages and workflows" />
<CommandDialog
open={open}
onOpenChange={onOpenChange}
contentProps={{
onCloseAutoFocus: (event) => {
const target = opener.current;
// `isConnected` because selecting an item navigates, and the opener
// may be a control the new route has already unmounted; falling back
// to Radix's default is better than focusing a detached node.
if (!target || !target.isConnected) return;
event.preventDefault();
target.focus();
},
}}
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search pages and workflows…"
aria-label="Search pages and workflows"
/>
<CommandList className="max-h-[min(70dvh,32rem)] p-1">
<CommandEmpty>No pages found.</CommandEmpty>
{groups.map((group, index) => (
+55 -32
View File
@@ -13,6 +13,8 @@ import {
XCircle,
} from 'lucide-react';
import { get } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import {
streamPiggyChat,
type PiggyChatContext,
@@ -69,6 +71,10 @@ export function PiggyAskButton({
const [open, setOpen] = useState(false);
const status = usePiggyStatus();
const unavailable = status.data && !status.data.canUse;
// An explicit prop always wins. Every existing call site passes the record
// the user pressed the button on, and the ambient page context is a guess
// that must never displace it.
const ambient = usePiggyCurrentContext();
return (
<>
<Button
@@ -84,7 +90,7 @@ export function PiggyAskButton({
<ResponsivePiggyChat
open={open}
onOpenChange={setOpen}
context={context}
context={context ?? ambient}
initialPrompt={prompt}
/>
</>
@@ -110,7 +116,7 @@ export function PiggyChatWorkspace() {
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
}
function ResponsivePiggyChat({
export function ResponsivePiggyChat({
open,
onOpenChange,
context,
@@ -121,14 +127,17 @@ function ResponsivePiggyChat({
context?: PiggyChatContext;
initialPrompt?: string;
}) {
const desktop = useDesktop();
// The same breakpoint the shell switches navigation at. It used to be `md`,
// which meant a 900px tablet got the desktop side sheet sliding in behind
// the phone tab bar it was still showing.
const desktop = !useIsMobile();
if (desktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
<SheetHeader className="border-b border-border px-5 py-4">
<SheetHeader className="border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]">
<SheetTitle>Ask Piggy</SheetTitle>
<SheetDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</SheetDescription>
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
</SheetHeader>
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
</SheetContent>
@@ -140,7 +149,7 @@ function ResponsivePiggyChat({
<DrawerContent className="h-[92dvh]">
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
<DrawerTitle>Ask Piggy</DrawerTitle>
<DrawerDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</DrawerDescription>
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
</DrawerHeader>
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
</DrawerContent>
@@ -148,14 +157,25 @@ function ResponsivePiggyChat({
);
}
function PiggyChatPanel({
/**
* The transcript and composer. Width-agnostic on purpose — it is used at a
* full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
*
* `compact` is for the dock only. At 22rem the ordinary spacing does not fail,
* it just crowds: the assistant avatar takes a tenth of the line, a user
* bubble at 88% leaves no gutter to read the alignment from, and the
* suggestion buttons wrap to three lines each.
*/
export function PiggyChatPanel({
context,
initialPrompt = '',
className,
compact = false,
}: {
context?: PiggyChatContext;
initialPrompt?: string;
className?: string;
compact?: boolean;
}) {
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [draft, setDraft] = useState(initialPrompt);
@@ -211,31 +231,31 @@ function PiggyChatPanel({
return (
<div className={cn('flex min-h-0 flex-col', className)}>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
<div className={cn('min-h-0 flex-1 overflow-y-auto py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
{messages.length === 0 ? (
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div>
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
<p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
<div className="mt-4 grid w-full gap-2">
{(context
{(context && context.type !== 'page'
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
).map((suggestion) => (
<button key={suggestion} type="button" className="min-h-11 rounded-lg border border-border px-3 text-left text-sm hover:bg-surface-2" onClick={() => setDraft(suggestion)}>{suggestion}</button>
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => setDraft(suggestion)}>{suggestion}</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
{messages.map((message) => <ChatMessage key={message.id} message={message} compact={compact} />)}
<div ref={bottomRef} />
</div>
)}
</div>
<form className="border-t border-border bg-surface p-3 sm:p-4" onSubmit={(event) => { event.preventDefault(); void send(); }}>
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {context.label ?? context.type.replaceAll('_', ' ')}</Badge> : null}
<form className={cn('border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); void send(); }}>
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
<div className="flex items-end gap-2">
<Textarea
value={draft}
@@ -256,19 +276,27 @@ function PiggyChatPanel({
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
)}
</div>
<p className="mt-2 text-center text-[11px] text-muted">Read-only session · Check source records before acting on material terms.</p>
<p className="mt-2 text-center text-[11px] leading-4 text-muted">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
</form>
</div>
);
}
function ChatMessage({ message }: { message: TranscriptMessage }) {
/** A page context has no id and its `type` is the literal 'page', which reads
* as nothing useful in a badge — show the route the dock is following. */
function contextLabel(context: PiggyChatContext): string {
if (context.label) return context.label;
if (context.type === 'page') return context.route === '/' ? 'Overview' : context.route.slice(1);
return context.type.replaceAll('_', ' ');
}
function ChatMessage({ message, compact = false }: { message: TranscriptMessage; compact?: boolean }) {
if (message.role === 'user') {
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
return <div className={cn('ml-auto rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'max-w-[94%] px-3' : 'max-w-[88%] px-4')}><p className="whitespace-pre-wrap">{message.content}</p></div>;
}
return (
<div className="flex gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg"><Bot aria-hidden /></div>
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
<div className="min-w-0 flex-1">
{message.reasoning ? (
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
@@ -312,21 +340,16 @@ function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): Transcri
return message;
}
function usePiggyStatus() {
/**
* The availability gate. Exported because anything that renders a chat surface
* — the workspace page, the ask button, the dock — has to check it first:
* `/api/piggy/chat` answers 503 when the runtime is off, and a panel that
* renders without asking shows a composer that cannot send.
*/
export function usePiggyStatus() {
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
}
function useDesktop(): boolean {
const [desktop, setDesktop] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches);
useEffect(() => {
const media = window.matchMedia('(min-width: 768px)');
const update = () => setDesktop(media.matches);
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
return desktop;
}
function toolLabel(name: string): string {
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Piggy, docked.
*
* The third pane. It is a column rather than a sheet because the point of
* docking an agent is that you can read the page and the answer at the same
* time — a sheet that covers the thing you are asking about defeats it.
*
* Three surfaces, one panel:
*
* ≥ xl — this permanent column, remembered between sessions.
* ≥ lg — the existing right-hand Sheet, because 1024px minus a sidebar
* minus a 22rem dock leaves the page narrower than a phone.
* < lg — the existing bottom Drawer.
*
* The status gate is not optional. `/api/piggy/chat` answers 503 when the
* runtime is disabled, so a dock that renders its composer without asking
* first is a permanent third of the window that fails on first use.
*/
import { useState } from 'react';
import { PanelRightClose, Sparkles } from 'lucide-react';
import { useHasDockRoom } from '@/hooks/use-media-query';
import { useLayout } from '@/lib/layout';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
import { PiggyMark } from './PiggyMark';
import { Button, EmptyState, Skeleton, cn } from './ui';
export function PiggyDock() {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
if (!hasRoom || !dockOpen) return null;
return (
<aside
// `xl:flex` as well as the hook: the media query and the class agree, so
// there is no frame where the column exists at the wrong width.
className={cn(
'hidden w-[--dock-width] shrink-0 flex-col overflow-hidden border-l border-border bg-surface xl:flex',
'pr-[var(--safe-right)]',
)}
style={{
position: 'sticky',
top: 'var(--app-header-h)',
height: 'calc(100dvh - var(--app-header-h))',
}}
aria-label="Piggy"
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
<PiggyMark className="size-5 shrink-0 text-accent-fg" />
<span className="min-w-0 truncate text-sm font-semibold">Piggy</span>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto size-9 min-h-0 min-w-0 text-muted"
aria-label="Close the Piggy panel"
onClick={() => setDockOpen(false)}
>
<PanelRightClose className="size-4" aria-hidden />
</Button>
</div>
{status.isLoading ? (
<div className="flex flex-col gap-3 p-3">
<Skeleton className="h-20 rounded-xl" />
<Skeleton className="h-12 rounded-xl" />
</div>
) : !status.data?.canUse ? (
<EmptyState
icon={<Sparkles />}
title="Piggy is unavailable"
description={
status.data?.enabled
? 'This credential does not have read access.'
: 'An administrator must enable the isolated Piggy runtime.'
}
/>
) : (
// Remounted per RECORD, so the transcript never carries an answer
// about one row into a conversation about another. Page contexts all
// share one key: they change on every navigation, and remounting there
// threw away the transcript, the composer draft and any in-flight
// stream (PiggyChat aborts on unmount) — which is the whole point of a
// pane that stays put while you move around the app. The panel reads
// `context` at send time, so the page it is asking about still tracks
// the route without a remount.
<PiggyChatPanel
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
context={context}
compact
className="min-h-0 flex-1"
/>
)}
</aside>
);
}
/**
* The header control for Piggy.
*
* Below `xl` there is no column to toggle, so the same button opens the sheet
* or drawer instead — one affordance in one place, whatever the viewport can
* accommodate.
*/
export function PiggyDockToggle({ className }: { className?: string }) {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const [overlayOpen, setOverlayOpen] = useState(false);
const unavailable = status.data && !status.data.canUse;
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className={cn('text-muted', dockOpen && hasRoom && 'bg-accent-subtle text-accent-fg', className)}
disabled={Boolean(unavailable)}
aria-pressed={hasRoom ? dockOpen : undefined}
aria-label={
unavailable
? 'Piggy is unavailable'
: hasRoom
? dockOpen
? 'Close the Piggy panel'
: 'Open the Piggy panel'
: 'Ask Piggy'
}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : 'Piggy'}
onClick={() => (hasRoom ? setDockOpen(!dockOpen) : setOverlayOpen(true))}
>
<PiggyMark className="size-5" />
</Button>
{hasRoom ? null : (
<ResponsivePiggyChat open={overlayOpen} onOpenChange={setOverlayOpen} context={context} />
)}
</>
);
}
+119 -200
View File
@@ -1,216 +1,135 @@
/**
* The application shell.
*
* Two navigation treatments rather than one responsive compromise:
* Three panes on a desktop, and on a phone the same thing the phone always
* had:
*
* Phone — a bottom tab bar, because the top of a large phone is out of
* thumb reach, and iOS users expect primary navigation there.
* Desktop — a persistent sidebar, because the horizontal room exists and
* hiding navigation behind a hamburger on a 27-inch display wastes
* it.
* Header — full width above everything, carrying the logo, where you are,
* the search field and Piggy. Full width rather than inset between
* the panes so both panes have one fixed edge to hang beneath, and
* so the sticky offset is a single CSS variable rather than a
* number repeated in three components.
* Left — navigation, collapsing to a 60px icon rail. Sticky in the flex
* row rather than `fixed` with a matching padding on the content:
* a padding that has to be kept in step with a width is exactly
* the pair that drifts, and the flex row makes the compiler's job
* the browser's job.
* Right — Piggy, docked from `xl` up. Below that it is the sheet or the
* drawer it has always been.
* Phone — the bottom tab bar, unchanged, because the top of a large phone
* is out of thumb reach. The full navigation is additionally
* reachable through the sidebar's Sheet, from the header trigger.
*
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
* it has the width, and the bottom bar looks lost across a tablet.
* `lg` is still the breakpoint at which the tab bar gives way to the sidebar
* an iPad in portrait has the width, and a bottom bar looks lost across a
* tablet. It is now declared once, in hooks/use-media-query.
*/
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import {
Boxes,
Building2,
FileText,
FileSpreadsheet,
LayoutDashboard,
Server,
Search,
MessageCircleMore,
ShieldCheck,
Settings,
TrendingUp,
Target,
Users,
} from 'lucide-react';
import { PiggyLogo, PiggyMark } from './PiggyMark';
import { CommandPalette, type CommandDestination } from './CommandPalette';
import { Button, cn } from './ui';
import { Outlet } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { useLayout } from '@/lib/layout';
import { visibleNav, type NavItem } from '@/lib/nav';
import { AppHeader } from './AppHeader';
import { AppSidebar } from './AppSidebar';
import { PiggyDock } from './PiggyDock';
import { SidebarInset, SidebarProvider } from './ui/sidebar';
import { cn } from './ui';
interface NavItem extends CommandDestination {
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
primary?: boolean;
group: 'Intelligence' | 'Marketplace' | 'Records' | 'Control';
}
const NAV: NavItem[] = [
{ to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true },
{ to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' },
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
{ to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
{ to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
{ to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records' },
{ to: '/team', label: 'Team', icon: Users, group: 'Control' },
{ to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control' },
{ to: '/settings', label: 'Settings', icon: Settings, group: 'Control' },
];
const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
/** How much room Piggy takes when docked. Read by the dock and by nothing else. */
const DOCK_WIDTH = '22rem';
export function Shell() {
const location = useLocation();
const [commandOpen, setCommandOpen] = useState(false);
const current = NAV.find((item) =>
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
setCommandOpen((open) => !open);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const identity = useIdentity();
const { sidebarOpen, setSidebarOpen, dockOpen } = useLayout();
const items = visibleNav(identity);
return (
<div className="app-canvas min-h-dvh bg-bg">
{/* ------------------------------------------------- desktop sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface/95 backdrop-blur-xl lg:flex',
// Respect the safe area on notched displays in landscape.
'pl-[var(--safe-left)]',
)}
>
<div className="flex h-16 items-center border-b border-border/70 px-5">
<PiggyLogo />
</div>
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="Workspace">
{NAV_GROUPS.map((group) => (
<div key={group} className="mb-3 last:mb-0">
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80">
{group}
</p>
<div className="flex flex-col gap-0.5">
{NAV.filter((item) => item.group === group).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'group flex min-h-[44px] items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-[background-color,color,transform]',
isActive
? 'bg-accent-subtle text-accent-fg shadow-sm'
: 'text-muted hover:bg-surface-2 hover:text-fg active:translate-x-0.5',
)
}
>
<item.icon className="size-4 shrink-0" aria-hidden />
{item.label}
</NavLink>
))}
</div>
</div>
))}
</nav>
<Button
type="button"
variant="ghost"
size="sm"
className="mx-3 mb-3 min-h-[44px] justify-start text-muted"
onClick={() => setCommandOpen(true)}
<SidebarProvider
open={sidebarOpen}
onOpenChange={setSidebarOpen}
className="app-canvas flex-col bg-bg"
style={
{
// Both side panes stick beneath the header and subtract it from the
// viewport. One variable, so collapsing or resizing anything is a
// CSS relayout and never a measurement in JavaScript.
'--sidebar-offset-top': 'var(--app-header-h)',
'--dock-width': DOCK_WIDTH,
} as React.CSSProperties
}
>
<AppHeader />
<div className="flex w-full min-w-0 flex-1">
<AppSidebar />
<SidebarInset
// Clears the tab bar and the home indicator beneath it. Without this
// the last row of any list is unreachable on a phone. Four pages set
// their own `md:pb-0` on top of this; keeping `lg` here means they
// still have their padding between md and lg, where the tab bar is
// very much still on screen.
className="pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0"
>
<Search className="h-4 w-4" aria-hidden />
Search
<kbd className="ml-auto rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
K
</kbd>
</Button>
<div className="border-t border-border px-5 py-3">
<p className="text-xs font-medium text-fg">Prime Intellect Growth</p>
<p className="mt-0.5 text-[11px] text-muted">Compute revenue system</p>
</div>
</aside>
<div
className={cn(
'mx-auto w-full min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-8',
// With Piggy docked the middle pane is already a column in a
// three-column layout; capping it at 7xl and centring it again
// strands the content between two gutters it does not need.
dockOpen ? 'max-w-[86rem]' : 'max-w-7xl',
)}
>
<Outlet />
</div>
</SidebarInset>
{/* ---------------------------------------------------- mobile header */}
<header
className={cn(
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
// A translucent bar with a blur reads as native on iOS; the opaque
// fallback keeps text legible where backdrop-filter is unsupported.
'bg-surface/90 px-4 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75 lg:hidden',
'pt-[var(--safe-top)]',
)}
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
>
<PiggyMark className="h-6 w-6 text-accent-fg" />
<span className="font-semibold lowercase tracking-tight">
{current?.label ?? 'pig'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setCommandOpen(true)}
aria-label="Search and navigate"
>
<Search className="h-5 w-5" aria-hidden />
</Button>
</header>
<PiggyDock />
</div>
{/* ---------------------------------------------------------- content */}
<main
className={cn(
'lg:pl-60',
// Bottom padding clears the tab bar and the home indicator beneath
// it. Without this the last row of any list is unreachable.
'pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0',
)}
>
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<Outlet />
</div>
</main>
{/* ------------------------------------------------- mobile tab bar */}
<nav
className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
'supports-[backdrop-filter]:bg-surface/80',
)}
style={{ paddingBottom: 'var(--safe-bottom)' }}
aria-label="Primary"
>
<div className="mx-auto flex max-w-lg items-stretch justify-around">
{NAV.filter((item) => item.primary).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className="tap flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
>
{({ isActive }) => (
<>
<span
className={cn(
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
<span className={isActive ? 'text-accent-fg' : undefined}>{item.label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
<CommandPalette destinations={NAV} open={commandOpen} onOpenChange={setCommandOpen} />
</div>
<MobileTabBar items={items.filter((item) => item.primary)} />
</SidebarProvider>
);
}
/**
* The phone tab bar. Unchanged in look and behaviour — it is the thing this
* product is best at and the rebuild had no business touching it.
*/
function MobileTabBar({ items }: { items: NavItem[] }) {
return (
<nav
className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
'supports-[backdrop-filter]:bg-surface/80',
)}
style={{ paddingBottom: 'var(--safe-bottom)' }}
aria-label="Primary"
>
<div className="mx-auto flex max-w-lg items-stretch justify-around">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
>
{({ isActive }) => (
<>
<span
className={cn(
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
<span className={cn('truncate', isActive && 'text-accent-fg')}>{item.label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
);
}
+100
View File
@@ -0,0 +1,100 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & { separator?: React.ReactNode }
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
),
);
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & { asChild?: boolean }
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-medium text-foreground', className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = 'BreadcrumbPage';
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
return (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+29 -54
View File
@@ -1,57 +1,32 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
/**
* shadcn's import path for the button.
*
* There is only one Button in PIG now — see the note in `./index`. This module
* exists so the shadcn compositions written against `@/components/ui/button`
* keep working unchanged, and it supplies the one thing they genuinely need
* that the PIG default does not: a bare `<Button>` here means a solid brand
* fill (shadcn's `default`), whereas a bare `<Button>` from `./index` means the
* quiet secondary. Changing either default silently restyles the other's call
* sites, which is why the shim is a default rather than a second component.
*
* `[&_svg]:size-4` likewise preserves shadcn's icon sizing for these call
* sites without imposing it on every PIG button in the app.
*/
import { forwardRef } from 'react';
import { cn } from "@/lib/utils"
import { Button as BaseButton, buttonVariants, cn, type ButtonProps } from './index';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', size = 'default', className, ...props }, ref) => (
<BaseButton
ref={ref}
variant={variant}
size={size}
className={cn('[&_svg]:size-4', className)}
{...props}
/>
),
);
Button.displayName = 'Button';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button, buttonVariants, type ButtonProps };
+11 -2
View File
@@ -21,10 +21,19 @@ const Command = React.forwardRef<
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
const CommandDialog = ({
children,
contentProps,
...props
}: DialogProps & {
contentProps?: React.ComponentPropsWithoutRef<typeof DialogContent>
}) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<DialogContent
{...contentProps}
className={cn("overflow-hidden p-0", contentProps?.className)}
>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
+39 -4
View File
@@ -10,6 +10,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import {
forwardRef,
type ButtonHTMLAttributes,
@@ -24,9 +25,25 @@ export function cn(...inputs: ClassValue[]): string {
// ------------------------------------------------------------------- button
/**
* One button, two vocabularies.
*
* There used to be two Button *components* — this one and a verbatim shadcn
* copy at `@/components/ui/button` with a different variant vocabulary
* (`default`/`destructive`/`link`) and a 36px size scale that fails PIG's own
* 44px touch-target rule. Two implementations of the same control drift, and
* these two already had: one grew a `danger` variant, the other a `link`.
*
* They are now a single cva. Both vocabularies are declared here as aliases of
* the same classes, so `variant="primary"` and `variant="default"` are the
* same button, and `@/components/ui/button` is a re-export that only supplies
* shadcn's different *default* variant. The remaining work is to retire the
* shadcn names at the three call sites that use them and delete the shim.
*/
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
'[&_svg]:shrink-0 ' +
// touch-manipulation removes the 300ms tap delay that older mobile Safari
// applies while waiting to see whether a tap is a double-tap zoom.
'touch-manipulation select-none whitespace-nowrap',
@@ -38,6 +55,10 @@ const buttonVariants = cva(
outline: 'border border-border bg-transparent hover:bg-surface-2',
ghost: 'bg-transparent hover:bg-surface-2',
danger: 'bg-danger text-white hover:opacity-90',
/* shadcn's vocabulary, mapped onto the same three treatments. */
default: 'bg-primary text-primary-foreground shadow-sm hover:opacity-90 active:opacity-80',
destructive: 'bg-danger text-white hover:opacity-90',
link: 'bg-transparent text-accent-fg underline-offset-4 hover:underline',
},
size: {
// min-h keeps the target tappable even when the label is short.
@@ -45,20 +66,34 @@ const buttonVariants = cva(
md: 'h-11 min-h-[44px] px-4',
lg: 'h-12 min-h-[48px] px-6 text-base',
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
/* shadcn's `default` size. Deliberately PIG's height, not 36px. */
default: 'h-11 min-h-[44px] px-4',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
},
);
export { buttonVariants };
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
VariantProps<typeof buttonVariants> {
/** Render the child element instead of a `<button>`, keeping the classes. */
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
),
({ className, variant, size, asChild = false, ...props }, ref) => {
const Component = asChild ? Slot : 'button';
return (
<Component
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = 'Button';
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* The compact, desktop-density input the shadcn compositions are written
* against — deliberately NOT the same component as `Input` from
* `@/components/ui`, which is 44px because it is used on phone forms.
*
* Use this one only where the control is desktop-only (the header search
* field, a sidebar filter). Anything that can be touched wants the 44px one.
* The base stylesheet still forces a 16px font size here, so Safari does not
* zoom the viewport if one ever does end up on a phone.
*/
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 shadow-sm transition-colors',
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };
+530
View File
@@ -0,0 +1,530 @@
/**
* The sidebar primitive.
*
* shadcn's `sidebar` block, with its API kept intact and two deliberate
* changes to its internals:
*
* 1. The desktop pane is `sticky`, not `fixed`. Upstream renders an
* invisible width-holding div next to a `fixed inset-y-0` pane so the
* pane can slide fully off-canvas. PIG only ever wants the icon rail, and
* a sticky pane in a flex row gets the same collapse animation from one
* element instead of two — and, unlike `inset-y-0`, it can start below a
* full-width application header. That header is the whole point of the
* layout, so the fixed variant was not usable as shipped.
* 2. Every control clears 44px, and the icon rail is 64px rather than
* shadcn's 48px so that a 44px button still has gutters. A 32px icon
* button is the one thing in the upstream block that fails PIG's own
* touch-target rule, and the rail is reachable on a tablet.
*
* Colours come from `--sidebar-*` in index.css, which alias the existing
* surface and accent variables rather than introducing a second palette — so
* the sidebar re-tints with the user's chosen accent and needs no dark-mode
* pass of its own.
*
* `collapsible="offcanvas"`, the `floating` and `inset` variants and the
* submenu parts are not implemented, because nothing here uses them and an
* unexercised variant is a variant that is quietly broken.
*/
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { PanelLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { Button } from './index';
import { Separator } from './separator';
import { Skeleton } from './skeleton';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
const SIDEBAR_COOKIE_NAME = 'pig_sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
/**
* 64px, not shadcn's 48px. A menu button collapses to a 44px square — PIG's
* touch minimum — and the group padding around it is 8px a side, so 48px
* would clip it against the border.
*/
export const SIDEBAR_WIDTH_ICON = '4rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
interface SidebarContextValue {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
}
const SidebarContext = React.createContext<SidebarContextValue | null>(null);
export function useSidebar(): SidebarContextValue {
const context = React.useContext(SidebarContext);
if (!context) throw new Error('useSidebar must be used within a SidebarProvider.');
return context;
}
export const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = openProp ?? internalOpen;
const setOpen = React.useCallback(
(value: boolean) => {
if (setOpenProp) setOpenProp(value);
else setInternalOpen(value);
// A cookie as well as whatever the caller persists: it is the only
// store the document can read before React has mounted, so a future
// server-rendered or inlined first paint has the width already.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; samesite=lax`;
},
[setOpenProp],
);
const toggleSidebar = React.useCallback(() => {
if (isMobile) setOpenMobile((current) => !current);
else setOpen(!open);
}, [isMobile, open, setOpen]);
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== SIDEBAR_KEYBOARD_SHORTCUT) return;
if (!event.metaKey && !event.ctrlKey) return;
event.preventDefault();
toggleSidebar();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [toggleSidebar]);
const value = React.useMemo<SidebarContextValue>(
() => ({
state: open ? 'expanded' : 'collapsed',
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={value}>
<TooltipProvider delayDuration={0}>
<div
ref={ref}
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn('group/sidebar-wrapper flex min-h-dvh w-full', className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = 'SidebarProvider';
export const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
side?: 'left' | 'right';
collapsible?: 'icon' | 'none';
}
>(({ side = 'left', collapsible = 'icon', className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
ref={ref}
className={cn(
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
side={side}
// The Sheet's own close button is suppressed: the sidebar header
// carries one that does not overlap the account switcher.
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden sm:max-w-[--sidebar-width]"
style={{ '--sidebar-width': SIDEBAR_WIDTH_MOBILE } as React.CSSProperties}
>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Move between the PIG workspaces.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col pb-[var(--safe-bottom)] pt-[var(--safe-top)]">
{children}
</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className={cn(
'group relative hidden shrink-0 self-start overflow-hidden bg-sidebar text-sidebar-foreground lg:flex lg:flex-col',
side === 'left' ? 'border-r border-sidebar-border' : 'border-l border-sidebar-border',
// The whole collapse animation is this one declaration. Width is
// driven by data-state, so nothing measures anything in JavaScript.
'transition-[width] duration-200 ease-linear',
'w-[calc(var(--sidebar-width)+var(--safe-left))] pl-[var(--safe-left)]',
'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+var(--safe-left))]',
className,
)}
style={{
position: 'sticky',
top: 'var(--sidebar-offset-top, 0px)',
height: 'calc(100dvh - var(--sidebar-offset-top, 0px))',
}}
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-side={side}
{...props}
>
{children}
</div>
);
});
Sidebar.displayName = 'Sidebar';
export const SidebarTrigger = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar, state, isMobile } = useSidebar();
return (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
className={cn('shrink-0 text-muted', className)}
aria-label={
isMobile ? 'Open navigation' : state === 'expanded' ? 'Collapse sidebar' : 'Expand sidebar'
}
aria-expanded={isMobile ? undefined : state === 'expanded'}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft className="size-5" aria-hidden />
</Button>
);
});
SidebarTrigger.displayName = 'SidebarTrigger';
/**
* The hit strip along the sidebar's outer edge.
*
* Wide enough to hit with a mouse without being a visible control, which is
* how every editor-style sidebar behaves and how people expect to collapse one
* without hunting for the button.
*/
export const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>(
({ className, ...props }, ref) => {
const { toggleSidebar, state } = useSidebar();
return (
<button
ref={ref}
type="button"
tabIndex={-1}
aria-hidden
onClick={toggleSidebar}
title={state === 'expanded' ? 'Collapse sidebar' : 'Expand sidebar'}
className={cn(
'absolute inset-y-0 right-0 z-20 hidden w-3 cursor-w-resize transition-colors lg:block',
'after:absolute after:inset-y-0 after:right-0 after:w-[2px] hover:after:bg-sidebar-border',
'group-data-[state=collapsed]:cursor-e-resize',
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = 'SidebarRail';
export const SidebarInset = React.forwardRef<HTMLElement, React.ComponentProps<'main'>>(
({ className, ...props }, ref) => (
// min-w-0 is not optional: this is a flex child holding tables and
// tabular-nums figures, and without it the page scrolls sideways.
<main ref={ref} className={cn('relative flex min-w-0 flex-1 flex-col', className)} {...props} />
),
);
SidebarInset.displayName = 'SidebarInset';
export const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
),
);
SidebarHeader.displayName = 'SidebarHeader';
export const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="footer"
className={cn('mt-auto flex flex-col gap-2 p-2', className)}
{...props}
/>
),
);
SidebarFooter.displayName = 'SidebarFooter';
export const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden',
// A scrollbar inside a 64px rail eats a third of it, and the rail has
// nothing that needs scrolling anyway.
'group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
),
);
SidebarContent.displayName = 'SidebarContent';
export const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
),
);
SidebarGroup.displayName = 'SidebarGroup';
export const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80',
'transition-[margin,opacity] duration-200 ease-linear',
// Pulled up rather than hidden, so the icons above and below do not
// jump as the label fades out.
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
export const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn('w-full', className)} {...props} />
),
);
SidebarGroupContent.displayName = 'SidebarGroupContent';
export const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0.5', className)}
{...props}
/>
),
);
SidebarMenu.displayName = 'SidebarMenu';
export const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
),
);
SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full min-h-[44px] items-center gap-3 overflow-hidden rounded-xl px-3 text-left text-sm font-medium outline-none ' +
'transition-[background-color,color,width,padding] duration-200 ' +
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ' +
'focus-visible:ring-2 focus-visible:ring-sidebar-ring ' +
'disabled:pointer-events-none disabled:opacity-50 ' +
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[active=true]:shadow-sm ' +
// Collapsed: a square 44px target centred in the 64px rail. The label is
// still in the DOM for screen readers; `overflow-hidden` on the pane and
// `truncate` here keep it from reflowing during the animation.
'group-data-[collapsible=icon]:!size-11 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:!px-0 ' +
// `sr-only`, not `hidden`. The label is the button's accessible name, and
// removing it from the tree leaves an icon-only control that a screen
// reader announces as "button" — the tooltip is a hover affordance and
// does not name anything. sr-only takes no layout space, so the icon
// still centres in the rail.
'group-data-[collapsible=icon]:[&>span:last-child]:sr-only ' +
'[&>svg]:size-4 [&>svg]:shrink-0 [&>span]:min-w-0 [&>span]:truncate',
{
variants: {
variant: {
default: 'text-muted',
outline: 'border border-sidebar-border bg-sidebar text-muted',
},
size: {
default: '',
lg: 'min-h-[52px]',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> &
VariantProps<typeof sidebarMenuButtonVariants> & {
asChild?: boolean;
isActive?: boolean;
/** Shown as a tooltip only while the rail is collapsed. */
tooltip?: string;
}
>(({ asChild = false, isActive = false, variant, size, tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
// No tooltip when the label is already visible: a tooltip repeating the text
// beside it is noise, and on mobile it fires on tap and eats the navigation.
if (!tooltip || state !== 'collapsed' || isMobile) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center">
{tooltip}
</TooltipContent>
</Tooltip>
);
});
SidebarMenuButton.displayName = 'SidebarMenuButton';
export const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
'nums pointer-events-none absolute right-3 top-1/2 h-5 min-w-5 -translate-y-1/2 select-none',
'flex items-center justify-center rounded-full bg-surface-2 px-1.5 text-[11px] font-medium text-muted',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
export function SidebarMenuSkeleton({
className,
showIcon = true,
...props
}: React.ComponentProps<'div'> & { showIcon?: boolean }) {
return (
<div
data-sidebar="menu-skeleton"
className={cn('flex h-11 items-center gap-3 rounded-xl px-3', className)}
{...props}
>
{showIcon ? <Skeleton className="size-4 shrink-0 rounded-md" /> : null}
<Skeleton className="h-4 max-w-[--skeleton-width] flex-1 group-data-[collapsible=icon]:hidden" />
</div>
);
}
export const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => (
<Separator
ref={ref}
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
));
SidebarSeparator.displayName = 'SidebarSeparator';
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-surface-2', className)} {...props} />;
}
export { Skeleton };