From 880abe5888d67c3a60bf95c6869890cad2adf541 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 22:25:51 -0500 Subject: [PATCH] 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. --- src/state/__tests__/viewModel.test.ts | 49 +++++++++++++++++++++++++++ src/state/runtime.ts | 4 +++ src/state/store.ts | 2 +- src/state/viewModel.ts | 7 ++++ src/ui/StoryTree.tsx | 2 +- src/ui/StoryView.tsx | 13 ++++--- 6 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 089c315..157d2a5 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -256,6 +256,55 @@ describe('action columns projection', () => { }); }); +describe('story.atBootIntro', () => { + it('is true at the boot-entry node and false after entering a different node', () => { + const base = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + ], + base.actionsById, + base.resourcesById, + ); + const c = { ...base, ...story }; + const state = createGameState(c); + // Simulate boot: enter the boot-entry node + enterStoryNode(state, c, 'boot_intro'); + expect(toView(state, c).story.atBootIntro).toBe(true); + // After moving past boot into fork_choice, atBootIntro must be false + enterStoryNode(state, c, 'fork_choice'); + expect(toView(state, c).story.atBootIntro).toBe(false); + }); +}); + describe('story tree projection', () => { it('builds a tree marking seen and active nodes', () => { const state = createGameState(content); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index fd0c1dc..ead630f 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -135,6 +135,10 @@ export class GameRuntime { } } + selectStoryNode(id: string): void { + useGameStore.getState().setSelectedStoryNodeId(id); + } + toggleActionGroupCollapsed(groupKey: string): void { useGameStore.getState().toggleActionGroupCollapsed(groupKey); } diff --git a/src/state/store.ts b/src/state/store.ts index 850ad63..90e010e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -43,7 +43,7 @@ export const useGameStore = create((set, get) => ({ queuedActionIds: [], queuedActionNames: [], actions: [], - story: { currentProse: null, choices: [], tree: [] }, + story: { currentProse: null, atBootIntro: false, choices: [], tree: [] }, actionColumns: [], log: [], storyHasUnread: false, diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 561a2a8..c49e35a 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -71,6 +71,7 @@ export interface StoryTreeNodeView { export interface StoryView { currentProse: string | null; + atBootIntro: boolean; choices: StoryChoiceView[]; tree: StoryTreeNodeView[]; } @@ -215,8 +216,14 @@ export function toView(state: GameState, content: GameContent): GameView { const availableChoices = getAvailableChoices(state, content); const allChoices = node?.choices ?? []; + const bootEntryNodeId = content.storyNodes.find((n) => + n.triggers?.some((t) => t.type === 'boot'), + )?.id; + const atBootIntro = node != null && node.id === bootEntryNodeId; + const story: StoryView = { currentProse: node?.prose ?? null, + atBootIntro, choices: allChoices.map((choice) => { const available = availableChoices.some((c) => c.id === choice.id); return { diff --git a/src/ui/StoryTree.tsx b/src/ui/StoryTree.tsx index f855594..26cda2f 100644 --- a/src/ui/StoryTree.tsx +++ b/src/ui/StoryTree.tsx @@ -36,7 +36,7 @@ function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps className={labelClasses} style={{ marginLeft: `${depth * 1}rem` }} onClick={() => onSelect(node.id)} - aria-pressed={isSelected} + aria-current={isSelected ? 'true' : undefined} > {node.active && ( diff --git a/src/ui/StoryView.tsx b/src/ui/StoryView.tsx index 332e116..dd932ea 100644 --- a/src/ui/StoryView.tsx +++ b/src/ui/StoryView.tsx @@ -3,19 +3,18 @@ import { useGameStore } from '../state/store'; import { StoryProseLog } from './StoryProseLog'; import { StoryTree } from './StoryTree'; +const SHELL_HEIGHT = 'h-[calc(100dvh-6rem)]'; + export function StoryView() { const tree = useGameStore((s) => s.story.tree); const log = useGameStore((s) => s.storyLog); const selectedId = useGameStore((s) => s.selectedStoryNodeId); const currentProse = useGameStore((s) => s.story.currentProse); - const choicesLength = useGameStore((s) => s.story.choices.length); - - // Boot intro: show Continue button when there's prose, no choices, and no tree yet - const isBootIntro = currentProse != null && choicesLength === 0 && tree.length === 0; + const isBootIntro = useGameStore((s) => s.story.atBootIntro); if (isBootIntro) { return ( -
+

{currentProse}

@@ -31,11 +30,11 @@ export function StoryView() { } return ( -
+
useGameStore.getState().setSelectedStoryNodeId(id)} + onSelect={(id) => gameRuntime.selectStoryNode(id)} />