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

36 KiB
Raw Blame History

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
  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
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.