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/.
1230 lines
36 KiB
Markdown
1230 lines
36 KiB
Markdown
# 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, 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.
|