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', () => {
|
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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user