diff --git a/openspec/changes/palette-chapter-picker/explore.md b/openspec/changes/palette-chapter-picker/explore.md new file mode 100644 index 0000000..71eeb3b --- /dev/null +++ b/openspec/changes/palette-chapter-picker/explore.md @@ -0,0 +1,211 @@ +# Exploration: palette-chapter-picker + +Builds on PR #12 (`palette-suggestions`). Adds two more stages to the palette flow: a chapter picker after book pick, and a verse picker after the chapter loads. Both pickers are bypass-able via free-typing the full ` :` reference. + +## Locked Flow (user-confirmed) + +1. **Book picker** — current behavior (PR #12). Type → fuzzy match → ↑/↓ select → Tab accepts. +2. **Chapter picker** — Tab on book transitions palette to chapter mode. Numeric grid (10 per row) with `▶` cursor on selected. Chapter count from `BOOK_CHAPTERS` table (hardcoded, ~70 lines, public-domain canon data — Genesis 50, Exodus 40, ..., Revelation 22). +3. **Tab on chapter** — fires a `loading` state with `intent: "pick-verse"`. Triggers the existing async fetch via `usePassageFetch`. Loading spinner shows briefly. +4. **Verse picker** — once `PassageFetched` lands, the reducer sees `intent: "pick-verse"` and renders an overlay over the loaded view: numeric grid 1..passage.verses.length. User picks → cursor lands on that verse, overlay closes, reader is in normal `loaded` state. +5. **Esc on verse picker** — closes overlay, keeps cursor at verse 1 (whole-chapter reading). +6. **Free-typing override** — at any palette stage, typing `:digits` or hitting Enter dispatches `QuerySubmitted` which parses the literal query and goes straight to `loading` with `intent: "view"` (no verse picker overlay). + +## Data Story + +**No hardcoded verse-count table.** Verse count for the picker comes from `passage.verses.length` — the chapter fetch we already do. One extra round-trip latency between chapter pick and verse picker (shown as the existing Braille spinner), but zero data commitment in the repo. Hardcoded verse counts would be ~1189 chapter entries; lazy is a clean trade. + +**`BOOK_CHAPTERS` IS hardcoded** — 66 entries, ~70 lines. Static, canonical, won't churn. Lives at `src/domain/book-chapters.ts`. + +## State Machine + +Three changes to `ReaderState`: + +```ts +type ReaderState = + | { + kind: "awaiting"; + phase: "book" | "chapter"; // NEW phase field + query: string; + parseError: ParseError | null; + suggestions: BookSuggestion[]; + chapters: number[]; // NEW — populated when phase: "chapter" + bookChosen: BookSuggestion | null; // NEW — set when phase: "chapter" + selectedIndex: number; + } + | { + kind: "loading"; + ref: Reference; + intent: "view" | "pick-verse"; // NEW — drives PassageFetched landing + } + | { + kind: "loaded"; + passage: Passage; + ref: Reference; + cursorIndex: number; + pageStartIndex: number; + versePicker: { selectedIndex: number } | null; // NEW — overlay state + } + | { kind: "network-error"; ref: Reference; reason: RepoError }; +``` + +## New Actions + +- `BookChosen` — replaces the book-phase branch of `SuggestionAccepted`. Sets `phase: "chapter"`, populates `chapters: chaptersForBook(canonical)`, sets `bookChosen`, resets `selectedIndex` to 0. +- `ChapterChosen` — replaces the chapter-phase branch. Transitions to `{ kind: "loading", ref: { book, chapter: selectedChapter, verses: { start: 1, end: 1 } }, intent: "pick-verse" }`. +- `VersePickerMovedUp/Down/Left/Right` — grid navigation. Grid is 10 columns; `Left/Right` move by 1, `Up/Down` move by 10 (clamped). +- `VersePickerAccepted` — sets `cursorIndex` to the selected verse's index, computes `pageStartIndex`, sets `versePicker: null`. +- `VersePickerCancelled` — Esc. Sets `versePicker: null`, cursor stays at 0. + +The existing `SuggestionAccepted` is preserved but only fires in `phase: "book"` (delegates to `BookChosen` via dispatch chain — or kept as an alias for backwards compat with tests). + +## Reducer Logic Sketch + +```ts +SuggestionAccepted: (s, _a) => { + if (s.kind !== "awaiting" || s.selectedIndex < 0) return s; + if (s.phase === "book") { + const book = s.suggestions[s.selectedIndex]; + const chapters = chaptersForBook(book.canonical); + return { ...s, phase: "chapter", bookChosen: book, chapters, selectedIndex: 0, suggestions: [], query: `${book.displayName} ` }; + } + if (s.phase === "chapter") { + if (!s.bookChosen) return s; + const chapter = s.chapters[s.selectedIndex]; + const book = bookIdFromCanonical(s.bookChosen.canonical); + return { + kind: "loading", + ref: { book, chapter, verses: { start: 1, end: 1 } }, + intent: "pick-verse", + }; + } + return s; +}, + +PassageFetched: (s, a) => { + if (s.kind !== "loading") return s; + const targetVerse = s.ref.verses.start; + const foundIndex = a.passage.verses.findIndex((v) => v.number === targetVerse); + const cursorIndex = foundIndex >= 0 ? foundIndex : 0; + const pageStartIndex = Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE; + const versePicker = s.intent === "pick-verse" ? { selectedIndex: 0 } : null; + return { kind: "loaded", passage: a.passage, ref: s.ref, cursorIndex, pageStartIndex, versePicker }; +}, + +QueryTyped: (s, a) => { + if (s.kind !== "awaiting") return s; + // Any keystroke drops back to book phase — free-typing always wins. + return { + ...s, + phase: "book", + bookChosen: null, + chapters: [], + query: a.query, + parseError: null, + suggestions: suggestBooks(a.query), + selectedIndex: -1, + }; +}, +``` + +## View + +- `phase: "book"`: existing suggestion list. +- `phase: "chapter"`: numeric grid below the input. Selected cell in accent. Title shows chosen book. +- `loaded` with `versePicker !== null`: overlay box over the dimmed reading view. Same numeric grid pattern. Esc cancels, Enter/Tab accepts. + +Grid rendering helper: + +```tsx +function NumberGrid({ numbers, selectedIndex, columns = 10 }) { + const rows: number[][] = []; + for (let i = 0; i < numbers.length; i += columns) { + rows.push(numbers.slice(i, i + columns)); + } + return ( + + {rows.map((row, ri) => ( + + {row.map((n, ci) => { + const idx = ri * columns + ci; + const focused = idx === selectedIndex; + return ( + + {focused ? `▶ ${String(n).padStart(2)} ` : ` ${String(n).padStart(2)} `} + + ); + })} + + ))} + + ); +} +``` + +## Driver Keybind Changes + +Current `awaiting` gate works for both `phase: "book"` and `phase: "chapter"` since the action handlers branch on `phase`. No driver change there. + +But we now need keybinds for verse picker (in `loaded` state when `versePicker !== null`): +- `↑`/`↓`/`←`/`→` → `VersePickerMovedUp/Down/Left/Right` +- `Tab` or `Enter` → `VersePickerAccepted` +- `Esc` → `VersePickerCancelled` +- Reading view's existing keybinds (`[`/`]`/`n`/`p`/`/`) must be GATED OFF when `versePicker !== null` + +Driver gate addition: +```ts +if (readerState.kind === "loaded" && readerState.versePicker !== null) { + if (keyEvent.name === "up") { dispatch({ type: "VersePickerMovedUp" }); return; } + if (keyEvent.name === "down") { dispatch({ type: "VersePickerMovedDown" }); return; } + if (keyEvent.name === "left") { dispatch({ type: "VersePickerMovedLeft" }); return; } + if (keyEvent.name === "right") { dispatch({ type: "VersePickerMovedRight" }); return; } + if (keyEvent.name === "tab") { dispatch({ type: "VersePickerAccepted" }); return; } + if (keyEvent.name === "return") { dispatch({ type: "VersePickerAccepted" }); return; } + if (keyEvent.name === "escape") { dispatch({ type: "VersePickerCancelled" }); return; } + return; // suppress everything else +} +``` + +`q`/`Q` quit still sits above and wins. + +## Approaches Considered + +| Approach | Pros | Cons | +|---|---|---| +| Hardcoded verse-count table (1189 entries) | No fetch latency | ~30KB data file; maintenance if any translation differs | +| **Lazy verse list from fetched passage (chosen)** | Zero data commitment; verse count is authoritative for the loaded translation | One extra spinner between chapter pick and verse picker | +| No verse picker (chapter pick loads immediately, cursor on verse 1) | Simplest | Doesn't satisfy "verse pick" requirement | + +## Slicing + +One PR, estimated: + +| File | Lines | +|------|-------| +| `src/domain/book-chapters.ts` (new) | ~70 | +| `src/domain/book-chapters.test.ts` (new) | ~25 | +| `src/tui/reader/reader-reducer.ts` | ~80 (phase + intent + versePicker + 6 new actions + existing handlers updated) | +| `src/tui/reader/reader-reducer.test.ts` | ~80 (existing awaiting tests need `phase: "book"`, `chapters: []`, `bookChosen: null`; new tests for all new transitions) | +| `src/tui/reader/reader-screen.tsx` | ~50 (chapter grid render, verse picker overlay, NumberGrid helper) | +| `src/tui/tui-driver.tsx` | ~15 (versePicker-mode gate) | +| **Total** | **~320 lines** | + +Under 400-line budget but close. If the budget gets tight, the NumberGrid helper extracts to its own component file. + +## Risks + +1. **`makeAwaiting` and `initialReaderState` shape change** — every test that builds an `awaiting` state needs `phase: "book"`, `chapters: []`, `bookChosen: null` added. ~12 test sites, mechanical. +2. **`loaded` shape change** — `versePicker: null` added. Existing `loaded` tests need the field. +3. **Verse picker grid keybinds** — `Left`/`Right` arrow keys aren't currently handled anywhere. Verify they exist in OpenTUI key event names (almost certainly `"left"` / `"right"` per the `KeyHandler` convention). +4. **Free-typing edge case** — when in `phase: "chapter"`, if user types a digit, our `QueryTyped` drops back to `phase: "book"`. The query becomes "John 3" (input had "John " + user typed "3"). `suggestBooks("John 3")` matches nothing — empty suggestions. On Enter, `parseReference` parses it correctly as John 3. Works. +5. **Esc keybind** — currently unused. Confirm `keyEvent.name === "escape"`. + +## Open Questions for Proposal + +1. **Grid columns**: 10 per row is standard. For chapters (≤150) and verses (≤176 in Psalm 119), 10 cols × 15 rows fits comfortably. Lock at 10. +2. **Verse picker `pageStartIndex`**: when `VersePickerAccepted` lands the cursor on verse N, the page must scroll to show it. Compute as `Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE`. Lock. +3. **`q` from chapter phase**: quits the app. The user might expect Esc to go back to book picker. Recommend: `Esc` goes back to book phase from chapter phase (clears `bookChosen`, restores book suggestions if query non-empty). Adds one more action `PickerBackedOut`. Small addition. + +## Ready for Proposal + +Yes. The state machine is concrete; the lazy verse list resolves the data question; the picker overlay pattern reuses the verse-cursor accent styling. diff --git a/openspec/changes/palette-chapter-picker/proposal.md b/openspec/changes/palette-chapter-picker/proposal.md new file mode 100644 index 0000000..38a677b --- /dev/null +++ b/openspec/changes/palette-chapter-picker/proposal.md @@ -0,0 +1,154 @@ +# Proposal: palette-chapter-picker + +## TL;DR + +- Extends the palette flow from PR #12 (palette-suggestions) with two new picker stages: chapter grid after book Tab-accept, and verse picker overlay after chapter loads. +- Chapter count comes from a hardcoded `BOOK_CHAPTERS` table (66 entries); verse count comes lazily from `passage.verses.length` — no 1189-row data file. +- Free-typing always wins: any `QueryTyped` resets to `phase: "book"`, and a bare `Enter` parses the literal query and routes straight to `loading` with `intent: "view"`. +- One PR, ~320 lines; under the 400-line review budget. +- Single atomic `ReaderState` extension — no new domain service or infrastructure layer. + +## Why + +User request: "chapter verse pick, remember make some UI sketches." Three approaches were explored and sketched; the user confirmed the **lazy verse list** approach (fetch verse count from the chapter passage already fetched, no hardcoded table). + +## What Changes + +| File | Status | Description | +|------|--------|-------------| +| `src/domain/book-chapters.ts` | New | `BOOK_CHAPTERS` map (66 entries) + `chaptersForBook()` helper | +| `src/domain/book-chapters.test.ts` | New | Unit tests for `chaptersForBook()` | +| `src/tui/reader/reader-reducer.ts` | Modified | Phase field, `intent` on `loading`, `versePicker` on `loaded`, 6 new actions | +| `src/tui/reader/reader-reducer.test.ts` | Modified | ~12 test sites updated for new `awaiting` shape; new transition tests | +| `src/tui/reader/reader-screen.tsx` | Modified | Chapter grid render, verse picker overlay, `NumberGrid` helper | +| `src/tui/tui-driver.tsx` | Modified | `versePicker !== null` gate + suppression of reading-view keybinds | + +## What Does NOT Change + +- Domain types (`Reference`, `Passage`, `BookSuggestion`, `ParseError`) — unchanged. +- Application use cases (`GetPassageUseCase`, `repository` contracts`) — unchanged. +- CLI entry points (`run`, `vod`) — unchanged. +- ADR 0010 TS-native dialect (Rule 13 + 14) retained throughout. +- `SuggestionAccepted` action preserved as-is (now branches on `phase`); test backwards-compat maintained. + +## State Machine Extension + +```ts +type ReaderState = + | { + kind: "awaiting"; + phase: "book" | "chapter"; // NEW + query: string; + parseError: ParseError | null; + suggestions: BookSuggestion[]; + chapters: number[]; // NEW — populated when phase: "chapter" + bookChosen: BookSuggestion | null; // NEW + selectedIndex: number; + } + | { + kind: "loading"; + ref: Reference; + intent: "view" | "pick-verse"; // NEW + } + | { + kind: "loaded"; + passage: Passage; + ref: Reference; + cursorIndex: number; + pageStartIndex: number; + versePicker: { selectedIndex: number } | null; // NEW + } + | { kind: "network-error"; ref: Reference; reason: RepoError }; +``` + +New actions: `BookChosen`, `ChapterChosen`, `VersePickerMovedUp`, `VersePickerMovedDown`, `VersePickerMovedLeft`, `VersePickerMovedRight`, `VersePickerAccepted`, `VersePickerCancelled`, `PickerBackedOut`. + +## Pick Flow + +1. **Book picker** — existing behavior (PR #12). Tab on suggestion dispatches `SuggestionAccepted` → `phase: "chapter"`. +2. **Chapter picker** — numeric grid (10 per row). `↑`/`↓` move by 10 rows; `←`/`→` move by 1. Tab dispatches `SuggestionAccepted` → `loading` with `intent: "pick-verse"`. +3. **Spinner** — existing Braille spinner from `usePassageFetch`. One extra round-trip between chapter pick and verse picker. +4. **Verse picker overlay** — `PassageFetched` lands; reducer sets `versePicker: { selectedIndex: 0 }`. Same `NumberGrid` component. Esc dispatches `VersePickerCancelled`, Tab/Enter dispatches `VersePickerAccepted`. +5. **Verse accepted** — `cursorIndex` set to picked verse; `pageStartIndex` computed via `Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE`; `versePicker` set to `null`. +6. **Free-typing override** — `QueryTyped` always resets to `phase: "book"`; `QuerySubmitted` parses literal and routes to `loading` with `intent: "view"`, bypassing verse picker entirely. + +## Driver Gate Change + +New branch at the top of the key-event handler, above existing `loaded` keybinds: + +```ts +if (readerState.kind === "loaded" && readerState.versePicker !== null) { + if (keyEvent.name === "up") { dispatch({ type: "VersePickerMovedUp" }); return; } + if (keyEvent.name === "down") { dispatch({ type: "VersePickerMovedDown" }); return; } + if (keyEvent.name === "left") { dispatch({ type: "VersePickerMovedLeft" }); return; } + if (keyEvent.name === "right") { dispatch({ type: "VersePickerMovedRight" }); return; } + if (keyEvent.name === "tab") { dispatch({ type: "VersePickerAccepted" }); return; } + if (keyEvent.name === "return") { dispatch({ type: "VersePickerAccepted" }); return; } + if (keyEvent.name === "escape") { dispatch({ type: "VersePickerCancelled" }); return; } + return; // suppress all other keybinds ([ ] n p / etc.) +} +``` + +`q`/`Q` quit sits above this gate and always wins. + +## First Reviewable Cut + +Single PR, ~320 lines. If `NumberGrid` extraction is needed to stay under budget, it moves to its own component file (adds ~0 net lines, just reorganizes). + +## Success Criterion + +- `verbum` → palette opens → type `joh` → Tab on John → chapter grid renders → Tab on 3 → spinner shows briefly → verse picker overlay renders → `↓↓` to verse 16 → Tab → reader loads John 3 with cursor on verse 16. +- Separately: type `john 3:16` → Enter → reader loads John 3:16 directly, no picker shown at any step. + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| `makeAwaiting` / `initialReaderState` shape cascade — ~12 test sites need `phase`, `chapters`, `bookChosen` fields | Med | Mechanical; tackle first in apply | +| `loaded` shape change — existing `loaded` tests need `versePicker: null` | Med | Mechanical; one-pass fix | +| `left`/`right` arrow key names not yet verified in OpenTUI `KeyHandler` | Low | Verify at apply time; fallback to checking the existing `KeyEvent` type | +| `escape` key name — currently unused, assumed `"escape"` | Low | Confirm at apply time via `KeyEvent` type | + +## Out of Scope + +- Verse-count hardcoded data table (1189 entries) — deliberately deferred in favour of lazy fetch. +- Recent references / passage history — separate feature. +- Chapter list displayed inside palette suggestion rows — different feature. +- Translation switching — separate feature. + +## Capabilities + +### New Capabilities + +- `chapter-verse-picker`: Multi-stage palette flow — chapter numeric grid and verse overlay — driven by the extended `ReaderState` machine and `BOOK_CHAPTERS` domain module. + +### Modified Capabilities + +- `palette-suggestions`: `awaiting` state shape gains `phase`, `chapters`, `bookChosen`; `SuggestionAccepted` branches on phase; driver gate updated. + +## Approach + +Pure state-machine extension inside the existing reducer. No new infrastructure. New domain module `book-chapters.ts` is side-effect-free and independently testable. View layer adds one reusable `NumberGrid` helper and one overlay branch in the reader screen. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/domain/book-chapters.ts` | New | Canonical chapter counts + lookup helper | +| `src/tui/reader/reader-reducer.ts` | Modified | Phase, intent, versePicker, 9 new actions | +| `src/tui/reader/reader-screen.tsx` | Modified | Chapter grid + verse picker overlay | +| `src/tui/tui-driver.tsx` | Modified | versePicker-mode key gate | + +## Rollback Plan + +All changes are additive to `ReaderState`. To revert: `git revert` the single PR. No data migration, no infrastructure change, no external dependency added. + +## Dependencies + +- PR #12 (palette-suggestions) — merged. This change builds directly on top. + +## Next Steps (Backlog) + +- USFM alignment polish (separate change). +- Recent references / passage history. +- Translation switching. diff --git a/openspec/changes/palette-chapter-picker/spec.md b/openspec/changes/palette-chapter-picker/spec.md new file mode 100644 index 0000000..f93f149 --- /dev/null +++ b/openspec/changes/palette-chapter-picker/spec.md @@ -0,0 +1,434 @@ +# Spec: palette-chapter-picker + +## Capability + +TUI palette — multi-stage book/chapter/verse picker with free-typing override. + +Extends the palette flow from PR #12 (palette-suggestions) with two additional picker stages: a chapter numeric grid after a book is Tab-accepted, and a verse picker overlay after the fetched chapter passage lands. Free-typing always wins at any stage. + +--- + +## Requirements + +### Domain — `book-chapters.ts` + +**REQ-01** A new file `src/domain/book-chapters.ts` exports `BOOK_CHAPTERS` as a `Record` (or equivalent `Map`) containing exactly 66 entries — one per canonical book name in standard Protestant canon order. + +**REQ-02** `src/domain/book-chapters.ts` exports `chaptersForBook(canonical: string): number` that returns the chapter count for the given canonical key. Returns `0` (or a safe fallback) when the key is not found. + +**REQ-03** `BOOK_CHAPTERS` is statically defined (no runtime fetch, no import from a JSON asset). It is the sole source of chapter counts in the codebase. + +--- + +### State Machine — `awaiting` shape + +**REQ-04** The `awaiting` variant of `ReaderState` gains three new fields: + +```ts +phase: "book" | "chapter"; +chapters: number[]; +bookChosen: BookSuggestion | null; +``` + +`phase` defaults to `"book"`. `chapters` defaults to `[]`. `bookChosen` defaults to `null`. + +**REQ-05** `makeAwaiting` (or equivalent `initialReaderState` factory) includes these three fields with their defaults. All existing call-sites that construct an `awaiting` value must supply them. + +--- + +### State Machine — `loading` shape + +**REQ-06** The `loading` variant of `ReaderState` gains one new field: + +```ts +intent: "view" | "pick-verse"; +``` + +Every transition that produces a `loading` state must supply `intent`. + +--- + +### State Machine — `loaded` shape + +**REQ-07** The `loaded` variant of `ReaderState` gains one new field: + +```ts +versePicker: { selectedIndex: number } | null; +``` + +`versePicker` is `null` when the verse picker overlay is not active. + +--- + +### New Actions + +**REQ-08** `BookChosen` is a valid action type. It has no payload. It is only meaningful when the current state is `awaiting` with `phase: "book"` and `selectedIndex >= 0`. + +**REQ-09** `ChapterChosen` is a valid action type. It has no payload. It is only meaningful when the current state is `awaiting` with `phase: "chapter"`, `bookChosen !== null`, and `selectedIndex >= 0`. + +**REQ-10** `VersePickerMovedUp` is a valid action type with no payload. + +**REQ-11** `VersePickerMovedDown` is a valid action type with no payload. + +**REQ-12** `VersePickerMovedLeft` is a valid action type with no payload. + +**REQ-13** `VersePickerMovedRight` is a valid action type with no payload. + +**REQ-14** `VersePickerAccepted` is a valid action type with no payload. + +**REQ-15** `VersePickerCancelled` is a valid action type with no payload. + +**REQ-16** `PickerBackedOut` is a valid action type with no payload. + +--- + +### Reducer transitions — `SuggestionAccepted` (book phase) + +**REQ-17** When `SuggestionAccepted` fires in `awaiting` state with `phase: "book"` and `selectedIndex >= 0`, the reducer transitions to `awaiting` with: + +- `phase: "chapter"` +- `bookChosen` set to `suggestions[selectedIndex]` +- `chapters` set to `chaptersForBook(bookChosen.canonical)` expressed as `[1, 2, ..., N]` +- `selectedIndex: 0` +- `suggestions: []` +- `query` rewritten to `"${bookChosen.displayName} "` (trailing space) + +**REQ-18** When `SuggestionAccepted` fires in `awaiting` state with `phase: "book"` and `selectedIndex < 0`, the state is returned unchanged. + +--- + +### Reducer transitions — `SuggestionAccepted` (chapter phase) + +**REQ-19** When `SuggestionAccepted` fires in `awaiting` state with `phase: "chapter"`, `bookChosen !== null`, and `selectedIndex >= 0`, the reducer transitions to: + +```ts +{ + kind: "loading", + ref: { book: bookIdFromCanonical(bookChosen.canonical), chapter: chapters[selectedIndex], verses: { start: 1, end: 1 } }, + intent: "pick-verse", +} +``` + +**REQ-20** When `SuggestionAccepted` fires in `awaiting` state with `phase: "chapter"` but `bookChosen === null`, the state is returned unchanged. + +--- + +### Reducer transitions — `QueryTyped` + +**REQ-21** `QueryTyped` in `awaiting` state ALWAYS resets `phase` to `"book"`, clears `bookChosen` to `null`, clears `chapters` to `[]`, updates `query` to the new query string, and re-computes `suggestions` via `suggestBooks(query)`. This applies regardless of whether `phase` was previously `"book"` or `"chapter"`. + +--- + +### Reducer transitions — `PassageFetched` + +**REQ-22** When `PassageFetched` fires in `loading` state with `intent: "pick-verse"`, the reducer transitions to `loaded` with `versePicker: { selectedIndex: 0 }`. + +**REQ-23** When `PassageFetched` fires in `loading` state with `intent: "view"`, the reducer transitions to `loaded` with `versePicker: null`. + +**REQ-24** In both REQ-22 and REQ-23, `cursorIndex` and `pageStartIndex` are computed from the existing logic (`ref.verses.start` → `findIndex` in passage verses; `pageStartIndex = Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE`). + +--- + +### Reducer transitions — `VersePickerAccepted` + +**REQ-25** `VersePickerAccepted` in `loaded` state with `versePicker !== null` sets: + +- `cursorIndex` to `versePicker.selectedIndex` +- `pageStartIndex` to `Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE` +- `versePicker` to `null` + +**REQ-26** `VersePickerAccepted` when `versePicker === null` returns the state unchanged. + +--- + +### Reducer transitions — `VersePickerCancelled` + +**REQ-27** `VersePickerCancelled` in `loaded` state with `versePicker !== null` sets `versePicker` to `null`. `cursorIndex` is left at `0` (the value it has when `versePicker` was active since `PassageFetched` set it). + +**REQ-28** `VersePickerCancelled` when `versePicker === null` returns the state unchanged. + +--- + +### Reducer transitions — `VersePickerMovedUp` / `VersePickerMovedDown` + +**REQ-29** `VersePickerMovedUp` in `loaded` state with `versePicker !== null` decrements `versePicker.selectedIndex` by 10, clamped to `0` (minimum). + +**REQ-30** `VersePickerMovedDown` in `loaded` state with `versePicker !== null` increments `versePicker.selectedIndex` by 10, clamped to `passage.verses.length - 1` (maximum). + +**REQ-31** `VersePickerMovedUp` and `VersePickerMovedDown` when `versePicker === null` return the state unchanged. + +--- + +### Reducer transitions — `VersePickerMovedLeft` / `VersePickerMovedRight` + +**REQ-32** `VersePickerMovedLeft` in `loaded` state with `versePicker !== null` decrements `versePicker.selectedIndex` by 1, clamped to `0`. + +**REQ-33** `VersePickerMovedRight` in `loaded` state with `versePicker !== null` increments `versePicker.selectedIndex` by 1, clamped to `passage.verses.length - 1`. + +**REQ-34** `VersePickerMovedLeft` and `VersePickerMovedRight` when `versePicker === null` return the state unchanged. + +--- + +### Reducer transitions — `PickerBackedOut` + +**REQ-35** `PickerBackedOut` in `awaiting` state with `phase: "chapter"` transitions to: + +- `phase: "book"` +- `bookChosen: null` +- `chapters: []` +- `selectedIndex: -1` +- `suggestions` re-computed via `suggestBooks(query)` + +**REQ-36** `PickerBackedOut` when `phase: "book"` or when state is not `awaiting` returns the state unchanged. + +--- + +### Driver — key event routing + +**REQ-37** In `tui-driver.tsx`, the `q`/`Q` quit handler is positioned above all other gates and fires unconditionally. + +**REQ-38** When `readerState.kind === "loaded"` and `readerState.versePicker !== null`, the driver intercepts the following keys before any other `loaded`-state handler: + +| Key name | Action dispatched | +|---|---| +| `"up"` | `VersePickerMovedUp` | +| `"down"` | `VersePickerMovedDown` | +| `"left"` | `VersePickerMovedLeft` | +| `"right"` | `VersePickerMovedRight` | +| `"tab"` | `VersePickerAccepted` | +| `"return"` | `VersePickerAccepted` | +| `"escape"` | `VersePickerCancelled` | + +After dispatching, the handler returns immediately. + +**REQ-39** When `readerState.kind === "loaded"` and `readerState.versePicker !== null`, all other key events (including `[`, `]`, `n`, `p`, `/`) are suppressed — the handler returns without dispatching. + +**REQ-40** When `readerState.kind === "awaiting"` and `readerState.phase === "chapter"`, the `"escape"` key dispatches `PickerBackedOut`. + +--- + +### View — chapter grid + +**REQ-41** When `readerState.kind === "awaiting"` and `readerState.phase === "chapter"`, the palette renders a `NumberGrid` below the text input instead of the suggestion list. + +**REQ-42** `NumberGrid` renders numbers in rows of exactly 10 columns. Numbers are right-padded to uniform width within a row. + +**REQ-43** The selected cell (at `selectedIndex`) is rendered with `fg={ACCENT_HEX}` and prefixed with a `▶` marker. All other cells have no explicit foreground color override. + +**REQ-44** The chapter grid title (`bottomTitleFor` or equivalent) displays a text variant identifying the chosen book and prompting the user to pick a chapter. + +--- + +### View — verse picker overlay + +**REQ-45** When `readerState.kind === "loaded"` and `readerState.versePicker !== null`, the reader screen renders a verse picker overlay on top of the (dimmed) reading view using the same `NumberGrid` component. + +**REQ-46** The verse picker overlay numbers run from `1` to `passage.verses.length`, matching the verse numbers in the loaded passage. + +**REQ-47** The verse picker overlay title (`bottomTitleFor` or equivalent) displays a text variant prompting the user to pick a verse. + +--- + +### View — `bottomTitleFor` variants + +**REQ-48** `bottomTitleFor` (or the equivalent title-string helper) produces distinct text for each of the following state combinations: + +| State | Expected text variant | +|---|---| +| `awaiting`, `phase: "book"` | Book search prompt (existing) | +| `awaiting`, `phase: "chapter"` | Chapter pick prompt, includes book display name | +| `loading`, `intent: "view"` | Loading / fetching text (existing) | +| `loading`, `intent: "pick-verse"` | Loading / fetching text (may be identical to view variant) | +| `loaded`, `versePicker: null` | Reading view title (existing) | +| `loaded`, `versePicker !== null` | Verse pick prompt | +| `network-error` | Error text (existing) | + +--- + +### Non-functional + +**REQ-49** No new runtime dependencies are introduced. `BOOK_CHAPTERS` is a plain TypeScript object/map literal. + +**REQ-50** TypeScript compilation passes cleanly (`tsc --noEmit`) with no new type errors. + +**REQ-51** New action handlers in the reducer use object dispatch (Rule 13): each action type maps to a handler function in a lookup object rather than being handled inline in a `switch`/`if` chain. Existing handlers retain their current pattern; new handlers follow Rule 13. + +**REQ-52** No comments that merely restate the code are added (Rule 14). Comments are permitted only for non-obvious intent, constraints, or external references. + +**REQ-53** Test count grows from 178 to approximately 210 or more: mechanical updates account for the `awaiting` and `loaded` shape changes (~12 test sites × 1 assertion each), and new tests cover all new reducer transitions and the `chaptersForBook` helper. + +--- + +## Acceptance Scenarios + +### SCENARIO-01 — `BOOK_CHAPTERS` coverage + +**Given** `BOOK_CHAPTERS` is imported from `src/domain/book-chapters.ts` +**When** its entries are counted +**Then** exactly 66 entries are present + +### SCENARIO-02 — `chaptersForBook` known book + +**Given** the canonical key `"gen"` (Genesis) +**When** `chaptersForBook("gen")` is called +**Then** it returns `50` + +### SCENARIO-03 — `chaptersForBook` unknown key + +**Given** a key `"xyz"` not present in `BOOK_CHAPTERS` +**When** `chaptersForBook("xyz")` is called +**Then** it returns `0` (or a safe numeric fallback, not `undefined`) + +### SCENARIO-04 — `SuggestionAccepted` in book phase transitions to chapter phase + +**Given** state is `awaiting` with `phase: "book"`, `suggestions: [{ canonical: "jhn", displayName: "John" }]`, `selectedIndex: 0` +**When** `SuggestionAccepted` is dispatched +**Then** state is `awaiting` with `phase: "chapter"`, `bookChosen.canonical === "jhn"`, `chapters.length === chaptersForBook("jhn")`, `selectedIndex === 0`, `query === "John "` + +### SCENARIO-05 — `SuggestionAccepted` in chapter phase transitions to loading/pick-verse + +**Given** state is `awaiting` with `phase: "chapter"`, `bookChosen: { canonical: "jhn", displayName: "John" }`, `chapters: [1..21]`, `selectedIndex: 2` (chapter 3) +**When** `SuggestionAccepted` is dispatched +**Then** state is `loading` with `ref.chapter === 3`, `intent === "pick-verse"` + +### SCENARIO-06 — `QueryTyped` resets from chapter phase to book phase + +**Given** state is `awaiting` with `phase: "chapter"`, `bookChosen: { canonical: "jhn", displayName: "John" }`, `chapters: [1..21]` +**When** `QueryTyped` is dispatched with `query: "j"` +**Then** state is `awaiting` with `phase: "book"`, `bookChosen === null`, `chapters === []`, `suggestions` from `suggestBooks("j")` + +### SCENARIO-07 — `PassageFetched` with `intent: "pick-verse"` opens verse picker + +**Given** state is `loading` with `intent: "pick-verse"` +**When** `PassageFetched` is dispatched with a passage of 21 verses +**Then** state is `loaded` with `versePicker: { selectedIndex: 0 }` + +### SCENARIO-08 — `PassageFetched` with `intent: "view"` does not open verse picker + +**Given** state is `loading` with `intent: "view"` +**When** `PassageFetched` is dispatched +**Then** state is `loaded` with `versePicker === null` + +### SCENARIO-09 — `VersePickerMovedDown` moves by 10, clamped + +**Given** state is `loaded` with `passage.verses.length === 21`, `versePicker: { selectedIndex: 5 }` +**When** `VersePickerMovedDown` is dispatched +**Then** `versePicker.selectedIndex === 15` + +**Given** state is `loaded` with `passage.verses.length === 21`, `versePicker: { selectedIndex: 15 }` +**When** `VersePickerMovedDown` is dispatched +**Then** `versePicker.selectedIndex === 20` (clamped to 20, not 25) + +### SCENARIO-10 — `VersePickerMovedUp` moves by 10, clamped + +**Given** state is `loaded` with `versePicker: { selectedIndex: 3 }` +**When** `VersePickerMovedUp` is dispatched +**Then** `versePicker.selectedIndex === 0` (clamped, not negative) + +### SCENARIO-11 — `VersePickerMovedRight` moves by 1, clamped + +**Given** state is `loaded` with `passage.verses.length === 5`, `versePicker: { selectedIndex: 4 }` +**When** `VersePickerMovedRight` is dispatched +**Then** `versePicker.selectedIndex === 4` (clamped, not 5) + +### SCENARIO-12 — `VersePickerMovedLeft` moves by 1, clamped + +**Given** state is `loaded` with `versePicker: { selectedIndex: 0 }` +**When** `VersePickerMovedLeft` is dispatched +**Then** `versePicker.selectedIndex === 0` (clamped, not -1) + +### SCENARIO-13 — `VersePickerAccepted` lands cursor and closes overlay + +**Given** state is `loaded` with `versePicker: { selectedIndex: 15 }`, passage has 21 verses +**When** `VersePickerAccepted` is dispatched +**Then** `versePicker === null`, `cursorIndex === 15`, `pageStartIndex === Math.floor(15 / VERSES_PER_PAGE) * VERSES_PER_PAGE` + +### SCENARIO-14 — `VersePickerCancelled` closes overlay, cursor stays at 0 + +**Given** state is `loaded` with `versePicker: { selectedIndex: 10 }`, `cursorIndex === 0` +**When** `VersePickerCancelled` is dispatched +**Then** `versePicker === null`, `cursorIndex === 0` + +### SCENARIO-15 — `PickerBackedOut` from chapter phase returns to book phase + +**Given** state is `awaiting` with `phase: "chapter"`, `query: "john "`, `bookChosen: { canonical: "jhn" }`, `chapters: [1..21]` +**When** `PickerBackedOut` is dispatched +**Then** state is `awaiting` with `phase: "book"`, `bookChosen === null`, `chapters === []`, `suggestions` from `suggestBooks("john ")` + +### SCENARIO-16 — `PickerBackedOut` from book phase is a no-op + +**Given** state is `awaiting` with `phase: "book"` +**When** `PickerBackedOut` is dispatched +**Then** state is returned unchanged + +### SCENARIO-17 — Driver: verse-picker gate suppresses reading-view keys + +**Given** `readerState.kind === "loaded"` and `readerState.versePicker !== null` +**When** the `[` key is pressed +**Then** no action is dispatched and no side effect occurs (reading-view navigation suppressed) + +### SCENARIO-18 — Driver: `q` quits above all gates + +**Given** `readerState.kind === "loaded"` and `readerState.versePicker !== null` +**When** the `q` key is pressed +**Then** the application exits (quit handler fires before the verse-picker gate) + +### SCENARIO-19 — Driver: `escape` in chapter phase backs out + +**Given** `readerState.kind === "awaiting"` and `readerState.phase === "chapter"` +**When** the `escape` key is pressed +**Then** `PickerBackedOut` is dispatched + +### SCENARIO-20 — End-to-end happy path (from proposal Success Criterion) + +> `verbum` → palette opens → type `joh` → Tab on John → chapter grid renders → Tab on 3 → spinner shows briefly → verse picker overlay renders → `↓↓` to verse 16 → Tab → reader loads John 3 with cursor on verse 16. + +**Given** the application starts in `awaiting` state with `phase: "book"` +**When** the user types `joh` +**Then** `suggestions` includes John (`canonical: "jhn"`) + +**When** the user presses Tab (dispatches `SuggestionAccepted`) +**Then** state is `awaiting` with `phase: "chapter"`, `bookChosen.canonical === "jhn"`, `chapters === [1..21]`, chapter grid is rendered + +**When** the user presses Tab on chapter 3 (dispatches `SuggestionAccepted`) +**Then** state is `loading` with `intent: "pick-verse"`, spinner is visible + +**When** `PassageFetched` fires (John 3, 21 verses) +**Then** state is `loaded` with `versePicker: { selectedIndex: 0 }`, verse picker overlay is rendered + +**When** the user presses `↓` twice (dispatches `VersePickerMovedDown` × 2) +**Then** `versePicker.selectedIndex === 20` ... but since grid is 10-cols, `↓` moves by 10; pressing once gives 10, pressing twice gives 20 — then the user navigates to verse 16 by pressing `↑` once (index 10) then `→` six times; OR the spec accepts that "↓↓ to verse 16" is colloquial for "navigate to verse 16" via whatever key combination + +**When** the user presses Tab (dispatches `VersePickerAccepted`) +**Then** state is `loaded` with `versePicker === null`, `cursorIndex` pointing to verse 16 in the passage, reader displays John 3 + +### SCENARIO-21 — End-to-end free-typing bypass (from proposal Success Criterion) + +> type `john 3:16` → Enter → reader loads John 3:16 directly, no picker shown at any step. + +**Given** the application starts in `awaiting` state +**When** the user types `john 3:16` and presses Enter (dispatches `QuerySubmitted`) +**Then** state transitions to `loading` with `intent: "view"` (not `"pick-verse"`) + +**When** `PassageFetched` fires +**Then** state is `loaded` with `versePicker === null` — no overlay appears at any point + +--- + +## Out of Scope + +- Hardcoded verse-count table (1189 entries) — verse counts come from `passage.verses.length` at runtime. +- Recent references / passage history. +- Palette result-list sections (References, Books, Commands). +- Translation switching. +- Chapter numbers displayed inside palette suggestion rows. + +--- + +## Assumptions Made + +1. `chaptersForBook` returns chapters as a sorted array `[1, 2, ..., N]` built from `BOOK_CHAPTERS[canonical]` — this is implied by the reducer sketch but not stated explicitly in the proposal. +2. `bookIdFromCanonical` is an existing or co-introduced helper that maps a canonical string to the `BookId` type used in `Reference`. If it does not exist, it must be introduced as part of this change. +3. The `"escape"` key name in the OpenTUI `KeyEvent` type is `"escape"` (lowercase). Verified at apply time from the `KeyEvent` type definition. +4. `"left"` and `"right"` key names in `KeyEvent` are `"left"` and `"right"` (lowercase). Verified at apply time. +5. `VERSES_PER_PAGE` is already defined in the reducer or view layer and is not introduced by this change. diff --git a/openspec/changes/palette-chapter-picker/tasks.md b/openspec/changes/palette-chapter-picker/tasks.md new file mode 100644 index 0000000..b9448f2 --- /dev/null +++ b/openspec/changes/palette-chapter-picker/tasks.md @@ -0,0 +1,133 @@ +# Tasks: palette-chapter-picker + +## Review Workload Forecast + +| Field | Value | +|---|---| +| Estimated changed lines | ~320 | +| 400-line budget risk | Low-Medium | +| Chained PRs recommended | No | +| Suggested split | Single PR | +| Delivery strategy | auto-chain | +| Chain strategy | size-exception | + +Decision needed before apply: No +Chained PRs recommended: No +Chain strategy: size-exception +400-line budget risk: Low + +### Suggested Work Units + +| Unit | Goal | Likely PR | Notes | +|------|------|-----------|-------| +| C1 | book-chapters domain module | PR 1 (single) | No dependencies; pure data + helper | +| C2 | reader-reducer extensions | PR 1 (single) | Depends on C1 (chaptersForBook) | +| C3 | chapter grid + verse picker views | PR 1 (single) | Depends on C2 (state shape) | +| C4 | tui-driver gate for verse picker keys | PR 1 (single) | Depends on C2 + C3 | + +--- + +## C1 — feat(domain): book-chapters module + +### RED + +- [x] 1.1 Create `src/domain/book-chapters.test.ts` — test `chaptersForBook("JHN")` returns 21 +- [x] 1.2 Add test: `chaptersForBook("GEN")` returns 50 +- [x] 1.3 Add test: `chaptersForBook("PSA")` returns 150 +- [x] 1.4 Add test: `chaptersForBook("REV")` returns 22 +- [x] 1.5 Add test: unknown key (e.g. `"XYZ"`) returns 0 (not `undefined`) +- [x] 1.6 Run `bun test` — confirm all 5 new tests fail (RED) + +### GREEN + +- [x] 1.7 Create `src/domain/book-chapters.ts` — export `BOOK_CHAPTERS: Record` with exactly 66 entries (Protestant canon, static literal, no runtime fetch) +- [x] 1.8 Export `chaptersForBook(canonical: string): number` — returns `BOOK_CHAPTERS[canonical] ?? 0` +- [x] 1.9 Run `bun test src/domain/book-chapters.test.ts` — confirm all 5 pass (GREEN) + +--- + +## C2 — feat(tui): reader-reducer phase + intent + versePicker state + +### RED — shape update tests + +- [x] 2.1 In `reader-reducer.test.ts`: update all `awaiting`-state snapshot sites (~12) to include `phase: "book"`, `chapters: []`, `bookChosen: null` +- [x] 2.2 Update all `loaded`-state snapshot sites (~10) to include `versePicker: null` +- [x] 2.3 Update all `loading`-state snapshot sites (~5) to include `intent: "view"` +- [x] 2.4 Run `bun test` — confirm updated sites fail because reducer/state don't yet include these fields (RED) + +### RED — new action tests + +- [x] 2.5 Add test group `BookChosen` — REQ-17, REQ-18 (book phase → chapter phase; no-op when selectedIndex < 0) +- [x] 2.6 Add test group `ChapterChosen` / `SuggestionAccepted chapter phase` — REQ-19, REQ-20 (chapter phase → loading/pick-verse; no-op when bookChosen null) +- [x] 2.7 Add test group `QueryTyped chapter-phase override` — REQ-21 (always resets phase/bookChosen/chapters) +- [x] 2.8 Add test group `PassageFetched intent branches` — REQ-22 (pick-verse → versePicker opened), REQ-23 (view → versePicker null) +- [x] 2.9 Add test group `VersePickerMovedDown` — REQ-30 (+10, clamped; SCENARIO-09) +- [x] 2.10 Add test group `VersePickerMovedUp` — REQ-29 (−10, clamped; SCENARIO-10) +- [x] 2.11 Add test group `VersePickerMovedRight` — REQ-33 (+1, clamped; SCENARIO-11) +- [x] 2.12 Add test group `VersePickerMovedLeft` — REQ-32 (−1, clamped; SCENARIO-12) +- [x] 2.13 Add test group `VersePickerAccepted` — REQ-25, REQ-26 (lands cursor, closes overlay; no-op when null) +- [x] 2.14 Add test group `VersePickerCancelled` — REQ-27, REQ-28 (closes overlay, cursor stays 0; no-op when null) +- [x] 2.15 Add test group `PickerBackedOut` — REQ-35, REQ-36 (chapter→book revert; no-op in book phase) +- [x] 2.16 Run `bun test` — confirm new groups all fail (RED) + +### GREEN + +- [x] 2.17 Extend `ReaderState` `awaiting` variant: add `phase`, `chapters`, `bookChosen` fields (REQ-04) +- [x] 2.18 Extend `ReaderState` `loading` variant: add `intent` field (REQ-06) +- [x] 2.19 Extend `ReaderState` `loaded` variant: add `versePicker` field (REQ-07) +- [x] 2.20 Update `initialReaderState` / `makeAwaiting` to include the three new awaiting defaults (REQ-05) +- [x] 2.21 Update existing `SuggestionAccepted` handler — add book-phase branch (REQ-17, REQ-18) and chapter-phase branch (REQ-19, REQ-20) +- [x] 2.22 Update `QueryTyped` handler — reset phase/bookChosen/chapters unconditionally (REQ-21) +- [x] 2.23 Update `PassageFetched` handler — branch on `intent`; set `versePicker` accordingly (REQ-22, REQ-23) +- [x] 2.24 Add new action handlers to the `satisfies` object-dispatch table (REQ-51): `VersePickerMovedUp`, `VersePickerMovedDown`, `VersePickerMovedLeft`, `VersePickerMovedRight`, `VersePickerAccepted`, `VersePickerCancelled`, `PickerBackedOut` +- [x] 2.25 Update every `loading` state construction site to pass `intent: "view"` (REQ-06) +- [x] 2.26 Run `bun test` — confirm all tests pass (GREEN) + +--- + +## C3 — feat(tui): chapter grid + verse picker overlay views + +- [x] 3.1 In `src/tui/reader-screen.tsx` awaiting branch: when `phase === "chapter"`, render `NumberGrid` (10-col rows) from `state.chapters`; highlight `selectedIndex` cell with `ACCENT_HEX` + `▶` prefix (REQ-41, REQ-42, REQ-43) +- [x] 3.2 In loaded branch: when `versePicker !== null`, render `NumberGrid` overlay above/over the (dimmed) page content; numbers run 1..`passage.verses.length` (REQ-45, REQ-46) +- [x] 3.3 Update `titleFor` / `bottomTitleFor` to produce distinct strings for: `awaiting/chapter`, `loading/pick-verse`, `loaded/versePicker!=null` (REQ-44, REQ-47, REQ-48) +- [x] 3.4 If `reader-screen.tsx` exceeds ~200 lines, extract `NumberGrid` to `src/tui/number-grid.tsx` +- [x] 3.5 Run `bun run tsc --noEmit` — confirm no new type errors (REQ-50) + +--- + +## C4 — feat(tui): driver gate handles verse picker keys + +- [x] 4.1 Inspect `node_modules/@opentui/core/lib/KeyHandler.d.ts` (or runtime source) — record exact key name strings for `escape`, `left`, `right`, `return`, `up`, `down`, `tab` +- [x] 4.2 In `src/tui/tui-driver.tsx`: add verse-picker gate BEFORE existing loaded-state branch — when `kind === "loaded"` and `versePicker !== null`, dispatch per REQ-38 key table using verified names +- [x] 4.3 In the same gate: suppress all other keys (return without dispatching) per REQ-39 +- [x] 4.4 In awaiting-state branch: when `phase === "chapter"`, map `escape` key to `PickerBackedOut` (REQ-40) +- [x] 4.5 Confirm `q`/`Q` quit handler sits above all new gates (REQ-37) +- [x] 4.6 Run `bun test` — full suite passes +- [x] 4.7 Run `bun run tsc --noEmit` — clean compile + +--- + +## Spec Coverage Map + +| REQ | Task(s) | +|-----|---------| +| REQ-01..03 | 1.7–1.9 | +| REQ-04..05 | 2.17, 2.20 | +| REQ-06 | 2.18, 2.25 | +| REQ-07 | 2.19 | +| REQ-08..16 | 2.5–2.16, 2.24 | +| REQ-17..18 | 2.5, 2.21 | +| REQ-19..20 | 2.6, 2.21 | +| REQ-21 | 2.7, 2.22 | +| REQ-22..24 | 2.8, 2.23 | +| REQ-25..34 | 2.9–2.14, 2.24 | +| REQ-35..36 | 2.15, 2.24 | +| REQ-37..40 | 4.2–4.5 | +| REQ-41..44 | 3.1, 3.3 | +| REQ-45..47 | 3.2, 3.3 | +| REQ-48 | 3.3 | +| REQ-49 | — (no npm installs) | +| REQ-50 | 3.5, 4.7 | +| REQ-51 | 2.24 | +| REQ-52 | — (convention) | +| REQ-53 | 2.5–2.16 | diff --git a/openspec/changes/palette-chapter-picker/verify-report.md b/openspec/changes/palette-chapter-picker/verify-report.md new file mode 100644 index 0000000..38220ed --- /dev/null +++ b/openspec/changes/palette-chapter-picker/verify-report.md @@ -0,0 +1,157 @@ +# Verify Report: palette-chapter-picker + +**Change**: palette-chapter-picker +**Branch**: feat/palette-chapter-picker +**Date**: 2026-05-12 +**Verdict**: PASS WITH WARNINGS + +--- + +## Build & Test Evidence + +| Check | Result | +|---|---| +| `bun test` | 208/208 pass, 0 fail (1060 expect() calls, 16 files) | +| `bun run tsc --noEmit` | Exit 0 — clean | +| Test delta | 178 → 208 (+30 tests, REQ-53 met) | + +--- + +## Task Completeness + +All 32 tasks across C1–C4 marked `[x]` complete. All 4 commits confirmed on branch: + +| Commit | SHA | Status | +|---|---|---| +| C1: feat(domain): book-chapters module | 828d651 | Done | +| C2: feat(tui): reader-reducer | f186861 | Done | +| C3: feat(tui): chapter grid + verse picker views | b921ce4 | Done | +| C4: feat(tui): driver gate | 6160c6c | Done | + +--- + +## Spec Compliance Matrix + +| REQ | Description | Status | Notes | +|---|---|---|---| +| REQ-01 | BOOK_CHAPTERS: 66 entries, Record | PASS | Object.keys count = 66, verified at runtime | +| REQ-02 | chaptersForBook returns chapter count, 0 for unknown | PASS | Implemented correctly with uppercase keys | +| REQ-03 | BOOK_CHAPTERS statically defined, sole source | PASS | Plain TS object literal, no JSON import | +| REQ-04 | awaiting gains phase/chapters/bookChosen fields | PASS | Type definition confirmed | +| REQ-05 | initialReaderState includes new fields with defaults | PASS | All three fields present with defaults | +| REQ-06 | loading gains intent: "view" | "pick-verse" | PASS | Type and all call sites confirmed | +| REQ-07 | loaded gains versePicker: {selectedIndex} \| null | PASS | Type definition confirmed | +| REQ-08 | BookChosen action defined | PASS | In ReaderAction union and handlers | +| REQ-09 | ChapterChosen action defined | PASS | In ReaderAction union and handlers | +| REQ-10 | VersePickerMovedUp action defined | PASS | | +| REQ-11 | VersePickerMovedDown action defined | PASS | | +| REQ-12 | VersePickerMovedLeft action defined | PASS | | +| REQ-13 | VersePickerMovedRight action defined | PASS | | +| REQ-14 | VersePickerAccepted action defined | PASS | | +| REQ-15 | VersePickerCancelled action defined | PASS | | +| REQ-16 | PickerBackedOut action defined | PASS | | +| REQ-17 | SuggestionAccepted book phase → chapter phase | PASS | Covered by test + correct runtime path | +| REQ-18 | SuggestionAccepted book phase, selectedIndex < 0 → no-op | PASS | Guarded at top of handler | +| REQ-19 | SuggestionAccepted chapter phase → loading/pick-verse | PASS | Covered by test | +| REQ-20 | SuggestionAccepted chapter phase, bookChosen null → no-op | PASS | Covered by test | +| REQ-21 | QueryTyped always resets to phase: "book" | PASS | Covered by dedicated test | +| REQ-22 | PassageFetched intent: "pick-verse" → versePicker: {selectedIndex:0} | PASS | Covered by test | +| REQ-23 | PassageFetched intent: "view" → versePicker: null | PASS | Covered by test | +| REQ-24 | cursorIndex/pageStartIndex computed from existing logic | PASS | Same formula, no regression | +| REQ-25 | VersePickerAccepted sets cursorIndex + pageStartIndex, clears versePicker | PASS | Covered by SCENARIO-13 test | +| REQ-26 | VersePickerAccepted when versePicker null → no-op | PASS | Covered by test | +| REQ-27 | VersePickerCancelled sets versePicker null, cursor stays at 0 | PASS | Covered by SCENARIO-14 test | +| REQ-28 | VersePickerCancelled when versePicker null → no-op | PASS | Covered by test | +| REQ-29 | VersePickerMovedUp decrements by 10, clamped to 0 | PASS | Covered by SCENARIO-10 test | +| REQ-30 | VersePickerMovedDown increments by 10, clamped to length-1 | PASS | Covered by SCENARIO-09 tests | +| REQ-31 | Up/Down no-op when versePicker null | PASS | Covered | +| REQ-32 | VersePickerMovedLeft decrements by 1, clamped to 0 | PASS | Covered by SCENARIO-12 test | +| REQ-33 | VersePickerMovedRight increments by 1, clamped to length-1 | PASS | Covered by SCENARIO-11 test | +| REQ-34 | Left/Right no-op when versePicker null | PASS | Covered | +| REQ-35 | PickerBackedOut from chapter phase → book phase | PASS | Covered by SCENARIO-15 test | +| REQ-36 | PickerBackedOut from book phase → no-op | PASS | Covered by SCENARIO-16 test | +| REQ-37 | q/Q quit above all gates | PASS | First block in useKeyboard handler | +| REQ-38 | Driver intercepts up/down/left/right/tab/return/escape for versePicker | PASS | All 7 keys handled | +| REQ-39 | Other keys suppressed when versePicker active | PASS | Trailing `return` in else path | +| REQ-40 | escape in chapter phase dispatches PickerBackedOut | PASS | First check in awaiting block | +| REQ-41 | NumberGrid rendered when phase: "chapter" | PASS | Conditional in Body JSX | +| REQ-42 | NumberGrid 10-column layout | PASS | COLS=10 constant, slice-based rows | +| REQ-43 | Selected cell fg=ACCENT_HEX, ▶ prefix | PASS | Confirmed in NumberGrid render | +| REQ-44 | bottomTitleFor chapter variant includes book name | PASS | Covered by reader-screen.test.ts | +| REQ-45 | Verse picker overlay renders above reading view | PASS (cosmetic gap) | Overlay present; no dim on reading view (OpenTUI BoxProps lacks attributes for dim) — SUGGESTION | +| REQ-46 | Overlay numbers 1..passage.verses.length | PASS | Array.from({length}, (_,i)=>i+1) | +| REQ-47 | bottomTitleFor verse picker variant | PASS | Returns " Pick a verse •..." | +| REQ-48 | 7 distinct bottomTitleFor variants | PASS | All 7 tested (loading/pick-verse uses same text as loading/view — permitted by spec) | +| REQ-49 | No new runtime dependencies | PASS | Pure TS object literal | +| REQ-50 | tsc --noEmit clean | PASS | Exit 0 confirmed | +| REQ-51 | Object dispatch for new handlers, no switch in new code | PASS | handlers satisfies {...} pattern; switch only in pre-existing titleFor | +| REQ-52 | No useless comments in new files | PASS | Only intent-bearing comments present | +| REQ-53 | Test count 178 → ~210+ | PASS | 208 tests (+30 net; spec said ~210+, delta is 30) | + +--- + +## Acceptance Scenarios + +| Scenario | Status | Notes | +|---|---|---| +| SCENARIO-01: BOOK_CHAPTERS 66 entries | PASS | Tested in book-chapters.test.ts | +| SCENARIO-02: chaptersForBook("gen") returns 50 | WARN | Spec uses lowercase key; impl uses UPPERCASE keys. chaptersForBook("gen") returns 0. chaptersForBook("GEN") returns 50. All real callers pass uppercase canonicals from BOOK_ALIASES — functional behavior correct. Spec text is inconsistent. | +| SCENARIO-03: chaptersForBook unknown key returns 0 | PASS | Tested | +| SCENARIO-04: SuggestionAccepted book phase → chapter | PASS | Tested | +| SCENARIO-05: SuggestionAccepted chapter phase → loading/pick-verse | PASS | Tested | +| SCENARIO-06: QueryTyped resets from chapter to book | PASS | Tested | +| SCENARIO-07: PassageFetched intent:pick-verse opens versePicker | PASS | Tested | +| SCENARIO-08: PassageFetched intent:view does not open versePicker | PASS | Tested | +| SCENARIO-09: VersePickerMovedDown ±10, clamped | PASS | Two tests | +| SCENARIO-10: VersePickerMovedUp ±10, clamped | PASS | Tested | +| SCENARIO-11: VersePickerMovedRight ±1, clamped | PASS | Tested | +| SCENARIO-12: VersePickerMovedLeft ±1, clamped | PASS | Tested | +| SCENARIO-13: VersePickerAccepted lands cursor | PASS | Tested | +| SCENARIO-14: VersePickerCancelled closes overlay | PASS | Tested | +| SCENARIO-15: PickerBackedOut chapter→book | PASS | Tested | +| SCENARIO-16: PickerBackedOut book phase no-op | PASS | Tested | +| SCENARIO-17: versePicker gate suppresses reading keys | PASS | Trailing return in driver | +| SCENARIO-18: q quits above all gates | PASS | First block unconditional | +| SCENARIO-19: escape in chapter phase dispatches PickerBackedOut | PASS | Driver confirmed | +| SCENARIO-20: End-to-end happy path (reducer path) | PASS | Each step individually tested | +| SCENARIO-21: Free-typing bypass lands with versePicker null | PASS | QuerySubmitted → intent:"view" → versePicker:null | + +--- + +## Issues + +### WARNING + +**W-01: BookChosen and ChapterChosen are dead code** + +`BookChosen` and `ChapterChosen` are defined in `ReaderAction` and have handler implementations in the reducer, but are never dispatched from any driver or component. Tab in both awaiting phases dispatches `SuggestionAccepted` (which branches on `phase`). These two actions are reachable only in tests. This is not a functional regression — all user-facing paths are covered by `SuggestionAccepted` — but it is unnecessary surface area in the public action union. + +**W-02: SCENARIO-02 spec wording uses lowercase canonical key** + +The spec's SCENARIO-02 says `chaptersForBook("gen")` should return 50. The actual implementation uses uppercase USFM keys (`"GEN"`), so `chaptersForBook("gen")` returns 0. The test correctly uses `chaptersForBook("GEN")`. This is a documentation inconsistency in the spec, not a logic bug. All runtime callers pass uppercase canonicals. + +### SUGGESTION + +**S-01: REQ-45 cosmetic gap — no dim on reading view behind overlay** + +The verse picker overlay renders correctly but the reading view behind it is not dimmed. OpenTUI `` does not expose `attributes` on `BoxProps` for DIM, so this was not implementable without workarounds. The overlay is functional; the visual polish is missing. Flag for future when OpenTUI adds attribute support. Flagged as suggestion per apply-progress deviations note. + +**S-02: REQ-53 test count is 208, spec said "~210 or more"** + +208 is within range of "approximately 210+." The spec's prediction was slightly high. Not a real issue. + +--- + +## Design Coherence + +- Object-dispatch `satisfies` pattern is preserved and extended correctly. +- `bookIdFromCanonical` wrapper added to `book-id.ts` — clean thin wrapper over `makeBookId`. +- `NumberGrid` kept inline in `reader-screen.tsx` (~230 lines total). Soft limit exceeded marginally; component is self-contained. Acceptable per apply judgment. +- No new runtime dependencies introduced. +- No switch statements in new code. + +--- + +## Final Verdict + +**PASS WITH WARNINGS** — 2 warnings (dead-code actions, spec key-casing inconsistency), 2 suggestions (cosmetic dim, test count variance). All spec requirements are functionally met. All 208 tests pass. TypeScript clean. Ready for archive. diff --git a/src/domain/book-chapters.test.ts b/src/domain/book-chapters.test.ts new file mode 100644 index 0000000..e9b074f --- /dev/null +++ b/src/domain/book-chapters.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "bun:test"; +import { chaptersForBook, BOOK_CHAPTERS } from "@/domain/book-chapters"; + +describe("BOOK_CHAPTERS", () => { + it("contains exactly 66 entries", () => { + expect(Object.keys(BOOK_CHAPTERS).length).toBe(66); + }); +}); + +describe("chaptersForBook", () => { + it("returns 21 for JHN", () => { + expect(chaptersForBook("JHN")).toBe(21); + }); + + it("returns 50 for GEN", () => { + expect(chaptersForBook("GEN")).toBe(50); + }); + + it("returns 150 for PSA", () => { + expect(chaptersForBook("PSA")).toBe(150); + }); + + it("returns 22 for REV", () => { + expect(chaptersForBook("REV")).toBe(22); + }); + + it("returns 0 for unknown key XYZ", () => { + expect(chaptersForBook("XYZ")).toBe(0); + }); + + it("is case-insensitive (lowercase gen still returns 50)", () => { + expect(chaptersForBook("gen")).toBe(50); + }); + + it("is case-insensitive (mixed-case JoH still returns 21)", () => { + expect(chaptersForBook("JoH")).toBe(0); + expect(chaptersForBook("jhn")).toBe(21); + }); +}); diff --git a/src/domain/book-chapters.ts b/src/domain/book-chapters.ts new file mode 100644 index 0000000..a916c62 --- /dev/null +++ b/src/domain/book-chapters.ts @@ -0,0 +1,25 @@ +export const BOOK_CHAPTERS: Record = { + // Old Testament + GEN: 50, EXO: 40, LEV: 27, NUM: 36, DEU: 34, + JOS: 24, JDG: 21, RUT: 4, + "1SA": 31, "2SA": 24, "1KI": 22, "2KI": 25, + "1CH": 29, "2CH": 36, EZR: 10, NEH: 13, + EST: 10, JOB: 42, PSA: 150, PRO: 31, + ECC: 12, SNG: 8, ISA: 66, JER: 52, + LAM: 5, EZK: 48, DAN: 12, HOS: 14, + JOL: 3, AMO: 9, OBA: 1, JON: 4, + MIC: 7, NAM: 3, HAB: 3, ZEP: 3, + HAG: 2, ZEC: 14, MAL: 4, + // New Testament + MAT: 28, MRK: 16, LUK: 24, JHN: 21, + ACT: 28, ROM: 16, "1CO": 16, "2CO": 13, + GAL: 6, EPH: 6, PHP: 4, COL: 4, + "1TH": 5, "2TH": 3, "1TI": 6, "2TI": 4, + TIT: 3, PHM: 1, HEB: 13, JAS: 5, + "1PE": 5, "2PE": 3, "1JN": 5, "2JN": 1, + "3JN": 1, JUD: 1, REV: 22, +}; + +export function chaptersForBook(canonical: string): number { + return BOOK_CHAPTERS[canonical.toUpperCase()] ?? 0; +} diff --git a/src/domain/book-id.test.ts b/src/domain/book-id.test.ts index dab9baf..ed0271f 100644 --- a/src/domain/book-id.test.ts +++ b/src/domain/book-id.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { makeBookId } from "@/domain/book-id"; +import { makeBookId, bookIdFromCanonical } from "@/domain/book-id"; describe("makeBookId", () => { it("accepts JHN — a canonical USFM code", () => { @@ -40,3 +40,21 @@ describe("makeBookId", () => { expect(result.ok).toBe(false); }); }); + +describe("bookIdFromCanonical", () => { + it("accepts canonical uppercase", () => { + expect(bookIdFromCanonical("JHN") as string).toBe("JHN"); + }); + + it("is case-insensitive (lowercase jhn returns JHN)", () => { + expect(bookIdFromCanonical("jhn") as string).toBe("JHN"); + }); + + it("is case-insensitive (mixed-case jHn returns JHN)", () => { + expect(bookIdFromCanonical("jHn") as string).toBe("JHN"); + }); + + it("throws on unknown canonical", () => { + expect(() => bookIdFromCanonical("XYZ")).toThrow(); + }); +}); diff --git a/src/domain/book-id.ts b/src/domain/book-id.ts index 6764ba8..dad126c 100644 --- a/src/domain/book-id.ts +++ b/src/domain/book-id.ts @@ -33,3 +33,9 @@ export function makeBookId(s: string): Result { } return { ok: true, value: s as BookId }; // the only `as BookId` cast — R6 } + +export function bookIdFromCanonical(canonical: string): BookId { + const result = makeBookId(canonical.toUpperCase()); + if (!result.ok) throw new Error(`bookIdFromCanonical: unknown book "${canonical}"`); + return result.value; +} diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index ad03184..e7da63c 100644 --- a/src/tui/reader/reader-reducer.test.ts +++ b/src/tui/reader/reader-reducer.test.ts @@ -5,6 +5,7 @@ import type { BookSuggestion } from "@/domain/book-suggestions"; import type { Passage } from "@/domain/passage"; import type { RepoError } from "@/domain/errors"; import type { Reference } from "@/domain/reference"; +import { chaptersForBook } from "@/domain/book-chapters"; const johnRef: Reference = { book: "JHN" as import("@/domain/book-id").BookId, @@ -34,7 +35,7 @@ function makeLoaded( cursorIndex: number, pageStartIndex: number, ): ReaderState { - return { kind: "loaded", passage, ref: johnRef, cursorIndex, pageStartIndex }; + return { kind: "loaded", passage, ref: johnRef, cursorIndex, pageStartIndex, versePicker: null }; } function dispatch(state: ReaderState, action: ReaderAction): ReaderState { @@ -50,6 +51,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }); }); }); @@ -61,23 +65,43 @@ describe("readerReducer", () => { }); describe("QueryTyped", () => { - it("updates query, clears parseError, recomputes suggestions, and resets selectedIndex when awaiting", () => { + it("updates query, clears parseError, recomputes suggestions, and auto-selects top match when awaiting", () => { const state: ReaderState = { kind: "awaiting", query: "", parseError: { kind: "empty_input" }, suggestions: [], selectedIndex: 2, + phase: "book", + chapters: [], + bookChosen: null, }; const next = dispatch(state, { type: "QueryTyped", query: "joh" }); expect(next.kind).toBe("awaiting"); if (next.kind !== "awaiting") return; expect(next.query).toBe("joh"); expect(next.parseError).toBeNull(); - expect(next.selectedIndex).toBe(-1); + expect(next.selectedIndex).toBe(0); expect(next.suggestions.length).toBeGreaterThan(0); }); + it("sets selectedIndex to -1 when query produces no suggestions", () => { + const state: ReaderState = { + kind: "awaiting", + query: "", + parseError: null, + suggestions: [], + selectedIndex: 0, + phase: "book", + chapters: [], + bookChosen: null, + }; + const next = dispatch(state, { type: "QueryTyped", query: "zzzzz" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.suggestions).toEqual([]); + expect(next.selectedIndex).toBe(-1); + }); + it("populates suggestions matching the query", () => { const state: ReaderState = { kind: "awaiting", @@ -85,6 +109,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }; const next = dispatch(state, { type: "QueryTyped", query: "joh" }); if (next.kind !== "awaiting") return; @@ -93,7 +120,7 @@ describe("readerReducer", () => { }); it("is a no-op when not awaiting", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "QueryTyped", query: "genesis 1" }); expect(next).toBe(state); }); @@ -101,7 +128,7 @@ describe("readerReducer", () => { describe("QuerySubmitted", () => { it("transitions awaiting → loading when query parses ok", () => { - const state: ReaderState = { kind: "awaiting", query: "john 3", parseError: null, suggestions: [], selectedIndex: -1 }; + const state: ReaderState = { kind: "awaiting", query: "john 3", parseError: null, suggestions: [], selectedIndex: -1, phase: "book", chapters: [], bookChosen: null }; const next = dispatch(state, { type: "QuerySubmitted" }); expect(next.kind).toBe("loading"); if (next.kind !== "loading") return; @@ -110,7 +137,7 @@ describe("readerReducer", () => { }); it("stays awaiting with parseError when query is malformed", () => { - const state: ReaderState = { kind: "awaiting", query: "jhn 3x", parseError: null, suggestions: [], selectedIndex: -1 }; + const state: ReaderState = { kind: "awaiting", query: "jhn 3x", parseError: null, suggestions: [], selectedIndex: -1, phase: "book", chapters: [], bookChosen: null }; const next = dispatch(state, { type: "QuerySubmitted" }); expect(next.kind).toBe("awaiting"); if (next.kind !== "awaiting") return; @@ -119,7 +146,7 @@ describe("readerReducer", () => { }); it("stays awaiting with parseError for empty query", () => { - const state: ReaderState = { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1 }; + const state: ReaderState = { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1, phase: "book", chapters: [], bookChosen: null }; const next = dispatch(state, { type: "QuerySubmitted" }); expect(next.kind).toBe("awaiting"); if (next.kind !== "awaiting") return; @@ -127,7 +154,7 @@ describe("readerReducer", () => { }); it("is a no-op when not awaiting", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "QuerySubmitted" }); expect(next).toBe(state); }); @@ -135,7 +162,7 @@ describe("readerReducer", () => { describe("PassageFetched", () => { it("defaults cursorIndex to 0 when the ref's target verse is not in the passage", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "PassageFetched", passage: mockPassage }); expect(next).toEqual({ kind: "loaded", @@ -143,6 +170,7 @@ describe("readerReducer", () => { ref: johnRef, cursorIndex: 0, pageStartIndex: 0, + versePicker: null, }); }); @@ -150,7 +178,7 @@ describe("readerReducer", () => { const passage = makePassage(VERSES_PER_PAGE * 3); const targetVerse = VERSES_PER_PAGE + 1; const ref: Reference = { ...johnRef, verses: { start: targetVerse, end: targetVerse } }; - const state: ReaderState = { kind: "loading", ref }; + const state: ReaderState = { kind: "loading", ref, intent: "view" }; const next = dispatch(state, { type: "PassageFetched", passage }); if (next.kind !== "loaded") throw new Error("expected loaded state"); expect(next.cursorIndex).toBe(targetVerse - 1); @@ -161,7 +189,7 @@ describe("readerReducer", () => { const passage = makePassage(VERSES_PER_PAGE * 3); const targetVerse = VERSES_PER_PAGE * 2 + 1; const ref: Reference = { ...johnRef, verses: { start: targetVerse, end: targetVerse } }; - const state: ReaderState = { kind: "loading", ref }; + const state: ReaderState = { kind: "loading", ref, intent: "view" }; const next = dispatch(state, { type: "PassageFetched", passage }); if (next.kind !== "loaded") throw new Error("expected loaded state"); expect(next.cursorIndex).toBe(targetVerse - 1); @@ -177,7 +205,7 @@ describe("readerReducer", () => { describe("FetchFailed", () => { it("transitions loading → network-error", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "FetchFailed", ref: johnRef, reason: networkError }); expect(next).toEqual({ kind: "network-error", ref: johnRef, reason: networkError }); }); @@ -209,7 +237,7 @@ describe("readerReducer", () => { }); it("is a no-op from loading", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "ChapterAdvanced" }); expect(next).toBe(state); }); @@ -252,7 +280,7 @@ describe("readerReducer", () => { }); it("is a no-op from loading", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "ChapterRetreated" }); expect(next).toBe(state); }); @@ -268,6 +296,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }); }); @@ -280,6 +311,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }); }); @@ -289,7 +323,7 @@ describe("readerReducer", () => { }); it("is a no-op from loading", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; const next = dispatch(state, { type: "PaletteReopened" }); expect(next).toBe(state); }); @@ -304,8 +338,8 @@ describe("readerReducer", () => { ]; const nonLoadedStates: ReaderState[] = [ - { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1 }, - { kind: "loading", ref: johnRef }, + { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1, phase: "book", chapters: [], bookChosen: null }, + { kind: "loading", ref: johnRef, intent: "view" }, { kind: "network-error", ref: johnRef, reason: networkError }, ]; @@ -410,6 +444,9 @@ describe("readerReducer", () => { parseError: null | { kind: string }; suggestions: BookSuggestion[]; selectedIndex: number; + phase: "book" | "chapter"; + chapters: number[]; + bookChosen: BookSuggestion | null; }> = {}): ReaderState { return { kind: "awaiting", @@ -417,6 +454,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, ...overrides, } as ReaderState; } @@ -465,20 +505,459 @@ describe("readerReducer", () => { }); }); - describe("SuggestionAccepted", () => { - it("sets query to displayName + space, clears suggestions, resets selectedIndex", () => { - const state = makeAwaiting({ suggestions: mockSuggestions, selectedIndex: 1 }); + describe("SuggestionAccepted (book phase)", () => { + it("transitions to chapter phase with bookChosen, chapters, selectedIndex 0", () => { + const state = makeAwaiting({ suggestions: mockSuggestions, selectedIndex: 0, phase: "book" }); const next = dispatch(state, { type: "SuggestionAccepted" }); if (next.kind !== "awaiting") throw new Error("expected awaiting"); - expect(next.query).toBe("1 John "); + expect(next.phase).toBe("chapter"); + expect(next.bookChosen?.canonical).toBe("JHN"); + expect(next.chapters).toEqual(Array.from({ length: chaptersForBook("JHN") }, (_, i) => i + 1)); + expect(next.selectedIndex).toBe(0); expect(next.suggestions).toEqual([]); - expect(next.selectedIndex).toBe(-1); + expect(next.query).toBe("John "); }); it("is a no-op when selectedIndex is -1", () => { - const state = makeAwaiting({ suggestions: mockSuggestions, selectedIndex: -1 }); + const state = makeAwaiting({ suggestions: mockSuggestions, selectedIndex: -1, phase: "book" }); + const next = dispatch(state, { type: "SuggestionAccepted" }); + expect(next).toBe(state); + }); + }); + + describe("SuggestionAccepted (chapter phase)", () => { + it("transitions to loading/pick-verse with correct ref", () => { + const chapters = Array.from({ length: 21 }, (_, i) => i + 1); + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters, + selectedIndex: 2, + }); + const next = dispatch(state, { type: "SuggestionAccepted" }); + expect(next.kind).toBe("loading"); + if (next.kind !== "loading") return; + expect(next.ref.chapter).toBe(3); + expect(next.intent).toBe("pick-verse"); + }); + + it("is a no-op when bookChosen is null", () => { + const state = makeAwaiting({ phase: "chapter", bookChosen: null, chapters: [1, 2, 3], selectedIndex: 0 }); const next = dispatch(state, { type: "SuggestionAccepted" }); expect(next).toBe(state); }); }); + + describe("QueryTyped (chapter phase override)", () => { + it("resets to book phase when the new query no longer starts with the chosen book name", () => { + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: [1, 2, 3], + selectedIndex: 1, + }); + const next = dispatch(state, { type: "QueryTyped", query: "j" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("book"); + expect(next.bookChosen).toBeNull(); + expect(next.chapters).toEqual([]); + expect(next.suggestions.length).toBeGreaterThan(0); + }); + + it("preserves chapter phase when the new query still has the chosen book prefix", () => { + // Why: OpenTUI's controlled synthesizes onInput when value changes + // programmatically (e.g. after SuggestionAccepted rewrites query to "John "). + // Without this preservation the user would be kicked out of chapter mode + // immediately on every book pick. + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: [1, 2, 3, 4, 5], + selectedIndex: 0, + query: "John ", + }); + const next = dispatch(state, { type: "QueryTyped", query: "John " }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.bookChosen).not.toBeNull(); + expect(next.chapters).toEqual([1, 2, 3, 4, 5]); + expect(next.selectedIndex).toBe(0); + }); + + it("preserves chapter phase case-insensitively", () => { + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: [1, 2, 3], + selectedIndex: 0, + query: "JOHN ", + }); + const next = dispatch(state, { type: "QueryTyped", query: "JOHN 3" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + }); + }); + + describe("PassageFetched (intent branches)", () => { + it("opens versePicker when intent is pick-verse", () => { + const passage = makePassage(21); + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "pick-verse" }; + const next = dispatch(state, { type: "PassageFetched", passage }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker).toEqual({ selectedIndex: 0 }); + }); + + it("sets versePicker null when intent is view", () => { + const passage = makePassage(21); + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; + const next = dispatch(state, { type: "PassageFetched", passage }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker).toBeNull(); + }); + }); + + describe("VersePickerMovedDown", () => { + it("increments selectedIndex by 10 (SCENARIO-09, normal)", () => { + const passage = makePassage(21); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 5 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerMovedDown" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker?.selectedIndex).toBe(15); + }); + + it("clamps at passage.verses.length - 1 (SCENARIO-09, clamped)", () => { + const passage = makePassage(21); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 15 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerMovedDown" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker?.selectedIndex).toBe(20); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(10), 0, 0); + const next = dispatch(state, { type: "VersePickerMovedDown" }); + expect(next).toBe(state); + }); + }); + + describe("VersePickerMovedUp", () => { + it("decrements selectedIndex by 10, clamped to 0 (SCENARIO-10)", () => { + const passage = makePassage(21); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 3 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerMovedUp" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker?.selectedIndex).toBe(0); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(10), 0, 0); + const next = dispatch(state, { type: "VersePickerMovedUp" }); + expect(next).toBe(state); + }); + }); + + describe("VersePickerMovedRight", () => { + it("increments by 1, clamped to verses.length - 1 (SCENARIO-11)", () => { + const passage = makePassage(5); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 4 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerMovedRight" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker?.selectedIndex).toBe(4); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(5), 0, 0); + const next = dispatch(state, { type: "VersePickerMovedRight" }); + expect(next).toBe(state); + }); + }); + + describe("VersePickerMovedLeft", () => { + it("decrements by 1, clamped to 0 (SCENARIO-12)", () => { + const passage = makePassage(5); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 0 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerMovedLeft" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker?.selectedIndex).toBe(0); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(5), 0, 0); + const next = dispatch(state, { type: "VersePickerMovedLeft" }); + expect(next).toBe(state); + }); + }); + + describe("VersePickerAccepted", () => { + it("lands cursor at selectedIndex and closes overlay (SCENARIO-13)", () => { + const passage = makePassage(21); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 15 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerAccepted" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker).toBeNull(); + expect(next.cursorIndex).toBe(15); + expect(next.pageStartIndex).toBe(Math.floor(15 / VERSES_PER_PAGE) * VERSES_PER_PAGE); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(21), 0, 0); + const next = dispatch(state, { type: "VersePickerAccepted" }); + expect(next).toBe(state); + }); + }); + + describe("VersePickerCancelled", () => { + it("closes overlay, leaves cursorIndex at 0 (SCENARIO-14)", () => { + const passage = makePassage(21); + const state = { ...makeLoaded(passage, 0, 0), versePicker: { selectedIndex: 10 } } as ReaderState; + const next = dispatch(state, { type: "VersePickerCancelled" }); + if (next.kind !== "loaded") throw new Error("expected loaded"); + expect(next.versePicker).toBeNull(); + expect(next.cursorIndex).toBe(0); + }); + + it("is a no-op when versePicker is null", () => { + const state = makeLoaded(makePassage(21), 0, 0); + const next = dispatch(state, { type: "VersePickerCancelled" }); + expect(next).toBe(state); + }); + }); + + describe("PickerBackedOut", () => { + it("returns from chapter to book phase, restores suggestBooks (SCENARIO-15)", () => { + const state = makeAwaiting({ + phase: "chapter", + query: "john ", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: [1, 2, 3], + selectedIndex: 1, + }); + const next = dispatch(state, { type: "PickerBackedOut" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("book"); + expect(next.bookChosen).toBeNull(); + expect(next.chapters).toEqual([]); + expect(next.selectedIndex).toBe(-1); + expect(next.suggestions.length).toBeGreaterThan(0); + }); + + it("is a no-op when phase is book (SCENARIO-16)", () => { + const state = makeAwaiting({ phase: "book" }); + const next = dispatch(state, { type: "PickerBackedOut" }); + expect(next).toBe(state); + }); + }); + + describe("QueryTyped (chapter phase digit suffix)", () => { + function chapterState(query: string, selectedIndex = 0): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex, + query: "John ", + }); + } + + it("sets selectedIndex from a single trailing digit", () => { + const next = dispatch(chapterState("John "), { type: "QueryTyped", query: "John 3" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.selectedIndex).toBe(2); + }); + + it("sets selectedIndex from a two-digit suffix (multi-digit support)", () => { + const next = dispatch(chapterState("John "), { type: "QueryTyped", query: "John 21" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(20); + }); + + it("keeps the previous selectedIndex when the digit is out of range", () => { + const next = dispatch(chapterState("John ", 4), { type: "QueryTyped", query: "John 99" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(4); + }); + + it("ignores the digit when n is 0 (chapters are 1-indexed)", () => { + const next = dispatch(chapterState("John ", 7), { type: "QueryTyped", query: "John 0" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(7); + }); + }); + + describe("QuerySubmitted (chapter phase)", () => { + function chapterState(query: string, selectedIndex = 2): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex, + query, + }); + } + + it("picks the highlighted chapter with pick-verse intent when query has no colon", () => { + const next = dispatch(chapterState("John 3", 2), { type: "QuerySubmitted" }); + expect(next.kind).toBe("loading"); + if (next.kind !== "loading") return; + expect(next.ref.chapter).toBe(3); + expect(next.intent).toBe("pick-verse"); + }); + + it("uses parseReference (view intent) when the query contains a colon", () => { + const next = dispatch(chapterState("John 3:16", 2), { type: "QuerySubmitted" }); + expect(next.kind).toBe("loading"); + if (next.kind !== "loading") return; + expect(next.ref.chapter).toBe(3); + expect(next.ref.verses).toEqual({ start: 16, end: 16 }); + expect(next.intent).toBe("view"); + }); + + it("is a no-op when selectedIndex is -1 and no colon present", () => { + const state = chapterState("John ", -1); + const next = dispatch(state, { type: "QuerySubmitted" }); + expect(next).toBe(state); + }); + }); + + describe("QuerySubmitted (book phase fallback to suggestion)", () => { + it("regression: typing 'john' then Enter enters chapter phase for John", () => { + let state: ReaderState = initialReaderState; + state = dispatch(state, { type: "QueryTyped", query: "john" }); + if (state.kind !== "awaiting") throw new Error("expected awaiting"); + expect(state.selectedIndex).toBe(0); + expect(state.suggestions[0]?.displayName).toBe("John"); + + const next = dispatch(state, { type: "QuerySubmitted" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting after enter"); + expect(next.phase).toBe("chapter"); + expect(next.bookChosen?.canonical).toBe("JHN"); + expect(next.query).toBe("John "); + expect(next.selectedIndex).toBe(0); + }); + + it("case-insensitively: typing 'JOHN' then Enter picks John", () => { + let state: ReaderState = initialReaderState; + state = dispatch(state, { type: "QueryTyped", query: "JOHN" }); + const next = dispatch(state, { type: "QuerySubmitted" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.bookChosen?.canonical).toBe("JHN"); + }); + + it("falls through to parseError when neither parse succeeds nor a suggestion is available", () => { + const state: ReaderState = makeAwaiting({ query: "zzz", suggestions: [], selectedIndex: -1 }); + const next = dispatch(state, { type: "QuerySubmitted" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.parseError).not.toBeNull(); + }); + }); + + describe("ChapterGridMovedDown", () => { + function chapterState(selectedIndex: number, total = 21): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: total }, (_, i) => i + 1), + selectedIndex, + query: "John ", + }); + } + + it("adds 10 (one row) to selectedIndex", () => { + const next = dispatch(chapterState(2), { type: "ChapterGridMovedDown" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(12); + }); + + it("clamps at chapters.length - 1", () => { + const next = dispatch(chapterState(18, 21), { type: "ChapterGridMovedDown" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(20); + }); + + it("is a no-op when not in chapter phase", () => { + const state = makeAwaiting({ phase: "book" }); + const next = dispatch(state, { type: "ChapterGridMovedDown" }); + expect(next).toBe(state); + }); + + it("is a no-op when chapters is empty", () => { + const state = makeAwaiting({ phase: "chapter", chapters: [], selectedIndex: 0 }); + const next = dispatch(state, { type: "ChapterGridMovedDown" }); + expect(next).toBe(state); + }); + }); + + describe("ChapterGridMovedUp", () => { + function chapterState(selectedIndex: number): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex, + query: "John ", + }); + } + + it("subtracts 10 (one row) from selectedIndex", () => { + const next = dispatch(chapterState(15), { type: "ChapterGridMovedUp" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(5); + }); + + it("clamps at 0", () => { + const next = dispatch(chapterState(5), { type: "ChapterGridMovedUp" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(0); + }); + }); + + describe("ChapterGridMovedRight", () => { + function chapterState(selectedIndex: number): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex, + query: "John ", + }); + } + + it("adds 1 to selectedIndex", () => { + const next = dispatch(chapterState(0), { type: "ChapterGridMovedRight" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(1); + }); + + it("clamps at chapters.length - 1", () => { + const next = dispatch(chapterState(20), { type: "ChapterGridMovedRight" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(20); + }); + }); + + describe("ChapterGridMovedLeft", () => { + function chapterState(selectedIndex: number): ReaderState { + return makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex, + query: "John ", + }); + } + + it("subtracts 1 from selectedIndex", () => { + const next = dispatch(chapterState(3), { type: "ChapterGridMovedLeft" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(2); + }); + + it("clamps at 0", () => { + const next = dispatch(chapterState(0), { type: "ChapterGridMovedLeft" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.selectedIndex).toBe(0); + }); + }); }); diff --git a/src/tui/reader/reader-reducer.ts b/src/tui/reader/reader-reducer.ts index 44f16a6..e750c7c 100644 --- a/src/tui/reader/reader-reducer.ts +++ b/src/tui/reader/reader-reducer.ts @@ -1,5 +1,7 @@ import { parseReference } from "@/domain/reference"; import { suggestBooks } from "@/domain/book-suggestions"; +import { chaptersForBook } from "@/domain/book-chapters"; +import { bookIdFromCanonical } from "@/domain/book-id"; import type { BookSuggestion } from "@/domain/book-suggestions"; import type { Reference } from "@/domain/reference"; import type { ParseError, RepoError } from "@/domain/errors"; @@ -8,9 +10,9 @@ import type { Passage } from "@/domain/passage"; export const VERSES_PER_PAGE = 15; export type ReaderState = - | { kind: "awaiting"; query: string; parseError: ParseError | null; suggestions: BookSuggestion[]; selectedIndex: number } - | { kind: "loading"; ref: Reference } - | { kind: "loaded"; passage: Passage; ref: Reference; cursorIndex: number; pageStartIndex: number } + | { kind: "awaiting"; query: string; parseError: ParseError | null; suggestions: BookSuggestion[]; selectedIndex: number; phase: "book" | "chapter"; chapters: number[]; bookChosen: BookSuggestion | null } + | { kind: "loading"; ref: Reference; intent: "view" | "pick-verse" } + | { kind: "loaded"; passage: Passage; ref: Reference; cursorIndex: number; pageStartIndex: number; versePicker: { selectedIndex: number } | null } | { kind: "network-error"; ref: Reference; reason: RepoError }; export type ReaderAction = @@ -27,20 +29,89 @@ export type ReaderAction = | { type: "PageRetreated" } | { type: "SuggestionMovedUp" } | { type: "SuggestionMovedDown" } - | { type: "SuggestionAccepted" }; + | { type: "SuggestionAccepted" } + | { type: "BookChosen" } + | { type: "ChapterChosen" } + | { type: "VersePickerMovedUp" } + | { type: "VersePickerMovedDown" } + | { type: "VersePickerMovedLeft" } + | { type: "VersePickerMovedRight" } + | { type: "VersePickerAccepted" } + | { type: "VersePickerCancelled" } + | { type: "PickerBackedOut" } + | { type: "ChapterGridMovedUp" } + | { type: "ChapterGridMovedDown" } + | { type: "ChapterGridMovedLeft" } + | { type: "ChapterGridMovedRight" }; const handlers = { - QueryTyped: (s: ReaderState, a: Extract): ReaderState => - s.kind === "awaiting" - ? { ...s, query: a.query, parseError: null, suggestions: suggestBooks(a.query), selectedIndex: -1 } - : s, + QueryTyped: (s: ReaderState, a: Extract): ReaderState => { + if (s.kind !== "awaiting") return s; + // Stay in chapter phase if the new query still has the chosen book's display + // name as a prefix. Without this, OpenTUI's controlled kicks us out + // of chapter mode the instant SuggestionAccepted programmatically rewrites + // the query, because the value-prop change synthesizes an onInput event. + if ( + s.phase === "chapter" && + s.bookChosen !== null && + a.query.toLowerCase().startsWith(`${s.bookChosen.displayName.toLowerCase()} `) + ) { + // Decode trailing digit suffix → grid selection. Multi-digit naturally works: + // "John 1" highlights chapter 1, "John 10" highlights chapter 10. + const suffix = a.query.slice(s.bookChosen.displayName.length + 1); + const digitMatch = /^(\d+)/.exec(suffix); + let selectedIndex = s.selectedIndex; + if (digitMatch) { + const n = parseInt(digitMatch[1], 10); + if (n >= 1 && n <= s.chapters.length) selectedIndex = n - 1; + } + return { ...s, query: a.query, parseError: null, selectedIndex }; + } + // Book phase: re-suggest and auto-highlight the top match so Tab/Enter + // accept the obvious choice without arrow-down first (fzf-style). + const suggestions = suggestBooks(a.query); + return { + ...s, + query: a.query, + parseError: null, + suggestions, + selectedIndex: suggestions.length > 0 ? 0 : -1, + phase: "book", + bookChosen: null, + chapters: [], + }; + }, QuerySubmitted: (s: ReaderState, _a: Extract): ReaderState => { if (s.kind !== "awaiting") return s; + // Chapter phase: Enter commits the highlighted chapter into the verse picker, + // UNLESS the user typed a colon — in which case they want a direct ref. + if (s.phase === "chapter" && s.bookChosen !== null) { + if (s.query.includes(":")) { + const result = parseReference(s.query); + return result.ok + ? { kind: "loading", ref: result.value, intent: "view" } + : { ...s, parseError: result.error }; + } + if (s.selectedIndex < 0) return s; + const chapter = s.chapters[s.selectedIndex]; + return { + kind: "loading", + ref: { book: bookIdFromCanonical(s.bookChosen.canonical), chapter, verses: { start: 1, end: 1 } }, + intent: "pick-verse", + }; + } + // Book phase: try parseReference; on failure, fall back to picking the + // highlighted suggestion so "john" + Enter enters chapter phase for John. const result = parseReference(s.query); - return result.ok - ? { kind: "loading", ref: result.value } - : { ...s, parseError: result.error }; + if (result.ok) return { kind: "loading", ref: result.value, intent: "view" }; + if (s.suggestions.length > 0 && s.selectedIndex >= 0) { + const chosen = s.suggestions[s.selectedIndex]; + const n = chaptersForBook(chosen.canonical); + const chapters = Array.from({ length: n }, (_, i) => i + 1); + return { ...s, phase: "chapter", bookChosen: chosen, chapters, selectedIndex: 0, suggestions: [], query: `${chosen.displayName} ` }; + } + return { ...s, parseError: result.error }; }, PassageFetched: (s: ReaderState, a: Extract): ReaderState => { @@ -49,7 +120,8 @@ const handlers = { const foundIndex = a.passage.verses.findIndex((v) => v.number === targetVerse); const cursorIndex = foundIndex >= 0 ? foundIndex : 0; const pageStartIndex = Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE; - return { kind: "loaded", passage: a.passage, ref: s.ref, cursorIndex, pageStartIndex }; + const versePicker = s.intent === "pick-verse" ? { selectedIndex: 0 } : null; + return { kind: "loaded", passage: a.passage, ref: s.ref, cursorIndex, pageStartIndex, versePicker }; }, FetchFailed: (s: ReaderState, a: Extract): ReaderState => @@ -59,18 +131,18 @@ const handlers = { ChapterAdvanced: (s: ReaderState, _a: Extract): ReaderState => s.kind === "loaded" - ? { kind: "loading", ref: { ...s.ref, chapter: s.ref.chapter + 1, verses: { start: 1, end: 1 } } } + ? { kind: "loading", ref: { ...s.ref, chapter: s.ref.chapter + 1, verses: { start: 1, end: 1 } }, intent: "view" } : s, ChapterRetreated: (s: ReaderState, _a: Extract): ReaderState => { if (s.kind !== "loaded") return s; if (s.ref.chapter <= 1) return s; - return { kind: "loading", ref: { ...s.ref, chapter: s.ref.chapter - 1, verses: { start: 1, end: 1 } } }; + return { kind: "loading", ref: { ...s.ref, chapter: s.ref.chapter - 1, verses: { start: 1, end: 1 } }, intent: "view" }; }, PaletteReopened: (s: ReaderState, _a: Extract): ReaderState => s.kind === "loaded" || s.kind === "network-error" - ? { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1 } + ? { kind: "awaiting", query: "", parseError: null, suggestions: [], selectedIndex: -1, phase: "book", chapters: [], bookChosen: null } : s, CursorMovedDown: (s: ReaderState, _a: Extract): ReaderState => { @@ -124,8 +196,99 @@ const handlers = { SuggestionAccepted: (s: ReaderState, _a: Extract): ReaderState => { if (s.kind !== "awaiting" || s.selectedIndex < 0) return s; + if (s.phase === "book") { + const chosen = s.suggestions[s.selectedIndex]; + const n = chaptersForBook(chosen.canonical); + const chapters = Array.from({ length: n }, (_, i) => i + 1); + return { ...s, phase: "chapter", bookChosen: chosen, chapters, selectedIndex: 0, suggestions: [], query: `${chosen.displayName} ` }; + } + if (s.phase === "chapter") { + if (s.bookChosen === null) return s; + const chapter = s.chapters[s.selectedIndex]; + return { + kind: "loading", + ref: { book: bookIdFromCanonical(s.bookChosen.canonical), chapter, verses: { start: 1, end: 1 } }, + intent: "pick-verse", + }; + } + return s; + }, + + BookChosen: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "book" || s.selectedIndex < 0) return s; const chosen = s.suggestions[s.selectedIndex]; - return { ...s, query: `${chosen.displayName} `, suggestions: [], selectedIndex: -1 }; + const n = chaptersForBook(chosen.canonical); + const chapters = Array.from({ length: n }, (_, i) => i + 1); + return { ...s, phase: "chapter", bookChosen: chosen, chapters, selectedIndex: 0, suggestions: [], query: `${chosen.displayName} ` }; + }, + + ChapterChosen: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter" || s.bookChosen === null || s.selectedIndex < 0) return s; + const chapter = s.chapters[s.selectedIndex]; + return { + kind: "loading", + ref: { book: bookIdFromCanonical(s.bookChosen.canonical), chapter, verses: { start: 1, end: 1 } }, + intent: "pick-verse", + }; + }, + + VersePickerMovedDown: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + const max = s.passage.verses.length - 1; + return { ...s, versePicker: { selectedIndex: Math.min(s.versePicker.selectedIndex + 10, max) } }; + }, + + VersePickerMovedUp: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + return { ...s, versePicker: { selectedIndex: Math.max(s.versePicker.selectedIndex - 10, 0) } }; + }, + + VersePickerMovedRight: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + const max = s.passage.verses.length - 1; + return { ...s, versePicker: { selectedIndex: Math.min(s.versePicker.selectedIndex + 1, max) } }; + }, + + VersePickerMovedLeft: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + return { ...s, versePicker: { selectedIndex: Math.max(s.versePicker.selectedIndex - 1, 0) } }; + }, + + VersePickerAccepted: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + const cursorIndex = s.versePicker.selectedIndex; + const pageStartIndex = Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE; + return { ...s, cursorIndex, pageStartIndex, versePicker: null }; + }, + + VersePickerCancelled: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "loaded" || s.versePicker === null) return s; + return { ...s, versePicker: null }; + }, + + PickerBackedOut: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter") return s; + return { ...s, phase: "book", bookChosen: null, chapters: [], selectedIndex: -1, suggestions: suggestBooks(s.query) }; + }, + + ChapterGridMovedUp: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter" || s.chapters.length === 0) return s; + return { ...s, selectedIndex: Math.max(s.selectedIndex - 10, 0) }; + }, + + ChapterGridMovedDown: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter" || s.chapters.length === 0) return s; + return { ...s, selectedIndex: Math.min(s.selectedIndex + 10, s.chapters.length - 1) }; + }, + + ChapterGridMovedLeft: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter" || s.chapters.length === 0) return s; + return { ...s, selectedIndex: Math.max(s.selectedIndex - 1, 0) }; + }, + + ChapterGridMovedRight: (s: ReaderState, _a: Extract): ReaderState => { + if (s.kind !== "awaiting" || s.phase !== "chapter" || s.chapters.length === 0) return s; + return { ...s, selectedIndex: Math.min(s.selectedIndex + 1, s.chapters.length - 1) }; }, } satisfies { [K in ReaderAction["type"]]: ( @@ -147,4 +310,7 @@ export const initialReaderState: ReaderState = { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }; diff --git a/src/tui/reader/reader-screen.test.ts b/src/tui/reader/reader-screen.test.ts index 7e6411a..3c1e0ff 100644 --- a/src/tui/reader/reader-screen.test.ts +++ b/src/tui/reader/reader-screen.test.ts @@ -3,6 +3,7 @@ import { bottomTitleFor } from "@/tui/reader/reader-screen"; import type { ReaderState } from "@/tui/reader/reader-reducer"; import type { Reference } from "@/domain/reference"; import type { BookSuggestion } from "@/domain/book-suggestions"; +import type { Passage } from "@/domain/passage"; const johnRef: Reference = { book: "JHN" as import("@/domain/book-id").BookId, @@ -12,39 +13,87 @@ const johnRef: Reference = { const emptySuggestions: BookSuggestion[] = []; +const mockPassage: Passage = { + reference: johnRef, + verses: [{ number: 1, text: "In the beginning" }], +}; + describe("bottomTitleFor", () => { - it("awaiting returns hint text with Tab complete, arrows, Enter, and quit", () => { + it("awaiting/book returns hint text with Tab complete, arrows, Enter, and quit", () => { const state: ReaderState = { kind: "awaiting", query: "", parseError: null, suggestions: emptySuggestions, selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }; expect(bottomTitleFor(state)).toBe( " Tab complete • ↑↓ suggest • Enter open • q quit ", ); }); - it("loading returns loading hint text", () => { - const state: ReaderState = { kind: "loading", ref: johnRef }; + it("awaiting/chapter returns chapter pick prompt including book name", () => { + const state: ReaderState = { + kind: "awaiting", + query: "John ", + parseError: null, + suggestions: emptySuggestions, + selectedIndex: 0, + phase: "chapter", + chapters: [1, 2, 3], + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + }; + const title = bottomTitleFor(state); + expect(title).toContain("John"); + expect(title).toContain("chapter"); + }); + + it("loading/view returns loading hint text", () => { + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "view" }; expect(bottomTitleFor(state)).toBe(" loading… • q quit "); }); - it("loaded returns navigation hint text", () => { - const mockPassage = { - reference: johnRef, - verses: [{ number: 1, text: "In the beginning" }], - }; + it("loading/pick-verse returns loading hint text", () => { + const state: ReaderState = { kind: "loading", ref: johnRef, intent: "pick-verse" }; + expect(typeof bottomTitleFor(state)).toBe("string"); + }); + + it("loaded/versePicker null returns navigation hint text", () => { const state: ReaderState = { kind: "loaded", passage: mockPassage, ref: johnRef, cursorIndex: 0, pageStartIndex: 0, + versePicker: null, }; expect(bottomTitleFor(state)).toBe( " ↑↓ verse • [ ] page • n p chapter • / palette • q quit ", ); }); + + it("loaded/versePicker active returns verse pick prompt", () => { + const state: ReaderState = { + kind: "loaded", + passage: mockPassage, + ref: johnRef, + cursorIndex: 0, + pageStartIndex: 0, + versePicker: { selectedIndex: 0 }, + }; + const title = bottomTitleFor(state); + expect(title).toContain("verse"); + }); + + it("network-error returns error hint text", () => { + const state: ReaderState = { + kind: "network-error", + ref: johnRef, + reason: { kind: "network", message: "unreachable" }, + }; + expect(typeof bottomTitleFor(state)).toBe("string"); + }); }); diff --git a/src/tui/reader/reader-screen.tsx b/src/tui/reader/reader-screen.tsx index 91e9dc8..29f82e4 100644 --- a/src/tui/reader/reader-screen.tsx +++ b/src/tui/reader/reader-screen.tsx @@ -63,16 +63,22 @@ function titleFor(state: ReaderState): string { } export function bottomTitleFor(state: ReaderState): string { - switch (state.kind) { - case "awaiting": - return " Tab complete • ↑↓ suggest • Enter open • q quit "; - case "loading": - return " loading… • q quit "; - case "loaded": - return " ↑↓ verse • [ ] page • n p chapter • / palette • q quit "; - case "network-error": - return " / palette • q quit "; + if (state.kind === "awaiting") { + if (state.phase === "chapter") { + return ` Pick a chapter — ${state.bookChosen?.displayName ?? ""} `; + } + return " Tab complete • ↑↓ suggest • Enter open • q quit "; } + if (state.kind === "loading") { + return " loading… • q quit "; + } + if (state.kind === "loaded") { + if (state.versePicker !== null) { + return " Pick a verse • ↑↓ row • ←→ cell • Tab accept • Esc cancel "; + } + return " ↑↓ verse • [ ] page • n p chapter • / palette • q quit "; + } + return " / palette • q quit "; } type BodyProps = { @@ -99,7 +105,12 @@ function Body({ state, dispatch, frame, boxWidth }: BodyProps) { {state.parseError !== null ? ( {` ⚠ couldn't parse "${state.query}"`} ) : null} - {state.suggestions.length > 0 ? ( + {state.phase === "chapter" ? ( + + ) : state.suggestions.length > 0 ? ( {state.suggestions.map((s, i) => { const selected = i === state.selectedIndex; @@ -137,7 +148,19 @@ function Body({ state, dispatch, frame, boxWidth }: BodyProps) { ); } - const { passage, cursorIndex, pageStartIndex } = state; + const { passage, cursorIndex, pageStartIndex, versePicker } = state; + + if (versePicker !== null) { + return ( + + i + 1)} + selectedIndex={versePicker.selectedIndex} + /> + + ); + } + const pageVerses = passage.verses.slice(pageStartIndex, pageStartIndex + VERSES_PER_PAGE); return ( + {rows.map((row, rowIdx) => ( + + {row.map((num, colIdx) => { + const idx = rowIdx * COLS + colIdx; + const selected = idx === selectedIndex; + const label = String(num).padEnd(colWidth); + return ( + + {selected ? `▶${label}` : ` ${label}`} + + ); + })} + + ))} + + ); +} + // Prefix layout matches docs/ui-sketches.md Reading view (line 122): // "▶ 16 For God..." or " 16 For God..." — 5 chars before text, 5-space continuation. const PREFIX_LEN = 5; diff --git a/src/tui/tui-driver.tsx b/src/tui/tui-driver.tsx index bb9fa8a..fca7913 100644 --- a/src/tui/tui-driver.tsx +++ b/src/tui/tui-driver.tsx @@ -23,7 +23,8 @@ function App({ const [readerState, dispatch] = useReducer(readerReducer, initialReaderState); useKeyboard((keyEvent) => { - if (keyEvent.name === "q" || keyEvent.name === "Q") { + const name = keyEvent.name.toLowerCase(); + if (name === "q") { renderer.destroy(); resolve(); return; @@ -33,19 +34,39 @@ function App({ return; } if (readerState.kind === "awaiting") { - if (keyEvent.name === "down") { dispatch({ type: "SuggestionMovedDown" }); return; } - if (keyEvent.name === "up") { dispatch({ type: "SuggestionMovedUp" }); return; } - if (keyEvent.name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; } + if (readerState.phase === "chapter") { + if (name === "escape") { dispatch({ type: "PickerBackedOut" }); return; } + if (name === "up") { dispatch({ type: "ChapterGridMovedUp" }); return; } + if (name === "down") { dispatch({ type: "ChapterGridMovedDown" }); return; } + if (name === "left") { dispatch({ type: "ChapterGridMovedLeft" }); return; } + if (name === "right") { dispatch({ type: "ChapterGridMovedRight" }); return; } + if (name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; } + return; + } + if (name === "down") { dispatch({ type: "SuggestionMovedDown" }); return; } + if (name === "up") { dispatch({ type: "SuggestionMovedUp" }); return; } + if (name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; } return; } - if (keyEvent.name === "up") { dispatch({ type: "CursorMovedUp" }); return; } - if (keyEvent.name === "down") { dispatch({ type: "CursorMovedDown" }); return; } - if (keyEvent.name === "[") { dispatch({ type: "PageRetreated" }); return; } - if (keyEvent.name === "]") { dispatch({ type: "PageAdvanced" }); return; } - if (keyEvent.name === "n") { dispatch({ type: "ChapterAdvanced" }); return; } - if (keyEvent.name === "p") { dispatch({ type: "ChapterRetreated" }); return; } - if (keyEvent.name === "/") { dispatch({ type: "PaletteReopened" }); return; } + if (readerState.kind === "loaded" && readerState.versePicker !== null) { + if (name === "up") { dispatch({ type: "VersePickerMovedUp" }); return; } + if (name === "down") { dispatch({ type: "VersePickerMovedDown" }); return; } + if (name === "left") { dispatch({ type: "VersePickerMovedLeft" }); return; } + if (name === "right") { dispatch({ type: "VersePickerMovedRight" }); return; } + if (name === "tab") { dispatch({ type: "VersePickerAccepted" }); return; } + if (name === "return") { dispatch({ type: "VersePickerAccepted" }); return; } + if (name === "escape") { dispatch({ type: "VersePickerCancelled" }); return; } + return; + } + + if (name === "up") { dispatch({ type: "CursorMovedUp" }); return; } + if (name === "down") { dispatch({ type: "CursorMovedDown" }); return; } + if (name === "[") { dispatch({ type: "PageRetreated" }); return; } + if (name === "]") { dispatch({ type: "PageAdvanced" }); return; } + if (name === "n") { dispatch({ type: "ChapterAdvanced" }); return; } + if (name === "p") { dispatch({ type: "ChapterRetreated" }); return; } + if (name === "/") { dispatch({ type: "PaletteReopened" }); return; } }); if (phase === "welcome") {