mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
2563e30424
Three visual adjustments to the Fleet Actions tab: 1. Drop the xl:grid-cols-3 breakpoint. The cards carry live previews and per-node estimate wells that compress badly at 3-up on a 1440 monitor. 2-up is the new max. Pair with auto-rows-fr so every row equalizes to the tallest content height, and let <FleetActionCard> stretch into the cell via flex flex-col h-full plus a flex-1 body. Footer pins to the bottom; the shorter card's body grows whitespace below its content. 2. Reorder the JSX to Prune, Bulk, Stop. Prune sits top-left with its live "~ N GB reclaimable" estimate populated on first render. Stop, which reads "awaiting target" until the operator types, drops to row 2 below the fold. 3. Replace the native <datalist> on the Stop card with a custom LabelAutocomplete: free-form text input + a Sencho-styled popover (border-glass-border, bg-popover, backdrop-blur) that filters the known-label set as the operator types. Click a suggestion via mousedown + preventDefault so the input stays focused and the selection registers before any blur-driven close. Outside-click and Escape close the popover. Operator can still run against a label that was not in the autocomplete set; the server-side match-preview resolves it. Also: whitespace-nowrap on the primary button so "Stop fleet" / "Prune fleet" do not wrap in tight viewports.
218 lines
8.4 KiB
TypeScript
218 lines
8.4 KiB
TypeScript
import * as React from 'react';
|
||
import { Card } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
// Tracked-mono kicker. The toolbar/footer band and the blast-radius readout
|
||
// share `0.18em`; SheetSection headers inside the body use `0.22em` (set in
|
||
// system-sheet.tsx).
|
||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||
|
||
export type FleetActionClass = 'destructive' | 'transformative' | 'maintenance';
|
||
export type BlastRadiusTone = 'warning' | 'success' | 'muted';
|
||
export type PrimaryActionVariant = 'primary' | 'destructive';
|
||
|
||
/**
|
||
* System-Sheet recipe card for the Fleet Actions tab (audit §18, DESIGN §10).
|
||
*
|
||
* Layout note: the outer <Card> is `flex flex-col h-full` and the body slot
|
||
* is `flex-1`, so when a parent gives the card explicit height (e.g. the
|
||
* equalized-row wrapper in FleetActionsTab.tsx), the body grows and the
|
||
* footer pins to the bottom. In a content-sized parent this is a no-op.
|
||
*/
|
||
export interface FleetActionCardProps {
|
||
/** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */
|
||
crumb: string[];
|
||
/** Italic serif noun for the action (e.g. "Stop by label."). Section rung. */
|
||
name: string;
|
||
/** One-line mono summary of the action's shape (e.g. "label-match · per-node fan-out"). */
|
||
meta: string;
|
||
/** Closed set; drives the chip color, the blast dot color, and the primary-button variant invariant. */
|
||
actionClass: FleetActionClass;
|
||
/**
|
||
* Live blast-radius readout shown in the toolbar slot. Literal-prefix rules:
|
||
* - "awaiting target" → dot muted, text muted.
|
||
* - "0 ..." → dot muted, primary + secondary forced-disabled.
|
||
* - "local · ..." → dot in --brand (cyan) instead of the class color.
|
||
* - Otherwise → dot in the class color; tone overrides text color only.
|
||
*/
|
||
blastRadius: { value: string; tone?: BlastRadiusTone };
|
||
/** Optional outline secondary action (Dry run / Reset). Disabled automatically on "awaiting"/"0 ..." readouts. */
|
||
secondaryAction?: { label: string; onClick: () => void; disabled?: boolean };
|
||
/** Required primary action. The variant must follow the action-class invariants documented below. */
|
||
primaryAction: { label: string; onClick: () => void; variant: PrimaryActionVariant; disabled?: boolean };
|
||
/** Tracked-mono footer line. Omits the band entirely when undefined. */
|
||
footerContext?: string;
|
||
/** `<SheetSection>` blocks. */
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
const CLASS_LABEL: Record<FleetActionClass, string> = {
|
||
destructive: 'Destructive',
|
||
transformative: 'Transformative',
|
||
maintenance: 'Maintenance',
|
||
};
|
||
|
||
const CHIP_CLASS: Record<FleetActionClass, string> = {
|
||
destructive: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||
transformative: 'border-action-transformative/40 bg-action-transformative/10 text-action-transformative',
|
||
maintenance: 'border-warning/40 bg-warning/10 text-warning',
|
||
};
|
||
|
||
const DOT_CLASS: Record<FleetActionClass, string> = {
|
||
destructive: 'bg-destructive',
|
||
transformative: 'bg-action-transformative',
|
||
maintenance: 'bg-warning',
|
||
};
|
||
|
||
const TONE_TEXT_CLASS: Record<BlastRadiusTone, string> = {
|
||
warning: 'text-warning',
|
||
success: 'text-success',
|
||
muted: 'text-stat-icon',
|
||
};
|
||
|
||
export function FleetActionCard(props: FleetActionCardProps) {
|
||
const {
|
||
crumb, name, meta, actionClass, blastRadius,
|
||
secondaryAction, primaryAction, footerContext, children,
|
||
} = props;
|
||
|
||
// Literal-prefix detection per audit §18.5. These strings are the source of
|
||
// truth callers pass in; do NOT layer additional state props onto the
|
||
// primitive for what the prefix already expresses.
|
||
const isAwaitingTarget = blastRadius.value === 'awaiting target';
|
||
const isZeroMatch = blastRadius.value.startsWith('0 ');
|
||
const isLocalScope = blastRadius.value.startsWith('local · ');
|
||
const forceDisable = isAwaitingTarget || isZeroMatch;
|
||
|
||
const dotClass = (isAwaitingTarget || isZeroMatch)
|
||
? 'bg-stat-subtitle/50'
|
||
: isLocalScope
|
||
? 'bg-brand'
|
||
: DOT_CLASS[actionClass];
|
||
|
||
const blastTextClass = (isAwaitingTarget || isZeroMatch)
|
||
? 'text-stat-icon'
|
||
: blastRadius.tone
|
||
? TONE_TEXT_CLASS[blastRadius.tone]
|
||
: 'text-stat-value';
|
||
|
||
// Dev-mode invariant: a maintenance card with a destructive primary should
|
||
// also signal irreversibility in its footer, otherwise the visual class
|
||
// (amber) and the action variant (rose) disagree about safety.
|
||
if (import.meta.env?.MODE !== 'production') {
|
||
if (actionClass === 'maintenance' && primaryAction.variant === 'destructive') {
|
||
if (!footerContext || !footerContext.includes('Reversible · no')) {
|
||
console.warn(
|
||
'[FleetActionCard] maintenance + destructive primary should carry a "Reversible · no" footer.',
|
||
{ name, footerContext },
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const lastCrumb = crumb[crumb.length - 1] ?? '';
|
||
const headCrumbs = crumb.slice(0, -1);
|
||
|
||
return (
|
||
<Card className="relative overflow-hidden p-0 flex flex-col h-full">
|
||
<span
|
||
aria-hidden="true"
|
||
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
|
||
/>
|
||
|
||
<header className="px-6 pt-4 pb-3">
|
||
<div className={cn(KICKER, 'flex flex-wrap items-baseline gap-1 leading-none')}>
|
||
{headCrumbs.map((segment, idx) => (
|
||
<React.Fragment key={`${segment}-${idx}`}>
|
||
<span className="text-stat-subtitle">{segment}</span>
|
||
<span className="text-stat-icon" aria-hidden="true">{'›'}</span>
|
||
</React.Fragment>
|
||
))}
|
||
<span className="text-stat-title">{lastCrumb}</span>
|
||
</div>
|
||
<h3 className="mt-2 font-display italic text-[22px] leading-[28px] text-stat-value">
|
||
{name}
|
||
</h3>
|
||
<div className={cn('mt-1.5 font-mono text-[11px] leading-none text-stat-subtitle tracking-[0.04em]')}>
|
||
{meta}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex items-center gap-2 px-6 py-2 border-y border-card-border/40 bg-card/60">
|
||
<span
|
||
className={cn(
|
||
KICKER,
|
||
'inline-flex items-center px-1.5 py-0.5 rounded-sm border shrink-0',
|
||
CHIP_CLASS[actionClass],
|
||
)}
|
||
>
|
||
{CLASS_LABEL[actionClass]}
|
||
</span>
|
||
<div className={cn(KICKER, 'flex items-center gap-1.5 leading-none min-w-0', blastTextClass)}>
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', dotClass)}
|
||
/>
|
||
<span className="truncate">{blastRadius.value}</span>
|
||
</div>
|
||
<div className="flex-1" />
|
||
{secondaryAction && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={secondaryAction.onClick}
|
||
disabled={secondaryAction.disabled || forceDisable}
|
||
className={cn(KICKER, 'h-7 px-3')}
|
||
>
|
||
{secondaryAction.label}
|
||
</Button>
|
||
)}
|
||
<PrimaryButton
|
||
action={primaryAction}
|
||
disabled={primaryAction.disabled || forceDisable}
|
||
/>
|
||
</div>
|
||
|
||
<div className="px-6 flex-1">
|
||
{children}
|
||
</div>
|
||
|
||
{footerContext && (
|
||
<div className="px-6 py-2 border-t border-card-border/40 bg-card/60">
|
||
<div className={cn(KICKER, 'text-stat-subtitle leading-none truncate')}>
|
||
{footerContext}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
interface PrimaryButtonProps {
|
||
action: { label: string; onClick: () => void; variant: PrimaryActionVariant };
|
||
disabled: boolean;
|
||
}
|
||
|
||
// Primary button kept inline to encode the §18.3 invariant: cyan-filled for
|
||
// transformative/maintenance (when reversible), ghost-rose for destructive.
|
||
// Never solid rose on the card; solid rose is reserved for AlertDialog (§10).
|
||
function PrimaryButton({ action, disabled }: PrimaryButtonProps) {
|
||
const cyanFilled = 'bg-brand text-brand-foreground hover:bg-brand/90 border border-transparent';
|
||
const ghostRose = 'border border-destructive/40 text-destructive bg-transparent hover:bg-destructive/15 hover:text-destructive hover:border-destructive/60';
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={action.onClick}
|
||
disabled={disabled}
|
||
className={cn(
|
||
KICKER,
|
||
'h-7 rounded-md px-3 whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||
action.variant === 'primary' ? cyanFilled : ghostRose,
|
||
)}
|
||
>
|
||
{action.label}
|
||
</button>
|
||
);
|
||
}
|