aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-23-lectio-go-rewrite-design.md
blob: a56f3459ecf9a902470f3ea22293540f7bebd112 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# lectio — design spec

Date: 2026-07-23
Status: approved (brainstorm), pre-plan

## Goal

Reimplement the Python `daily-reading` tool (`ewangelia.py`) as a self-contained
Go project with two binaries:

- **`lectio`** — terminal CLI (subcommand-based).
- **`lectio-ui`** — TUI reader (Bubble Tea), modelled on the user's `bread-calc`.

It fetches the daily Catholic liturgy readings from niezbednik.niedziela.pl and
shows them in Polish plus four other versions (Wujek Polish, Vulgate Latin,
Greek, Douay-Rheims English), with correct psalm versification across them.

This is a **fork**: the Python `daily-reading` project is left untouched and
keeps working. `lectio` is a fresh project.

## Non-goals

- **Computing** the day's citations from a liturgical-calendar engine (for dates
  the site never published, or with no network and no prior harvest) remains the
  separate `offline_readings` effort. `lectio` gets citations from the site, or
  from a prior `lectio update` harvest — it does not compute them. When an
  offline engine exists it slots in behind `internal/liturgy` unchanged.
- No new translations beyond the five already supported.

Note: harvesting the site's **published** future sigla (`lectio update`) and
reading them offline *is* in scope — it is the "harvest" path, distinct from
calendar computation.

## Key decisions

| Decision | Choice |
|---|---|
| Language / stack | Go; Bubble Tea + Lipgloss (TUI), go-toml/v2 (config). Match bread-calc. |
| Binaries | `lectio` (CLI), `lectio-ui` (TUI). |
| Self-contained | Embed all four Bible corpora via `go:embed`; verse lookup in Go. No external `vul`/`grb`/`wuj`/`drb` tools. |
| Daily data | Still fetched from niedziela.pl (source of citations + Polish text); cached on disk. |
| CLI shape | Subcommands (hand-rolled dispatch, no cobra). |
| TUI shape | Reader + version-switch: full day in one pane, `tab` cycles versions, arrows change date. |
| Config | `~/.config/lectio/config.toml`, auto-seeded, flags override. |
| Versification | Port `psalm_versify.py` behaviour exactly. |

## Project layout

Module: `github.com/lukaszkasprzak/lectio`. Location: `~/git/projects/lectio`.

```
cmd/lectio/main.go        -> cli.Run(args, stdin, stdout, stderr) int
cmd/lectio-ui/main.go     -> load config+data, tui.New(...), tea.NewProgram(...).Run()
internal/liturgy/         Section type; fetch + parse niedziela.pl; cache; harvest/offline; Load router
internal/tradlit/         missalemeum API: fetch + parse 1962 propers into []Section
internal/bible/           embedded corpora + reference lookup + book aliases
internal/psalter/         psalm versification (port of psalm_versify.py)
internal/render/          text rendering: compare columns, section text (CLI)
internal/config/          TOML config load + auto-seed
internal/cli/             subcommand dispatch
internal/tui/             Bubble Tea reader
Makefile, README.md, LICENSE, go.mod
```

Each `internal/*` package has one clear purpose, a small exported surface, and
is unit-testable in isolation. CLI and TUI are thin front-ends over the same
core (liturgy + bible + psalter + render).

## Data architecture

### Embedded corpora
`internal/bible` embeds four TSV files with `go:embed`:

- `wuj.tsv` — Biblia Wujka (Polish), from `offline_readings/corpus/wuj.tsv`.
- `drb.tsv` — Douay-Rheims (English), from `offline_readings/drb/drb.tsv`.
- `vul.tsv` — Vulgate (Latin), extracted from the installed `vul` tool
  (`sed '1,/^#EOF$/d' $(command -v vul) | tar xzf - -O vul.tsv`).
- `grb.tsv` — Greek, extracted from the installed `grb` tool the same way.

All four share the 6-column format `Book | Abbrev | BookNum | Chapter | Verse | Text`
(the kjv-family format). Total ~15 MB, embedded into the binary.

### Reference lookup (replaces the awk engine + shell-out)
`internal/bible` reimplements the reference grammar in Go. It must support the
subset the tool actually feeds it:

