Files
idlegame/docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md
T
ginnoir 203478268c
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s
docs(m1): add PR3 shell UI spec and implementation plans
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/.
2026-06-11 20:01:30 -05:00

1230 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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`:
```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<string, boolean>;
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 (
<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**
```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**
```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<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**
```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 (
<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**
```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, 36 |
| 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.