Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit. The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
This commit is contained in:
@@ -198,7 +198,7 @@ export function AllocationSheet({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaults(preferredCommitmentId, defaultGpuHours),
|
||||
});
|
||||
const { data: availability, isLoading: availabilityLoading } = useQuery({
|
||||
const { data: availability, isLoading: availabilityLoading, error: availabilityError } = useQuery({
|
||||
queryKey: ['availability'],
|
||||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||
enabled: open,
|
||||
@@ -208,7 +208,7 @@ export function AllocationSheet({
|
||||
queryFn: () => get<CommitmentRow[]>('/api/commitments'),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: demand } = useQuery({
|
||||
const { data: demand, isLoading: demandLoading, error: demandError } = useQuery({
|
||||
queryKey: ['/api/deals/demand'],
|
||||
queryFn: () => get<DemandBoard>('/api/deals/demand'),
|
||||
enabled: open,
|
||||
@@ -309,7 +309,7 @@ export function AllocationSheet({
|
||||
description:
|
||||
values.kind === 'hold'
|
||||
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
|
||||
: `${hours} GPU-hours committed. Margin and utilisation have been updated.`,
|
||||
: `${hours} GPU-hours committed. Margin and sold-capacity reporting have been updated.`,
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -344,7 +344,7 @@ export function AllocationSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<SheetContent className="flex h-full w-full max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
|
||||
<SheetTitle>Reserve capacity</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -358,13 +358,14 @@ export function AllocationSheet({
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={form.handleSubmit((values) => save.mutate(values))}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-5 py-5 sm:px-6">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-5 py-5 sm:gap-6 sm:px-6">
|
||||
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
|
||||
{(['allocation', 'hold'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => form.setValue('kind', value)}
|
||||
aria-pressed={kind === value}
|
||||
className={
|
||||
kind === value
|
||||
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
|
||||
@@ -376,6 +377,8 @@ export function AllocationSheet({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -412,7 +415,7 @@ export function AllocationSheet({
|
||||
<FormLabel>Demand deal</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder="Select the customer deal" /></SelectTrigger>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -496,7 +499,7 @@ export function AllocationSheet({
|
||||
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="shrink-0" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}>
|
||||
<Button type="button" variant="outline" className="w-full shrink-0 sm:w-auto" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}>
|
||||
{release.isPending && release.variables === allocation.id ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <RotateCcw data-icon="inline-start" aria-hidden />}
|
||||
Release
|
||||
</Button>
|
||||
@@ -543,12 +546,12 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
|
||||
<section className="rounded-xl border border-border bg-surface-2 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{row.name}</p>
|
||||
<p className="break-words font-semibold leading-snug">{row.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">{row.gpuCount}× {row.gpuType} · {row.interconnectType} · {row.securityTier.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
|
||||
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours available`}>
|
||||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
@@ -15,6 +17,7 @@ export interface CommandDestination {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
@@ -27,30 +30,39 @@ export function CommandPalette({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Go to a page…" />
|
||||
<CommandList>
|
||||
<CommandInput 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>
|
||||
<CommandGroup heading="Navigate">
|
||||
{destinations.map((destination) => (
|
||||
<CommandItem
|
||||
key={destination.to}
|
||||
value={destination.label}
|
||||
onSelect={() => {
|
||||
navigate(destination.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<destination.icon aria-hidden />
|
||||
<span>{destination.label}</span>
|
||||
{destination.shortcut ? (
|
||||
<CommandShortcut>{destination.shortcut}</CommandShortcut>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group}>
|
||||
{index > 0 ? <CommandSeparator /> : null}
|
||||
<CommandGroup heading={group}>
|
||||
{destinations
|
||||
.filter((destination) => (destination.group ?? 'Navigate') === group)
|
||||
.map((destination) => (
|
||||
<CommandItem
|
||||
key={destination.to}
|
||||
value={`${destination.label} ${group}`}
|
||||
className="min-h-11 rounded-lg"
|
||||
onSelect={() => {
|
||||
navigate(destination.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<destination.icon aria-hidden />
|
||||
<span>{destination.label}</span>
|
||||
{destination.shortcut ? (
|
||||
<CommandShortcut>{destination.shortcut}</CommandShortcut>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
|
||||
@@ -225,7 +225,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3"
|
||||
className="-ml-3 min-h-11"
|
||||
onClick={() => column.toggleSorting(direction === 'asc')}
|
||||
aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`}
|
||||
>
|
||||
|
||||
@@ -102,8 +102,8 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server.</p>
|
||||
<Button variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
|
||||
<p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server and are never returned to this page.</p>
|
||||
<Button className="w-full sm:w-auto" variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
|
||||
{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />}
|
||||
Connect Google
|
||||
</Button>
|
||||
@@ -117,7 +117,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-sm font-medium">Google Sheets connected</p><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'}</p></div>
|
||||
<div><div className="mb-1 flex items-center gap-2"><p className="text-sm font-medium">Google Sheets connected</p><Badge tone="neutral">Read only</Badge></div><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'} · selecting a range only stages a preview</p></div>
|
||||
<Button variant="outline" disabled={disconnect.isPending} onClick={() => disconnect.mutate()}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
|
||||
</div>
|
||||
{disconnect.isError ? <ErrorText error={disconnect.error} /> : null}
|
||||
@@ -131,13 +131,13 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
setPageToken(null);
|
||||
setPreviousTokens([]);
|
||||
}}>
|
||||
<Input value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" />
|
||||
<Input name="spreadsheetSearch" aria-label="Search spreadsheet names" value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" />
|
||||
<Button type="submit" variant="outline"><Search data-icon="inline-start" aria-hidden />Search</Button>
|
||||
</form>
|
||||
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{files.data?.files.map((file) => (
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<button key={file.id} type="button" aria-pressed={spreadsheetId === file.id} onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm font-medium">A1 range
|
||||
<Input value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
|
||||
<Input name="a1Range" value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
|
||||
</label>
|
||||
{metadata.isError ? <div className="sm:col-span-2"><ErrorText error={metadata.error} /></div> : null}
|
||||
{selectedSheet ? <p className="text-xs text-muted sm:col-span-2">Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.</p> : null}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function PiggyChatWorkspace() {
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable Piggy and connect the internal service.'
|
||||
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -216,7 +216,7 @@ function PiggyChatPanel({
|
||||
<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>
|
||||
<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.</p>
|
||||
<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>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
@@ -256,7 +256,7 @@ 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">Check source records before acting on material terms.</p>
|
||||
<p className="mt-2 text-center text-[11px] text-muted">Read-only session · Check source records before acting on material terms.</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LoaderCircle } from 'lucide-react';
|
||||
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Badge, Input } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -306,7 +306,7 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp
|
||||
|
||||
const side = form.watch('side');
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit account' : 'New account'} description="Keep the commercial side explicit. It controls which team can work the record and where its deals belong.">
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Relationship" title={record ? 'Edit account' : 'New account'} description="Keep the commercial side explicit. It controls which team can work the record and where its deals belong.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -395,7 +395,7 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit contact' : 'New contact'} description="Record only what is known. PIG never guesses a real person’s address or employment relationship.">
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Person" title={record ? 'Edit contact' : 'New contact'} description="Record only what is known. PIG never guesses a real person’s address or employment relationship.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -469,7 +469,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit demand deal' : 'New demand deal'} description="Capture the commercial case and paper state. Capacity requirements remain separate so the matcher can reason about the technical shape.">
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Demand · sell-side" title={record ? 'Edit demand deal' : 'New demand deal'} description="Capture the commercial case and paper state. Capacity requirements remain separate so the matcher can reason about the technical shape.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -545,7 +545,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit supply deal' : 'New supply deal'} description="Qualify the capacity and economics independently. A supplier relationship is not interchangeable with a customer opportunity.">
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Supply · buy-side" title={record ? 'Edit supply deal' : 'New supply deal'} description="Qualify the capacity and economics independently. A supplier relationship is not interchangeable with a customer opportunity.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -582,11 +582,12 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
|
||||
);
|
||||
}
|
||||
|
||||
function RecordSheet({ open, onOpenChange, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; title: string; description: string; children: React.ReactNode }) {
|
||||
function RecordSheet({ open, onOpenChange, category, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; category: string; title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-xl">
|
||||
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 pr-14 text-left sm:px-6 sm:pr-14">
|
||||
<Badge className="mb-1 w-fit" tone="neutral">{category}</Badge>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
@@ -598,7 +599,7 @@ function RecordSheet({ open, onOpenChange, title, description, children }: { ope
|
||||
}
|
||||
|
||||
function SheetBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>;
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">{children}</div>;
|
||||
}
|
||||
|
||||
function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) {
|
||||
@@ -622,7 +623,7 @@ function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="flex flex-col gap-4 border-t border-border pt-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null}
|
||||
|
||||
@@ -36,24 +36,27 @@ import { Button, 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, primary: true },
|
||||
{ to: '/growth', label: 'Growth', icon: Target },
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText },
|
||||
{ to: '/imports', label: 'Import', icon: FileSpreadsheet },
|
||||
{ to: '/team', label: 'Team', icon: Users },
|
||||
{ to: '/facts', label: 'Fact review', icon: ShieldCheck },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
{ 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;
|
||||
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
@@ -72,36 +75,45 @@ export function Shell() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg">
|
||||
<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 lg:flex',
|
||||
'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 px-5">
|
||||
<div className="flex h-16 items-center border-b border-border/70 px-5">
|
||||
<PiggyLogo />
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-subtle text-accent-fg'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
<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
|
||||
@@ -117,8 +129,9 @@ export function Shell() {
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="border-t border-border px-5 py-3 text-xs text-muted">
|
||||
Prime Intellect Growth
|
||||
<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>
|
||||
|
||||
@@ -128,7 +141,7 @@ export function Shell() {
|
||||
'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/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden',
|
||||
'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))' }}
|
||||
@@ -166,7 +179,7 @@ export function Shell() {
|
||||
{/* ------------------------------------------------- mobile tab bar */}
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/90 backdrop-blur-md lg:hidden',
|
||||
'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)' }}
|
||||
@@ -178,15 +191,21 @@ export function Shell() {
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'tap flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium',
|
||||
isActive ? 'text-accent-fg' : 'text-muted',
|
||||
)
|
||||
}
|
||||
className="tap flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
|
||||
>
|
||||
<item.icon className="h-5 w-5" aria-hidden />
|
||||
{item.label}
|
||||
{({ 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FactBand, FactStatus } from '@pig/core';
|
||||
import { ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import { Clock3, ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import {
|
||||
@@ -66,6 +66,9 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
const summary = evidenceSummary(fact.evidence);
|
||||
const score = Number(fact.score);
|
||||
const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored';
|
||||
const observedDate = new Date(fact.observedAt);
|
||||
const observedLabel = Number.isNaN(observedDate.getTime()) ? 'Observation date unavailable' : observedDate.toLocaleDateString(undefined, { dateStyle: 'medium' });
|
||||
const sourceHost = sourceUrl ? new URL(sourceUrl).hostname.replace(/^www\./, '') : null;
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}>
|
||||
@@ -74,8 +77,8 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="tap -m-2 inline-flex shrink-0 items-center justify-center rounded-md p-2 text-accent-fg hover:bg-accent-subtle"
|
||||
aria-label={`View evidence for ${fact.field}`}
|
||||
className="tap -my-2 inline-flex size-11 shrink-0 items-center justify-center rounded-md text-accent-fg hover:bg-accent-subtle"
|
||||
aria-label={`View ${fact.band} evidence for ${fact.field}`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
@@ -88,7 +91,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{humanise(fact.field)}
|
||||
Source evidence · {humanise(fact.field)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-sm font-medium">{fact.value}</p>
|
||||
</div>
|
||||
@@ -109,6 +112,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
<Badge tone="neutral">{humanise(fact.status)}</Badge>
|
||||
{fact.method ? <span>via {humanise(fact.method)}</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted"><Clock3 className="size-3.5 shrink-0" aria-hidden /><span>Observed <time dateTime={fact.observedAt}>{observedLabel}</time></span></div>
|
||||
{sourceUrl ? (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
@@ -116,7 +120,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-h-11 items-center gap-2 break-all text-sm font-medium text-accent-fg hover:underline"
|
||||
>
|
||||
Open source
|
||||
Open source{sourceHost ? ` · ${sourceHost}` : ''}
|
||||
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80',
|
||||
primary: 'bg-primary text-primary-foreground shadow-sm hover:opacity-90 active:opacity-80',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
|
||||
outline: 'border border-border bg-transparent hover:bg-surface-2',
|
||||
ghost: 'bg-transparent hover:bg-surface-2',
|
||||
@@ -41,7 +41,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
// min-h keeps the target tappable even when the label is short.
|
||||
sm: 'h-9 min-h-[36px] px-3 text-xs',
|
||||
sm: 'h-11 min-h-[44px] px-3 text-xs',
|
||||
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',
|
||||
@@ -113,10 +113,14 @@ export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivEleme
|
||||
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex items-center gap-3 p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- badge
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
|
||||
Reference in New Issue
Block a user