feat: form/visual mode toggle for CustomModifierEditor

- Add buttons in editor header to switch between 'form' and 'visual' modes
- Persist user's preferred mode to localStorage
- Wrap main layout area in conditional render
- In 'visual' mode, center/right column replaced with VisualBuilderPane
- Adds suite of UI tests with vitest + node testing with mocked localStorage
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 18:29:51 -06:00
commit 8017a0d590
No known key found for this signature in database
3 changed files with 934 additions and 144 deletions

View file

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { CustomModifierEditor } from './CustomModifierEditor.js';
import React from 'react';
// Mock localStorage since we're in a Node environment for SSR tests
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key: string) => {
delete store[key];
})
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock
});
describe('CustomModifierEditor', () => {
const LOCAL_STORAGE_KEY = 'houserules:custom-modifier-editor-mode:v1';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
localStorage.clear();
});
function renderEditor() {
return renderToStaticMarkup(
<CustomModifierEditor isOpen={true} onClose={() => {}} />
);
}
it("default mode is 'form' when localStorage empty", () => {
const html = renderEditor();
// Toggle buttons should reflect form is active
expect(html).toContain('data-testid="custom-modifier-editor-mode-form" aria-pressed="true"');
expect(html).toContain('data-testid="custom-modifier-editor-mode-visual" aria-pressed="false"');
// Should render the tree view / inspector (form mode components)
expect(html).toContain('Effect Sequence');
expect(html).toContain('Parameter Inspector');
});
it("reading mode='visual' from localStorage renders VisualBuilderPane", () => {
localStorage.setItem(LOCAL_STORAGE_KEY, 'visual');
const html = renderEditor();
// Toggle buttons should reflect visual is active
expect(html).toContain('data-testid="custom-modifier-editor-mode-form" aria-pressed="false"');
expect(html).toContain('data-testid="custom-modifier-editor-mode-visual" aria-pressed="true"');
// Should render VisualBuilderPane content, not form components
expect(html).not.toContain('Effect Sequence');
expect(html).not.toContain('Parameter Inspector');
// VisualBuilderPane has 'Primitives' text and BlockList container
expect(html).toContain('Primitives');
});
});

View file

