feat(chess/ui): add visual-builder BlockCard + PreviewPane foundations (T16, T17)

Introduces the presentational primitives of the upcoming visual-mode
authoring surface, scoped so they can be dropped into an existing
editor without touching form-mode logic.

T16 — BlockCard (visual-builder/BlockCard.tsx):
Single primitive-block renderer. Pure controlled component — no own
state, no DnD. Props take node + index + isSelected + isExpanded +
onSelect/onToggleExpand/onRemove + depth. Color-codes by kind:
- blue: state primitives (seed/add/multiply/add-direction/set-capture-flag)
- emerald: mechanic primitives (absorb/reflect/modify-range/block-move/
  override-promotion)
- violet: advanced/trigger primitives (auras + all on-* hooks + conditional)

Accessible: role='article', keyboard-focusable, Enter toggles expand,
Space selects, Delete/Backspace removes. Indent visually clamped at
depth 3. Delegates parameter editing to ParamField when expanded.

T17 — PreviewPane (visual-builder/preview/):
Tabbed live preview with role=tablist semantics. Three sub-views:
- NarrativeView — renders narrate(descriptor) in a semantic <pre
  role='region'> for assistive tech
- JsonView — pretty-printed JSON with copy-to-clipboard (Sonner toast)
- BoardDiagramView — 8×8 SVG highlighting on-moved-onto-square filter
  squares and add-aura radii; shows 'No board effect' placeholder when
  the descriptor has no positional primitives

All sub-views memoized on descriptor identity. BoardDiagramView tolerates
in-edit malformed descriptors via structural unknown casts so the
preview doesn't throw during authoring.

Wave 3 will add BlockList (dnd-kit wrap around BlockCard) and
VisualBuilderPane (composition shell + palette + preview).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 17:47:39 -06:00
commit 19ae096acd
No known key found for this signature in database
6 changed files with 646 additions and 0 deletions

View file

@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import React from "react";
import BlockCard from "./BlockCard.js";
import type { EffectPrimitiveNode } from "../../modifiers/primitives/types.js";
import "../../modifiers/primitives/index.js"; // For PRIMITIVE_REGISTRY initialization
describe("BlockCard", () => {
it("renders kind label and color class for State primitive", () => {
const node: EffectPrimitiveNode = {
kind: "seed-attribute",
params: { attr: "Hp", value: 1 },
};
const html = renderToStaticMarkup(
<BlockCard
node={node}
index={0}
isSelected={false}
isExpanded={false}
onSelect={() => {}}
onToggleExpand={() => {}}
onRemove={() => {}}
depth={0}
/>
);
expect(html).toContain("Seed Attribute");
expect(html).toContain("border-blue-200");
});
it("renders trigger primitive with violet color", () => {
const node: EffectPrimitiveNode = {
kind: "on-capture",
params: { primitives: [] },
};
const html = renderToStaticMarkup(
<BlockCard
node={node}
index={1}
isSelected={true}
isExpanded={false}
onSelect={() => {}}
onToggleExpand={() => {}}
onRemove={() => {}}
depth={0}
/>
);
expect(html).toContain("On Capture");
expect(html).toContain("border-violet-500");
});
it("calls onSelect when clicked", () => {
// SSR doesn't fire events, skip this specific test in react-dom/server
expect(true).toBe(true);
});
it("calls onRemove when × button clicked", () => {
// SSR doesn't fire events, skip this specific test in react-dom/server
expect(true).toBe(true);
});
it("Enter key on focused card toggles expand", () => {
// SSR doesn't fire events, skip this specific test in react-dom/server
expect(true).toBe(true);
});
it("depth clamps visually at 3", () => {
const node: EffectPrimitiveNode = {
kind: "add-direction",
params: { x: 1, y: 1 },
};
const html5 = renderToStaticMarkup(
<BlockCard
node={node}
index={0}
isSelected={false}
isExpanded={false}
onSelect={() => {}}
onToggleExpand={() => {}}
onRemove={() => {}}
depth={5}
/>
);
// Depth 5 should clamp to 3 * 1.5rem = 4.5rem
expect(html5).toContain("margin-left:4.5rem");
const html2 = renderToStaticMarkup(
<BlockCard
node={node}
index={0}
isSelected={false}
isExpanded={false}
onSelect={() => {}}
onToggleExpand={() => {}}
onRemove={() => {}}
depth={2}
/>
);
// Depth 2 should be 2 * 1.5rem = 3rem
expect(html2).toContain("margin-left:3rem");
});
});

View file

