refactor(ui): stack per-instance board + square detail vertically

The Modifier Profile editor rendered four visible columns in practice:
PerType | board | square-detail sidebar | library/presets. The inner
split inside PerInstancePanel (board on the left, w-72 detail sidebar
on the right) was the culprit — it squeezed the 8x8 board into a
cramped square the moment a piece was selected.

Restructure PerInstancePanel so the board stacks ABOVE the detail
strip instead of beside it:
- Board centered in the top region (full column width, max-w-md).
- Detail strip below: horizontal flex with existing-modifiers list on
  the left and the Add Modifier form on the right, wrapping to
  stacked rows on narrow widths. max-h-[45%] so the board never
  loses most of its space when many modifiers are present.
- Empty state gains a short one-line prompt; the Choose Layout button
  is kept for backward-compat with existing e2e tests.

Testids unchanged (per-instance-panel, per-instance-board,
instance-modifier-*, instance-modifiers-list, copy/paste-instance-modifiers,
instance-modifier-kind, instance-modifier-value, instance-modifier-add,
per-instance-choose-layout). All Playwright e2e tests continue to pass.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 15:42:20 -06:00
commit ef730eefbe
No known key found for this signature in database

View file

@ -56,13 +56,23 @@ export function PerInstancePanel({
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
// No layout bound — show the empty state prompt.
// Per-instance modifiers are square-bound (b1, e4, etc.), so we
// can't render a board preview or accept clicks until a layout
// supplies the pieces. The prompt + button focus the layout picker
// in the subhead so the user can bind one in a single click.
if (boundLayout === null) {
return (
<div
data-testid="per-instance-panel"
className="flex flex-1 flex-col items-center justify-center gap-4 p-8"
>
<p className="text-sm text-neutral-500 italic">Select a layout first</p>
<div className="max-w-sm text-center space-y-2">
<p className="text-sm text-neutral-500 italic">Select a layout first</p>
<p className="text-xs text-neutral-400 leading-relaxed">
Per-piece modifiers are tied to specific squares. Bind a starting
layout to pick which pieces get modifiers.
</p>
</div>
<button
data-testid="per-instance-choose-layout"
onClick={onLayoutSelect}
@ -95,29 +105,44 @@ export function PerInstancePanel({
}
return (
<div data-testid="per-instance-panel" className="flex flex-1 overflow-hidden">
{/* Board preview */}
<div className="flex-1 flex items-center justify-center p-4 bg-white">
<LayoutBoardView
pieces={boundLayout.pieces}
selectedSquare={selectedSquare}
testId="per-instance-board"
onSquareClick={handleSquareClick}
/>
<div
data-testid="per-instance-panel"
className="flex flex-1 flex-col overflow-hidden bg-white"
>
{/* Board preview stacked on top and centered so it gets the
full column width (no more sharing with a side detail panel
that squeezes it to a tiny square). Scroll-locked. */}
<div className="flex-1 min-h-0 flex items-center justify-center p-4 overflow-auto">
<div className="w-full max-w-md">
<LayoutBoardView
pieces={boundLayout.pieces}
selectedSquare={selectedSquare}
testId="per-instance-board"
onSquareClick={handleSquareClick}
/>
</div>
</div>
{/* Square detail sidebar */}
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
{/* Square detail strip stacked BELOW the board instead of
beside it. Fixed-height, scrolls its own overflow. Keeps
this column to ONE visual lane so the modal no longer
renders four cramped columns side-by-side. */}
<div
className={`border-t border-neutral-200 bg-neutral-50 shrink-0 ${
selectedSquare === null ? 'py-3 px-4' : 'p-4'
} max-h-[45%] overflow-y-auto`}
>
{selectedSquare === null ? (
<p className="text-sm text-neutral-500 italic">
<p className="text-xs text-neutral-500 italic text-center">
Click a piece on the board to add modifiers.
</p>
) : (
<>
{/* Selected square header */}
<div className="flex items-start justify-between">
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1">
<div className="space-y-3">
{/* Selected square header horizontal so it's compact
inside the bottom strip. */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-baseline gap-3">
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
Square {selectedSquare}
</h3>
{selectedPiece !== undefined && (
@ -126,7 +151,7 @@ export function PerInstancePanel({
</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1">
{squareModifiers.length > 0 && (
<button
type="button"
@ -137,7 +162,7 @@ export function PerInstancePanel({
entries: squareModifiers.map(({ modifier }) => {
const { square: _sq, ...rest } = modifier;
return rest;
})
}),
});
}}
className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 transition-colors"
@ -151,7 +176,12 @@ export function PerInstancePanel({
data-testid="paste-instance-modifiers"
onClick={() => {
if (clipboard.kind === 'instance-modifiers') {
onPaste(clipboard.entries.map(entry => ({ ...entry, square: selectedSquare })));
onPaste(
clipboard.entries.map((entry) => ({
...entry,
square: selectedSquare,
})),
);
}
}}
className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-green-600 bg-green-50 border border-green-200 rounded hover:bg-green-100 transition-colors"
@ -162,35 +192,48 @@ export function PerInstancePanel({
</div>
</div>
{/* Existing modifiers list */}
{squareModifiers.length > 0 && (
<ul data-testid="instance-modifiers-list" className="space-y-1">
{squareModifiers.map(({ modifier, index }) => (
<li
key={index}
data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`}
className="flex items-center gap-2 px-3 py-2 bg-white border border-neutral-200 rounded text-sm"
{/* Horizontal band: existing modifier chips on the left,
add-form on the right. Wraps to stacked on narrow
widths via flex-wrap. */}
<div className="flex flex-wrap gap-3 items-start">
<div className="flex-1 min-w-[240px] space-y-1">
{squareModifiers.length > 0 ? (
<ul
data-testid="instance-modifiers-list"
className="space-y-1"
>
<span className="flex-1 font-medium text-neutral-700 break-all">
{modifier.kind}: {JSON.stringify(modifier.value)}
</span>
<button
onClick={() => onDelete(index)}
aria-label={`Delete ${modifier.kind} modifier`}
className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none"
>
</button>
</li>
))}
</ul>
)}
{/* Add modifier form */}
<AddModifierForm square={selectedSquare} onAdd={onAdd} />
</>
{squareModifiers.map(({ modifier, index }) => (
<li
key={index}
data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`}
className="flex items-center gap-2 px-3 py-1.5 bg-white border border-neutral-200 rounded text-xs"
>
<span className="flex-1 font-medium text-neutral-700 break-all">
{modifier.kind}: {JSON.stringify(modifier.value)}
</span>
<button
onClick={() => onDelete(index)}
aria-label={`Delete ${modifier.kind} modifier`}
className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none"
>
</button>
</li>
))}
</ul>
) : (
<p className="text-[11px] text-neutral-400 italic py-1">
No modifiers on this square yet.
</p>
)}
</div>
<div className="w-full sm:w-[280px] shrink-0">
<AddModifierForm square={selectedSquare} onAdd={onAdd} />
</div>
</div>
</div>
)}
</aside>
</div>
</div>
);
}
@ -309,7 +352,7 @@ function AddModifierForm({
}
return (
<div className="space-y-2 border-t border-neutral-200 pt-3">
<div className="space-y-2">
<h4 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
Add Modifier
</h4>