Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions openspec/changes/palette-chapter-picker/explore.md
Original file line number Diff line number Diff line change
@@ -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 `<book> <chapter>:<verse>` 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 (
<box flexDirection="column">
{rows.map((row, ri) => (
<text key={ri}>
{row.map((n, ci) => {
const idx = ri * columns + ci;
const focused = idx === selectedIndex;
return (
<span key={n} fg={focused ? ACCENT_HEX : undefined}>
{focused ? `▶ ${String(n).padStart(2)} ` : ` ${String(n).padStart(2)} `}
</span>
);
})}
</text>
))}
</box>
);
}
```

## 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.
154 changes: 154 additions & 0 deletions openspec/changes/palette-chapter-picker/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading