summaryrefslogtreecommitdiff
path: root/docs/TUI-GUIDELINES.md
blob: c22351467f0f7188eead13dec75465c25028d49e (plain)
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
463
464
465
466
# bokftui guidelines

Rules for the ncurses client so every view behaves the same. When in doubt,
copy the behaviour of the voucher list / voucher form; they are the reference
implementations. Inspired by Midnight Commander, htop, mutt and calcurse.

The **Interaction model** section is the agreed target (settled 2026-09-22,
`DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in
`$XDG_CACHE_HOME/bokf/drafts.json`, `<UTKAST>` marking, the explicit `Spara`
row and draft deletion work there. The context menu (`→`/`^O`, below) and
the web-safe keys are implemented in every screen (2026-09-23,
`DECISIONS.md` #29). Every other screen
still follows the sections below; the interaction model wins where they
conflict as each screen moves over.

## Interaction model (target, 2026-09-22)

Every entity has **one lifecycle**, every form **one commit gesture**, and
every action **one declaration**. Nothing is written to the backend
implicitly.

### Two focus modes

The cursor is always in one of two modes:

- **Navigation** — `Tab`/`Shift-Tab`, arrows, `Home`/`End`, `PgUp`/`PgDn`
  only move focus, selection or the viewport. They never mutate data, never
  save and never run an action.
- **Editing** — the focused field is reverse video with a caret. Inside a
  field, `←`/`→`/`Home`/`End` move the caret, `Backspace`/`Del`/`Ctrl+U`
  edit the text and `Up`/`Down` leave the field. `Enter` commits the field
  and advances; `Esc` restores it (the scratch-copy semantics stay).

`Enter` activates the focused item **when that item owns an action** (a menu
item, an action row, a list row that opens a detail). A report, a pager or a
blank area has no such item, so `Enter` does nothing there. `Enter` never
saves a whole form, never deletes and never posts.

### Entity lifecycle

| Class | Draft | Commit event | `Esc`/`q` | Examples |
|---|---|---|---|---|
| **Register** | in memory + local draft file | explicit `Spara` (the save action row or the context menu) after validation | back; the draft stays | customers, employees, templates, momsregler, org data |
| **Document** | the form is the draft | explicit `Posta` (the save action row or the context menu); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut |
| **Settings** | an edit buffer until `Spara` | explicit `Spara` | back | faktura/SMTP/serie-inställningar |

- Every savable form ends with a visible commit action row (`Spara`, or
  `Posta` for documents), or — in a row table, where rows follow — offers it
  first in the context menu (`^O`, `Enter`). `Enter` on the focused row
  commits (it is an item with an action).
- There is no commit hotkey: `Ctrl+Enter` cannot be sent by gnome-terminal/
  VTE and `F9` is dropped with the other function keys (see "Keys and the
  web"). The action row and the menu work in every terminal and browser.
- Commit is blocked while the data is invalid: the first invalid field is
  focused and its error shown. The draft is untouched. A server error at
  commit keeps the draft as well; only a successful command removes it.
- `Ctrl+R` (reload) and `Ctrl+C` never flush drafts to the backend; the
  client-side draft file makes them survive both, so no work is lost.

### Drafts

- Every non-committed edit is a **draft**, held in memory and mirrored to
  `$XDG_CACHE_HOME/bokf/drafts.json` (mode 0600, atomic replace) on every
  change. This is the "nothing is ever lost" guarantee — `Ctrl+R`, a crash
  and `Ctrl+C` included.
- A draft is keyed by `(org, entity, id)`; a new entity gets a temporary id
  and shows up in its list immediately.
- Drafts are marked `<UTKAST>` directly after the row number in lists
  (`3. <UTKAST> Namn`) and in the editor's frame title (`Kund <UTKAST>`).
- **Delete draft** is an action both on the list row (`d`, also in the
  context menu) and inside the editor (context menu), with a confirmation. It removes the memory and file draft and
  never touches the backend. A successful commit removes the draft too.
- Drafts are client-local and never synced; another client sees the last
  committed value.
- When the backend entity is gone at commit time (`NOT_FOUND`), the editor
  offers "spara som ny" or "radera utkast".
- Review point: a draft of an encrypted field (an employee's personnummer)
  puts plaintext in the cache file. 0600 is the same protection as the
  Bitwarden session file; decide whether such fields are excluded from
  drafts.

### Actions and the context menu

Screens declare actions, never keys:

```c
struct tui_action {
    const char *id;      /* stable, e.g. "customer.archive" */
    const char *label;   /* Swedish UI text */
    int key;             /* accelerator, or a virtual key (menu only) */
    int enabled;         /* 1 runnable, 0 dimmed with a reason, -1 heading */
    const char *reason;  /* why a disabled action is dim */
};
```

- **`→` opens the context menu** ("Åtgärder") wherever no caret uses the
  arrow: lists, menus, pagers (report/detail views), forms and a row
  table's header fields. **`^O` opens it everywhere**, also while typing in
  a table cell, where `→` moves the caret. The menu is a box at the right
  edge next to the selected row; each line shows the label and, right-
  aligned, the key that does the same thing — the menu doubles as the
  hotkey list. Up/down/Home/End move, `Enter` (or the action's own key)
  runs, `Esc`/`←`/`q`/`^O` close. A disabled action is dim and `Enter`
  shows its reason; a separator line divides the groups.
- The screen registers its actions with `tui_set_actions(acts, n)` right
  before the widget call; the widget takes them at entry, so they never leak
  into a widget opened from a hook. The menu lists the screen's actions
  first, then the widget's own (Öppna, Ny, Gå till/sök, Uppdatera,
  Tillbaka; in forms Ändra/Utför; in row tables Ändra fält/Rensa raden,
  Validera, Spara, Avbryt), skipping own actions whose key the screen took
  (`tui_ctx_merge`).
- **Choosing an action is the same as pressing its key**: the widget gets
  the key as if it was typed, so the accelerator and the menu share one
  code path in the screen's key hook. An action without a physical key uses
  a virtual key: `TUI_KEY_SUBMIT` (spara/bokför/utfärda — was F9),
  `TUI_KEY_REFRESH` (uppdatera/validera/förhandsvisa — was F5) or
  `TUI_KEY_ACTION(n)` for the screen's own menu-only actions (mall, bifoga
  in the voucher form, nästa fakturanummer).
- Right before the menu opens the key hook gets `TUI_KEY_MENU`, so a screen
  can set `enabled`/`label`/`reason` for the current row in its own array
  (the Kunder list shows "Radera utkast" for a draft row and "Arkivera" or
  "Återaktivera" otherwise, all on `d`).
- Footers show `→ = åtgärder` (`^O` in row tables) and at most the few
  letter accelerators that matter on the screen. Universal navigation keys
  are not repeated there.

### Keys and the web

The TUI is meant to run in a browser terminal too (xterm.js/ttyd). A
browser keeps some keys for itself, so the TUI only uses keys every
terminal and browser delivers (checked by `make check`):

- **No function keys.** F1/F3/F5/F6/F7/F10/F11/F12 are browser keys and F2,
  F4, F9 need `Fn` on many laptops. Their old jobs are context-menu actions
  (F2 → `→`, F5 → "Uppdatera"/"Validera"/"Förhandsvisa" with `r` in lists
  and pagers, F9 → "Spara"/"Bokför"/"Utfärda", F4 → "Hämta mall…").
- **No `Ctrl+N`, `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab`**: Chrome never lets a page
  have them. "Ny" is `n` in lists, menus and the voucher detail.
- **No `Ctrl+Enter`** and no extended keyboard protocol: the TUI no longer
  enables xterm `modifyOtherKeys` or the Kitty protocol, so every key comes
  in its legacy encoding.
- **Allowed**: arrows, `Enter`, `Esc`, `Tab`/`Shift-Tab`, PgUp/PgDn,
  Home/End, letters and digits, and the control keys a page can take
  (`^O` menu, `^X` clear row, `^A`/`^E`/`^U` in fields, `^C` quit, `^R`
  reload — `^R` is a developer convenience and never the only way).
- Every action is reachable with arrows + `Enter` through the menu; a
  letter accelerator is a shortcut, never the only path.
- **Web mode** (`BOKF_WEB=1`, set by the browser-terminal wrapper): the TUI
  runs on the web frontend, not on the user's machine, so a screen never
  reads or writes a local file or starts a program there. Every such path
  goes through `ui_web_block(title)` (`clients/ui.c`), which shows why and
  returns 1: the file browser, `save_cache_and_open` (PDF viewer), the
  attachment download and the SRU/eSKD/årsredovisning save prompts. New
  file features must call it too (pty scenario `web-mode`).

### Implementation status

1. Done: `struct tui_action`, the context menu (`tui_action_menu_at`,
   `tui_set_actions`, `tui_ctx_merge`, `tui_key_name`) and `→`/`^O` in all
   widgets, unit-tested in `tests/test_tui.c`; pty scenario `context-menu`.
2. Done: every screen declares its actions; no F-keys, `^N` or `^Enter`
   remain (`scripts/check-consistency.sh` fails on `KEY_F(`, `^N` and
   `CTRL_ENTER` in `clients/`).
3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write,
   temporary ids, `<UTKAST>` marking, delete action), unit-tested.
4. Done for Kunder: draft/`Spara` model with the `Spara` action row and
   draft deletion from both the list and the editor. The other register
   screens and the settings forms (explicit `Spara`, no per-field autosave)
   are next.
5. Done for Kunder: pty scenarios `customer-draft` and
   `customer-draft-save` cover create, edit, `<UTKAST>`, delete and save.

## Session start

After login the org picker ("Välj organisation att representera") is always
shown (`--org ID` bypasses it for scripts). "Byt bolag" in the main menu
opens the same picker, with the cursor on the current org, and switches the
session to the chosen one: `session.use_org`, then the org's name, role,
current fiscal year and series are reloaded and the remembered list
selections are cleared; `Esc` keeps the current org. `Ctrl+R` keeps the
switched org. "Byt lösenord" asks for the current password, the new one
and the new one again in masked prompts (`Esc` in any of them cancels),
checks length (≥ 10), match and difference before calling
`user.set_password`, and reports that the user's other logins were logged
out and that a Bitwarden item has to be updated; `Ctrl+R` keeps working
with the new password. The fiscal year is chosen from the dashboard and is changeable
during the session; the `Räkenskapsår` screen also closes and reopens years
there.

## Universal keys

| Key | Meaning |
|---|---|
| `→` | Open the context menu ("Åtgärder") for the selected row/view: every action with its key. Not in a table cell, where `→` moves the caret |
| `^O` | Open the context menu anywhere, also while typing in a table cell |
| `n` | Ny: new verifikat (dashboard, voucher list/detail), new mall, kund, faktura, anställd, lönekörning, regel, ledamot, new voucher from a bank transaction, upload (Underlag) |
| `r` | Uppdatera: reload the list or view |
| `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app. In a row table (`tui_rt_run`) and while a field is being edited `q` is text; only `Esc` goes back there |
| `Ctrl+C` | Quit the application (closes the session). The only key that exits |
| `Ctrl+R` | Reload the client in place: re-execs the installed binary and restores session, org, fiscal year and the current view (for after a rebuild) |
| `Ctrl+A` | Close or reopen the highlighted räkenskapsår (Räkenskapsår screen; asks for confirmation) |
| `1`–`9` | In lists: jump to that row. In menus: activate that item |
| `g` | Live goto or search in every list and menu. The first key decides: a digit makes it "Gå till rad/nummer:" (the selection follows the number as you type, backspace steps back, `g` closes); any other printable key makes it "Sök:" — the selection jumps to the first row (from the top) whose text contains the typed text, case-insensitive also for åäö; up/down step to the next/previous match and wrap; "(ingen träff)" when nothing matches. Digits, `g`, `q` and screen keys (`s` = sortera, `d`…) are text while searching. Backspace to empty returns to the undecided prompt. Enter/Esc close the prompt without opening anything; the selection stays |
| arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible. Pages stop at the first/last row, they never wrap |
| `e` | Edit the shown object (IB, where applicable) |
| `a` | Add/upload: upload (Underlag), import a bank file (Bankavstämning), attach a file (voucher detail) |
| `c` | Correct (voucher detail) |
| `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) |
| `s` | Verifikat list: cycle the sort order — nummer stigande/fallande, datum stigande/fallande (ties by series and number). The title shows the current order; it is saved as `voucher_sort` in `tui.conf` |
| up/down (voucher detail) | Previous/next voucher in the list's current order; the title shows `(n av N)`. The detail scrolls with PgUp/PgDn/Home/End (`TUI_PAGER_NO_ARROWS`). A voucher posted from the detail (`n`) is shown without stepping until the list is reopened |
| `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same |
| `p` / `s` / `u` / `b` | Faktura detail: `p` shows the PDF, `s` sends it, `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid |
| `u` | Bankavstämning: remove the highlighted transaction's match |
| `k` | Underlag: link the highlighted attachment to a voucher picked from a list |
| `Ctrl+X` | Clear the current row — only inside row editors (never "new") |

Saving, posting, validating, previewing, templates and attaching in forms
have no hotkey: they are context-menu actions (see "Keys and the web").
Every key a screen handles is listed in its context menu; the footer shows
`→ = åtgärder` and the most important letters. Control keys are written
compactly as `^O`, `^X`, `^C`, `^R` to save width.

## Lists (`select_list`, `menu`)

- Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up.
- Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`),
  using the org's `series_voucher` (Bolaget → Verifikationsserier) for new
  vouchers.
- The last row may be an action (e.g. `+ Nytt verifikat   (n)`); selecting it
  runs the action instead of opening a detail view.
- Selection memory: lists remember the selected row by identity (voucher id),
  not index, across detail round-trips, refreshes and screen re-entry.
- Digits move the highlight; only Enter activates. Menus are the exception:
  digits activate directly (they are shortcuts).
- Empty lists are never a dead end: show a message or keep the add-row.
- Menus may be split into sections: an item whose text starts with
  `\x01` (`TUI_MARK_HEADING`) is a non-selectable header, drawn dim and
  without a number. Numbering, `1`–`9` and `g` count only the selectable
  items and continue across sections; arrows/PgUp/PgDn/Home/End skip the
  headers. A cursor that points at a header snaps to the next selectable
  item.

## Forms

- Layout: bold header fields at the top, then a bold column header, then rows;
  footer hints carry the keys.
- Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between
  rows. `Enter` advances in simple forms; it never saves unless the screen is
  a one-line prompt.
- Focus memory: the screen keeps the focused row in an `int` and passes its
  address as the last argument to `tui_form_run`, `tui_form_run_hook` and
  `tui_form_run_actions` (like a list cursor). On entry `*focus` is clamped
  to a selectable row — out of range snaps to the nearest end, a heading row
  snaps forward and wraps — and on every return (`Esc`, Uppdatera, Spara, a
  chosen action, an edited field) the row is written back, so a re-run after
  a save or a round-trip through another screen lands on the same row.
  `NULL` starts at the first focusable row and stores nothing.
- Focus marks the **value, not the row**: the focused field's label is
  accent/bold and only its value is reverse video, in a box at least 24
  columns wide so an empty field is visible. Action rows (`Spara`, …) are
  buttons and highlight their whole label.
- Fields are **edited in place**, in the value column (never in a prompt at
  the bottom): `Enter` opens the editor on the focused field, which widens
  to the right border and shows the caret. One editor (`field_loop` in
  `tui.c`) serves forms, row-table header fields and the bottom-line
  prompts. The caret is a real position: ←/→/Home/End/Del work inside the
  field, `Ctrl+U` clears it. Text longer than the field scrolls
  horizontally (`tui_field_scroll`) so the caret is always visible; it
  never wraps onto the lines below.
- In the editor `Enter` commits and stays on the field, `Tab`/`Shift-Tab`
  commit and move to the next/previous row, `Esc` restores the old value
  (the editor works on a scratch copy). A choice field shows `< value >`
  in place (←/→ or space cycle). An amount that does not parse is reported
  and stays in the field for correction.
- First keystroke in a freshly focused field replaces its content
  (`field_fresh`), so prefilled values like dates can be typed over.
  Backspace/Del/`^U`/caret moves edit what is there and end that state: the
  next printable key is inserted, it never wipes the field.
- Byte input is UTF-8 safe: a character that does not fit in the buffer is
  dropped whole, never half-inserted.
- Forms taller than the screen scroll with the focus; dim `↑ fler` /
  `↓ fler` markers show that rows are hidden above/below
  (`tui_form_scroll`).
- Date fields (`date_field_edit`, `date_prompt`) accept digits only and insert
  the dashes themselves: type `20260315` and the field shows `2026-03-15`.
  Backspace deletes a digit (with its separator), ←/→/Home/End move by digit,
  and the caret renders like in any other field. Validation (`util_parse_iso_date`)
  happens on Validera/Spara.
- Derived values (account names, balances) are dim and non-editable.
- Row tables: always exactly one empty trailing row; entering data appends a
  new empty row; an empty row followed by another empty row collapses.
  `Ctrl+X` clears the selected row. `Tab` walks the editable cells and wraps
  to the next row's first cell (`Shift-Tab` back). A dim footer callback
  shows one derived line (balance, attachment list) on its own row above the
  hints (`LINES-2`), never over a table row; `\n` in the text becomes a
  space. A cell holding one of a fixed set of values (unit, VAT code) is
  changed with `Enter`, which opens `tui_choice_prompt` from the row table's
  key hook; the chosen value stays in the same cell buffer as any other row.
- Fields with `mask` set are shown as `*` (`tui_form_field.mask`); used for
  passwords. ACTION fields show their `value` when set, else `(lista)`.
- Header fields plus rows: one `tui_rt_run` with `tui_rt_set_fields` draws
  the fields above the table on the same screen. Focus starts on the first
  field; `Tab`/`Shift-Tab` walk fields, then the cells row by row, and wrap
  back to the first field. `Up`/`Down` move between fields (in the table
  they change row). `Enter` opens the field's in-place editor (text/date/
  amount/choice) and advances to the next field on commit; `Esc` cancels
  the editor without changing anything. Typing a printable character on a
  focused text/date/amount field starts editing that field with the key
  (replacing the value, like any fresh field); on a choice/action field it
  does nothing. The hardware cursor only shows in a table cell. `q` is text
  here — only `Esc` leaves the table. Moving to another cell (arrows,
  `Enter`, `Tab`) resets the caret to the end of the new cell. A focused
  cell scrolls horizontally like a form field. `^O`/`Esc`
  work the same from both.