- Book match: canonical English name (`John`), English prefix (`Joh`), and the
  Polish/English alias table (`J`, `Łk`, `1 Kor`, `Jana`, `Mdr`, ...) resolved
  to a canonical book. Longest alias wins; exact before prefix (fixing the old
  `J`→Joshua bug).
- Chapter+verse forms: `Book C:V`, `Book C:V-V` (range), `Book C:V,V,...`
  (list). Mixed lists like `John 20:1,11-18` are split into single-group
  queries and merged (port of `split_ref`).
- Returns `[]Verse{Chapter, Verse, Text}` in reference order; reports the groups
  a corpus lacked (for the "brak w …" note).

### Daily readings
`internal/liturgy`:

- `Fetch(date, refresh) (html, error)` — GET
  `https://niezbednik.niedziela.pl/liturgia/{date}/Ewangelia` with a browser
  User-Agent. Cache only fully-published pages.
- `Parse(html) ([]Section, error)` — pick the **new** lectionary tab
  (`tabnowy0all`), fall back to `tabstary0all` with a warning; extract each
  section's heading, subtitle, citation, and paragraphs. Fail loudly if the tab
  is present but empty or absent (layout change).
- `Section{Heading, Subtitle, Citation, PartID string; Paragraphs [][]string}`
  (shared by both lectionaries; `PartID` drives part-filtering — see Config).
- `Load(cfg, opts) ([]Section, error)` routes to this modern source or to
  `tradlit` by `cfg.Lectionary`, then applies part-filtering.

### Caching (fast repeat loads)
Two layers under `~/.cache/lectio/` (honor `XDG_CACHE_HOME`):

- Raw HTML `{date}.html` — skips the network re-fetch of a published page.
- Parsed sections `{date}.json` — skips re-parsing; a repeat load the same day
  hits this and is near-instant (no network, no parse).

Only fully-published pages are cached (an unpublished future date keeps being
retried, never cached as "empty"). `--refresh` bypasses both layers.
`Load(date)` tries JSON → HTML(+parse, write JSON) → fetch(+cache both).

### Sigla harvest (`lectio update`) and offline use
The site publishes each day's reading **sigla** (the scripture citations) weeks
to months ahead. `lectio update` walks forward from today, fetching each date
and extracting its citations, until it reaches the unpublished horizon (a page
with no readings). It writes them to a persistent TSV:

    ~/.local/share/lectio/sigla.tsv     (honor XDG_DATA_HOME)

One row per reading section: `date <TAB> section_label <TAB> citation`, e.g.

    2026-07-22    1. czytanie    Pnp 8, 6-7
    2026-07-22    Psalm          Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab)
    2026-07-22    Ewangelia      J 20, 1. 11-18

`update` merges with the existing file (re-harvesting a date replaces its rows),
warms the HTML/JSON cache for those dates, and reports how many days it added and
the furthest date reached.

**Offline use.** When offline (config `offline = true`, the `--offline` flag, or
an automatic fallback after a failed fetch), `Load` reads the sigla TSV instead
of the network. If the date is present it builds the sections from the stored
citations and renders the four **embedded** Bible versions from the corpora — so
after one `lectio update`, any harvested day reads fully offline in Wujek Polish,
Latin, Greek and Douay-Rheims. The `pl` version (modern niedziela.pl / Biblia
Tysiąclecia) is online-only and not embedded (copyright); **offline, `wuj`
(Wujek) takes the Polish role** — `pl` is dropped from any version list and
replaced by `wuj` where needed (see Config → `offline`). A date absent from the
harvest gives a clear "run `lectio update` online" error.

## Lectionaries: new vs traditional

The `lectionary` config selects the source; both produce a `[]Section`, so the
render/CLI/TUI/cache layers are shared.

- **`new`** (default) — the modern Ordinary-Form readings from niezbednik.niedziela.pl
  (`internal/liturgy`), as described above.
