Files
idlegame/src/ui/StoryTree.tsx
T
ginnoir 880abe5888 fix(ui): restore boot-intro continue and fix story tree a11y
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.
2026-06-11 22:25:51 -05:00

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>
);
}