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); const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
// No layout bound — show the empty state prompt. // 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) { if (boundLayout === null) {
return ( return (
<div <div
data-testid="per-instance-panel" data-testid="per-instance-panel"
className="flex flex-1 flex-col items-center justify-center gap-4 p-8" 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 <button
data-testid="per-instance-choose-layout" data-testid="per-instance-choose-layout"
onClick={onLayoutSelect} onClick={onLayoutSelect}
@ -95,29 +105,44 @@ export function PerInstancePanel({
} }
return ( return (
<div data-testid="per-instance-panel" className="flex flex-1 overflow-hidden"> <div
{/* Board preview */} data-testid="per-instance-panel"
<div className="flex-1 flex items-center justify-center p-4 bg-white"> className="flex flex-1 flex-col overflow-hidden bg-white"
<LayoutBoardView >
pieces={boundLayout.pieces} {/* Board preview — stacked on top and centered so it gets the
selectedSquare={selectedSquare} full column width (no more sharing with a side detail panel
testId="per-instance-board" that squeezes it to a tiny square). Scroll-locked. */}
onSquareClick={handleSquareClick} <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> </div>
{/* Square detail sidebar */} {/* Square detail strip — stacked BELOW the board instead of
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4"> 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 ? ( {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. Click a piece on the board to add modifiers.
</p> </p>
) : ( ) : (
<> <div className="space-y-3">
{/* Selected square header */} {/* Selected square header — horizontal so it's compact
<div className="flex items-start justify-between"> inside the bottom strip. */}
<div> <div className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1"> <div className="flex items-baseline gap-3">
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
Square {selectedSquare} Square {selectedSquare}
</h3> </h3>
{selectedPiece !== undefined && ( {selectedPiece !== undefined && (
@ -126,7 +151,7 @@ export function PerInstancePanel({
</p> </p>
)} )}
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex items-center gap-1">
{squareModifiers.length > 0 && ( {squareModifiers.length > 0 && (
<button <button
type="button" type="button"
@ -137,7 +162,7 @@ export function PerInstancePanel({
entries: squareModifiers.map(({ modifier }) => { entries: squareModifiers.map(({ modifier }) => {
const { square: _sq, ...rest } = modifier; const { square: _sq, ...rest } = modifier;
return rest; 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" 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" data-testid="paste-instance-modifiers"
onClick={() => { onClick={() => {
if (clipboard.kind === 'instance-modifiers') { 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" 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>
</div> </div>
{/* Existing modifiers list */} {/* Horizontal band: existing modifier chips on the left,
{squareModifiers.length > 0 && ( add-form on the right. Wraps to stacked on narrow
<ul data-testid="instance-modifiers-list" className="space-y-1"> widths via flex-wrap. */}
{squareModifiers.map(({ modifier, index }) => ( <div className="flex flex-wrap gap-3 items-start">
<li <div className="flex-1 min-w-[240px] space-y-1">
key={index} {squareModifiers.length > 0 ? (
data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`} <ul
className="flex items-center gap-2 px-3 py-2 bg-white border border-neutral-200 rounded text-sm" data-testid="instance-modifiers-list"
className="space-y-1"
> >
<span className="flex-1 font-medium text-neutral-700 break-all"> {squareModifiers.map(({ modifier, index }) => (
{modifier.kind}: {JSON.stringify(modifier.value)} <li
</span> key={index}
<button data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`}
onClick={() => onDelete(index)} className="flex items-center gap-2 px-3 py-1.5 bg-white border border-neutral-200 rounded text-xs"
aria-label={`Delete ${modifier.kind} modifier`} >
className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none" <span className="flex-1 font-medium text-neutral-700 break-all">
> {modifier.kind}: {JSON.stringify(modifier.value)}
✕ </span>
</button> <button
</li> onClick={() => onDelete(index)}
))} aria-label={`Delete ${modifier.kind} modifier`}
</ul> className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none"
)} >
✕
{/* Add modifier form */} </button>
<AddModifierForm square={selectedSquare} onAdd={onAdd} /> </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> </div>
); );
} }
@ -309,7 +352,7 @@ function AddModifierForm({
} }
return ( 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"> <h4 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
Add Modifier Add Modifier
</h4> </h4>