- **`traditional`** — the 1962-Missal propers from the missalemeum API
  (`internal/tradlit`): `GET https://www.missalemeum.com/{lang}/api/v5/proper/{date}`,
  `lang` = `traditional_lang` (`pl` or `en` — the only two the API serves). The
  JSON is `[{info{title,...}, sections:[{id,label,body:[["...text..."]]}]}]`. Each
  section becomes a `Section{Heading:label, PartID:lower(id), Paragraphs, Citation}`;
  the citation is extracted from the `*Book c:v*` marker embedded in a reading's
  body. Section ids: `Introitus, Oratio, Lectio, Graduale, Evangelium,
  Offertorium, Secreta, Communio, Postcommunio` (plus Alleluia/Tractus/commemorations).

**How the versions apply.** A traditional `Section` carries missalemeum's
vernacular text as its `Paragraphs` (the `pl`-role source text) and, for scripture
propers, a `Citation`. So the scripture parts (Introit, Epistle, Gradual, Gospel,
Offertory, Communion) flow through the *same* version-compare as the modern mode —
`vul` (Latin), `wuj`, `grb`, `drb` looked up from the embedded corpora by citation.
The prayer parts (Collect, Secret, Postcommunion) have no citation, so they render
only the missalemeum vernacular.

**Latin caveat.** The missalemeum v5 API returns vernacular only (en/pl) — no
Latin. Latin for the *readings* comes from the embedded `vul` corpus via the
citation; Latin for the *prayers* is not available and those show vernacular only.
This is a documented limitation, surfaced with a short note on prayer sections.

