Skip to content
Merged
108 changes: 108 additions & 0 deletions openspec/changes/archive/tui-reader-paging/archive-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Archive Report: tui-reader-paging

**Archived**: 2026-05-12
**Status**: SHIPPED on branch `feat/tui-reader-paging`

---

## Executive Summary

Adds verse paging (15 verses/page) and verse cursor navigation (`↑`/`↓` with `▶` marker in accent blue) to the reader. Reassigns keybinds: `[`/`]` → page nav, `n`/`p` → chapter nav, `↑`/`↓` → cursor, `/` → palette, `q` → quit. Fixes the existing palette-state bug where `/` would clear the query mid-typing by gating reader keybinds behind `state.kind !== "awaiting"`. Introduces a new `getChapter` application use case so single-verse refs (`john 3:16`) drop the reader into the full chapter page with the cursor positioned on the requested verse. Book-page layout: bordered frame capped at 70 chars centered horizontally, 5-char prefix matching the `ui-sketches.md` Reading view, blank row between verses for vertical rhythm, focused verse rendered in accent color (not inverse). 156/156 tests pass.

---

## Branch Status

**Branch**: `feat/tui-reader-paging`
**Commits**: 8 — the 3 from the original SDD apply, the SDD-trail/verify-report commit, then 4 user-sanctioned post-verify iterations driven by manual smoke.
**Working tree**: clean, ready to push.

### Commit Log

| # | SHA | Message |
|---|---|---|
| 1 | `a85a974` | feat(tui): reader-reducer paging + cursor state and actions |
| 2 | `8764733` | feat(tui): reader-screen page slice + cursor gutter + inverse selection |
| 3 | `7032496` | feat(tui): keybind rebinding + awaiting gate fix; welcome hint |
| 4 | `cb749dc` | docs(openspec): add SDD trail + verify report for tui-reader-paging |
| 5 | `fbd7d71` | fix(tui): reader fetches whole chapter; verse refs position cursor |
| 6 | `5a5ef32` | feat(tui): denser pages + word-wrap with hanging indent |
| 7 | `0ac25aa` | feat(tui): book-page layout — capped width, centered, blank-line verse rhythm |
| 8 | (HEAD) | style(tui): focused verse uses accent color, not inverse |

---

## Verification Summary

**Initial SDD verify verdict**: PASS WITH WARNINGS
**Tests**: 156/156 pass (127 → +29: 24 reducer + 5 across get-chapter + new PassageFetched scenarios)
**tsc --noEmit**: exits 0
**Critical**: 0 | **Warning**: 1 (manual smoke pending — now resolved) | **Suggestion**: 1 (cursor color washout — fixed in commit 8)

### Post-verify sanctioned iterations (commits 5–8)

Manual PTY smoke after the initial verify surfaced four UX issues. All addressed on the same branch as user-directed improvements, not spec drift:

1. **C5 — Whole-chapter fetch + cursor positioning (commit `fbd7d71`)**: `john 3:16` was showing only verse 16 instead of dropping into the chapter page. New `getChapter` use case returns the whole chapter regardless of `ref.verses`; `PassageFetched` now positions `cursorIndex` to the index of the verse matching `ref.verses.start`, with `pageStartIndex` derived from `Math.floor(cursorIndex / VERSES_PER_PAGE) * VERSES_PER_PAGE`. `ChapterAdvanced`/`Retreated` reset `ref.verses` to `{1, 1}` so cursor lands on verse 1 of the new chapter. Also caps palette input width to 50 chars; fixes verify suggestion S1 (accent gutter was washing out under DIM).

2. **C6 — Denser pages + word-wrap (commit `5a5ef32`)**: `VERSES_PER_PAGE` bumped from 8 to 15. Word-wrap with hanging indent (5-space continuation) at `terminalWidth - 8`, floor 20. Tests rewritten to use `VERSES_PER_PAGE` symbol instead of hardcoded indices so future page-size tuning won't churn the suite.

