From 828d6512ddf47c32a28aad780a7a668e28211a5f Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 10:43:57 -0300 Subject: [PATCH 1/7] feat(domain): book-chapters module with 66-entry canon --- src/domain/book-chapters.test.ts | 30 ++++++++++++++++++++++++++++++ src/domain/book-chapters.ts | 25 +++++++++++++++++++++++++ src/domain/book-id.ts | 6 ++++++ 3 files changed, 61 insertions(+) create mode 100644 src/domain/book-chapters.test.ts create mode 100644 src/domain/book-chapters.ts diff --git a/src/domain/book-chapters.test.ts b/src/domain/book-chapters.test.ts new file mode 100644 index 0000000..5590e77 --- /dev/null +++ b/src/domain/book-chapters.test.ts @@ -0,0 +1,30 @@ +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); + }); +}); diff --git a/src/domain/book-chapters.ts b/src/domain/book-chapters.ts new file mode 100644 index 0000000..41d210e --- /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] ?? 0; +} diff --git a/src/domain/book-id.ts b/src/domain/book-id.ts index 6764ba8..23b55f9 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); + if (!result.ok) throw new Error(`bookIdFromCanonical: unknown book "${canonical}"`); + return result.value; +} From f186861f9150718187a18fa52cfd1325bd7b5eae Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 10:47:16 -0300 Subject: [PATCH 2/7] feat(tui): reader-reducer phase, intent, and verse-picker state --- src/tui/reader/reader-reducer.test.ts | 246 ++++++++++++++++++++++++-- src/tui/reader/reader-reducer.ts | 108 +++++++++-- 2 files changed, 325 insertions(+), 29 deletions(-) diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index ad03184..efbc428 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, }); }); }); @@ -93,7 +97,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); }); @@ -127,7 +131,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 +139,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 +147,7 @@ describe("readerReducer", () => { ref: johnRef, cursorIndex: 0, pageStartIndex: 0, + versePicker: null, }); }); @@ -150,7 +155,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 +166,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 +182,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 +214,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 +257,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 +273,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }); }); @@ -280,6 +288,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }); }); @@ -289,7 +300,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 +315,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 +421,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 +431,9 @@ describe("readerReducer", () => { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, ...overrides, } as ReaderState; } @@ -465,19 +482,212 @@ 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("always resets to book phase, clears bookChosen and chapters, recomputes suggestions", () => { + 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); + }); + }); + + 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); }); }); diff --git a/src/tui/reader/reader-reducer.ts b/src/tui/reader/reader-reducer.ts index 44f16a6..1ec9b72 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,19 +29,28 @@ 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" }; const handlers = { QueryTyped: (s: ReaderState, a: Extract): ReaderState => s.kind === "awaiting" - ? { ...s, query: a.query, parseError: null, suggestions: suggestBooks(a.query), selectedIndex: -1 } + ? { ...s, query: a.query, parseError: null, suggestions: suggestBooks(a.query), selectedIndex: -1, phase: "book", bookChosen: null, chapters: [] } : s, QuerySubmitted: (s: ReaderState, _a: Extract): ReaderState => { if (s.kind !== "awaiting") return s; const result = parseReference(s.query); return result.ok - ? { kind: "loading", ref: result.value } + ? { kind: "loading", ref: result.value, intent: "view" } : { ...s, parseError: result.error }; }, @@ -49,7 +60,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 +71,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 +136,79 @@ 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) }; }, } satisfies { [K in ReaderAction["type"]]: ( @@ -147,4 +230,7 @@ export const initialReaderState: ReaderState = { parseError: null, suggestions: [], selectedIndex: -1, + phase: "book", + chapters: [], + bookChosen: null, }; From b921ce4bf38a84bcd2ef416bb3136a6ce96e8092 Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 10:49:11 -0300 Subject: [PATCH 3/7] feat(tui): chapter grid + verse-picker overlay views --- src/tui/reader/reader-reducer.test.ts | 12 +++-- src/tui/reader/reader-screen.test.ts | 65 +++++++++++++++++++++--- src/tui/reader/reader-screen.tsx | 73 +++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index efbc428..c023947 100644 --- a/src/tui/reader/reader-reducer.test.ts +++ b/src/tui/reader/reader-reducer.test.ts @@ -72,6 +72,9 @@ describe("readerReducer", () => { 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"); @@ -89,6 +92,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; @@ -105,7 +111,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; @@ -114,7 +120,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; @@ -123,7 +129,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; 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; From 6160c6cdc79500ec24a43541056811c9af595d8d Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 10:49:38 -0300 Subject: [PATCH 4/7] feat(tui): driver gate handles verse-picker keys --- src/tui/tui-driver.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tui/tui-driver.tsx b/src/tui/tui-driver.tsx index bb9fa8a..764798a 100644 --- a/src/tui/tui-driver.tsx +++ b/src/tui/tui-driver.tsx @@ -33,12 +33,24 @@ function App({ return; } if (readerState.kind === "awaiting") { + if (readerState.phase === "chapter" && keyEvent.name === "escape") { dispatch({ type: "PickerBackedOut" }); return; } if (keyEvent.name === "down") { dispatch({ type: "SuggestionMovedDown" }); return; } if (keyEvent.name === "up") { dispatch({ type: "SuggestionMovedUp" }); return; } if (keyEvent.name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; } return; } + 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; + } + if (keyEvent.name === "up") { dispatch({ type: "CursorMovedUp" }); return; } if (keyEvent.name === "down") { dispatch({ type: "CursorMovedDown" }); return; } if (keyEvent.name === "[") { dispatch({ type: "PageRetreated" }); return; } From 14522bd0871364e6b55ab25955c2d690a2d14bf8 Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 10:56:24 -0300 Subject: [PATCH 5/7] fix(domain,tui): make all key + lookup paths case-insensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: 'also make it everything not case sensitive'. Three sites normalized: 1. chaptersForBook(canonical): upper-cases the input before BOOK_CHAPTERS lookup. chaptersForBook('gen') and chaptersForBook('GEN') both return 50. 2. bookIdFromCanonical(canonical): upper-cases before delegating to makeBookId. bookIdFromCanonical('jhn') returns 'JHN' as BookId. 3. tui-driver useKeyboard handler: normalizes keyEvent.name to lowercase once at the top, so all letter keybinds (q, n, p) accept either case (Q, N, P with Shift) without explicit pair-checks. Special keys (up, down, tab, escape, [, ], /) are already lowercase emitted by OpenTUI. makeBookId itself stays strict — it's the domain invariant gate; only canonical uppercase IDs cross it. The two helpers above wrap it with case-insensitive input handling. Tests: 208 → 214 (+6 case-insensitivity assertions). tsc clean. --- .../changes/palette-chapter-picker/explore.md | 211 +++++++++ .../palette-chapter-picker/proposal.md | 154 +++++++ .../changes/palette-chapter-picker/spec.md | 434 ++++++++++++++++++ .../changes/palette-chapter-picker/tasks.md | 133 ++++++ .../palette-chapter-picker/verify-report.md | 157 +++++++ src/domain/book-chapters.test.ts | 9 + src/domain/book-chapters.ts | 2 +- src/domain/book-id.test.ts | 20 +- src/domain/book-id.ts | 2 +- src/tui/tui-driver.tsx | 39 +- 10 files changed, 1139 insertions(+), 22 deletions(-) create mode 100644 openspec/changes/palette-chapter-picker/explore.md create mode 100644 openspec/changes/palette-chapter-picker/proposal.md create mode 100644 openspec/changes/palette-chapter-picker/spec.md create mode 100644 openspec/changes/palette-chapter-picker/tasks.md create mode 100644 openspec/changes/palette-chapter-picker/verify-report.md 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 index 5590e77..e9b074f 100644 --- a/src/domain/book-chapters.test.ts +++ b/src/domain/book-chapters.test.ts @@ -27,4 +27,13 @@ describe("chaptersForBook", () => { 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 index 41d210e..a916c62 100644 --- a/src/domain/book-chapters.ts +++ b/src/domain/book-chapters.ts @@ -21,5 +21,5 @@ export const BOOK_CHAPTERS: Record = { }; export function chaptersForBook(canonical: string): number { - return BOOK_CHAPTERS[canonical] ?? 0; + 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 23b55f9..dad126c 100644 --- a/src/domain/book-id.ts +++ b/src/domain/book-id.ts @@ -35,7 +35,7 @@ export function makeBookId(s: string): Result { } export function bookIdFromCanonical(canonical: string): BookId { - const result = makeBookId(canonical); + const result = makeBookId(canonical.toUpperCase()); if (!result.ok) throw new Error(`bookIdFromCanonical: unknown book "${canonical}"`); return result.value; } diff --git a/src/tui/tui-driver.tsx b/src/tui/tui-driver.tsx index 764798a..bbfda25 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,31 +34,31 @@ function App({ return; } if (readerState.kind === "awaiting") { - if (readerState.phase === "chapter" && keyEvent.name === "escape") { dispatch({ type: "PickerBackedOut" }); return; } - 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" && name === "escape") { dispatch({ type: "PickerBackedOut" }); return; } + if (name === "down") { dispatch({ type: "SuggestionMovedDown" }); return; } + if (name === "up") { dispatch({ type: "SuggestionMovedUp" }); return; } + if (name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; } return; } 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; } + 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 (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 (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") { From 1e4f37d0a60bfa4d3162a4c7b5853d45ac998e29 Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 11:08:00 -0300 Subject: [PATCH 6/7] fix(tui): chapter palette stays visible after book pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: pressing Tab on a book suggestion showed the chapter grid for exactly one render, then immediately disappeared back to book mode. Root cause: OpenTUI's controlled synthesizes an onInput event whenever its value prop changes — including programmatic changes. SuggestionAccepted rewrites state.query to '{DisplayName} ' (e.g. 'John ') to autocomplete the book token. That value change fires onInput with 'John ', which dispatches QueryTyped. QueryTyped's old handler unconditionally reset phase to 'book', clearing bookChosen and chapters. Fix: QueryTyped now preserves phase: 'chapter' when the new query still has the chosen book's display name as a case-insensitive prefix (e.g. 'John ', 'John 3', 'JOHN 3:16' all keep chapter mode). Free- typing past the book name (e.g. backspacing to 'Joh') correctly drops back to book mode. This is also the foundation for typing chapter digits to filter the chapter list in a future iteration. Tests: +2 (chapter-phase preservation, case-insensitive variant); existing 'resets to book phase' test still passes because its query 'j' doesn't have the book prefix. 214 → 216. tsc clean. --- src/tui/reader/reader-reducer.test.ts | 35 ++++++++++++++++++++++++++- src/tui/reader/reader-reducer.ts | 28 ++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index c023947..12ae6c8 100644 --- a/src/tui/reader/reader-reducer.test.ts +++ b/src/tui/reader/reader-reducer.test.ts @@ -532,7 +532,7 @@ describe("readerReducer", () => { }); describe("QueryTyped (chapter phase override)", () => { - it("always resets to book phase, clears bookChosen and chapters, recomputes suggestions", () => { + 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" }, @@ -546,6 +546,39 @@ describe("readerReducer", () => { 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)", () => { diff --git a/src/tui/reader/reader-reducer.ts b/src/tui/reader/reader-reducer.ts index 1ec9b72..bb29f68 100644 --- a/src/tui/reader/reader-reducer.ts +++ b/src/tui/reader/reader-reducer.ts @@ -41,10 +41,30 @@ export type ReaderAction = | { type: "PickerBackedOut" }; const handlers = { - QueryTyped: (s: ReaderState, a: Extract): ReaderState => - s.kind === "awaiting" - ? { ...s, query: a.query, parseError: null, suggestions: suggestBooks(a.query), selectedIndex: -1, phase: "book", bookChosen: null, chapters: [] } - : 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()} `) + ) { + return { ...s, query: a.query, parseError: null }; + } + return { + ...s, + query: a.query, + parseError: null, + suggestions: suggestBooks(a.query), + selectedIndex: -1, + phase: "book", + bookChosen: null, + chapters: [], + }; + }, QuerySubmitted: (s: ReaderState, _a: Extract): ReaderState => { if (s.kind !== "awaiting") return s; From d9e0cbe25fe64a676733bf7b3c201ca3d42b098f Mon Sep 17 00:00:00 2001 From: ivanmaierg Date: Tue, 12 May 2026 22:15:45 -0300 Subject: [PATCH 7/7] feat(tui): chapter grid navigation, digit selection, enter commits chapter - Add ChapterGridMoved {Up,Down,Left,Right} actions for 10-wide stride grid nav - Decode trailing digit suffix in query to highlight chapter (e.g. "John 10") - Enter in chapter phase commits highlighted chapter into verse picker; query containing ':' still routes through parseReference for direct refs - Auto-highlight top book suggestion (fzf-style) so Tab/Enter accept without arrow-down - Driver gates arrow keys to grid actions when phase is chapter --- src/tui/reader/reader-reducer.test.ts | 234 +++++++++++++++++++++++++- src/tui/reader/reader-reducer.ts | 74 +++++++- src/tui/tui-driver.tsx | 10 +- 3 files changed, 308 insertions(+), 10 deletions(-) diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index 12ae6c8..e7da63c 100644 --- a/src/tui/reader/reader-reducer.test.ts +++ b/src/tui/reader/reader-reducer.test.ts @@ -65,7 +65,7 @@ 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: "", @@ -81,10 +81,27 @@ describe("readerReducer", () => { 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", @@ -730,4 +747,217 @@ describe("readerReducer", () => { 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 bb29f68..e750c7c 100644 --- a/src/tui/reader/reader-reducer.ts +++ b/src/tui/reader/reader-reducer.ts @@ -38,7 +38,11 @@ export type ReaderAction = | { type: "VersePickerMovedRight" } | { type: "VersePickerAccepted" } | { type: "VersePickerCancelled" } - | { type: "PickerBackedOut" }; + | { type: "PickerBackedOut" } + | { type: "ChapterGridMovedUp" } + | { type: "ChapterGridMovedDown" } + | { type: "ChapterGridMovedLeft" } + | { type: "ChapterGridMovedRight" }; const handlers = { QueryTyped: (s: ReaderState, a: Extract): ReaderState => { @@ -52,14 +56,26 @@ const handlers = { s.bookChosen !== null && a.query.toLowerCase().startsWith(`${s.bookChosen.displayName.toLowerCase()} `) ) { - return { ...s, query: a.query, parseError: null }; + // 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: suggestBooks(a.query), - selectedIndex: -1, + suggestions, + selectedIndex: suggestions.length > 0 ? 0 : -1, phase: "book", bookChosen: null, chapters: [], @@ -68,10 +84,34 @@ const handlers = { 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, intent: "view" } - : { ...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 => { @@ -230,6 +270,26 @@ const handlers = { 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"]]: ( state: ReaderState, diff --git a/src/tui/tui-driver.tsx b/src/tui/tui-driver.tsx index bbfda25..fca7913 100644 --- a/src/tui/tui-driver.tsx +++ b/src/tui/tui-driver.tsx @@ -34,7 +34,15 @@ function App({ return; } if (readerState.kind === "awaiting") { - if (readerState.phase === "chapter" && name === "escape") { dispatch({ type: "PickerBackedOut" }); 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; }