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.
This commit is contained in:
ginnoir
2026-06-11 22:25:51 -05:00
parent 91348ed42f
commit 880abe5888
6 changed files with 68 additions and 9 deletions
+49
View File
@@ -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', () => { describe('story tree projection', () => {
it('builds a tree marking seen and active nodes', () => { it('builds a tree marking seen and active nodes', () => {
const state = createGameState(content); const state = createGameState(content);
+4
View File
@@ -135,6 +135,10 @@ export class GameRuntime {
} }
} }
selectStoryNode(id: string): void {
useGameStore.getState().setSelectedStoryNodeId(id);
}
toggleActionGroupCollapsed(groupKey: string): void { toggleActionGroupCollapsed(groupKey: string): void {
useGameStore.getState().toggleActionGroupCollapsed(groupKey); useGameStore.getState().toggleActionGroupCollapsed(groupKey);
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export const useGameStore = create<GameStoreState>((set, get) => ({
queuedActionIds: [], queuedActionIds: [],
queuedActionNames: [], queuedActionNames: [],
actions: [], actions: [],
story: { currentProse: null, choices: [], tree: [] }, story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [], actionColumns: [],
log: [], log: [],
storyHasUnread: false, storyHasUnread: false,
+7
View File
@@ -71,6 +71,7 @@ export interface StoryTreeNodeView {
export interface StoryView { export interface StoryView {
currentProse: string | null; currentProse: string | null;
atBootIntro: boolean;
choices: StoryChoiceView[]; choices: StoryChoiceView[];
tree: StoryTreeNodeView[]; tree: StoryTreeNodeView[];
} }
@@ -215,8 +216,14 @@ export function toView(state: GameState, content: GameContent): GameView {
const availableChoices = getAvailableChoices(state, content); const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? []; 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 = { const story: StoryView = {
currentProse: node?.prose ?? null, currentProse: node?.prose ?? null,
atBootIntro,
choices: allChoices.map((choice) => { choices: allChoices.map((choice) => {
const available = availableChoices.some((c) => c.id === choice.id); const available = availableChoices.some((c) => c.id === choice.id);
return { return {
+1 -1
View File
@@ -36,7 +36,7 @@ function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps
className={labelClasses} className={labelClasses}
style={{ marginLeft: `${depth * 1}rem` }} style={{ marginLeft: `${depth * 1}rem` }}
onClick={() => onSelect(node.id)} onClick={() => onSelect(node.id)}
aria-pressed={isSelected} aria-current={isSelected ? 'true' : undefined}
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{node.active && ( {node.active && (
+6 -7
View File
@@ -3,19 +3,18 @@ import { useGameStore } from '../state/store';
import { StoryProseLog } from './StoryProseLog'; import { StoryProseLog } from './StoryProseLog';
import { StoryTree } from './StoryTree'; import { StoryTree } from './StoryTree';
const SHELL_HEIGHT = 'h-[calc(100dvh-6rem)]';
export function StoryView() { export function StoryView() {
const tree = useGameStore((s) => s.story.tree); const tree = useGameStore((s) => s.story.tree);
const log = useGameStore((s) => s.storyLog); const log = useGameStore((s) => s.storyLog);
const selectedId = useGameStore((s) => s.selectedStoryNodeId); const selectedId = useGameStore((s) => s.selectedStoryNodeId);
const currentProse = useGameStore((s) => s.story.currentProse); const currentProse = useGameStore((s) => s.story.currentProse);
const choicesLength = useGameStore((s) => s.story.choices.length); const isBootIntro = useGameStore((s) => s.story.atBootIntro);
// Boot intro: show Continue button when there's prose, no choices, and no tree yet
const isBootIntro = currentProse != null && choicesLength === 0 && tree.length === 0;
if (isBootIntro) { if (isBootIntro) {
return ( return (
<div className="flex h-[calc(100dvh-6rem)] flex-col items-center justify-center gap-6"> <div className={`flex ${SHELL_HEIGHT} flex-col items-center justify-center gap-6`}>
<p className="max-w-lg text-center text-lg leading-relaxed text-slate-100 whitespace-pre-wrap"> <p className="max-w-lg text-center text-lg leading-relaxed text-slate-100 whitespace-pre-wrap">
{currentProse} {currentProse}
</p> </p>
@@ -31,11 +30,11 @@ export function StoryView() {
} }
return ( return (
<div className="grid h-[calc(100dvh-6rem)] grid-cols-[3fr_2fr] gap-4"> <div className={`grid ${SHELL_HEIGHT} grid-cols-[3fr_2fr] gap-4`}>
<StoryTree <StoryTree
nodes={tree} nodes={tree}
selectedId={selectedId} selectedId={selectedId}
onSelect={(id) => useGameStore.getState().setSelectedStoryNodeId(id)} onSelect={(id) => gameRuntime.selectStoryNode(id)}
/> />
<StoryProseLog log={log} selectedId={selectedId} /> <StoryProseLog log={log} selectedId={selectedId} />
</div> </div>