3. **C7 — Book-page layout (commit `0ac25aa`)**: Wide terminals were producing a single 200-char wall of text that ran off the edges or floated at the top with empty space below. Now the bordered frame caps at 70 chars (`PAGE_MAX_WIDTH`), centers horizontally via `alignItems="center"`, auto-sizes to content height, with `paddingTop={1}` for top margin and a blank `<text>` row between verses. Matches `ui-sketches.md` Reading view (line 122) exactly.

4. **C8 — Focused verse color (HEAD)**: `INVERSE` attribute on the focused verse text rendered as black-on-light, jarring against a dark terminal. Replaced with `fg={ACCENT_HEX}` per `ui-sketches.md` line 148 ("Focused verse: accent color on the line + ▶ marker in the gutter"). Marker and text now share the accent token, reading as a single highlighted unit.

---

## Residual Manual Step — DONE

User confirmed PTY smoke after each post-verify iteration:
- Welcome → any key → reader palette
- Type `john 3:16` Enter → drops into page 2 of John 3 with cursor on verse 16 (accent blue)
- `↑` / `↓` move cursor through verses (rolls across page boundaries)
- `[` / `]` page back/forward (silent clamp at edges)
- `n` / `p` chapter nav (resets to verse 1 of new chapter)
- `/` reopens palette (no longer clears mid-typing — `awaiting`-state gate working)
- `q` exits cleanly

---

## Out-of-Scope Follow-Ups

Tracked for future SDD changes:

1. **Dynamic `VERSES_PER_PAGE`** from terminal height (current value is a static 15)
2. **Cross-chapter cursor flow** — `↓` past the last verse currently clamps; could trigger `ChapterAdvanced`
3. **Two-page open-book spread** on wide terminals (matches welcome screen aesthetic)
4. **Visual feedback on page-boundary clamp** (current behavior is silent)
5. **Long-word wrap** — a single word longer than wrap width currently overflows; `wordWrap` could break long tokens

---

## Engram Observations (Traceability)

| Topic | ID | Type | Notes |
|---|---|---|---|
| `sdd/tui-reader-paging/explore` | 279 | architecture | Library survey, state machine sketch, keybind map |
| `sdd/tui-reader-paging/proposal` | 280 | architecture | Locked decisions |
| `sdd/tui-reader-paging/spec` | 281 | architecture | 27 REQs |
| `sdd/tui-reader-paging/tasks` | 282 | architecture | 37 tasks across 3 commits |
| `sdd/tui-reader-paging/apply-progress` | 283 | architecture | All 3 commits, 127 → 151 tests |
| `sdd/tui-reader-paging/verify-report` | 284 | architecture | PASS WITH WARNINGS |
| `sdd/tui-reader-paging/archive-report` | 287 | architecture | This report |