**Caching / offline.** Traditional pages cache like modern ones (`{date}.json`
under a `traditional/` subdir so the two lectionaries don't collide). `lectio
update` harvests whichever lectionary is active into its own sigla file
(`sigla.tsv` / `sigla-traditional.tsv`); offline builds the scripture-part
sections from the harvested citations. Prayer text is not harvested (it is not a
citation), so offline traditional shows the readings only.

## Versions and psalm systems

Ported from `ewangelia.py` constants:

```
versions:   pl, wuj, vul, grb, drb   (canonical order; = config `versions` default)
labels:     pl="Polski (niedziela.pl)"  wuj="Wujek (pol.)"  vul="Wulgata (lac.)"
            grb="Grecki"               drb="Douay-Rheims (ang.)"
bible tools: wuj, vul, grb, drb   (pl comes from the fetched paragraphs)
psalm system: vul/grb/wuj -> "vulgate"   drb -> "drb"
```

`lectio show VERSION` accepts any of the five (pl renders the fetched paragraphs;
the others render looked-up verses).

`pl` renders the fetched Polish paragraphs (dropping the "Słowa Ewangelii"
incipit, and de-duplicating a repeated responsorial refrain). The four bible
versions render verses looked up from the embedded corpora, with the citation
converted per that version's psalm system.

## Reference conversion & psalm versification

`internal/bible` (conversion) + `internal/psalter` (versification) port
`to_english_ref` / `_psalm_ref` / `psalm_versify.py`:

- Strip a leading `por.` (compare marker) and a trailing `(R.: ...)`
  responsorial refrain from the citation.
- Convert the Polish citation to English style and normalise verse groups
  (`Mt 7, 1-5` -> `Mat 7:1-5`; disjoint groups preserved as a comma list).
- Psalms: the lectionary cites `Ps H (V)`. The Vulgate versions use chapter `V`,
  verses unchanged. `drb` uses chapter `H` and shifts each verse by the
  title-fold count `k` for that psalm: `drb_verse = lectionary_verse - k`
  (clamped ≥ 1). `k` comes from the `DRB_TITLE_FOLD` table (1 or 2 title lines;
  0 for untitled), derived by aligning the Wujek/DRB corpora. ~140 psalms exact,
  ~10 approximate (documented in the package).

## CLI surface (`internal/cli`)

Hand-rolled subcommand dispatch. `lectio` with no args = `lectio today`.

```
lectio today   [--all] [--raw] [--width N] [--refresh]
lectio date D  [--all] [--raw] [--width N] [--refresh]        # D = YYYY-MM-DD
lectio compare LIST [--date D] [--all] [--width N] [--refresh]
      LIST = comma versions (default: config `versions`)
lectio show VERSION [--date D] [--all] [--refresh]            # one version's text
lectio update [--days N] [--from D]     # harvest future sigla to the TSV
lectio --version | lectio -v
lectio help | lectio -h | lectio <cmd> -h
```

- `--all` shows every reading (1st/2nd, psalm, acclamation, gospel); default is
  the gospel only, unless config `all = true`.
- `--raw` drops banner/headings for piping.
- `--refresh` bypasses the cache and re-fetches.
- `--offline` (global) skips the network and uses the sigla TSV; also applied
  automatically when a fetch fails.
- `--lectionary new|traditional` and `--lang pl|en` (global) override the config
  `lectionary` / `traditional_lang` for one run.
- `update` harvests forward from today (or `--from D`) up to `--days N` (default:
  until the unpublished horizon), writing the sigla TSV.
- Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2
  usage error.

## TUI (`internal/tui`)

Bubble Tea Elm architecture, per the approved mockup (reader + version-switch):

- One scrolling pane showing the whole day's readings for the **active version**.
- Header: date + active version label. Footer: keybar.
- Keys: `tab`/`shift+tab` cycle versions (order = config `versions`, starting at
  `default_version`); `←/→` change date (async re-fetch with a loading state);
  `j/k` and `space`/`b` scroll; `g/G` top/bottom; `r` refresh; `q`/`ctrl+c` quit.
- Offline (config/flag): the version cycle omits `pl` (uses `wuj` for Polish);
  dates come from the harvest; a header hint shows the offline state.
- Async fetch via `tea.Cmd` returning a `readingsMsg` or `errMsg`; a spinner or
  "ładowanie…" line while in flight.
- Lipgloss styling, theme-neutral (works on light/dark terminals); width-aware
  wrapping from `tea.WindowSizeMsg`.
- `Model{ config, cache, date, version, sections, scroll, width, loading, err }`.

The TUI shows one version at a time (not columns); the CLI `compare` is where
side-by-side lives.

### Colour scheme
Colours distinguish the parts of a reading. Each role is a named Lipgloss style
in one place (`internal/tui`), using `lipgloss.AdaptiveColor` so it reads on both
light and dark terminals; a `NO_COLOR` env / non-TTY output degrades to plain.

| Role | Style |
|---|---|
| Section heading (`1. czytanie`, `Ewangelia`) | bold, accent colour |
| Citation (`Pnp 8, 6-7`) | dim / muted, next to the heading |
| Verse number (`20:1`) | distinct muted colour, separated from text |
| Verse text | default foreground |
| Responsorial refrain (psalm) | italic / secondary colour |
| Header (date + active version) | accent background or bold |
| Footer keybar | dim |

Exact hues are finalised in planning, but the role → style mapping above is
fixed. The CLI stays plain text (colour is a TUI concern).

## Web UI (`lectio-web`, `internal/web`)

A third binary — an `hledger-web`-style local companion. Run `lectio-web`; it
starts a local HTTP server and opens the browser at `http://localhost:<port>`
(`web_port`, 0 = auto-pick). Pure Go, everything embedded (`go:embed`), still a
self-contained single binary — no cgo, no build step.

- **Interactivity: HTMX.** Server-rendered `html/template` pages; controls carry
  `hx-get`/`hx-post` and swap only the reading pane via handlers that return HTML
  partials. HTMX (~14 KB) is embedded. State lives in URL query params
  (bookmarkable, back-button works). No SPA, no npm.
- **UI.** A top bar with: date `←/→` + date picker; lectionary toggle
  (new/traditional); version checkboxes (pl/wuj/vul/grb/drb) driving the compare
  columns; all-parts vs gospel toggle; a **theme** selector; and a passage
  lookup box (`bible.Lookup` over the embedded corpora — type `J 20:1`, pick
  versions, see it). The main pane shows the day's readings as one version or
  responsive compare columns.
- **Themes.** Minimal, ascetic (spare typography, generous whitespace, no
  gradients/shadows/rounded chrome — just the palette applied to the role
  classes). Embedded CSS themes based on the popular named palettes, switchable
  live in the UI and defaulted by `web_theme`: `transfiguration` (the user's own
  palette — dark hill-green, parchment text, gold accents; canonical hex in
  `~/git/transfiguration-themes/palettes/transfiguration.json`), plus
  `catppuccin`, `tokyonight`, `gruvbox`, `nord`, `dracula`, `solarized`,
  `everforest`, `rosepine`, `onedark`. The colour roles (heading / citation /
  verse number / refrain / body) are CSS classes each theme restyles. The active
  theme is stored in a cookie; `web_theme` is the initial default (ships as
  `transfiguration`).
- **Reuse.** Consumes the same core: `readings.Load` → `[]liturgy.Section`,
  `render.GatherVersion`'s `(label, blocks)` fed into templates. No changes to
  the domain packages — `internal/web` imports them, never the reverse.

## Config (`internal/config`)

`Config` struct loaded via go-toml. Resolution: `LECTIO_CONFIG` env →
`~/.config/lectio/config.toml` (auto-seeded from an embedded default on first
run, honoring `XDG_CONFIG_HOME`) → built-in defaults. Flags override.

```toml
schema_version   = 1
lectionary       = "new"    # "new" (niedziela.pl) or "traditional" (missalemeum, 1962)
traditional_lang = "pl"     # vernacular for traditional propers: "pl" or "en"
versions         = ["pl", "wuj", "vul", "grb", "drb"]  # compare set + TUI cycle order
default_version  = "pl"     # TUI start / `lectio show` default
width            = 0        # CLI wrap width; 0 = detect terminal
all              = false    # default to all parts (true) or just the gospel (false)
offline          = false    # true = never fetch; read only harvested sigla + cache
web_theme        = "transfiguration"  # theme: transfiguration|catppuccin|tokyonight|gruvbox|nord|dracula|solarized|everforest|rosepine|onedark
web_port         = 0        # lectio-web port; 0 = auto-pick a free port

# Which parts to show. Both tables are commented out -> every part is shown.
# Uncomment a table and set a part to false to hide it; parts you don't list
# stay shown. (Parsed as a map: a part is hidden only if explicitly false.)
#
# [parts.new]
# pierwsze_czytanie = true   # 1. czytanie (1st reading)
# psalm             = true   # Psalm
# drugie_czytanie   = true   # 2. czytanie (2nd reading, on feasts)
# aklamacja         = true   # Aklamacja (acclamation)
# ewangelia         = true   # Ewangelia (gospel)
#
# [parts.traditional]
# introitus    = true   # Introit
# oratio       = true   # Collect
# lectio       = true   # Epistle
# graduale     = true   # Gradual / Alleluia / Tract
# evangelium   = true   # Gospel
# offertorium  = true   # Offertory
# secreta      = true   # Secret
# communio     = true   # Communion
# postcommunio = true   # Postcommunion
```

`Config` fields: `SchemaVersion int`, `Lectionary string`, `TraditionalLang
string`, `Versions []string`, `DefaultVersion string`, `Width int`, `All bool`,
`Offline bool`, and `Parts map[string]map[string]bool` (the `[parts.new]` /
`[parts.traditional]` tables). `Lectionary` must be `new` or `traditional`;
`TraditionalLang` must be `pl` or `en`; unknown versions/values are rejected with
a clear error. Invalid TOML falls back to defaults with a stderr warning.

**Part filtering.** `Config.PartShown(lectionary, partID string) bool` returns
`true` unless `Parts[lectionary][partID]` is present and `false`. So a commented
(empty) `Parts` shows everything, and a user hides a part by uncommenting the
table and setting just that part to `false`. Part IDs: for `new`,
`pierwsze_czytanie`/`psalm`/`drugie_czytanie`/`aklamacja`/`ewangelia` (matched
from the niedziela heading); for `traditional`, the missalemeum section ids
lower-cased (`introitus`, `oratio`, `lectio`, `graduale`, `evangelium`,
`offertorium`, `secreta`, `communio`, `postcommunio`). Filtering applies whenever
all parts are in play (`--all`, `compare`, the TUI); the gospel-only default
(`all = false`) always shows just the gospel / `evangelium`.

`offline = true` (or the `--offline` flag) makes lectio work purely from the
`lectio update` harvest and cache — it never touches the network, and `pl`
(online-only) is dropped in favour of `wuj` as the Polish version:

- Any version list drops `pl`; if the list had `pl` but not `wuj`, `wuj` takes
  its place. So default `versions` become `["wuj","vul","grb","drb"]` offline.
- `default_version`/`show pl` fall back to `wuj` offline.
- A date not present in the sigla harvest gives a clear "not harvested; run
  `lectio update` online" error.

Seeded default is `offline = false` (a fresh install has no harvest yet); a user
who runs `lectio update` on a schedule can flip it to `true` for a fast,
network-free daily read.

## Porting map (Python → Go)

| Python (`ewangelia.py` / `psalm_versify.py`) | Go |
|---|---|
| `fetch`, `parse_sections`, `html_to_lines`, `extract_reference` | `internal/liturgy` |
| awk engine, `run_bible_tool`, `split_ref` | `internal/bible` (lookup) |
| `POLISH_TO_EN`, `to_english_ref`, `_psalm_ref` | `internal/bible` (aliases + conversion) |
| `psalm_versify.py` (`DRB_TITLE_FOLD`, `drb_verse`, chapter map) | `internal/psalter` |
| `gather_version`, `run_bible_section`, `render_compare`, `render_section`, refrain dedup | `internal/render` |
| `VERSION_LABELS`, `PSALM_SYSTEM`, `COMPARE_CODES`, `BIBLE_TOOLS` | `internal/bible` / `internal/render` constants |
| `argparse` main | `internal/cli` |

## Error handling

- Network failure: fall back to the sigla TSV (offline mode) if the date is
  harvested — render the four embedded versions, note that `pl` is unavailable.
  If the date is not harvested either, clear stderr message, exit 1 (CLI) or an
  error line in the TUI (stay usable, let the user change date).
- Unpublished future date: "no reading published for this date yet", exit 1;
  `update` treats it as the horizon and stops.
- Layout change (tab missing/empty): explicit "site layout may have changed"
  error, exit 1.
- A corpus lacking a passage (e.g. deuterocanonical book absent from a version):
  per-section note, not a crash; other versions still render.
- Unknown version code: usage error, exit 2.

## Testing

- **bible**: table tests for book matching (incl. `J`→John, `Łk`→Luke, `1 Kor`),
  reference grammar (ranges, lists, split groups), and known verses across all
  four corpora (Gen 1:1, John 20:1, a deuterocanonical, a psalm).
- **psalter**: unit tests for `drb_verse` on titled/untitled/2-line-title psalms.
- **liturgy.Parse**: fixture tests against a few cached HTML pages (a normal day,
  a split-reading feast, an unpublished date), asserting sections/citations.
- **tradlit.Parse**: fixture test against a saved missalemeum JSON response
  (2026-07-22), asserting the proper sections, part ids, the extracted Gospel
  citation, and vernacular paragraphs.
- **config.PartShown**: a commented (empty) `Parts` shows all; `{new:{psalm:false}}`
  hides only the psalm and keeps the rest shown.
- **liturgy cache + sigla**: round-trip the parsed JSON cache; harvest sigla from
  fixture pages into a temp TSV and read them back; offline `Load` builds sections
  from the TSV and renders the embedded versions with `pl` noted unavailable.
- **render**: golden-text tests for a compare block and a section, including the
  refrain dedup and psalm-number divergence.
- **config**: load/seed/override tests with a temp `XDG_CONFIG_HOME`.
- `go vet ./...` clean; `gofmt`-formatted.

## Build & install

Makefile cloned from bread-calc:

```
make build     -> ./lectio and ./lectio-ui
make install   -> $(PREFIX)/bin (default ~/.local/bin)
make cross     -> dist/ for linux/darwin/windows amd64+arm64
make test | vet | fmt | clean
```

`.gitignore`: `/lectio`, `/lectio-ui`, `dist/`, `*.test`, coverage, cache.

## Open items (decide during planning)

- Exact Lipgloss hues for the colour roles (mapping is fixed in the TUI section;
  only the specific colours are open).
- Whether `lectio show` and the TUI share a single "gather one version" function
  (they should).
- Whether to vendor the extracted `vul.tsv`/`grb.tsv` into the repo or fetch them
  at build time (lean: vendor them under `internal/bible/`, like wuj/drb).