# M1 PR3 T3.0 — Shell UI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the PR2 single-column overlay UX with a three-region app shell (nav / center / right rail), action columns by behavior kind with collapsible groups, story forks via Play actions only, and a Story tab with branching tree + prose log. **Architecture:** Extend content schema with `kind` + `group`; add `performAction` dispatcher in pure engine (`instant`, `timed`, `loop`, `story`, `context`); view model projects actions into columns/groups; store tracks `activePanel` instead of overlay open state; runtime routes story beats to nav badge/auto-switch. **Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm, React 19, Zustand, Tailwind 4. **Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md` **Follow-on plan:** `docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md` (T3.1 automation + recipes) **Branch:** `feat/m1-progression` off `main`. **Worktree:** Create/use isolated worktree before starting (`superpowers:using-git-worktrees`). --- ## File map | File | Responsibility | |---|---| | `src/content/schema.ts` | `kind`, `group`, optional `durationMs`, `storyChoiceId`, `loopPriority`; relaxed yields validation | | `src/content/__tests__/schema.test.ts` | Schema tests for kinds/groups/storyChoiceId | | `src/content/definitions.ts` | Migrate stub actions; add `pick_high_road`, `follow_river` story actions | | `src/content/storySchema.ts` | Export choice id index helper for buildContent cross-check | | `src/engine/game.ts` | `performAction`, `executeInstant`, loop idle runner, `enabledLoopActionIds` state | | `src/engine/__tests__/game.test.ts` | instant/loop/story dispatch tests | | `src/engine/story.ts` | `isStoryChoiceActionAvailable` helper | | `src/engine/save.ts` | Persist `enabledLoopActionIds` | | `src/state/viewModel.ts` | Column/group projection, story tree nodes, remove choice buttons from StoryView | | `src/state/__tests__/viewModel.test.ts` | Column projection + story tree tests | | `src/state/store.ts` | `activePanel`, remove `storyPanelOpen`; keep `storyHasUnread` | | `src/state/prefs.ts` | `collapsedActionGroups`, `showEventLog`; bump prefs key to v2 | | `src/state/storyOrchestration.ts` | Remap auto-open → nav switch signal | | `src/state/runtime.ts` | `performAction`, `setActivePanel`, story action wiring, loop post-tick | | `src/ui/AppShell.tsx` | **Create** — grid layout | | `src/ui/NavRail.tsx` | **Create** — Play/Story/Settings/About | | `src/ui/PlayPanel.tsx` | **Create** — column grid wrapper | | `src/ui/ActionColumn.tsx` | **Create** — one kind column | | `src/ui/ActionGroup.tsx` | **Create** — collapsible group + cards | | `src/ui/ActionCard.tsx` | **Create** — extract card from ActionPanel | | `src/ui/StoryView.tsx` | **Create** — 60/40 split | | `src/ui/StoryTree.tsx` | **Create** — indented tree from graph | | `src/ui/StoryProseLog.tsx` | **Create** — scrollable prose | | `src/ui/RightRail.tsx` | **Create** — resources, inventory placeholder, event log | | `src/ui/SettingsPanel.tsx` | **Create** — move SettingsDrawer content | | `src/ui/AboutPanel.tsx` | **Create** — static stub | | `src/ui/App.tsx` | Wire AppShell; remove overlay | | `src/ui/StoryPanel.tsx` | **Delete** | | `src/ui/ActionPanel.tsx` | **Delete** after extraction | | `src/ui/ResourceBar.tsx` | **Delete** — absorbed by RightRail | | `src/ui/EventLog.tsx` | **Delete** — absorbed by RightRail | | `docs/architecture.md` | Shell + action kinds section | --- ### Task 1: Action kind schema **Files:** - Modify: `src/content/schema.ts` - Modify: `src/content/__tests__/schema.test.ts` - [ ] **Step 1: Write the failing test** Add to `src/content/__tests__/schema.test.ts`: ```typescript describe('action kind schema', () => { it('accepts kind, group, and storyChoiceId', () => { const content = buildContent({ resources: [{ id: 'supplies', name: 'Supplies' }], actions: [ { id: 'pick_high_road', name: 'Take the high road', kind: 'story', group: { id: 'fork', label: 'Crossroads' }, storyChoiceId: 'pick_a', yields: [], }, ], }); expect(content.actionsById.pick_high_road.kind).toBe('story'); expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads'); }); it('defaults kind to timed and requires durationMs for timed actions', () => { expect(() => buildContent({ resources: [{ id: 'supplies', name: 'Supplies' }], actions: [ { id: 'broken', name: 'Broken', kind: 'timed', group: { id: 'camp', label: 'Camp' }, yields: [{ resourceId: 'supplies', amount: 1 }], }, ], }), ).toThrow(); }); it('requires durationMs for loop actions', () => { const content = buildContent({ resources: [{ id: 'supplies', name: 'Supplies' }], actions: [ { id: 'rest', name: 'Rest', kind: 'loop', group: { id: 'camp_loop', label: 'Camp activities' }, durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], }, ], }); expect(content.actionsById.rest.kind).toBe('loop'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/content/__tests__/schema.test.ts -t "action kind"` Expected: FAIL — unknown keys / missing kind. - [ ] **Step 3: Implement schema** Replace `actionDefSchema` in `src/content/schema.ts`: ```typescript export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']); export const actionGroupSchema = z.object({ id: z.string().min(1), label: z.string().min(1), }); export const actionDefSchema = z .object({ id: z.string().min(1), name: z.string().min(1), kind: actionKindSchema.default('timed'), group: actionGroupSchema, durationMs: z.number().positive().optional(), loopPriority: z.number().int().nonnegative().optional(), costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).default([]), unlock: unlockDefSchema.optional(), storyHint: z.string().min(1).optional(), storyTooltip: z.string().min(1).optional(), storyChoiceId: z.string().min(1).optional(), contextId: z.string().min(1).optional(), automation: z .object({ unlockAfterManualCompletions: z.number().int().positive().default(1), }) .optional(), }) .superRefine((action, ctx) => { if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) { ctx.addIssue({ code: 'custom', message: `${action.kind} actions require durationMs`, path: ['durationMs'], }); } if (action.kind === 'story' && !action.storyChoiceId) { ctx.addIssue({ code: 'custom', message: 'story actions require storyChoiceId', path: ['storyChoiceId'], }); } if (action.kind === 'timed' && action.yields.length === 0) { ctx.addIssue({ code: 'custom', message: 'timed actions require at least one yield', path: ['yields'], }); } }); export type ActionKind = z.infer; export type ActionGroup = z.infer; ``` Export `ActionKind`, `ActionGroup` from `src/content/index.ts`. - [ ] **Step 4: Run tests** Run: `pnpm test src/content/__tests__/schema.test.ts` Expected: PASS (update any existing tests that omit `group` / `kind`). - [ ] **Step 5: Commit** ```bash git add src/content/schema.ts src/content/__tests__/schema.test.ts src/content/index.ts git commit -m "feat(content): add action kind and group schema" ``` --- ### Task 2: Migrate stub action definitions **Files:** - Modify: `src/content/definitions.ts` - Modify: `src/content/__tests__/definitions.test.ts` - [ ] **Step 1: Update all action defs with kind + group** Replace `src/content/definitions.ts` action entries (keep resource defs): ```typescript export const actionDefs = [ { id: 'gather_supplies', name: 'Gather supplies', kind: 'timed', group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }], storyHint: 'Basic camp labor.', storyTooltip: 'Yields 2 Supplies. No cost.', }, { id: 'scout_path', name: 'Scout the path', kind: 'timed', group: { id: 'travel', label: 'Travel' }, durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }], storyHint: 'Map the crossing.', storyTooltip: 'Costs 2 Supplies. Yields 1 Coin. Triggers scout aftermath story.', }, { id: 'trade_supplies', name: 'Trade at camp', kind: 'timed', group: { id: 'camp', label: 'Camp' }, durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], unlock: { minResources: { coin: 1 } }, storyHint: 'Barter with travelers.', storyTooltip: 'Costs 3 Supplies. Yields 2 Coin. Unlocks at 1 Coin.', }, { id: 'fortify_camp', name: 'Fortify camp', kind: 'timed', group: { id: 'camp', label: 'Camp' }, durationMs: 8000, costs: [ { resourceId: 'supplies', amount: 5 }, { resourceId: 'coin', amount: 2 }, ], yields: [{ resourceId: 'supplies', amount: 4 }], unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] }, storyHint: 'Walls for the high road camp.', storyTooltip: 'Route A only. Costs supplies and coin.', }, { id: 'push_onward', name: 'Push onward', kind: 'timed', group: { id: 'travel', label: 'Travel' }, durationMs: 6000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 3 }], unlock: { requireStoryFlags: ['route_b'] }, storyHint: 'Follow the river route.', storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.', }, { id: 'rest', name: 'Rest briefly', kind: 'loop', group: { id: 'camp_loop', label: 'Camp activities' }, loopPriority: 0, durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], storyHint: 'Catch your breath.', storyTooltip: 'Idle upkeep — runs when nothing else is queued.', }, { id: 'pick_high_road', name: 'Take the high road', kind: 'story', group: { id: 'fork', label: 'Crossroads' }, storyChoiceId: 'pick_a', unlock: { requireStoryFlags: [] }, storyHint: 'Route A — high ground and supplies.', storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.', yields: [], }, { id: 'follow_river', name: 'Follow the river', kind: 'story', group: { id: 'fork', label: 'Crossroads' }, storyChoiceId: 'pick_b', storyHint: 'Route B — river trade and coin.', storyTooltip: 'Story fork: grants route B flag. Hides high road path.', yields: [], }, ]; ``` Add story action unlock: use custom unlock in view model (Task 6) — for content, add optional `unlock.requireAtStoryNodeId: 'fork_choice'` to unlock schema in Task 6. - [ ] **Step 2: Fix failing content tests** Run: `pnpm test src/content/__tests__/definitions.test.ts` Update tests to use `performAction` or `enqueueAction` on timed ids only. - [ ] **Step 3: Commit** ```bash git add src/content/definitions.ts src/content/__tests__/definitions.test.ts git commit -m "feat(content): migrate stub actions to kind/group model" ``` --- ### Task 3: Instant action execution **Files:** - Modify: `src/engine/game.ts` - Modify: `src/engine/__tests__/game.test.ts` - [ ] **Step 1: Write the failing test** ```typescript describe('executeInstant()', () => { it('applies costs and yields immediately without queueing', () => { const content = buildContent({ resources: [{ id: 'coin', name: 'Coin', startAmount: 5 }], actions: [ { id: 'buy_supply', name: 'Buy supply', kind: 'instant', group: { id: 'buy', label: 'Buy' }, costs: [{ resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'coin', amount: 0 }], }, ], }); // fix yields - instant needs at least one yield or allow empty; use supplies: const state = createGameState(content); // ... use proper test fixture from Task 1 pattern }); }); ``` Use a minimal inline fixture: ```typescript describe('executeInstant()', () => { const instantContent = buildContent({ resources: [ { id: 'supplies', name: 'Supplies', startAmount: 0 }, { id: 'coin', name: 'Coin', startAmount: 5 }, ], actions: [ { id: 'buy_supply', name: 'Buy supply', kind: 'instant', group: { id: 'buy', label: 'Buy' }, costs: [{ resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 1 }], }, ], }); it('applies costs and yields immediately without queueing', () => { const state = createGameState(instantContent); executeInstant(state, instantContent, 'buy_supply'); expect(state.resources.coin).toBe(3); expect(state.resources.supplies).toBe(1); expect(state.activeActionId).toBeNull(); expect(state.actionQueue).toEqual([]); }); it('throws when unaffordable', () => { const state = createGameState(instantContent); state.resources.coin = 0; expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/engine/__tests__/game.test.ts -t "executeInstant"` Expected: FAIL — `executeInstant` not defined. - [ ] **Step 3: Implement executeInstant** Add to `src/engine/game.ts`: ```typescript export function executeInstant(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action || action.kind !== 'instant') { throw new Error(`Action "${actionId}" is not instant`); } if (!isActionAvailable(state, content, actionId)) { throw new Error(`Cannot perform instant action "${actionId}"`); } deductCosts(state, content, actionId); grantYields(state, content, actionId); } ``` - [ ] **Step 4: Run tests** Run: `pnpm test src/engine/__tests__/game.test.ts -t "executeInstant"` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/__tests__/game.test.ts git commit -m "feat(engine): add instant action execution" ``` --- ### Task 4: Story action execution **Files:** - Modify: `src/engine/story.ts` - Modify: `src/engine/game.ts` - Create: `src/engine/__tests__/storyActions.test.ts` - [ ] **Step 1: Write the failing test** Create `src/engine/__tests__/storyActions.test.ts`: ```typescript import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; import { storyNodeDefs } from '../../content/story'; import { createGameState } from '../game'; import { enterStoryNode } from '../story'; import { executeStoryAction } from '../game'; const story = buildStoryContent(storyNodeDefs, []); const base = buildContent({ resources: [ { id: 'supplies', name: 'Supplies', startAmount: 10 }, { id: 'coin', name: 'Coin', startAmount: 0 }, ], actions: [ { id: 'pick_high_road', name: 'Take the high road', kind: 'story', group: { id: 'fork', label: 'Fork' }, storyChoiceId: 'pick_a', yields: [], }, { id: 'follow_river', name: 'Follow the river', kind: 'story', group: { id: 'fork', label: 'Fork' }, storyChoiceId: 'pick_b', yields: [], }, ], }); const content = { ...base, ...story }; describe('executeStoryAction()', () => { it('applies the linked story choice', () => { const state = createGameState(content); enterStoryNode(state, content, 'fork_choice'); executeStoryAction(state, content, 'pick_high_road'); expect(state.storyFlags.route_a).toBe(true); expect(state.currentStoryNodeId).toBe('route_a_beat'); }); it('throws when choice is not available', () => { const state = createGameState(content); enterStoryNode(state, content, 'fork_choice'); executeStoryAction(state, content, 'pick_high_road'); expect(() => executeStoryAction(state, content, 'follow_river')).toThrow(); }); }); ``` Add helper in `src/engine/story.ts`: ```typescript export function isStoryChoiceAvailable( state: GameState, content: GameContent, storyChoiceId: string, ): boolean { const node = getCurrentNode(state, content); if (!node?.choices) return false; const choice = node.choices.find((c) => c.id === storyChoiceId); if (!choice) return false; return meetsChoiceRequirements(state, choice.requirements); } ``` Export `meetsChoiceRequirements` or wrap it — if private, duplicate check via `getAvailableChoices`. - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/engine/__tests__/storyActions.test.ts` Expected: FAIL - [ ] **Step 3: Implement executeStoryAction** In `src/engine/game.ts`: ```typescript import { applyChoice, isStoryChoiceAvailable } from './story'; import type { GameContent } from '../content/index'; export function executeStoryAction(state: GameState, content: GameContent, actionId: string): void { const action = content.actionsById[actionId]; if (!action || action.kind !== 'story' || !action.storyChoiceId) { throw new Error(`Action "${actionId}" is not a story action`); } if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { throw new Error(`Story choice "${action.storyChoiceId}" is not available`); } applyChoice(state, content, action.storyChoiceId); } ``` Note: `game.ts` currently imports `Content` only — switch story execution imports to use a minimal interface or import `GameContent` from content index. **Engine purity test** allows importing from `content/schema` and `content/storySchema` but not `content/index` if it pulls React — check `purity.test.ts`; if blocked, pass choice availability check via injected callback. Current codebase imports `Content` from schema in game.ts — use `GameContent` type from a types-only re-export or duplicate the intersection in story.ts. Preferred: add `import type { GameContent } from '../content/index'` — verify purity test still passes (index is data-only). - [ ] **Step 4: Run tests** Run: `pnpm test src/engine/__tests__/storyActions.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/story.ts src/engine/__tests__/storyActions.test.ts git commit -m "feat(engine): execute story actions via storyChoiceId" ``` --- ### Task 5: Loop idle runner + enabledLoopActionIds **Files:** - Modify: `src/engine/game.ts` - Modify: `src/engine/save.ts` - Modify: `src/engine/__tests__/game.test.ts` - [ ] **Step 1: Write the failing test** Extend `GameState` test + loop runner: ```typescript describe('loop idle runner', () => { const loopContent = buildContent({ resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], actions: [ { id: 'rest', name: 'Rest', kind: 'loop', group: { id: 'camp_loop', label: 'Camp' }, durationMs: 1000, loopPriority: 0, yields: [{ resourceId: 'supplies', amount: 1 }], }, ], }); it('starts enabled loop action when idle', () => { const state = createGameState(loopContent); state.enabledLoopActionIds = { rest: true }; maybeStartLoopAction(state, loopContent); expect(state.activeActionId).toBe('rest'); }); it('does not start loop when queue has items', () => { const state = createGameState(loopContent); state.enabledLoopActionIds = { rest: true }; state.actionQueue.push('rest'); maybeStartLoopAction(state, loopContent); expect(state.activeActionId).toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/engine/__tests__/game.test.ts -t "loop idle"` Expected: FAIL - [ ] **Step 3: Extend GameState and implement maybeStartLoopAction** ```typescript export interface GameState { // ...existing fields enabledLoopActionIds: Record; } // in createGameState: enabledLoopActionIds: {}, export function maybeStartLoopAction(state: GameState, content: Content): void { if (state.activeActionId !== null || state.actionQueue.length > 0) return; const candidates = content.actions .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); for (const action of candidates) { if (isActionAvailable(state, content, action.id)) { beginAction(state, content, action.id); return; } } } ``` After `startNextFromQueue` leaves idle, call `maybeStartLoopAction`. When loop action completes in `tickGame`, if still idle, call `maybeStartLoopAction` again for repeat. Update `save.ts` `gameStateSchema`: ```typescript enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), ``` - [ ] **Step 4: Run tests + save tests** Run: `pnpm test src/engine` Expected: PASS (update save round-trip test) - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/save.ts src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts git commit -m "feat(engine): loop idle runner with enabledLoopActionIds" ``` --- ### Task 6: performAction dispatcher **Files:** - Modify: `src/engine/game.ts` - Modify: `src/engine/__tests__/game.test.ts` - [ ] **Step 1: Write the failing test** ```typescript describe('performAction()', () => { it('dispatches timed actions through enqueueAction', () => { const content = testContent(); // existing timed fixture — add group/kind const state = createGameState(content); performAction(state, content, 'forage'); expect(state.activeActionId).toBe('forage'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/engine/__tests__/game.test.ts -t "performAction"` - [ ] **Step 3: Implement performAction** ```typescript export function performAction(state: GameState, content: GameContent, actionId: string): void { const action = content.actionsById[actionId]; if (!action) throw new Error(`Unknown action "${actionId}"`); switch (action.kind) { case 'instant': executeInstant(state, content, actionId); break; case 'timed': enqueueAction(state, content, actionId); break; case 'loop': { if (!isActionAvailable(state, content, actionId)) { throw new Error(`Cannot enable loop action "${actionId}"`); } state.enabledLoopActionIds[actionId] = !state.enabledLoopActionIds[actionId]; if (state.enabledLoopActionIds[actionId]) { maybeStartLoopAction(state, content); } break; } case 'story': executeStoryAction(state, content, actionId); break; case 'context': throw new Error(`Context action "${actionId}" is not implemented`); default: throw new Error(`Unknown action kind`); } } ``` - [ ] **Step 4: Run full engine tests** Run: `pnpm test src/engine` - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/__tests__/game.test.ts git commit -m "feat(engine): add performAction dispatcher by kind" ``` --- ### Task 7: View model column projection **Files:** - Modify: `src/state/viewModel.ts` - Modify: `src/state/__tests__/viewModel.test.ts` - [ ] **Step 1: Write the failing test** ```typescript describe('action columns projection', () => { it('groups actions by kind column and group', () => { const view = toView(stateWithMixedActions, content); const kinds = view.actionColumns.map((c) => c.kind); expect(kinds).toEqual(['instant', 'loop', 'timed', 'story', 'context']); const timed = view.actionColumns.find((c) => c.kind === 'timed'); expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true); }); it('hides unavailable story actions after fork taken', () => { // after pick_a, follow_river action.available === false }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "action columns"` - [ ] **Step 3: Implement projection** Add types: ```typescript export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const; export interface ActionGroupView { id: string; label: string; actions: ActionView[]; } export interface ActionColumnView { kind: (typeof ACTION_COLUMN_ORDER)[number]; label: string; groups: ActionGroupView[]; } export interface StoryTreeNodeView { id: string; label: string; seen: boolean; active: boolean; children: StoryTreeNodeView[]; } export interface StoryView { currentProse: string | null; selectedNodeId: string | null; tree: StoryTreeNodeView[]; log: StoryLogEntryView[]; } ``` Add `kind` to `ActionView`. Build columns by filtering `content.actions` into kind buckets, then group by `action.group.id`. Story action availability: use `isStoryChoiceAvailable` when `kind === 'story'`. Remove `choices` from `StoryView`. Implement `buildStoryTree(content, state)` — minimal: start at `boot_intro`, recurse choices where `seenStoryNodeIds` includes target or flag set on taken branch. - [ ] **Step 4: Run tests** Run: `pnpm test src/state/__tests__/viewModel.test.ts` - [ ] **Step 5: Commit** ```bash git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts git commit -m "feat(state): project actions into columns and story tree" ``` --- ### Task 8: Store nav panel + prefs **Files:** - Modify: `src/state/store.ts` - Modify: `src/state/prefs.ts` - Modify: `src/state/__tests__/prefs.test.ts` - [ ] **Step 1: Write the failing test** ```typescript describe('expanded prefs', () => { it('defaults collapsedActionGroups and showEventLog', () => { const prefs = getPrefs(); expect(prefs.collapsedActionGroups).toEqual({}); expect(prefs.showEventLog).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/state/__tests__/prefs.test.ts -t "expanded prefs"` - [ ] **Step 3: Update prefs and store** `src/state/prefs.ts`: ```typescript const PREFS_KEY = 'idlegame:prefs:v2'; export type ActivePanel = 'play' | 'story' | 'settings' | 'about'; export interface GamePrefs { storyOpenMode: StoryOpenMode; actionDetailMode: ActionDetailMode; collapsedActionGroups: Record; showEventLog: boolean; } const DEFAULTS: GamePrefs = { storyOpenMode: 'auto', actionDetailMode: 'inline', collapsedActionGroups: {}, showEventLog: true, }; ``` `src/state/store.ts` — replace overlay state: ```typescript activePanel: ActivePanel; selectedStoryNodeId: string | null; // remove: storyPanelOpen, settingsOpen setActivePanel: (panel: ActivePanel) => void; setSelectedStoryNodeId: (id: string | null) => void; toggleActionGroupCollapsed: (groupKey: string) => void; ``` `groupKey` format: `${kind}:${groupId}`. - [ ] **Step 4: Run tests** Run: `pnpm test src/state/__tests__/prefs.test.ts` - [ ] **Step 5: Commit** ```bash git add src/state/store.ts src/state/prefs.ts src/state/__tests__/prefs.test.ts git commit -m "feat(state): nav panel store and expanded prefs" ``` --- ### Task 9: Runtime — performAction + nav story signals **Files:** - Modify: `src/state/runtime.ts` - Modify: `src/state/storyOrchestration.ts` - [ ] **Step 1: Replace enqueueAction/applyStoryChoice with performAction** In `runtime.ts`: ```typescript performAction(actionId: string): void { const state = this.state; if (!state) return; try { const action = content.actionsById[actionId]; if (!action) return; const wasStory = action.kind === 'story'; performActionEngine(state, content, actionId); if (wasStory) { const events = /* last story events from applyChoice — refactor applyChoice to return events already */; // reuse storyEventsToLogEntries + appendStoryLog this.runPublishTriggers(); } // log line this.publish(); } catch (err) { useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed'); } } setActivePanel(panel: ActivePanel): void { const store = useGameStore.getState(); store.setActivePanel(panel); if (panel === 'story') store.setStoryHasUnread(false); } ``` Remove: `openStoryPanel`, `closeStoryPanel`, `applyStoryChoice`, `continueStory` choice path. Update `applyStoryUiEffect`: ```typescript if (effect.shouldOpenPanel) { if (prefs.storyOpenMode === 'auto') { store.setActivePanel('story'); store.setStoryHasUnread(false); } else { store.setStoryHasUnread(true); } } ``` Remap `shouldAutoOpenPanel` in `storyOrchestration.ts` — rename to `shouldAutoNavigateToStory` (same logic, semantic change). After `tickGame` completions, call `maybeStartLoopAction(state, content)`. Wire `boot_intro` auto-advance: on `continueStory` equivalent, use `enterStoryNode` to `fork_choice` when user opens Story tab on first beat — or auto-enter on boot via existing trigger (no Continue button needed if prose shows in log). - [ ] **Step 2: Run state tests** Run: `pnpm test src/state` - [ ] **Step 3: Commit** ```bash git add src/state/runtime.ts src/state/storyOrchestration.ts git commit -m "feat(state): runtime performAction and nav story signals" ``` --- ### Task 10: AppShell + NavRail **Files:** - Create: `src/ui/AppShell.tsx` - Create: `src/ui/NavRail.tsx` - Modify: `src/ui/App.tsx` - [ ] **Step 1: Create AppShell layout** `src/ui/AppShell.tsx`: ```tsx import type { ReactNode } from 'react'; interface AppShellProps { nav: ReactNode; center: ReactNode; right?: ReactNode; } export function AppShell({ nav, center, right }: AppShellProps) { return (
{nav}
{center}
{right ? ( ) : null}
); } ``` `src/ui/NavRail.tsx` — buttons call `gameRuntime.setActivePanel`, Story shows badge when `storyHasUnread`. - [ ] **Step 2: Wire App.tsx** ```tsx export function App() { const activePanel = useGameStore((s) => s.activePanel); useEffect(() => { void gameRuntime.boot(); }, []); return ( } center={ activePanel === 'play' ? : activePanel === 'story' ? : activePanel === 'settings' ? : } right={activePanel === 'play' ? : undefined} /> ); } ``` - [ ] **Step 3: Manual smoke** Run: `pnpm dev` — verify nav switches panels, no overlay. - [ ] **Step 4: Commit** ```bash git add src/ui/AppShell.tsx src/ui/NavRail.tsx src/ui/App.tsx git commit -m "feat(ui): app shell with nav rail" ``` --- ### Task 11: PlayPanel action columns **Files:** - Create: `src/ui/PlayPanel.tsx` - Create: `src/ui/ActionColumn.tsx` - Create: `src/ui/ActionGroup.tsx` - Create: `src/ui/ActionCard.tsx` - [ ] **Step 1: Build column grid** `PlayPanel.tsx`: ```tsx const columns = useGameStore((s) => s.actionColumns); const COLUMN_LABELS: Record = { instant: 'Instant', loop: 'Loop', timed: 'Timed', story: 'Story', context: 'Context', }; return (
{columns.map((col) => ( ))}
); ``` `ActionGroup.tsx` — collapse toggle calls `gameRuntime.toggleActionGroupCollapsed(`${kind}:${groupId}`)`. `ActionCard.tsx` — migrate from `ActionPanel.tsx`; story kind gets `border-amber-500/50` + fork badge; loop kind shows enabled toggle state from `enabledLoopActionIds` via view model field `loopEnabled?: boolean`. - [ ] **Step 2: Delete old ActionPanel** Remove `src/ui/ActionPanel.tsx` after migration. - [ ] **Step 3: Commit** ```bash git add src/ui/PlayPanel.tsx src/ui/ActionColumn.tsx src/ui/ActionGroup.tsx src/ui/ActionCard.tsx git rm src/ui/ActionPanel.tsx git commit -m "feat(ui): action columns with collapsible groups" ``` --- ### Task 12: StoryView split pane **Files:** - Create: `src/ui/StoryView.tsx` - Create: `src/ui/StoryTree.tsx` - Create: `src/ui/StoryProseLog.tsx` - Delete: `src/ui/StoryPanel.tsx` - [ ] **Step 1: Build 60/40 split** `StoryView.tsx`: ```tsx export function StoryView() { const tree = useGameStore((s) => s.story.tree); const log = useGameStore((s) => s.story.log); const selectedId = useGameStore((s) => s.selectedStoryNodeId); return (
gameRuntime.selectStoryNode(id)} />
); } ``` `StoryTree.tsx` — recursive `
    ` with indentation; dim `seen: false` nodes. `StoryProseLog.tsx` — render full prose; **no buttons**. - [ ] **Step 2: Remove StoryPanel and runtime overlay methods** ```bash git rm src/ui/StoryPanel.tsx ``` - [ ] **Step 3: Commit** ```bash git add src/ui/StoryView.tsx src/ui/StoryTree.tsx src/ui/StoryProseLog.tsx src/state/runtime.ts git commit -m "feat(ui): story tab with tree and prose log" ``` --- ### Task 13: RightRail **Files:** - Create: `src/ui/RightRail.tsx` - Create: `src/ui/SettingsPanel.tsx` - Create: `src/ui/AboutPanel.tsx` - Delete: `src/ui/ResourceBar.tsx`, `src/ui/EventLog.tsx`, `src/ui/SettingsDrawer.tsx` - [ ] **Step 1: Implement RightRail** Sections: Resources (from `view.resources`), Inventory placeholder (duplicate resource list with "Items coming soon"), Event log (collapsible, hidden when `!prefs.showEventLog`). - [ ] **Step 2: SettingsPanel** Move `SettingsDrawer` selects + add `showEventLog` checkbox. - [ ] **Step 3: AboutPanel** Static stub: "Idlegame — M1 vertical slice." - [ ] **Step 4: Commit** ```bash git add src/ui/RightRail.tsx src/ui/SettingsPanel.tsx src/ui/AboutPanel.tsx git rm src/ui/ResourceBar.tsx src/ui/EventLog.tsx src/ui/SettingsDrawer.tsx git commit -m "feat(ui): right rail and settings/about panels" ``` --- ### Task 14: Integration verify + docs **Files:** - Modify: `docs/architecture.md` - [ ] **Step 1: Run pre-PR chain** ```powershell pnpm typecheck pnpm lint pnpm test:coverage pnpm build ``` Expected: all green; engine ≥80% coverage. - [ ] **Step 2: Browser smoke checklist** 1. Boot → nav Play visible; no overlay. 2. Continue boot story via Story tab prose (fork appears in tree). 3. Fork only in Story **column** as actions — pick one; sibling hides. 4. Timed actions queue in center column; loop rest toggles and runs when idle. 5. Story tab: tree click updates prose; 60/40 split readable. 6. Right rail: resources + log; log collapsible. 7. Reload preserves state. - [ ] **Step 3: Update architecture.md** Add §Shell layout and §Action kinds referencing spec. - [ ] **Step 4: Commit** ```bash git add docs/architecture.md git commit -m "docs: document PR3 shell UI and action kinds" ``` --- ## Spec coverage self-review | Spec requirement | Task | |---|---| | Three-region shell | Task 10 | | Column order Instant→Loop→Timed→Story→Context | Task 7, 11 | | Collapsible groups | Task 8, 11 | | Story forks via actions only | Task 4, 6, 9, 11 | | Story tab 60/40 tree+log | Task 12 | | No overlay | Task 9, 10, 12 | | Right rail resources/inventory/log | Task 13 | | Optional event log pref | Task 8, 13 | | Instant/loop/timed/story/context kinds | Tasks 1, 3–6 | | Loop idle behavior | Task 5 | | Context column stub | Task 6 throws | | Remove PR2 overlay | Task 12 | | Automation/recipes | **T3.1 plan** | | Mobile responsive | PR4 — out of scope | | manualCompletionCounts | **T3.1 plan** | ## Placeholder scan No TBD/TODO steps. All tasks include file paths and code snippets.