- A form may carry an action list under its fields (`tui_form_run_actions`):
  fields first, then action rows, one focus ring (`Tab`/`Shift-Tab`/
  up/down, `Home`/`End`; the ring wraps and heading rows are skipped).
  `enabled: 1` runs on `Enter`; `0` is dimmed but selectable and shown as
  `label (reason)`, `Enter` shows the reason in a message; `-1` is a dim
  non-selectable heading/status row like a menu section. The call returns
  the field index after an edit, `TUI_FORM_ACTION + i` for a chosen action,
  or `TUI_FORM_BACK`/`REFRESH`/`SUBMIT`, and stores the row it stopped on in
  the `int *focus` argument (see focus memory above); `tui_form_hint()`
  overrides the footer of the next run as usual.
- `Bokslut` is the year-end hub in one view: the year fields, the status
  heading `Bokslut bokfört: ja/nej` (derived from the `Skatt på årets
  resultat` voucher that `bokslut.post` creates; a closed year counts as
  posted) and the actions Årsredovisning (K2), Inkomstdeklaration
  (INK2/SRU), Bokslutsplan (torrkörning) and Bokför bokslut — the last
  dimmed with a reason when the year is closed or already posted. The
  context menu's "Visa bokslutsplan" shows the plan, "Bokför planen…" posts
  it.