@ -15,6 +15,7 @@ import {
CUSTOM_MODIFIER_RECIPES,
type CustomModifierRecipe,
} from '../modifiers/custom/recipes.js';
import { VisualBuilderPane } from './visual-builder/VisualBuilderPane.js';
import { ParamField } from './ParamField.js';
interface Props {
@ -126,6 +127,24 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
// Templates picker state (built-in recipe gallery)
const [showTemplates, setShowTemplates] = useState(false);
const [mode, setMode] = useState<'form' | 'visual'>(() => {
try {
const stored = localStorage.getItem('houserules:custom-modifier-editor-mode:v1');
return stored === 'visual' ? 'visual' : 'form';
} catch {
return 'form';
}
});
const setModeAndPersist = (m: 'form' | 'visual') => {
setMode(m);
try {
localStorage.setItem('houserules:custom-modifier-editor-mode:v1', m);
} catch {
// ignore
}
};
// Open library
const openLibrary = () => {
setLibraryItems(loadCustomModifierLibrary());
@ -220,6 +239,32 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
</div>
</div>
<div className="flex items-center gap-2">
<div role="group" aria-label="Editor mode" data-testid="custom-modifier-editor-mode-toggle" className="flex bg-neutral-100 p-0.5 rounded-lg border border-neutral-200 mr-2">
<button
data-testid="custom-modifier-editor-mode-form"
aria-pressed={mode === 'form'}
onClick={() => setModeAndPersist('form')}
className={`px-3 py-1 text-sm font-semibold rounded-md transition-colors ${
mode === 'form'
? 'bg-white text-neutral-900 shadow-sm'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Form
</button>
<button
data-testid="custom-modifier-editor-mode-visual"
aria-pressed={mode === 'visual'}
onClick={() => setModeAndPersist('visual')}
className={`px-3 py-1 text-sm font-semibold rounded-md transition-colors ${
mode === 'visual'
? 'bg-white text-neutral-900 shadow-sm'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Visual
</button>
</div>
<button
data-testid="custom-templates"
onClick={() => setShowTemplates(true)}
@ -268,154 +313,168 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
{/* Main Body (3 Columns) */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Left: Palette */}
<aside className="w-64 border-r border-neutral-200 overflow-y-auto bg-neutral-50/50 flex flex-col p-4 gap-6 shrink-0">
{Object.entries(CATEGORIES).map(([category, kinds]) => (
<div key={category}>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-3">
{category}
</h3>
<div className="flex flex-col gap-2">
{kinds.map((kind) => {
const primitive = PRIMITIVE_REGISTRY.get(kind);
if (!primitive) return null;
// Build a richer hover tooltip: the long description
// if present (full behaviour explanation), otherwise
// the short palette blurb. Adds the first example
// headline so users see a concrete use-case on hover.
const tooltipParts = [
primitive.longDescription ?? primitive.description,
];
if (primitive.examples && primitive.examples.length > 0) {
tooltipParts.push(
`\n\nExample — ${primitive.examples[0]!.title}: ${primitive.examples[0]!.effect}`,
{/* Left: Palette - Only show in form mode (VisualBuilderPane has its own) */}
{mode === 'form' && (
<aside className="w-64 border-r border-neutral-200 overflow-y-auto bg-neutral-50/50 flex flex-col p-4 gap-6 shrink-0">
{Object.entries(CATEGORIES).map(([category, kinds]) => (
<div key={category}>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-3">
{category}
</h3>
<div className="flex flex-col gap-2">
{kinds.map((kind) => {
const primitive = PRIMITIVE_REGISTRY.get(kind);
if (!primitive) return null;
// Build a richer hover tooltip: the long description
// if present (full behaviour explanation), otherwise
// the short palette blurb. Adds the first example
// headline so users see a concrete use-case on hover.
const tooltipParts = [
primitive.longDescription ?? primitive.description,
];
if (primitive.examples && primitive.examples.length > 0) {
tooltipParts.push(
`\n\nExample — ${primitive.examples[0]!.title}: ${primitive.examples[0]!.effect}`,
);
}
const tooltip = tooltipParts.join('');
return (
<button
key={kind}
data-testid={`custom-primitive-palette-${kind}`}
title={tooltip}
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
onClick={() => {
const newNode: EffectPrimitiveNode = {
kind,
params: generateDefaultParams(primitive.paramsSchema),
};
updateDescriptor((p) => ({
...p,
primitives: [...p.primitives, newNode],
}));
setSelectedIndex(descriptor.primitives.length); // select the newly added node
}}
>
<div className="text-sm font-semibold text-neutral-800">
{primitive.label}
</div>
<div className="text-xs text-neutral-500 truncate mt-0.5">
{primitive.description}
</div>
</button>
);
}
const tooltip = tooltipParts.join('');
return (
<button
key={kind}
data-testid={`custom-primitive-palette-${kind}`}
title={tooltip}
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
onClick={() => {
const newNode: EffectPrimitiveNode = {
kind,
params: generateDefaultParams(primitive.paramsSchema),
};
updateDescriptor((p) => ({
...p,
primitives: [...p.primitives, newNode],
}));
setSelectedIndex(descriptor.primitives.length); // select the newly added node
}}
>
<div className="text-sm font-semibold text-neutral-800">
{primitive.label}
</div>
<div className="text-xs text-neutral-500 truncate mt-0.5">
{primitive.description}
</div>
</button>
);
})}
})}
</div>
</div>
</div>
))}
</aside>
))}
</aside>
)}
{/* Center: Tree View */}
<main className="flex-[2] border-r border-neutral-200 overflow-y-auto bg-white p-6 flex flex-col">
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Effect Sequence ({descriptor.primitives.length})
</h3>
{descriptor.primitives.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400 italic">
Add primitives from the palette to build the modifier.
</div>
) : (
<div className="flex flex-col gap-2">
{descriptor.primitives.map((node, i) => {
const isSelected = selectedIndex === i;
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
return (
<div
key={i}
data-testid={`custom-primitive-node-${i}`}
onClick={() => setSelectedIndex(i)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors
${isSelected
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
: 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'}
`}
>
<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
${isSelected ? 'bg-blue-200 text-blue-800' : 'bg-neutral-100 text-neutral-500'}
`}>
{i + 1}
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold ${isSelected ? 'text-blue-900' : 'text-neutral-800'}`}>
{primitive?.label ?? node.kind}
</div>
<div className="text-xs text-neutral-500 truncate">
{JSON.stringify(node.params).substring(0, 60)}
{JSON.stringify(node.params).length > 60 ? '...' : ''}
</div>
</div>
</div>
<button
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors shrink-0"
onClick={(e) => {
e.stopPropagation();
updateDescriptor((p) => ({
...p,
primitives: p.primitives.filter((_, idx) => idx !== i),
}));
if (selectedIndex === i) setSelectedIndex(null);
else if (selectedIndex && selectedIndex > i) setSelectedIndex(selectedIndex - 1);
}}
title="Remove primitive"
>
×
</button>
</div>
);
})}
</div>
)}
</main>
{/* Right: Inspector */}
<aside
data-testid="custom-primitive-inspector"
className="flex-[1.5] bg-neutral-50/30 overflow-y-auto p-6"
>
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Parameter Inspector
</h3>
{selectedIndex === null || !descriptor.primitives[selectedIndex] ? (
<div className="flex items-center justify-center h-48 text-sm text-neutral-400 italic">
Select a primitive to edit its parameters.
</div>
) : (
<ParamField
node={descriptor.primitives[selectedIndex]}
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
allPrimitives={descriptor.primitives}
onChange={(newParams) => {
updateDescriptor((p) => {
const next = [...p.primitives];
next[selectedIndex] = { kind: next[selectedIndex]!.kind, params: newParams };
return { ...p, primitives: next };
});
}}
{mode === 'visual' ? (
<main className="flex-1 overflow-hidden">
<VisualBuilderPane
descriptor={descriptor}
onChange={setDescriptor}
validationResult={validationResult}
/>
)}
</aside>
</main>
) : (
<>
{/* Center: Tree View */}
<main className="flex-[2] border-r border-neutral-200 overflow-y-auto bg-white p-6 flex flex-col">
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Effect Sequence ({descriptor.primitives.length})
</h3>
{descriptor.primitives.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400 italic">
Add primitives from the palette to build the modifier.
</div>
) : (
<div className="flex flex-col gap-2">
{descriptor.primitives.map((node, i) => {
const isSelected = selectedIndex === i;
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
return (
<div
key={i}
data-testid={`custom-primitive-node-${i}`}
onClick={() => setSelectedIndex(i)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors
${isSelected
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
: 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'}
`}
>
<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
${isSelected ? 'bg-blue-200 text-blue-800' : 'bg-neutral-100 text-neutral-500'}
`}>
{i + 1}
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold ${isSelected ? 'text-blue-900' : 'text-neutral-800'}`}>
{primitive?.label ?? node.kind}
</div>
<div className="text-xs text-neutral-500 truncate">
{JSON.stringify(node.params).substring(0, 60)}
{JSON.stringify(node.params).length > 60 ? '...' : ''}
</div>
</div>
</div>
<button
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors shrink-0"
onClick={(e) => {
e.stopPropagation();
updateDescriptor((p) => ({
...p,
primitives: p.primitives.filter((_, idx) => idx !== i),
}));
if (selectedIndex === i) setSelectedIndex(null);
else if (selectedIndex && selectedIndex > i) setSelectedIndex(selectedIndex - 1);
}}
title="Remove primitive"
>
×
</button>
</div>
);
})}
</div>
)}
</main>
{/* Right: Inspector */}
<aside
data-testid="custom-primitive-inspector"
className="flex-[1.5] bg-neutral-50/30 overflow-y-auto p-6"
>
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Parameter Inspector
</h3>
{selectedIndex === null || !descriptor.primitives[selectedIndex] ? (
<div className="flex items-center justify-center h-48 text-sm text-neutral-400 italic">
Select a primitive to edit its parameters.
</div>
) : (
<ParamField
node={descriptor.primitives[selectedIndex]}
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
allPrimitives={descriptor.primitives}
onChange={(newParams) => {
updateDescriptor((p) => {
const next = [...p.primitives];
next[selectedIndex] = { kind: next[selectedIndex]!.kind, params: newParams };
return { ...p, primitives: next };
});
}}
/>
)}
</aside>
</>
)}
</div>
{/* Footer: Validation Status */}