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:
@@ -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);
|
||||
|
||||
@@ -135,6 +135,10 @@ export class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
selectStoryNode(id: string): void {
|
||||
useGameStore.getState().setSelectedStoryNodeId(id);
|
||||
}
|
||||
|
||||
toggleActionGroupCollapsed(groupKey: string): void {
|
||||
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export const useGameStore = create<GameStoreState>((set, get) => ({
|
||||
queuedActionIds: [],
|
||||
queuedActionNames: [],
|
||||
actions: [],
|
||||
story: { currentProse: null, choices: [], tree: [] },
|
||||
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
|
||||
actionColumns: [],
|
||||
log: [],
|
||||
storyHasUnread: false,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user