diff --git a/packages/chess/src/ui/visual-builder/BlockCard.test.tsx b/packages/chess/src/ui/visual-builder/BlockCard.test.tsx new file mode 100644 index 0000000..79aa40a --- /dev/null +++ b/packages/chess/src/ui/visual-builder/BlockCard.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + onToggleExpand={() => {}} + onRemove={() => {}} + depth={5} + /> + ); + + // Depth 5 should clamp to 3 * 1.5rem = 4.5rem + expect(html5).toContain("margin-left:4.5rem"); + + const html2 = renderToStaticMarkup( + {}} + onToggleExpand={() => {}} + onRemove={() => {}} + depth={2} + /> + ); + + // Depth 2 should be 2 * 1.5rem = 3rem + expect(html2).toContain("margin-left:3rem"); + }); +}); diff --git a/packages/chess/src/ui/visual-builder/BlockCard.tsx b/packages/chess/src/ui/visual-builder/BlockCard.tsx new file mode 100644 index 0000000..07a9dfd --- /dev/null +++ b/packages/chess/src/ui/visual-builder/BlockCard.tsx @@ -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 = { + 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 ( +
{ + 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 */} +
+
+
+ {index + 1} +
+
+
+ {label} +
+ {!isSelected && ( +
+ {paramsPreview} +
+ )} +
+
+ +
+ {hasChildren && ( + + )} + +
+
+ + {/* Inspector Overlay (When selected and expanded) */} + {isSelected && isExpanded && primitive && ( +
e.stopPropagation()} // Prevent inspector clicks from triggering card select + > + { + // 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). + }} + /> +
+ )} + + {/* Nested Children Area */} + {isExpanded && childBlocks && ( +
+ {childBlocks} +
+ )} +
+ ); +} diff --git a/packages/chess/src/ui/visual-builder/preview/BoardDiagramView.tsx b/packages/chess/src/ui/visual-builder/preview/BoardDiagramView.tsx new file mode 100644 index 0000000..518b92a --- /dev/null +++ b/packages/chess/src/ui/visual-builder/preview/BoardDiagramView.tsx @@ -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(); + 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( + + ); + + if (highlightedSquares.has(sq)) { + rects.push( + + ); + } + } + + // 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( + + ); + } + + const hasContent = highlightedSquares.size > 0 || auraRadius > 0; + + if (!hasContent) { + return ( +
+ No board effect +
+ ); + } + + return ( +
+ + {rects} + +
+ ); +} diff --git a/packages/chess/src/ui/visual-builder/preview/JsonView.tsx b/packages/chess/src/ui/visual-builder/preview/JsonView.tsx new file mode 100644 index 0000000..f336c16 --- /dev/null +++ b/packages/chess/src/ui/visual-builder/preview/JsonView.tsx @@ -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 ( +
+ +
+        {json}
+      
+
+ ); +} diff --git a/packages/chess/src/ui/visual-builder/preview/NarrativeView.tsx b/packages/chess/src/ui/visual-builder/preview/NarrativeView.tsx new file mode 100644 index 0000000..7a730ee --- /dev/null +++ b/packages/chess/src/ui/visual-builder/preview/NarrativeView.tsx @@ -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 ( +
+      {text || 'Empty effect.'}
+    
+ ); +} diff --git a/packages/chess/src/ui/visual-builder/preview/PreviewPane.tsx b/packages/chess/src/ui/visual-builder/preview/PreviewPane.tsx new file mode 100644 index 0000000..5db0034 --- /dev/null +++ b/packages/chess/src/ui/visual-builder/preview/PreviewPane.tsx @@ -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('narrative'); + + const narrativeContent = useMemo(() => , [descriptor]); + const jsonContent = useMemo(() => , [descriptor]); + const boardContent = useMemo(() => , [descriptor]); + + return ( +
+
+ + + +
+ +
+
+ {narrativeContent} +
+
+ {jsonContent} +
+
+ {boardContent} +
+
+
+ ); +}