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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{node.active && (
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
{currentProse}
|
||||
</p>
|
||||
@@ -31,11 +30,11 @@ export function StoryView() {
|
||||
}
|
||||
|
||||
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
|
||||
nodes={tree}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => useGameStore.getState().setSelectedStoryNodeId(id)}
|
||||
onSelect={(id) => gameRuntime.selectStoryNode(id)}
|
||||
/>
|
||||
<StoryProseLog log={log} selectedId={selectedId} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user