Capture the PR3 UX brainstorm: three-region shell, action columns by kind, story-via-actions, Story tab tree+log, and universal automation with shareable recipes. Includes T3.0/T3.1 implementation plans and M1 vertical slice plan. Also ignore local .worktrees/.
36 KiB
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:
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:
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<typeof actionKindSchema>;
export type ActionGroup = z.infer<typeof actionGroupSchema>;
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
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):
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
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
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:
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:
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
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:
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:
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:
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
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:
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
export interface GameState {
// ...existing fields
enabledLoopActionIds: Record<string, boolean>;
}
// 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:
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
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
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
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
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
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:
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
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
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:
const PREFS_KEY = 'idlegame:prefs:v2';
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
export interface GamePrefs {
storyOpenMode: StoryOpenMode;
actionDetailMode: ActionDetailMode;
collapsedActionGroups: Record<string, boolean>;
showEventLog: boolean;
}
const DEFAULTS: GamePrefs = {
storyOpenMode: 'auto',
actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
};
src/state/store.ts — replace overlay state:
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
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:
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:
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
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:
import type { ReactNode } from 'react';
interface AppShellProps {
nav: ReactNode;
center: ReactNode;
right?: ReactNode;
}
export function AppShell({ nav, center, right }: AppShellProps) {
return (
<div className="grid min-h-dvh grid-cols-[auto_1fr] md:grid-cols-[12rem_1fr_auto]">
<div className="border-slate-800 border-r bg-slate-950">{nav}</div>
<main className="overflow-hidden p-4">{center}</main>
{right ? (
<aside className="hidden w-64 border-slate-800 border-l bg-slate-950 p-4 md:block">
{right}
</aside>
) : null}
</div>
);
}
src/ui/NavRail.tsx — buttons call gameRuntime.setActivePanel, Story shows badge when storyHasUnread.
- Step 2: Wire App.tsx
export function App() {
const activePanel = useGameStore((s) => s.activePanel);
useEffect(() => { void gameRuntime.boot(); }, []);
return (
<AppShell
nav={<NavRail />}
center={
activePanel === 'play' ? <PlayPanel /> :
activePanel === 'story' ? <StoryView /> :
activePanel === 'settings' ? <SettingsPanel /> :
<AboutPanel />
}
right={activePanel === 'play' ? <RightRail /> : undefined}
/>
);
}
- Step 3: Manual smoke
Run: pnpm dev — verify nav switches panels, no overlay.
- Step 4: Commit
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:
const columns = useGameStore((s) => s.actionColumns);
const COLUMN_LABELS: Record<ActionKind, string> = {
instant: 'Instant',
loop: 'Loop',
timed: 'Timed',
story: 'Story',
context: 'Context',
};
return (
<section aria-label="Actions" className="flex gap-3 overflow-x-auto pb-2">
{columns.map((col) => (
<ActionColumn key={col.kind} label={COLUMN_LABELS[col.kind]} groups={col.groups} />
))}
</section>
);
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
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:
export function StoryView() {
const tree = useGameStore((s) => s.story.tree);
const log = useGameStore((s) => s.story.log);
const selectedId = useGameStore((s) => s.selectedStoryNodeId);
return (
<div className="grid h-[calc(100dvh-2rem)] grid-cols-[3fr_2fr] gap-4">
<StoryTree nodes={tree} selectedId={selectedId} onSelect={(id) => gameRuntime.selectStoryNode(id)} />
<StoryProseLog log={log} selectedId={selectedId} />
</div>
);
}
StoryTree.tsx — recursive <ul> with indentation; dim seen: false nodes.
StoryProseLog.tsx — render full prose; no buttons.
- Step 2: Remove StoryPanel and runtime overlay methods
git rm src/ui/StoryPanel.tsx
- Step 3: Commit
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
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
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
Expected: all green; engine ≥80% coverage.
- Step 2: Browser smoke checklist
- Boot → nav Play visible; no overlay.
- Continue boot story via Story tab prose (fork appears in tree).
- Fork only in Story column as actions — pick one; sibling hides.
- Timed actions queue in center column; loop rest toggles and runs when idle.
- Story tab: tree click updates prose; 60/40 split readable.
- Right rail: resources + log; log collapsible.
- Reload preserves state.
- Step 3: Update architecture.md
Add §Shell layout and §Action kinds referencing spec.
- Step 4: Commit
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.