Plus discovery memories surfaced during this change:
- `opentui/text-children-rule` (#276) — `<input>` cannot nest inside `<text>` (caught in the previous PR but reinforced here)

---

## SDD Cycle Complete

The `tui-reader-paging` change has been fully:

- Proposed, specified, designed (via explore), tasked
- Applied (3 original commits)
- Verified (PASS WITH WARNINGS)
- Smoke-tested (4 post-verify user-directed iterations)
- Archived (this report)

Cycle closed. No blockers for merge.
224 changes: 224 additions & 0 deletions openspec/changes/archive/tui-reader-paging/explore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Exploration: tui-reader-paging

## Current State

PR #10 (`tui-reader-screen`) shipped:

- `reader-reducer.ts` — state union: `awaiting | loading | loaded | network-error`. Current `loaded` shape: `{ kind: "loaded"; passage: Passage; ref: Reference }`.
- `reader-screen.tsx` — renders all passage verses in a single bordered frame, no cursor, no paging.
- `tui-driver.tsx` — `useKeyboard` binds `]`/`[` → `ChapterAdvanced`/`ChapterRetreated`, `/` → `PaletteReopened`. No guard against `awaiting` state (bug confirmed).
- `welcome-screen.tsx` — hint line: `" any key to start • / palette • ] next ch • [ prev ch • q quit"`

The change required is a UX extension: verse cursor navigation + chapter paging within the reader, plus a keybind reshuffle and a driver-level state gate fix.

## Arrow Key Name Discovery

From `node_modules/@opentui/core/testing/mock-keys.d.ts`:

- `ARROW_UP` → escape sequence `\x1B[A`
- `ARROW_DOWN` → escape sequence `\x1B[B`
- `pressArrow(direction: "up" | "down" | "left" | "right")` — confirms the `keyEvent.name` strings.

**Confirmed**: `keyEvent.name === "up"` and `keyEvent.name === "down"`.

## Affected Areas

- `src/tui/reader/reader-reducer.ts` — extend `loaded` variant, add 4 new actions
- `src/tui/reader/reader-reducer.test.ts` — add tests for all new transitions
- `src/tui/reader/reader-screen.tsx` — filter verses to current page, add `▶` cursor gutter marker, update `bottomTitleFor`
- `src/tui/tui-driver.tsx` — rebind `[`/`]` to page nav, add `n`/`p` for chapter nav, gate reader keybinds, add arrow handlers
- `src/tui/welcome/welcome-screen.tsx` — update hint line with new keybind scheme

## State Machine Extensions

### New `loaded` variant

```ts
| {
kind: "loaded";
passage: Passage;
ref: Reference;
cursorIndex: number; // 0-based array index of focused verse in passage.verses
pageStartIndex: number; // 0-based array index of first verse on current page
}
```

**Index-based, NOT verse-number-based** — passage.verses verse numbers are not guaranteed contiguous (a passage starting at John 3:14 has verses[0].number === 14). Index-based state keeps page arithmetic correct on any passage slice.

`loading` and `network-error` variants are UNCHANGED — no cursor carry-over needed.

### New actions

```ts
| { type: "CursorMovedUp" }
| { type: "CursorMovedDown" }
| { type: "PageAdvanced" }
| { type: "PageRetreated" }
```

Existing `ChapterAdvanced` / `ChapterRetreated` keep their names and reducer logic — only their driver-level keybind changes from `]`/`[` to `n`/`p`.

### `PassageFetched` handler update

Set `cursorIndex: 0, pageStartIndex: 0`. Applies for both initial fetch and chapter-transition fetches.

## Page Boundaries Logic

```ts
export const VERSES_PER_PAGE = 8;
```

Rationale: 8 fits comfortably in a 20-row minimum terminal. Dynamic computation from terminal height is a future optimization.

**Page slice:** `verses.slice(pageStartIndex, pageStartIndex + VERSES_PER_PAGE)`

- `PageAdvanced` at last page: **clamp** silently — no wrap, no chapter cascade.
- `PageRetreated` at first page: **clamp** silently.
- `CursorMovedDown` at last verse on current page: advance to next page, cursor on first verse of new page. At last verse of last page: clamp.
- `CursorMovedUp` at first verse on current page: retreat to previous page, cursor on last verse of previous page. At first verse of first page: clamp.

## Reducer Handler Sketches

All handlers no-op when `s.kind !== "loaded"`.

```ts
CursorMovedDown: (s, _a) => {
if (s.kind !== "loaded") return s;
const total = s.passage.verses.length;
const pageEnd = Math.min(s.pageStartIndex + VERSES_PER_PAGE - 1, total - 1);
if (s.cursorIndex < pageEnd) {
return { ...s, cursorIndex: s.cursorIndex + 1 };
}
const nextPageStart = s.pageStartIndex + VERSES_PER_PAGE;
if (nextPageStart >= total) return s; // clamp at last page
return { ...s, pageStartIndex: nextPageStart, cursorIndex: nextPageStart };
},

CursorMovedUp: (s, _a) => {
if (s.kind !== "loaded") return s;
if (s.cursorIndex > s.pageStartIndex) {
return { ...s, cursorIndex: s.cursorIndex - 1 };
}
const prevPageStart = s.pageStartIndex - VERSES_PER_PAGE;
if (prevPageStart < 0) return s; // clamp at first page
const prevPageEnd = Math.min(prevPageStart + VERSES_PER_PAGE - 1, s.passage.verses.length - 1);
return { ...s, pageStartIndex: prevPageStart, cursorIndex: prevPageEnd };
},

PageAdvanced: (s, _a) => {
if (s.kind !== "loaded") return s;
const nextPageStart = s.pageStartIndex + VERSES_PER_PAGE;
if (nextPageStart >= s.passage.verses.length) return s;
return { ...s, pageStartIndex: nextPageStart, cursorIndex: nextPageStart };
},

PageRetreated: (s, _a) => {
if (s.kind !== "loaded") return s;
const prevPageStart = s.pageStartIndex - VERSES_PER_PAGE;
if (prevPageStart < 0) return s;
return { ...s, pageStartIndex: prevPageStart, cursorIndex: prevPageStart };
},
```

## View Changes

```tsx
const pageVerses = state.passage.verses.slice(
state.pageStartIndex,
state.pageStartIndex + VERSES_PER_PAGE,
);

{pageVerses.map((v, i) => {
const verseIndex = state.pageStartIndex + i;
const isFocused = verseIndex === state.cursorIndex;
return (
<text key={v.number}>
<span fg={isFocused ? ACCENT_HEX : undefined}>
{isFocused ? "▶ " : " "}
</span>
<span attributes={DIM}>{`${String(v.number).padStart(3)} `}</span>
<span attributes={isFocused ? TextAttributes.INVERSE : undefined}>
{v.text}
</span>
</text>
);
})}
```

Per `ui-sketches.md` Typography: focused verse uses `▶` marker (in accent color) + `selection` (`TextAttributes.INVERSE`) on the line.

**`bottomTitleFor` for loaded state:**
```ts
" ↑↓ verse • [ ] page • n p chapter • / palette • q quit "
```

## Driver-Level Keybind Changes

```ts
useKeyboard((keyEvent) => {
if (keyEvent.name === "q" || keyEvent.name === "Q") {
renderer.destroy(); resolve(); return;
}
if (phase === "welcome") {
setPhase("reader"); return;
}
if (readerState.kind === "awaiting") 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; }
});
```

The existing palette-key conflict (`/` clearing query while input focused) is fixed by the `awaiting`-state gate. `q`/`Q` sits above the guard — quit always works.

## Welcome Screen Hint Update

```ts
" any key to start • ↑↓ verse • [ ] page • n p chapter • / palette • q quit"
```

## Slicing Recommendation

**One PR.** Estimate:

| File | Lines changed |
|---|---|
| `reader-reducer.ts` | ~35 |
| `reader-reducer.test.ts` | ~60 |
| `reader-screen.tsx` | ~25 |
| `tui-driver.tsx` | ~15 |
| `welcome-screen.tsx` | ~1 |
| **Total** | **~136 lines** |

Well under the 400-line review budget.

## Approaches

| Approach | Pros | Cons | Effort |
|---|---|---|---|
| **A — One PR (recommended)** | Single coherent UX change; under budget; reducer + view ship together | None significant | Low |
| B — Split: reducer PR then view PR | Smaller diffs | No observable UX until view lands; artificial split | Medium overhead |
| C — Dynamic page size from terminal rows | Adaptive UX | More complex; deferred to future change | Medium |

**Recommendation: Approach A** with `VERSES_PER_PAGE = 8` constant, index-based state.

## Risks

- **Index vs. verse-number state** — verse numbers in `passage.verses` are not guaranteed contiguous (a passage starting at John 3:14 has `verses[0].number === 14`). Page arithmetic must use array indices, not verse numbers. Locked: index-based.
- **`useKeyboard` always fires** — the `awaiting`-state gate suppresses keybinds while `<input>` is focused. The guard must sit BELOW the `q`/`Q` check so quit always works.
- **`TextAttributes.INVERSE`** — confirm exported from `@opentui/core` (only `DIM` used in codebase today). Trivial check at impl time.

## Open Questions for Proposal

1. **Index-based vs verse-number-based state** — **LOCKED: index-based.** Verse numbers aren't contiguous in passage slices.
2. **`▶` marker color** — **LOCKED: ACCENT_HEX.** Per `ui-sketches.md` Typography table.
3. **`PageAdvanced` at last page behavior** — **LOCKED: silent clamp.** No visual feedback, no chapter cascade.

## Ready for Proposal

Yes. All decisions resolved with defensible defaults.
Loading
Loading