fix(fleet): equalize action card heights, swap order, replace native autocomplete (#1138)

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.
This commit is contained in:
Anso
2026-05-21 12:51:54 -04:00
committed by GitHub
parent 0a3e76c0bb
commit 2563e30424
3 changed files with 111 additions and 17 deletions
@@ -22,14 +22,17 @@ export function FleetActionsTab({ nodes }: Props) {
return <EmptyState />;
}
// Grid breakpoints per audit §18.7: 1 / 2 / 3 columns at 760 / 1280. The
// 4-column breakpoint is reserved for when a 4th card lands; v1 has three
// so it is intentionally omitted to avoid a hanging gap on wide displays.
// Grid: 1 col under lg, 2 cols at lg and above. auto-rows-fr distributes
// available height evenly across the auto-created rows, and each card uses
// `flex flex-col h-full` (in <FleetActionCard>) with a `flex-1` body so it
// fills its grid cell. Result: all three cards render at the same height,
// with shorter cards growing their body whitespace before the footer pins.
// Order: Prune top-left, Bulk top-right, Stop on row 2.
return (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
<LabelFleetStopCard nodes={nodes} />
<BulkLabelAssignCard nodes={nodes} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
<FleetPruneCard nodes={nodes} />
<BulkLabelAssignCard nodes={nodes} />
<LabelFleetStopCard nodes={nodes} />
</div>
);
}
@@ -201,18 +201,13 @@ export function LabelFleetStopCard({ nodes }: Props) {
title="Label · target"
meta={`auto-suggested · ${knownLabelNames.length} known`}
>
<Input
id="fleet-stop-label-input"
list="fleet-stop-label-suggestions"
<LabelAutocomplete
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
placeholder="e.g. production"
className="h-9 text-sm"
onChange={setLabelName}
suggestions={knownLabelNames}
disabled={running}
placeholder="e.g. production"
/>
<datalist id="fleet-stop-label-suggestions">
{knownLabelNames.map(n => <option key={n} value={n} />)}
</datalist>
</SheetSection>
{previewSection}
@@ -308,3 +303,91 @@ function PreviewWell({ perNode }: PreviewWellProps) {
</div>
);
}
interface LabelAutocompleteProps {
value: string;
onChange: (next: string) => void;
suggestions: string[];
disabled?: boolean;
placeholder?: string;
}
// Free-form text input with a Sencho-styled suggestion popover. Replaces the
// browser-native <datalist> so the dropdown matches the rest of the kit (same
// surface tokens as <Combobox>). The operator can still type a label name
// that was not in the suggestions list; the server-side match-preview will
// resolve it or report 0 nodes match.
function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder }: LabelAutocompleteProps) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = value.trim().toLowerCase();
if (q.length === 0) return suggestions;
return suggestions.filter(s => s.toLowerCase().includes(q));
}, [value, suggestions]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
const handleSelect = (next: string) => {
onChange(next);
setOpen(false);
};
return (
<div ref={wrapperRef} className="relative">
<Input
id="fleet-stop-label-input"
value={value}
onChange={(e) => {
onChange(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => { if (!disabled) setOpen(true); }}
placeholder={placeholder}
className="h-9 text-sm"
disabled={disabled}
autoComplete="off"
spellCheck={false}
/>
{open && filtered.length > 0 && (
<div className="absolute left-0 top-full mt-1 z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
<ul className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
{filtered.map((s) => (
<li key={s}>
<button
type="button"
// mousedown + preventDefault keeps the input focused so the
// selection registers before any blur-driven close fires.
onMouseDown={(e) => { e.preventDefault(); handleSelect(s); }}
className="flex w-full items-center rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
>
{s}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -12,6 +12,14 @@ 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[];
@@ -107,7 +115,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
const headCrumbs = crumb.slice(0, -1);
return (
<Card className="relative overflow-hidden p-0">
<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"
@@ -166,7 +174,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
/>
</div>
<div className="px-6">
<div className="px-6 flex-1">
{children}
</div>