- Validation: the "Validera" action (`TUI_KEY_REFRESH`) validates without
  writing and reports exactly what is wrong
  (field, row number). Server `dry_run` is used where available.
- Saving: the "Spara"/"Bokför" action (`TUI_KEY_SUBMIT`) writes, shows a
  confirmation message, and returns to
  the previous view. `Esc` cancels without saving.
- Screen-specific keys (template picker, attach, link, download) are handled
  in a key hook, never with a local loop. The hook runs for keys the widget
  itself does not handle and returns `TUI_HOOK_*`: `STAY` (consumed, redraw),
  `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc` or the
  Uppdatera/Spara actions). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`,
  `tui_pager_hook` and `tui_select_list_hook`.
- File browser (`file_browser`): starts in the directory where the last
  attachment was picked (remembered in `tui.conf`), falling back to `$HOME`
  when it is gone (a leading `~` is expanded to `$HOME`), `.. (uppåt)` is
  the first row, directories sort first with a trailing `/`, hidden files
  are skipped. Enter enters a directory or picks a file; `Esc` cancels.
  Picking a file remembers its directory. Selected files are uploaded
  immediately as unlinked underlag and linked when the voucher is posted.

## Messages

- Info/confirmation: `message(title, ...)` box, dismissed with Enter.
  Dialogs read keys through the application's input hook like every other
  widget, so arrow keys are decoded (never leak `[A` into the next view)
  and `^C`/`^R` work inside them.
- Quitting: only `Ctrl+C` (from anywhere) or "Logga ut / avsluta" ends the
  app; `Esc`/`q` only navigate. After `Ctrl+C` every screen unwinds, the
  session is closed and the terminal restored.
- Errors: `show_error(title, resp)` prints `CODE: message` from the server
  error object; if the response is missing/empty it says
  "Inget svar från servern (kör daemonen?)". Never render an empty error.
- Irreversible actions (close year, lock, archive) ask first.

## Layout and text

- Screen frame: title top-left, dim status line under it
  (`org | start - end | role | user`), hints in the last line.
- Columns are padded with `pad_field()` (UTF-8 display width) and truncated to
  their column; amounts right-aligned via `kr_format()`; never pad with `%s`
  widths directly on user text.
- Selection = reverse video, headers = bold, derived data = dim. Drawing uses
  the semantic styles below, never raw `A_BOLD`/`A_DIM`.

## Theme and styles

`clients/tui.c` owns the theme. The widget layer (and only the widget layer)
calls `tui_style(enum tui_style)` to set a semantic style and
`tui_style_reset()` to return to the terminal default:

| Style | Meaning |
|---|---|
| `TUI_TITLE` | screen title in the frame |
| `TUI_HEADING` | headings, column headers, form labels |
| `TUI_SUBHEADING` | secondary headings |
| `TUI_DIM` | derived/secondary data, section headers |
| `TUI_ACCENT` | interactive prompts (goto) |
| `TUI_POS` / `TUI_NEG` | positive/negative amounts |
| `TUI_RULE` | full-width rules |
| `TUI_STATUS` | status line, hints, footers |

Colours are initialised in `tui_keys_setup()`: a small set of pairs (cyan
headings, yellow accent, green/red amounts, blue rules) on the default
background. If the terminal has no colour support or `NO_COLOR` is set in the
environment, every style falls back to attributes (bold/dim) via the pure
`tui_style_attrs(style, colours_ok, no_color)`; reverse video for the
selection is kept in both modes.

## Pager markup

Pager text may carry a one-byte leading marker per line (set by the report
formatters, interpreted by `tui_markup`):

| Marker | Effect |
|---|---|
| `\x01` | line is a heading (`TUI_HEADING`) |
| `\x02` | line is dimmed (`TUI_DIM`) |
| `\x03` | full-width `ACS_HLINE` rule (`TUI_RULE`); the rest of the line is ignored |
| `\x04` | line is pinned as a column header: the pager keeps it visible above the scrolling body (combine with another marker, e.g. `\x04\x01`) |

Report formatters mark headings, sums/derived lines and rules before the
text reaches `tui_pager`; the markers never change column widths. Anything
that saves pager text to a file strips the markers first
(`strip_markup()` in `bokftui.c`).

## Widget layer (`clients/tui.[ch]`)

Every element a screen needs is a widget here, and the widget layer is the
only place that touches ncurses. Rules:

- Screens never call ncurses directly (`mvadd*`, `attron`, `getch`…) and
  never keep a local one-off input or drawing loop.
- If no widget fits, **extend the widget layer first** (spec in this file +
  unit tests in `tests/test_tui.c`) and then use it. There is no "do it
  locally once" option.
- Pure logic (line editing, date input, list navigation, prompts parsing)
  is separated from drawing so it can be tested without a terminal.
- Widgets return key codes (`-1` back, `-2` refresh, `-4` new, `-6` remove,
  `-7` toggle) and write text into the caller's buffer; nothing returns
  static storage. `TUI_NAV_NONE`, `TUI_FORM_BACK/REFRESH/SUBMIT` and the
  `TUI_HOOK_*` codes are all distinct.
- `tui_pager_hook` takes `TUI_PAGER_SAVE` to enable the `s` save action; the
  `extra_hint` is shown in the footer. `TUI_PAGER_NO_ARROWS` leaves Up/Down
  to the key hook (record stepping) and the footer says
  `PgUp/PgDn/Home/End rullar` instead. Plain `tui_pager` keeps `s` on when a
  save hint is passed. Pager lines go through `tui_markup` (see below).
- `struct tui_list_nav.items` holds the row texts the goto search matches
  (`tui_text_match`); `tui_select_list*`, `tui_menu` and `tui_action_menu`
  set it. Keys typed into the goto prompt never reach a screen's key hook.
- Styles come from `tui_style`/`tui_style_attrs`; report markup and menu
  sections from `tui_markup`/`TUI_MARK_HEADING`. Their fallbacks and the
  navigation over section headers are unit-tested in `tests/test_tui.c`.
- `tui_redraw` flushes a `tui_frame`/`tui_set_status` pair drawn by the
  application outside a widget loop (startup/reconnect messages).
- The application supplies input, frame/hints and quit through
  `tui_set_input`/`tui_set_screen`/`tui_set_quit`.

## Adding a view — checklist

1. Data comes from public protocol commands only.
2. Wrap the screen in `tui_frame()` and let the widgets carry the footer
   hints; return `Esc`/`q` to the parent.
3. Use `tui_menu`/`tui_select_list` instead of writing a new loop; pass
   `allow_new`/`allow_refresh` so the universal keys apply.
4. Forms use `tui_form_run*`/`tui_rt_run`, which edit fields in place;
   one-off questions use the prompts (`tui_prompt_into`,
   `tui_date_prompt_into`, `tui_amount_prompt_into`, `tui_choice_prompt`).
   All of them share one field editor.
5. Support refresh (`r` / "Uppdatera") if the data can change elsewhere.
6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction.
7. Declare the screen's actions in one `tui_action` list (once the
   interaction model is implemented); dispatch, the context menu and the
   footer
   hint all read that list, so a key cannot exist without a visible action.