Finding 1 (critical): atBootIntro was derived from tree.length === 0 in
StoryView.tsx, but buildStoryTree always returns all topology-root nodes,
so tree.length is always > 0 and the Continue button never rendered.
Fix: add atBootIntro: boolean to StoryView (viewModel), computed by
finding the boot-trigger node id without hardcoding the literal string,
then comparing to the current node. StoryView.tsx reads it from the store.
Also add a viewModel test covering both true and false cases.
Finding 2: onSelect in StoryView.tsx called useGameStore.getState()
directly, bypassing the runtime command layer. Fix: add
GameRuntime.selectStoryNode(id) and wire onSelect through gameRuntime.
Finding 3: aria-pressed on story tree nodes wrongly signals toggle-button
semantics. Fix: replace with aria-current={isSelected ? 'true' : undefined}.
Optional: hoist h-[calc(100dvh-6rem)] to SHELL_HEIGHT const in StoryView.tsx.
93 lines
2.6 KiB
TypeScript
93 lines
2.6 KiB
TypeScript
import type { StoryTreeNodeView } from '../state/viewModel';
|
|
|
|
interface StoryTreeProps {
|
|
nodes: StoryTreeNodeView[];
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
}
|
|
|
|
interface StoryTreeNodeProps {
|
|
node: StoryTreeNodeView;
|
|
depth: number;
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
}
|
|
|
|
function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps) {
|
|
const isSelected = node.id === selectedId;
|
|
|
|
let labelClasses =
|
|
'w-full cursor-pointer rounded px-2 py-1 text-left text-sm transition-colors duration-150';
|
|
|
|
if (isSelected) {
|
|
labelClasses += ' bg-amber-500/15 border border-amber-500/40 text-amber-200';
|
|
} else if (node.active) {
|
|
labelClasses += ' text-amber-400 hover:bg-slate-800/60 border border-transparent';
|
|
} else if (!node.seen) {
|
|
labelClasses += ' text-slate-600 hover:bg-slate-800/40 border border-transparent';
|
|
} else {
|
|
labelClasses += ' text-slate-300 hover:bg-slate-800/60 border border-transparent';
|
|
}
|
|
|
|
return (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
className={labelClasses}
|
|
style={{ marginLeft: `${depth * 1}rem` }}
|
|
onClick={() => onSelect(node.id)}
|
|
aria-current={isSelected ? 'true' : undefined}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
{node.active && (
|
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
|
)}
|
|
{node.label}
|
|
</span>
|
|
</button>
|
|
{node.children.length > 0 && (
|
|
<ul className="mt-0.5 space-y-0.5">
|
|
{node.children.map((child) => (
|
|
<StoryTreeNode
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function StoryTree({ nodes, selectedId, onSelect }: StoryTreeProps) {
|
|
if (nodes.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-sm text-slate-500 italic">No story branches yet…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-y-auto rounded-xl border border-slate-800/60 bg-slate-900/40 p-4 shadow-inner">
|
|
<h2 className="mb-3 text-xs font-semibold tracking-wider text-slate-400 uppercase">
|
|
Story Tree
|
|
</h2>
|
|
<ul className="space-y-0.5">
|
|
{nodes.map((node) => (
|
|
<StoryTreeNode
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|