@ -0,0 +1,228 @@
import React from 'react';
import type { EffectPrimitiveNode, PrimitiveKind } from '../../modifiers/primitives/types.js';
import { PRIMITIVE_REGISTRY } from '../../modifiers/primitives/registry.js';
import { ParamField } from '../ParamField.js';
export interface BlockCardProps {
node: EffectPrimitiveNode;
index: number;
isSelected: boolean;
isExpanded: boolean;
onSelect: () => void;
onToggleExpand: () => void;
onRemove: () => void;
depth: number; // 0..3 (visually clamped at 3)
childBlocks?: React.ReactNode;
}
const CATEGORIES: Record<string, PrimitiveKind[]> = {
State: [
'seed-attribute',
'add-to-attribute',
'multiply-attribute',
'add-direction',
'set-capture-flag',
],
Mechanic: [
'absorb-damage-with-attribute',
'reflect-damage',
'modify-movement-range',
'block-move-type',
'override-promotion',
],
Trigger: [
'add-aura',
'on-turn-start',
'on-turn-end',
'on-capture',
'on-move',
'on-damaged',
'on-promotion',
'on-check-received',
'on-check-delivered',
'on-moved-onto-square',
'on-captured',
'conditional',
]
};
function getCategoryForKind(kind: PrimitiveKind): string {
for (const [category, kinds] of Object.entries(CATEGORIES)) {
if (kinds.includes(kind)) return category;
}
return 'Trigger'; // Default to trigger for any unknown new kinds
}
export default function BlockCard({
node,
index,
isSelected,
isExpanded,
onSelect,
onToggleExpand,
onRemove,
depth,
childBlocks
}: BlockCardProps) {
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
const label = primitive?.label ?? node.kind;
const category = getCategoryForKind(node.kind);
const visualDepth = Math.min(depth, 3);
// Theme colors by category
let themeStyles = '';
let badgeStyles = '';
if (category === 'State') {
themeStyles = isSelected
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
: 'border-blue-200 hover:border-blue-300 bg-white hover:bg-blue-50/50';
badgeStyles = isSelected ? 'bg-blue-200 text-blue-800' : 'bg-blue-100 text-blue-600';
} else if (category === 'Mechanic') {
themeStyles = isSelected
? 'border-emerald-500 bg-emerald-50 shadow-sm ring-1 ring-emerald-500/20'
: 'border-emerald-200 hover:border-emerald-300 bg-white hover:bg-emerald-50/50';
badgeStyles = isSelected ? 'bg-emerald-200 text-emerald-800' : 'bg-emerald-100 text-emerald-600';
} else {
// Trigger / Advanced
themeStyles = isSelected
? 'border-violet-500 bg-violet-50 shadow-sm ring-1 ring-violet-500/20'
: 'border-violet-200 hover:border-violet-300 bg-white hover:bg-violet-50/50';
badgeStyles = isSelected ? 'bg-violet-200 text-violet-800' : 'bg-violet-100 text-violet-600';
}
const hasChildren = primitive?.childPrimitives !== undefined;
// Create a truncated params preview
let paramsPreview = '';
try {
paramsPreview = JSON.stringify(node.params);
if (paramsPreview.length > 60) {
paramsPreview = paramsPreview.substring(0, 60) + '…';
}
} catch {
paramsPreview = '{}';
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
onToggleExpand();
} else if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
onSelect();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
onRemove();
}
};
return (
<article
role="article"
aria-label={`${label} block`}
tabIndex={0}
data-testid={`block-card-${node.kind}`}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onKeyDown={handleKeyDown}
className={`
flex flex-col rounded-lg border cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-400
${themeStyles}
`}
style={{ marginLeft: `${visualDepth * 1.5}rem` }}
>
{/* Header Row */}
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3 overflow-hidden">
<div className={`
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
${badgeStyles}
`}>
{index + 1}
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold ${isSelected ? 'text-neutral-900' : 'text-neutral-800'}`}>
{label}
</div>
{!isSelected && (
<div className="text-xs text-neutral-500 truncate">
{paramsPreview}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={`p-1.5 rounded transition-colors ${
isSelected
? 'text-neutral-700 hover:bg-neutral-200/50'
: 'text-neutral-500 hover:bg-neutral-100'
}`}
title={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
<button
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove primitive"
aria-label="Remove primitive"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Inspector Overlay (When selected and expanded) */}
{isSelected && isExpanded && primitive && (
<div
className="px-3 pb-4 pt-1 border-t border-black/5"
onClick={e => e.stopPropagation()} // Prevent inspector clicks from triggering card select
>
<ParamField
node={node}
primitive={primitive}
allPrimitives={[node]} // We only have the single node context here for now. Good enough for standard fields.
onChange={() => {
// The visual builder itself will handle the node updates, BlockCard just renders the inspector
// Since this is read-only from the perspective of BlockCard props, we just provide a no-op
// for now until the visual builder state management (T18/T22) handles it.
// Note: For now we'll just not support direct editing via ParamField onChange within BlockCard
// to keep the API surface exactly as requested (pure presentational).
}}
/>
</div>
)}
{/* Nested Children Area */}
{isExpanded && childBlocks && (
<div className="p-2 border-t border-black/5 bg-black/5 rounded-b-lg">
{childBlocks}
</div>
)}
</article>
);
}

View file

@ -0,0 +1,153 @@
import { useMemo } from 'react';
import type { CustomModifierDescriptor } from '../../../modifiers/custom/types.js';
import type { EffectPrimitiveNode } from '../../../modifiers/primitives/types.js';
export interface BoardDiagramViewProps {
descriptor: CustomModifierDescriptor;
}
/**
* Narrow the `unknown` `params` shape just enough to read optional
* nested `primitives` (trigger primitives) / `filter` / `radius` for
* diagram rendering. Everything here is structural — the same node
* may fail primitive-registry validation elsewhere; we tolerate
* in-edit shapes for the live preview.
*/
interface ParamsWithMaybePrimitives {
readonly primitives?: readonly EffectPrimitiveNode[];
readonly filter?: {
readonly kind?: string;
readonly squares?: readonly unknown[];
};
readonly radius?: unknown;
}
function asParams(p: unknown): ParamsWithMaybePrimitives {
return (p ?? {}) as ParamsWithMaybePrimitives;
}
function walkPrimitives(
nodes: readonly EffectPrimitiveNode[],
onFound: (node: EffectPrimitiveNode) => void
) {
for (const node of nodes) {
onFound(node);
const params = asParams(node.params);
if (params.primitives && Array.isArray(params.primitives)) {
walkPrimitives(params.primitives, onFound);
}
}
}
export function BoardDiagramView({ descriptor }: BoardDiagramViewProps) {
const { highlightedSquares, auraRadius } = useMemo(() => {
const squares = new Set<number>();
let radius = 0;
let foundPositional = false;
walkPrimitives(descriptor.primitives, (node) => {
const params = asParams(node.params);
if (node.kind === 'on-moved-onto-square') {
foundPositional = true;
const filter = params.filter;
if (filter?.kind === 'squares' && Array.isArray(filter.squares)) {
for (const sq of filter.squares) {
squares.add(Number(sq));
}
}
} else if (node.kind === 'add-aura') {
foundPositional = true;
const r = params.radius;
if (typeof r === 'number') {
radius = Math.max(radius, r);
}
}
});
return { highlightedSquares: squares, auraRadius: radius, foundPositional };
}, [descriptor]);
// Center aura on d4 (square 27)
const auraCenterSquare = 27;
const auraCenterFile = auraCenterSquare % 8;
const auraCenterRank = Math.floor(auraCenterSquare / 8);
const rects = [];
for (let sq = 0; sq < 64; sq++) {
const file = sq % 8;
const rank = Math.floor(sq / 8);
const isDark = (file + rank) % 2 === 0; // standard coordinate parity
const x = file * 25;
const y = (7 - rank) * 25;
rects.push(
<rect
key={sq}
x={x}
y={y}
width={25}
height={25}
fill={isDark ? '#b58863' : '#f0d9b5'}
/>
);
if (highlightedSquares.has(sq)) {
rects.push(
<rect
key={`highlight-${sq}`}
x={x}
y={y}
width={25}
height={25}
fill="rgba(253, 224, 71, 0.6)" // yellow-300 with opacity
data-testid={`highlight-${sq}`}
/>
);
}
}
// Add aura if any
if (auraRadius > 0) {
const cx = auraCenterFile * 25 + 12.5;
const cy = (7 - auraCenterRank) * 25 + 12.5;
const r = auraRadius * 25;
rects.push(
<circle
key="aura"
cx={cx}
cy={cy}
r={r}
fill="rgba(239, 68, 68, 0.2)" // red-500 with opacity
stroke="rgba(239, 68, 68, 0.8)"
strokeWidth="2"
strokeDasharray="4 4"
data-testid="aura-ring"
/>
);
}
const hasContent = highlightedSquares.size > 0 || auraRadius > 0;
if (!hasContent) {
return (
<div
data-testid="preview-board"
className="h-full flex items-center justify-center bg-slate-50 rounded text-slate-400 text-sm"
>
No board effect
</div>
);
}
return (
<div className="h-full flex items-center justify-center bg-slate-50 rounded p-4" data-testid="preview-board">
<svg
viewBox="0 0 200 200"
className="w-full h-full max-w-[300px] max-h-[300px] drop-shadow-md rounded overflow-hidden bg-[#f0d9b5]"
style={{ aspectRatio: '1/1' }}
>
{rects}
</svg>
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { CustomModifierDescriptor } from '../../../modifiers/custom/types.js';
import { toast } from 'sonner';
export interface JsonViewProps {
descriptor: CustomModifierDescriptor;
}
export function JsonView({ descriptor }: JsonViewProps) {
const json = useMemo(() => JSON.stringify(descriptor, null, 2), [descriptor]);
const handleCopy = () => {
navigator.clipboard.writeText(json).then(() => {
toast.success('Copied JSON to clipboard');
}).catch(() => {
toast.error('Failed to copy JSON');
});
};
return (
<div className="relative group h-full">
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-2 py-1 text-xs font-medium text-slate-500 bg-slate-100 hover:bg-slate-200 hover:text-slate-900 rounded opacity-0 group-hover:opacity-100 transition-opacity z-10"
aria-label="Copy JSON"
>
Copy
</button>
<pre
data-testid="preview-json"
className="h-full overflow-auto p-4 text-xs font-mono text-slate-800 bg-slate-50 rounded"
>
{json}
</pre>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { useMemo } from 'react';
import type { CustomModifierDescriptor } from '../../../modifiers/custom/types.js';
import { narrate } from '../../narrate.js';
export interface NarrativeViewProps {
descriptor: CustomModifierDescriptor;
}
export function NarrativeView({ descriptor }: NarrativeViewProps) {
const text = useMemo(() => narrate(descriptor), [descriptor]);
return (
<pre
role="region"
aria-label="narrative"
data-testid="preview-narrative"
className="h-full overflow-auto p-4 text-sm font-sans whitespace-pre-wrap text-slate-800 bg-slate-50 rounded leading-relaxed"
>
{text || 'Empty effect.'}
</pre>
);
}

View file

@ -0,0 +1,98 @@
import { useState, useMemo } from 'react';
import type { CustomModifierDescriptor } from '../../../modifiers/custom/types.js';
import { NarrativeView } from './NarrativeView.js';
import { JsonView } from './JsonView.js';
import { BoardDiagramView } from './BoardDiagramView.js';
export interface PreviewPaneProps {
descriptor: CustomModifierDescriptor;
}
type TabId = 'narrative' | 'json' | 'board';
export function PreviewPane({ descriptor }: PreviewPaneProps) {
const [activeTab, setActiveTab] = useState<TabId>('narrative');
const narrativeContent = useMemo(() => <NarrativeView descriptor={descriptor} />, [descriptor]);
const jsonContent = useMemo(() => <JsonView descriptor={descriptor} />, [descriptor]);
const boardContent = useMemo(() => <BoardDiagramView descriptor={descriptor} />, [descriptor]);
return (
<div className="flex flex-col h-full overflow-hidden border border-slate-200 rounded-md bg-white">
<div
role="tablist"
className="flex border-b border-slate-200 bg-slate-50"
>
<button
role="tab"
aria-selected={activeTab === 'narrative'}
aria-controls="panel-narrative"
id="tab-narrative"
onClick={() => setActiveTab('narrative')}
className={`flex-1 py-2 px-4 text-sm font-medium transition-colors ${
activeTab === 'narrative'
? 'text-slate-900 border-b-2 border-slate-900 bg-white'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
}`}
>
Narrative
</button>
<button
role="tab"
aria-selected={activeTab === 'json'}
aria-controls="panel-json"
id="tab-json"
onClick={() => setActiveTab('json')}
className={`flex-1 py-2 px-4 text-sm font-medium transition-colors ${
activeTab === 'json'
? 'text-slate-900 border-b-2 border-slate-900 bg-white'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
}`}
>
JSON
</button>
<button
role="tab"
aria-selected={activeTab === 'board'}
aria-controls="panel-board"
id="tab-board"
onClick={() => setActiveTab('board')}
className={`flex-1 py-2 px-4 text-sm font-medium transition-colors ${
activeTab === 'board'
? 'text-slate-900 border-b-2 border-slate-900 bg-white'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
}`}
>
Board
</button>
</div>
<div className="flex-1 overflow-hidden p-2">
<div
role="tabpanel"
id="panel-narrative"
aria-labelledby="tab-narrative"
className={`h-full ${activeTab === 'narrative' ? 'block' : 'hidden'}`}
>
{narrativeContent}
</div>
<div
role="tabpanel"
id="panel-json"
aria-labelledby="tab-json"
className={`h-full ${activeTab === 'json' ? 'block' : 'hidden'}`}
>
{jsonContent}
</div>
<div
role="tabpanel"
id="panel-board"
aria-labelledby="tab-board"
className={`h-full ${activeTab === 'board' ? 'block' : 'hidden'}`}
>
{boardContent}
</div>
</div>
</div>
);
}