mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
4d9617a5c6
DESIGN.md §9.11 codifies one canonical right-side detail-sheet shell (cyan
rail, mono crumb, italic serif name, mono meta, ESC chip + close glyph,
fixed three-slot toolbar, cyan-underline tabs, ScrollArea body, footer
freshness band). Today the 16 sheet consumers each render their own
header chrome with stock shadcn SheetHeader/SheetTitle.
Introduce <SystemSheet> + <SheetSection> in
frontend/src/components/ui/system-sheet.tsx, composing the existing
<Sheet>/<SheetContent> primitive. Add a backward-compatible showClose
prop to SheetContent so SystemSheet can render its own ESC chip + close
glyph instead of the stock cyan square close.
Migrate the four mesh sheets as the first batch:
* MeshActivitySheet: crumb Fleet › Mesh › Activity, footer freshness from
most-recent event timestamp.
* MeshOptInSheet: crumb Fleet › Mesh › {nodeName}, meta of opted-in
count, drops the redundant bottom Close button (ESC chip dismisses).
* MeshDiagnosticsSheet: removes the icon-prefixed title (forbidden by
§9.11), lifts Refresh/Restart buttons from the body into the toolbar
band, three SheetSection blocks for sidecar status, streams, cache.
* MeshRouteDetailSheet: adds Overview/Events/Raw tabs, lifts Test probe
into the toolbar primary slot, footer surfaces last probe latency.
304 lines
9.1 KiB
TypeScript
304 lines
9.1 KiB
TypeScript
import * as React from 'react';
|
||
import { X, type LucideIcon } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { Button } from '@/components/ui/button';
|
||
|
||
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
|
||
const CRUMB_CLASS = `${KICKER_CLASS} text-stat-subtitle`;
|
||
|
||
type SystemSheetSize = 'sm' | 'md' | 'lg' | 'xl';
|
||
|
||
const SIZE_CLASS: Record<SystemSheetSize, string> = {
|
||
sm: 'sm:max-w-[420px]',
|
||
md: 'sm:max-w-[560px]',
|
||
lg: 'sm:max-w-[720px]',
|
||
xl: 'sm:max-w-[960px]',
|
||
};
|
||
|
||
export interface SystemSheetAction {
|
||
label: React.ReactNode;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
icon?: LucideIcon;
|
||
}
|
||
|
||
export interface SystemSheetTab {
|
||
id: string;
|
||
label: string;
|
||
count?: number;
|
||
}
|
||
|
||
export interface SystemSheetProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
|
||
// Header
|
||
crumb: string[];
|
||
name: React.ReactNode;
|
||
meta?: React.ReactNode;
|
||
|
||
// Toolbar band (omit entirely when no actions provided)
|
||
primaryAction?: SystemSheetAction;
|
||
secondaryActions?: SystemSheetAction[];
|
||
destructiveAction?: SystemSheetAction;
|
||
|
||
// Tabs band (omit entirely when undefined)
|
||
tabs?: SystemSheetTab[];
|
||
activeTab?: string;
|
||
onTabChange?: (id: string) => void;
|
||
|
||
// Footer freshness band (omit entirely when undefined)
|
||
footerContext?: React.ReactNode;
|
||
|
||
size?: SystemSheetSize;
|
||
children?: React.ReactNode;
|
||
}
|
||
|
||
export function SystemSheet({
|
||
open,
|
||
onOpenChange,
|
||
crumb,
|
||
name,
|
||
meta,
|
||
primaryAction,
|
||
secondaryActions,
|
||
destructiveAction,
|
||
tabs,
|
||
activeTab,
|
||
onTabChange,
|
||
footerContext,
|
||
size = 'md',
|
||
children,
|
||
}: SystemSheetProps) {
|
||
const hasToolbar = !!(primaryAction || (secondaryActions && secondaryActions.length > 0) || destructiveAction);
|
||
const hasTabs = !!(tabs && tabs.length > 0);
|
||
const hasFooter = footerContext !== undefined && footerContext !== null;
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
showClose={false}
|
||
className={cn(
|
||
'w-full p-0 flex flex-col gap-0 border-glass-border',
|
||
SIZE_CLASS[size],
|
||
)}
|
||
>
|
||
<SheetDescription className="sr-only">{crumb.join(' › ')}</SheetDescription>
|
||
|
||
<SheetHeaderBand
|
||
crumb={crumb}
|
||
name={name}
|
||
meta={meta}
|
||
onDismiss={() => onOpenChange(false)}
|
||
/>
|
||
|
||
{hasToolbar && (
|
||
<ToolbarBand
|
||
primary={primaryAction}
|
||
secondaries={secondaryActions}
|
||
destructive={destructiveAction}
|
||
/>
|
||
)}
|
||
|
||
{hasTabs && (
|
||
<TabsBand
|
||
tabs={tabs}
|
||
activeTab={activeTab ?? tabs[0].id}
|
||
onTabChange={onTabChange}
|
||
/>
|
||
)}
|
||
|
||
<ScrollArea className="flex-1">
|
||
<div className="px-6 py-5">{children}</div>
|
||
</ScrollArea>
|
||
|
||
{hasFooter && <FooterBand context={footerContext} />}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
interface SheetHeaderBandProps {
|
||
crumb: string[];
|
||
name: React.ReactNode;
|
||
meta?: React.ReactNode;
|
||
onDismiss: () => void;
|
||
}
|
||
|
||
function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps) {
|
||
const lastIdx = crumb.length - 1;
|
||
return (
|
||
<div className="relative bg-popover/95 backdrop-blur-md border-b border-card-border/60 px-6 pt-5 pb-4 pr-14">
|
||
<span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" />
|
||
|
||
<nav aria-label="Sheet location" className={cn(CRUMB_CLASS, 'flex items-center gap-1.5 leading-none')}>
|
||
{crumb.map((segment, idx) => {
|
||
const isFirst = idx === 0;
|
||
const isLast = idx === lastIdx;
|
||
return (
|
||
<React.Fragment key={`${segment}-${idx}`}>
|
||
{isFirst ? (
|
||
<button
|
||
type="button"
|
||
onClick={onDismiss}
|
||
className="hover:text-stat-value transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50 rounded-sm"
|
||
>
|
||
{segment}
|
||
</button>
|
||
) : (
|
||
<span className={cn(isLast && 'text-stat-value')}>{segment}</span>
|
||
)}
|
||
{!isLast && <span aria-hidden className="text-stat-subtitle/60">›</span>}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<SheetTitle className="mt-2 font-display italic text-[1.5rem] leading-tight text-stat-value text-left">
|
||
{name}
|
||
</SheetTitle>
|
||
|
||
{meta && (
|
||
<div className="mt-1.5 font-mono text-xs text-stat-subtitle tabular-nums">{meta}</div>
|
||
)}
|
||
|
||
<CloseSlot onDismiss={onDismiss} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CloseSlot({ onDismiss }: { onDismiss: () => void }) {
|
||
return (
|
||
<div className="absolute right-4 top-4 flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden
|
||
className="font-mono text-[9px] tracking-[0.18em] uppercase border border-card-border rounded px-1.5 py-0.5 text-stat-subtitle leading-none"
|
||
>
|
||
ESC
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onDismiss}
|
||
aria-label="Close"
|
||
className="inline-flex items-center justify-center h-6 w-6 rounded-sm text-stat-subtitle hover:text-stat-value hover:bg-glass-highlight transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
|
||
>
|
||
<X className="h-4 w-4" strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ToolbarBandProps {
|
||
primary?: SystemSheetAction;
|
||
secondaries?: SystemSheetAction[];
|
||
destructive?: SystemSheetAction;
|
||
}
|
||
|
||
function ToolbarBand({ primary, secondaries, destructive }: ToolbarBandProps) {
|
||
return (
|
||
<div className="bg-popover/95 backdrop-blur-md flex items-center gap-2 border-b border-card-border/60 px-6 py-3">
|
||
{primary && (
|
||
<Button size="sm" onClick={primary.onClick} disabled={primary.disabled} className="gap-1.5">
|
||
{primary.icon && <primary.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||
{primary.label}
|
||
</Button>
|
||
)}
|
||
{secondaries?.map((action, idx) => (
|
||
<Button
|
||
key={idx}
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={action.onClick}
|
||
disabled={action.disabled}
|
||
className="gap-1.5"
|
||
>
|
||
{action.icon && <action.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||
{action.label}
|
||
</Button>
|
||
))}
|
||
<span className="flex-1" />
|
||
{destructive && (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={destructive.onClick}
|
||
disabled={destructive.disabled}
|
||
className="gap-1.5 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||
>
|
||
{destructive.icon && <destructive.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||
{destructive.label}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface TabsBandProps {
|
||
tabs: SystemSheetTab[];
|
||
activeTab: string;
|
||
onTabChange?: (id: string) => void;
|
||
}
|
||
|
||
function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
|
||
return (
|
||
<div role="tablist" className="bg-popover/95 backdrop-blur-md flex items-stretch gap-0 border-b border-card-border/60 px-4">
|
||
{tabs.map((tab) => {
|
||
const isActive = tab.id === activeTab;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
onClick={() => onTabChange?.(tab.id)}
|
||
className={cn(
|
||
'relative inline-flex items-center gap-1.5 px-3 py-2.5',
|
||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50',
|
||
isActive ? 'text-stat-value' : 'text-stat-subtitle hover:text-stat-value',
|
||
)}
|
||
>
|
||
{tab.label}
|
||
{tab.count !== undefined && (
|
||
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">{tab.count}</span>
|
||
)}
|
||
{isActive && (
|
||
<span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-[2px] bg-brand" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FooterBand({ context }: { context: React.ReactNode }) {
|
||
return (
|
||
<div className="bg-popover/95 backdrop-blur-md border-t border-card-border/60 px-6 py-3">
|
||
<div className={cn(KICKER_CLASS, 'text-stat-subtitle leading-none')}>{context}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export interface SheetSectionProps {
|
||
title: string;
|
||
/** Hide the title rule + label (useful when a section is the only one in a tab). */
|
||
hideHeader?: boolean;
|
||
className?: string;
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) {
|
||
return (
|
||
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
|
||
{!hideHeader && (
|
||
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle mb-3 leading-none')}>{title}</h3>
|
||
)}
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|