tuile 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
data/DECISIONS.md CHANGED
@@ -13,10 +13,15 @@ It is the *why-we-chose* record; it is not the *how-it-works* reference
13
13
  those, put it there and don't restate it — an entry here links out rather
14
14
  than duplicating.
15
15
 
16
- **Format.** One entry per decision. The ID is a slug, not a number: `D-`
17
- (says "this is a decision") plus a 1–4-word kebab hint at the subject
18
- (`D-bg-inherit`), so a reference carries meaning on its own — a running
19
- counter would not. The `(date)` on the heading is *decided* provenance,
16
+ **Format.** One entry per decision. The ID is a slug, not a number: `D_`
17
+ (says "this is a decision") plus a 1–4-word hint at the subject
18
+ (`D_bg_inherit`), so a reference carries meaning on its own — a running
19
+ counter would not. **Underscores throughout, never hyphens**: the id has
20
+ to be one *token*, so that vim's `w` / `*` / `ciw` and `grep -w` act on
21
+ the whole thing rather than on a fragment. Backtick it in prose, both
22
+ because some downstream Markdown parsers italicise intraword `_` and
23
+ because a backticked id is copy-pasteable into a search. The `(date)` on
24
+ the heading is *decided* provenance,
20
25
  not a log position; git owns the edit history (consistent with the "No
21
26
  history" rule — don't narrate how an entry used to read). Keep each entry
22
27
  tight: context, the decision, the alternatives rejected and why, and the
@@ -34,7 +39,7 @@ choice, now sharper or broader). Two things this does *not* license:
34
39
  It's the most valuable thing in the file.
35
40
  - **A reversed *shipped* decision forks a tombstone, it is not overwritten.**
36
41
  When a design was tried, shipped, and then thrown away, leave the old
37
- entry as the scar, set its `Status:` to **Superseded by D-<slug>**, and
42
+ entry as the scar, set its `Status:` to **Superseded by `D_<slug>`**, and
38
43
  write the replacement fresh. (The shape of such a reversal: the deleted
39
44
  bottom-up `content_size` sizing channel, replaced by top-down layout —
40
45
  see AGENTS.md "Layout is top-down".) The line: *refined or extended* →
@@ -42,7 +47,7 @@ choice, now sharper or broader). Two things this does *not* license:
42
47
 
43
48
  ---
44
49
 
45
- ## D-bg-inherit — Background color: fill-the-gaps inheritance (2026-07-23)
50
+ ## D_bg_inherit — Background color: fill-the-gaps inheritance (2026-07-23)
46
51
 
47
52
  **Status:** Accepted; implemented 2026-07-23. Tracks
48
53
  [issue #1](https://github.com/mvysny/tuile/issues/1).
@@ -115,21 +120,21 @@ are opt-in"). {Component::Label} already carried its own `#bg` (override-all
115
120
  via `with_bg`); it composes with `bg_color` (explicit span bgs survive
116
121
  `under_bg`, so `#bg` wins locally), but the two-knob overlap is a wart
117
122
  flagged for a later consolidation decision. The theme-token variant that
118
- surfaced during design landed separately — see `D-theme-ref`.
123
+ surfaced during design landed separately — see `D_theme_ref`.
119
124
 
120
125
  ---
121
126
 
122
- ## D-theme-ref — Live theme references for `bg_color` (2026-07-23)
127
+ ## D_theme_ref — Live theme references for `bg_color` (2026-07-23)
123
128
 
124
129
  **Status:** Accepted; implemented 2026-07-23. Tracks
125
130
  [issue #1](https://github.com/mvysny/tuile/issues/1). Relaxes the
126
- `bg_color`-takes-`Color`-only stance of `D-bg-inherit`, which rejected a
131
+ `bg_color`-takes-`Color`-only stance of `D_bg_inherit`, which rejected a
127
132
  built-in `panel_bg` token and deferred the general "themeable color
128
133
  property" question.
129
134
 
130
135
  **Context.** Tracking a themed background meant setting the color *twice* —
131
136
  once as a concrete `Color`, and again in an `on_theme_changed` block so it
132
- survives light/dark flips — for every tinted panel. `D-bg-inherit` deferred
137
+ survives light/dark flips — for every tinted panel. `D_bg_inherit` deferred
133
138
  the fix; this is it.
134
139
 
135
140
  **Decision.** `Component#bg_color` accepts a `Theme::Ref` (built by
@@ -163,7 +168,7 @@ existing chrome accent and tracking flips — concretely
163
168
  {Component::ComboBox}'s borderless dropdown, which tints with
164
169
  `input_bg_color` (tying it to the field's own well) and would otherwise need
165
170
  the very `on_theme_changed`/resolve-on-open boilerplate `Theme::Ref` exists
166
- to kill. The invariant `D-bg-inherit` actually protects is *the Theme
171
+ to kill. The invariant `D_bg_inherit` actually protects is *the Theme
167
172
  carries no global bg/fg field* — and every chrome token is an **accent**
168
173
  (`active_bg`, `active_border`, `input_bg`, `hint`), never a global
169
174
  background. A `Ref` to one adds no new token and creates no global
@@ -200,7 +205,7 @@ reaching chrome tokens leaves the no-global-bg/fg guard untouched.
200
205
  **Consequences.**
201
206
  - A `Ref` adds no new token (chrome tokens are all accents; `custom` is
202
207
  app-supplied), so it **cannot** reintroduce the global bg/fg token that
203
- `D-bg-inherit` and the AGENTS.md theme stance refuse — the two stay
208
+ `D_bg_inherit` and the AGENTS.md theme stance refuse — the two stay
204
209
  orthogonal.
205
210
  - Collision precedence is chrome-wins; a `custom` token named after a chrome
206
211
  token is shadowed when referenced by `Ref` (harmless, documented on
@@ -214,7 +219,7 @@ reaching chrome tokens leaves the no-global-bg/fg guard untouched.
214
219
 
215
220
  ---
216
221
 
217
- ## D-has-value — Typed value seam (`HasValue`) over String-only (2026-07-23)
222
+ ## D_has_value — Typed value seam (`HasValue`) over String-only (2026-07-23)
218
223
 
219
224
  **Status:** Accepted; implemented 2026-07-23 (`Component::HasValue`, included
220
225
  by `AbstractStringField`; first typed consumer is `ComboBox`). Tracks the "do input
@@ -271,10 +276,10 @@ values *and* a uniform seam for free.
271
276
 
272
277
  ---
273
278
 
274
- ## D-combobox — `ComboBox`: composed, typed, filterable-first (2026-07-23)
279
+ ## D_combobox — `ComboBox`: composed, typed, filterable-first (2026-07-23)
275
280
 
276
281
  **Status:** Accepted; implemented 2026-07-23 (`Component::ComboBox`, demoed in
277
- the sampler). Builds on `D-has-value`, `D-bg-inherit`, `D-theme-ref`.
282
+ the sampler). Builds on `D_has_value`, `D_bg_inherit`, `D_theme_ref`.
278
283
 
279
284
  **Context.** A text field with a filtering dropdown. The ad-hoc version already
280
285
  existed in the sampler's slash-command demo (a `TextField` + a non-modal
@@ -302,18 +307,18 @@ component.
302
307
  make value-before-items the normal case rather than a corner. The cost — the
303
308
  app owns keeping them in sync, reconciling with a one-line intersection when
304
309
  it wants to — is smaller than any framework reconcile step (see the rejected
305
- three in `D-checkbox-group`, where the set-valued case forced the question).
310
+ three in `D_checkbox_group`, where the set-valued case forced the question).
306
311
  One rule, two instances: singular here, a `Set` of items in `CheckboxGroup`.
307
312
  - **Two values, never conflated.** `value` = the committed selection (changes
308
313
  only on Enter/click; sole trigger of `on_value_change`); the field's `text`
309
314
  = a transient **query** that filters the list and reverts to the value's
310
315
  label on ESC/blur.
311
316
  - **Filterable first;** the non-filterable `Select` is deferred (it wants the
312
- read-only field behavior `D-has-value` parked for the forms layer).
317
+ read-only field behavior `D_has_value` parked for the forms layer).
313
318
  - **Borderless tinted dropdown** (no `Window`): a bare `Popup(List)` told apart
314
319
  from the content by a background tint, `bg_color = Theme.ref(:input_bg_color)`
315
- — live-tracked, no `on_theme_changed` hook (leans on `D-bg-inherit` +
316
- `D-theme-ref`). A `▾` affordance marks the field; the dropdown flips above
320
+ — live-tracked, no `on_theme_changed` hook (leans on `D_bg_inherit` +
321
+ `D_theme_ref`). A `▾` affordance marks the field; the dropdown flips above
317
322
  when it would overrun the screen bottom.
318
323
 
319
324
  **Alternatives rejected.**
@@ -321,7 +326,7 @@ component.
321
326
  above.
322
327
  - *String value (the display text):* fails identity-across-duplicate-labels,
323
328
  the whole reason to prefer a component over `List` + a lookup hash
324
- (`D-has-value`).
329
+ (`D_has_value`).
325
330
  - *Store the selected **index** rather than the object* (and clear the selection
326
331
  when `value=` gets something not in `items`): the plausible misreading of the
327
332
  identity rule, and it breaks the chrome/value split above — replacing `items`
@@ -330,7 +335,7 @@ component.
330
335
  *resolution* mechanism, valid only at the instant of a click.
331
336
  - *`Window`-framed dropdown:* the border is redundant chrome once a tint
332
337
  separates the panel, and costs 2 rows + 2 cols; the tint is what
333
- `D-bg-inherit` was built to make solid.
338
+ `D_bg_inherit` was built to make solid.
334
339
  - *`allow_custom_value`* (Vaadin's "typed text not in the list" escape hatch):
335
340
  deferred — a custom value is a `String`, reintroducing the String/`T` tension
336
341
  at the value boundary; no use case needs it yet.
@@ -347,10 +352,10 @@ component.
347
352
 
348
353
  ---
349
354
 
350
- ## D-integer-field — `IntegerField`: the second typed input, and the composed-field taxonomy (2026-07-23)
355
+ ## D_integer_field — `IntegerField`: the second typed input, and the composed-field taxonomy (2026-07-23)
351
356
 
352
357
  **Status:** Accepted; implemented 2026-07-23 (`Component::IntegerField`). Builds
353
- on `D-has-value`, `D-combobox`. Its real job was to *validate the `HasValue`
358
+ on `D_has_value`, `D_combobox`. Its real job was to *validate the `HasValue`
354
359
  seam* for the case where `value`'s type diverges from the editing buffer:
355
360
  `ComboBox` proved the fully-detached case (value ⟂ query), `IntegerField`
356
361
  probes the *derived* case (value = a parse of the buffer). Extended 2026-08-02
@@ -367,7 +372,7 @@ moment to settle the input taxonomy while still pre-1.0.
367
372
  not reuse: subclassing drags `TextField`'s `String`-typed `text`/`value` seam
368
373
  onto the field's public face, next to the real `Integer` `value` as a
369
374
  conflicting second seam, and Ruby can't cleanly hide inherited public
370
- methods. (Same shape as `D-combobox`; makes `IntegerField` a *simpler
375
+ methods. (Same shape as `D_combobox`; makes `IntegerField` a *simpler
371
376
  ComboBox* — the identical structure minus the dropdown.)
372
377
  **The taxonomy is two-sided: compose when the value's type diverges from the
373
378
  buffer, subclass when it doesn't.** `Component::PasswordField < TextField`
@@ -388,7 +393,7 @@ moment to settle the input taxonomy while still pre-1.0.
388
393
  `cycle_focus` collects stops via `on_tree`).
389
394
  - **The converter stays private and hardcoded** (`Integer(t, 10)` / `to_s`),
390
395
  exactly as `TextField` hardcodes identity-String. No public `converter=`
391
- strategy — that is the future Binder's job (`D-has-value` keeps converters
396
+ strategy — that is the future Binder's job (`D_has_value` keeps converters
392
397
  *above* the field).
393
398
  - **Value is a derived parse, fired eagerly.** `value` is recomputed from the
394
399
  buffer on read; `on_value_change` fires per keystroke but only on a real
@@ -443,7 +448,7 @@ what Java needs a class for, and `is_a?(HasValue)` is the Binder's marker.
443
448
 
444
449
  ---
445
450
 
446
- ## D-ambiguous-width — Bet on ambiguous-as-narrow; keep the inventory small (2026-07-30)
451
+ ## D_ambiguous_width — Bet on ambiguous-as-narrow; keep the inventory small (2026-07-30)
447
452
 
448
453
  **Status:** Accepted 2026-07-30; describes what Tuile already does, plus one
449
454
  new *forward-looking* rule (the inventory discipline) that governs new glyph
@@ -546,7 +551,7 @@ Detect once and swap glyphs, rather than re-deriving widths everywhere:
546
551
 
547
552
  ---
548
553
 
549
- ## D-key-dispatch — Delete `key_shortcut`; scope-wide keys ride the bubble (2026-07-30)
554
+ ## D_key_dispatch — Delete `key_shortcut`; scope-wide keys ride the bubble (2026-07-30)
550
555
 
551
556
  **Status:** Accepted 2026-07-30; implemented the same day. Supersedes the
552
557
  shipped capture phase of `ScreenPane#handle_key` — see *the scar* at the end.
@@ -758,13 +763,13 @@ on a per-binding flag).
758
763
 
759
764
  ---
760
765
 
761
- ## D-boolean-fields — `Checkbox`: two-state value, painted extent, ASCII glyphs (2026-07-30)
766
+ ## D_boolean_fields — `Checkbox`: two-state value, painted extent, ASCII glyphs (2026-07-30)
762
767
 
763
768
  **Status:** Accepted; `Component::Checkbox` implemented 2026-07-30. Builds on
764
- `D-has-value`. The glyph and caption rulings are shared with
765
- `Component::CheckboxGroup` (`D-checkbox-group`, which scopes the key and hit-test
769
+ `D_has_value`. The glyph and caption rulings are shared with
770
+ `Component::CheckboxGroup` (`D_checkbox_group`, which scopes the key and hit-test
766
771
  rulings below to a *standalone* widget) and with `RadioGroup`
767
- (`D-radio-group`). Tri-state is settled here but **not built**, and this
772
+ (`D_radio_group`). Tri-state is settled here but **not built**, and this
768
773
  entry is its only home — see the last section.
769
774
 
770
775
  **Context.** The first boolean input: one row, `[x] Enable syslog forwarding`,
@@ -791,7 +796,7 @@ will follow.
791
796
  don't need is the irreversible direction. What tipped it is *consistency with
792
797
  the group components*: a checkable row inside a `List` toggles on Enter, since
793
798
  Enter is `List`'s own choose-the-item-under-the-cursor gesture
794
- (`D-checkbox-group`, `D-radio-group`). So `[ ] Verbose` flipped on Enter when
799
+ (`D_checkbox_group`, `D_radio_group`). So `[ ] Verbose` flipped on Enter when
795
800
  it sat in a `CheckboxGroup` and did nothing when it sat alone in a form — a
796
801
  distinction the user cannot see, and one that reads as a bug in the standalone
797
802
  widget rather than as restraint. One gesture set, both shapes, is worth more
@@ -827,7 +832,7 @@ will follow.
827
832
  `ellipsize(rect.width)` already equals `ellipsize(extent.width)` in both
828
833
  directions.
829
834
  **The rule is scoped to a *standalone* one-row field.** A checkable row
830
- *inside a list* hit-tests its full width instead (`D-checkbox-group`), and the
835
+ *inside a list* hit-tests its full width instead (`D_checkbox_group`), and the
831
836
  difference is perceptual rather than a relaxation of rigor: with a cursor
832
837
  visible and ten rows stacked, the unit the user aims at is a **row**, and a
833
838
  row's affordance is its whole width — which is what `List`'s row-wide cursor
@@ -845,7 +850,7 @@ will follow.
845
850
  so the two states degrade *asymmetrically* to tofu — checked renders,
846
851
  unchecked doesn't, which reads as a bug rather than a fallback) and on **ink
847
852
  overflow** (the fallback glyph is drawn wider than its cell in Alacritty —
848
- cosmetic, coordinates stay correct; see `D-ambiguous-width` for why that's a
853
+ cosmetic, coordinates stay correct; see `D_ambiguous_width` for why that's a
849
854
  different problem). Locally, three columns is also a bigger click target that
850
855
  survives a monochrome terminal, and keeps `region_text` assertions ASCII.
851
856
 
@@ -860,7 +865,7 @@ will follow.
860
865
  the promise in `CheckboxGroup` would have forced it onto the
861
866
  `ListDropdown::Menu` shape — a non-focusable `List` subclass plus
862
867
  hand-forwarded movement keys — to protect a guarantee nothing relied on
863
- (`D-checkbox-group`). Enter-reaches-your-form is a per-assembly property the
868
+ (`D_checkbox_group`). Enter-reaches-your-form is a per-assembly property the
864
869
  app verifies for its own focusable widgets, not a framework invariant. Still
865
870
  rejected, and now moot in both directions: the standalone widget claims Enter
866
871
  too, which is what made the two shapes agree.
@@ -892,7 +897,7 @@ will follow.
892
897
  (`Screen#refresh_status_bar` can't even reach a leaf field — it consults the
893
898
  active `Window` or the top popup's *direct* content.)
894
899
  - *A read-only flag:* parked with the rest of the forms-layer axes by
895
- `D-has-value`.
900
+ `D_has_value`.
896
901
 
897
902
  **Tri-state (indeterminate) — settled, not built.** When it lands it adopts
898
903
  **Vaadin's orthogonal flag**: `indeterminate`/`indeterminate=` as a plain
@@ -924,23 +929,23 @@ marker is live-resolved chrome like every other built-in accent.
924
929
 
925
930
  Deferred because the use case (a partially-checked tree parent) has no home in
926
931
  Tuile today. Its first plausible consumer would be a `CheckboxGroup` header row
927
- — which `D-checkbox-group` declined to build, leaving this unbuilt too; that
932
+ — which `D_checkbox_group` declined to build, leaving this unbuilt too; that
928
933
  entry names the forcing function to watch for.
929
934
 
930
935
  ---
931
936
 
932
- ## D-checkbox-group — `CheckboxGroup`: a field composing a `List`, `Set`-valued (2026-07-30)
937
+ ## D_checkbox_group — `CheckboxGroup`: a field composing a `List`, `Set`-valued (2026-07-30)
933
938
 
934
939
  **Status:** Accepted; `Component::CheckboxGroup` implemented 2026-07-30, demoed
935
- in the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split it
936
- generalizes), `D-integer-field` (the composed-field taxonomy it extends) and
937
- `D-boolean-fields` (the glyphs, and the two rulings it scopes).
940
+ in the sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split it
941
+ generalizes), `D_integer_field` (the composed-field taxonomy it extends) and
942
+ `D_boolean_fields` (the glyphs, and the two rulings it scopes).
938
943
 
939
944
  **Context.** Multi-select from a handful of typed items, one `[x] label` row
940
945
  each. The cursor and the selection are genuinely two pieces of state here —
941
946
  which is exactly the shape `List` already implements, so the question was how
942
947
  much of `List` to reuse and what the value should be. (A single-select group
943
- *could* have conflated them, and `D-radio-group` records why it doesn't.)
948
+ *could* have conflated them, and `D_radio_group` records why it doesn't.)
944
949
 
945
950
  **Decision.**
946
951
  - **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
@@ -949,7 +954,7 @@ much of `List` to reuse and what the value should be. (A single-select group
949
954
  `lines=` on any change to items/labels/selection, claim **Space** in
950
955
  `handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
951
956
  *and* click (`list.rb:209` and `:264`), so there is no `handle_mouse` override
952
- at all. This **extends `D-integer-field`'s taxonomy** from "a typed field
957
+ at all. This **extends `D_integer_field`'s taxonomy** from "a typed field
953
958
  composes a `TextField`" to "a typed field composes whatever widget already has
954
959
  the interaction" — the tab stop lives on the inner widget, the wrapper is not
955
960
  one, exactly as for `ComboBox`.
@@ -969,10 +974,10 @@ much of `List` to reuse and what the value should be. (A single-select group
969
974
  end — i.e. the observable order is the user's *toggle history*. Documented as
970
975
  unordered so nobody builds on that; `items & value.to_a` is the idiom for
971
976
  items order, and the sampler pane uses it visibly.
972
- - **Items are chrome (`D-combobox`), so `items=` never touches `value`** and never
977
+ - **Items are chrome (`D_combobox`), so `items=` never touches `value`** and never
973
978
  fires `on_value_change`. A selected item absent from `items` renders no checked
974
979
  row and survives intact.
975
- - **Two `D-boolean-fields` rulings are scoped, not broken.** A click anywhere on
980
+ - **Two `D_boolean_fields` rulings are scoped, not broken.** A click anywhere on
976
981
  a row toggles it (a row's affordance is its full width, which its cursor
977
982
  highlight already advertises) while a *standalone* checkbox still ignores its
978
983
  blank tail; and Enter toggles here because that is `List`'s choose gesture. The
@@ -980,7 +985,7 @@ much of `List` to reuse and what the value should be. (A single-select group
980
985
  `on_item_chosen` only for `line < @lines.size`, so a click below the last row
981
986
  toggles nothing.
982
987
  - **No header row, no tri-state, no select-all.** A header is the only plausible
983
- consumer of `D-boolean-fields`' settled-but-unbuilt `indeterminate` flag, and
988
+ consumer of `D_boolean_fields`' settled-but-unbuilt `indeterminate` flag, and
984
989
  it is also where every policy question lives: which children it governs,
985
990
  whether checking it selects all, one change event or N, whether it scrolls with
986
991
  the rows. That entry already rules a header *app policy*, so building one here
@@ -998,11 +1003,11 @@ much of `List` to reuse and what the value should be. (A single-select group
998
1003
  whatever now occupies that index; *re-map by `==`* is the honest one but still
999
1004
  can't preserve intent across duplicates and must decide whether to fire; *clear*
1000
1005
  discards the user's work when items merely gained a row. Storing items deletes
1001
- the question rather than answering it — see `D-combobox`'s matching rejection.
1006
+ the question rather than answering it — see `D_combobox`'s matching rejection.
1002
1007
  - *The `ListDropdown::Menu` shape — a non-focusable `List` subclass, focus on the
1003
1008
  wrapper, movement keys hand-forwarded:* the design forced by taking Enter away
1004
1009
  from the list. Correct, and about 15 lines of forwarding plus a subclass, all
1005
- to protect a promise nothing relied on (see `D-boolean-fields`' rejected Enter
1010
+ to protect a promise nothing relied on (see `D_boolean_fields`' rejected Enter
1006
1011
  reservation). Reach for it only if a driver genuinely needs Enter for itself.
1007
1012
  - *Paint the rows directly (`< Component`, `draw_text` per row):* wrong here.
1008
1013
  The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
@@ -1010,7 +1015,7 @@ much of `List` to reuse and what the value should be. (A single-select group
1010
1015
  re-implementing the cursor, the viewport, the scrollbar and the mouse
1011
1016
  arithmetic. This was left explicitly open for a radio group, on the grounds
1012
1017
  that three rows and a selection-follows-cursor model would need almost none of
1013
- it; `D-radio-group` then closed it the same way, because dropping that model
1018
+ it; `D_radio_group` then closed it the same way, because dropping that model
1014
1019
  removed the friction that made painting attractive.
1015
1020
  - *An `Array`-valued `value` in `items` order:* would make ordering meaningful and
1016
1021
  so make it a contract to maintain, plus `==` would then treat two identical
@@ -1022,7 +1027,7 @@ much of `List` to reuse and what the value should be. (A single-select group
1022
1027
  because that rule is `ComboBox`'s already (the `cop` duplicate-rather-than-fold
1023
1028
  rule).
1024
1029
  - *Public `CHECKED`/`UNCHECKED` glyph constants shared with `Checkbox`:* declined
1025
- again here for the reason `D-boolean-fields` gives — the group paints its own
1030
+ again here for the reason `D_boolean_fields` gives — the group paints its own
1026
1031
  rows and never instantiates a `Checkbox`, so importing a constant would read as
1027
1032
  a dependency that isn't there. Drift between the two copies surfaces as a
1028
1033
  `region_text` mismatch, not a silent bug.
@@ -1040,12 +1045,12 @@ independent.
1040
1045
 
1041
1046
  ---
1042
1047
 
1043
- ## D-radio-group — `RadioGroup`: cursor roams, selection commits; the cursor is chrome (2026-07-31)
1048
+ ## D_radio_group — `RadioGroup`: cursor roams, selection commits; the cursor is chrome (2026-07-31)
1044
1049
 
1045
1050
  **Status:** Accepted; `Component::RadioGroup` implemented 2026-07-31, demoed in
1046
- the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split),
1047
- `D-integer-field` (the composed-field taxonomy), `D-checkbox-group` (the
1048
- `List`-composing shape it copies) and `D-ambiguous-width` (the glyphs). Most of
1051
+ the sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split),
1052
+ `D_integer_field` (the composed-field taxonomy), `D_checkbox_group` (the
1053
+ `List`-composing shape it copies) and `D_ambiguous_width` (the glyphs). Most of
1049
1054
  this component was settled by those five; what it owns is the **interaction
1050
1055
  model**, which reverses both the desktop convention and this note's own first
1051
1056
  design.
@@ -1114,12 +1119,12 @@ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
1114
1119
  note held open:* it existed to escape the four frictions above, which the
1115
1120
  interaction model removes. Composing a `List` then costs nothing and keeps the
1116
1121
  cursor, viewport, scrollbar and mouse arithmetic in one place.
1117
- - *A `glyphs=` knob for `(•)`:* `D-ambiguous-width` blesses an opt-in knob but
1122
+ - *A `glyphs=` knob for `(•)`:* `D_ambiguous_width` blesses an opt-in knob but
1118
1123
  doesn't demand one, and `Checkbox`/`CheckboxGroup` both ship literals. Adding
1119
1124
  it here alone would create symmetry pressure for a third. Ship `(*)`/`( )`;
1120
1125
  add the knob to all three the day someone wants the bullet.
1121
1126
  - *A shared base with `CheckboxGroup`:* declined for the third time (see
1122
- `D-checkbox-group`). The two differ in exactly one line — `Set` membership vs
1127
+ `D_checkbox_group`). The two differ in exactly one line — `Set` membership vs
1123
1128
  `==` — and the `cop` duplicate-rather-than-fold rule covers the rest.
1124
1129
 
1125
1130
  **Consequences.** Space on the already-selected row is a no-op, not a deselect:
@@ -1129,10 +1134,10 @@ selection and *both* rows render `(*)`, while two distinct items sharing a label
1129
1134
  stay independent (a row resolves to an item by index). The sampler pane reports
1130
1135
  value and cursor side by side, which is the cheapest way to see the split.
1131
1136
 
1132
- ## D-text-field-axes — `TextField`: two axes, horizontal scrolling, a logical cap (2026-07-31)
1137
+ ## D_text_field_axes — `TextField`: two axes, horizontal scrolling, a logical cap (2026-07-31)
1133
1138
 
1134
1139
  **Status:** Accepted; `Component::TextField` rewritten 2026-07-31. Builds on
1135
- `D-ambiguous-width` (which already asserted that "every rect, caret column and
1140
+ `D_ambiguous_width` (which already asserted that "every rect, caret column and
1136
1141
  clip derives from `StyledString#display_width`" — a claim `TextField` was quietly
1137
1142
  violating). Scoped to `TextField`; `TextArea` carries the same bug and is *not*
1138
1143
  fixed here.
@@ -1184,8 +1189,8 @@ half-painted.
1184
1189
  (`nil` by default), counted **in characters** — a wide glyph counts once — and it
1185
1190
  gates *typing only*: at the cap a printable key does nothing and is still
1186
1191
  consumed. It deliberately does not police `text=`, which stays authoritative as
1187
- it is for `ComboBox#value` and `CheckboxGroup#value` (`D-combobox`,
1188
- `D-checkbox-group`), so lowering the cap under an existing value leaves that
1192
+ it is for `ComboBox#value` and `CheckboxGroup#value` (`D_combobox`,
1193
+ `D_checkbox_group`), so lowering the cap under an existing value leaves that
1189
1194
  value intact instead of silently trimming it. A cap in *columns* was rejected: it
1190
1195
  would make the maximum text depend on which characters were typed, which is
1191
1196
  exactly the width-vs-length confusion this note removes.
@@ -1215,12 +1220,12 @@ or a long number is now reachable instead of rejected. `TextArea` is now the
1215
1220
  only component still conflating the axes — its wrap computation measures
1216
1221
  characters against a column width, so CJK prose overflows every row.
1217
1222
 
1218
- ## D-text-area-columns — `TextArea`: a cluster-iterating wrap over two axes (2026-07-31)
1223
+ ## D_text_area_columns — `TextArea`: a cluster-iterating wrap over two axes (2026-07-31)
1219
1224
 
1220
1225
  **Status:** Accepted; `Component::TextArea` wrap rewritten 2026-07-31. The second
1221
- half of `D-text-field-axes`, which fixed `TextField` and recorded this as open.
1226
+ half of `D_text_field_axes`, which fixed `TextField` and recorded this as open.
1222
1227
  Deliberately does **not** touch how the caret *steps* — that is
1223
- `D-cluster-caret`.
1228
+ `D_cluster_caret`.
1224
1229
 
1225
1230
  **Context.** `compute_display_rows` filled each row by counting **characters**
1226
1231
  against `rect.width`, a **column** budget. So CJK prose wrapped at roughly twice
@@ -1285,17 +1290,17 @@ not just a bug fix, and it matches every editor.
1285
1290
  integer per row for a conversion on every mutation.
1286
1291
 
1287
1292
  **Consequences.** A row's `start` and `length` stay **character** counts, and
1288
- `D-cluster-caret` kept them that way — boundary-locking the caret needed no
1293
+ `D_cluster_caret` kept them that way — boundary-locking the caret needed no
1289
1294
  change here at all, precisely because this wrap is already cluster-iterating and
1290
1295
  `chars_for_column` / `caret_to_display` already return boundary-aligned counts.
1291
1296
  The cluster-**width** question this entry left open was closed separately by
1292
- `D-cluster-width`.
1297
+ `D_cluster_width`.
1293
1298
 
1294
- ## D-cluster-width — Emoji width policy `:rgi`; a cluster may exceed two columns (2026-07-31)
1299
+ ## D_cluster_width — Emoji width policy `:rgi`; a cluster may exceed two columns (2026-07-31)
1295
1300
 
1296
1301
  **Status:** Accepted; implemented 2026-07-31. Completes the width story begun in
1297
- `D-ambiguous-width` and continued through `D-text-field-axes` /
1298
- `D-text-area-columns`, which fixed *where* widths were measured while this fixes
1302
+ `D_ambiguous_width` and continued through `D_text_field_axes` /
1303
+ `D_text_area_columns`, which fixed *where* widths were measured while this fixes
1299
1304
  *what a width is*.
1300
1305
 
1301
1306
  **Context.** Two independent bugs, both about the grapheme cluster as the unit a
@@ -1333,7 +1338,7 @@ cosmetics. `:rgi` is the only setting never wrong in the corrupting direction
1333
1338
  for a sequence it is exact when the terminal draws the parts and over-measures
1334
1339
  when the terminal combines them, and it treats VS16 emoji presentation as 2.
1335
1340
 
1336
- Note this bets the *opposite* way from `D-ambiguous-width`, deliberately. That
1341
+ Note this bets the *opposite* way from `D_ambiguous_width`, deliberately. That
1337
1342
  note bets narrow because the glyphs at stake are Tuile's **own chrome** — box
1338
1343
  drawing, the scrollbar block — which the framework controls and needs at one
1339
1344
  column. Here the glyphs are **app content**, where the framework controls
@@ -1370,7 +1375,7 @@ rather than assumed.
1370
1375
  Rejected: it makes layout arithmetic non-reproducible across machines and
1371
1376
  makes the spec suite depend on whoever's `$TERM_PROGRAM` runs it — and Tuile's
1372
1377
  whole width strategy is one global answer with a small, enumerable inventory
1373
- (`D-ambiguous-width`). An app that needs its terminal's exact answer is better
1378
+ (`D_ambiguous_width`). An app that needs its terminal's exact answer is better
1374
1379
  served by a future explicit override than by ambient detection.
1375
1380
  - **Clamp any cluster to 2 columns.** Would have avoided touching `put_char`,
1376
1381
  and is simply wrong for a non-RGI sequence the terminal really does draw
@@ -1384,15 +1389,15 @@ of its parts to 2, so any app that hard-coded the old number will disagree.
1384
1389
  return *fewer* columns than asked when a wide glyph straddles the boundary — it
1385
1390
  drops the glyph rather than halving it, as it already did for CJK. Unaffected: a
1386
1391
  cluster spanning two style spans takes the first span's style rather than being
1387
- split. The caret stepped by character when this landed; `D-cluster-caret` fixed
1392
+ split. The caret stepped by character when this landed; `D_cluster_caret` fixed
1388
1393
  that separately.
1389
1394
 
1390
1395
  ---
1391
1396
 
1392
- ## D-screen-lifecycle — UI thread confinement, and three named screen states (2026-08-01)
1397
+ ## D_screen_lifecycle — UI thread confinement, and three named screen states (2026-08-01)
1393
1398
 
1394
1399
  **Status:** Accepted; implemented 2026-08-01. First step of the tree-first
1395
- sequencing (`D-tree-first`), and independent of the rest of it.
1400
+ sequencing (`D_tree_first`), and independent of the rest of it.
1396
1401
 
1397
1402
  **Context.** `Screen` carried a two-valued, unnamed state machine:
1398
1403
  `@pretend_ui_lock = true` in `initialize`, flipped to `false` on
@@ -1469,7 +1474,7 @@ raised `ENOTTY`, masking the real error.
1469
1474
 
1470
1475
  ---
1471
1476
 
1472
- ## D-tree-api — `@children` is authoritative; `add_child`/`remove_child` are the only path (2026-08-01)
1477
+ ## D_tree_api — `@children` is authoritative; `add_child`/`remove_child` are the only path (2026-08-01)
1473
1478
 
1474
1479
  **Status:** Accepted and implemented 2026-08-01. No `children` override
1475
1480
  remains in `lib/`; the only `parent =` assignments left are the two inside
@@ -1478,7 +1483,7 @@ remains in `lib/`; the only `parent =` assignments left are the two inside
1478
1483
  **Context.** Five call sites used to hand-wire `child.parent = …` alongside
1479
1484
  their own child bookkeeping, each in its own order. That is where the
1480
1485
  transient tree inconsistency and the focus-repair ordering accident came
1481
- from (`D-tree-first`), and it is what the attach/detach hooks would
1486
+ from (`D_tree_first`), and it is what the attach/detach hooks would
1482
1487
  have to fire *through*. Two shapes fix it, and they are not equivalent:
1483
1488
 
1484
1489
  - **A** — `Component` owns an `@children` array; `children` is a plain
@@ -1546,10 +1551,10 @@ for B.
1546
1551
 
1547
1552
  ---
1548
1553
 
1549
- ## D-attach-hooks — `on_attached` / `on_detached`: an edge trigger on the component (2026-08-01)
1554
+ ## D_attach_hooks — `on_attached` / `on_detached`: an edge trigger on the component (2026-08-01)
1550
1555
 
1551
1556
  **Status:** Accepted and implemented 2026-08-01. Last step of the tree-first
1552
- sequencing (`D-tree-first`); both `ideas/` notes it was designed in are retired.
1557
+ sequencing (`D_tree_first`); both `ideas/` notes it was designed in are retired.
1553
1558
 
1554
1559
  **Context.** Tuile had two thirds of a tree lifecycle: `attached?` (a computed
1555
1560
  predicate) and `on_child_removed` (a *container-side* notification used for
@@ -1606,7 +1611,7 @@ it must release in the mirror, because nothing else will.
1606
1611
  that goes on serving other sessions — so a missed `onDetach` there leaks into
1607
1612
  a *surviving* process and here it does not. That still holds, and it is why
1608
1613
  teardown-detach was never *urgent*; what overrode it is that `attached?`
1609
- became a type test (`D-tree-api`), so a tree rooted at a nilled `@pane` went
1614
+ became a type test (`D_tree_api`), so a tree rooted at a nilled `@pane` went
1610
1615
  on claiming to be attached forever and touching it raised "Screen not
1611
1616
  initialized". Firing is also just cheaper than explaining that. So
1612
1617
  `Screen#close` now calls `ScreenPane#detach_all`.
@@ -1622,7 +1627,7 @@ it must release in the mirror, because nothing else will.
1622
1627
  - **A generic `Component#remove_all_children`** as the unmount primitive.
1623
1628
  Unsafe: a slot container calling it would empty `@children` while `#content`
1624
1629
  / `#footer` still pointed at detached components — exactly the desync
1625
- `D-tree-api` exists to prevent. Unmounting also has to clear the pane's own
1630
+ `D_tree_api` exists to prevent. Unmounting also has to clear the pane's own
1626
1631
  slots, so it is not a generic tree operation. Named `detach_all` rather than
1627
1632
  `close` because `Popup#close` already means "remove *me* from the pane".
1628
1633
 
@@ -1637,16 +1642,16 @@ being detached (repair runs after), and must not inspect the ex-parent's
1637
1642
  bookkeeping. A raising hook propagates and leaves the tree undefined —
1638
1643
  durably so on the detach path, where the container's remaining work is skipped.
1639
1644
  Finally, hooks fire during `:idle` on the normal app path (a tree is assembled
1640
- before `run_event_loop`), which `D-screen-lifecycle` made a decision rather
1645
+ before `run_event_loop`), which `D_screen_lifecycle` made a decision rather
1641
1646
  than an accident.
1642
1647
 
1643
1648
  ---
1644
1649
 
1645
- ## D-tree-first — `Screen` is the service, `ScreenPane` is the UI (2026-08-01)
1650
+ ## D_tree_first — `Screen` is the service, `ScreenPane` is the UI (2026-08-01)
1646
1651
 
1647
1652
  **Status:** Accepted and implemented 2026-08-01, in five steps
1648
- (`D-screen-lifecycle`, the one-axis `attached?`, `D-tree-api` in two parts,
1649
- `D-attach-hooks`). The `ideas/` note it was designed in is retired.
1653
+ (`D_screen_lifecycle`, the one-axis `attached?`, `D_tree_api` in two parts,
1654
+ `D_attach_hooks`). The `ideas/` note it was designed in is retired.
1650
1655
 
1651
1656
  **Context.** Designing two no-op lifecycle hooks
1652
1657
  (`Component#on_attached` / `#on_detached`) took *ten* documented corner cases:
@@ -1670,8 +1675,8 @@ bookkeeping, each in its own order.
1670
1675
  `attached?` became `root.is_a?(ScreenPane)`: one axis, no `Screen`
1671
1676
  reference, so it never raises and a tree can be assembled with no screen in
1672
1677
  the process.
1673
- - **The tree API is final** (`D-tree-api`), and `parent=` — reachable only
1674
- through it — is the sole lifecycle firing site (`D-attach-hooks`).
1678
+ - **The tree API is final** (`D_tree_api`), and `parent=` — reachable only
1679
+ through it — is the sole lifecycle firing site (`D_attach_hooks`).
1675
1680
 
1676
1681
  Deleting the second axis deleted six edges outright rather than documenting
1677
1682
  them: the raise, the status-bar exception, the two-`@pane`-writes framing, the
@@ -1710,13 +1715,13 @@ means one screen, and the one-line change if that ever stops being true.
1710
1715
 
1711
1716
  ---
1712
1717
 
1713
- ## D-color-slots — A component color slot, not a new chrome token (2026-08-01)
1718
+ ## D_color_slots — A component color slot, not a new chrome token (2026-08-01)
1714
1719
 
1715
1720
  **Status:** Accepted; first applied by `Component::ProgressBar#bar_color`
1716
1721
  (implemented 2026-08-02). Binds Slider and Badge when they land — the question
1717
1722
  was cross-component from the start, so it is settled once here rather than
1718
- re-argued per widget. Builds on `D-bg-inherit` (accents-only theme, no global
1719
- bg/fg token) and `D-theme-ref` (the live-resolved slot machinery this reuses).
1723
+ re-argued per widget. Builds on `D_bg_inherit` (accents-only theme, no global
1724
+ bg/fg token) and `D_theme_ref` (the live-resolved slot machinery this reuses).
1720
1725
 
1721
1726
  **Context.** {Theme} carries four chrome tokens — `active_bg_color`,
1722
1727
  `active_border_color`, `input_bg_color`, `hint_color` — and a component
@@ -1781,17 +1786,17 @@ member is additive, removing one is not.
1781
1786
  all. A `track_color` would have doubled the surface to weaken that.
1782
1787
  - **A slot's `Ref` is validated eagerly** (KeyError at assignment, as
1783
1788
  `bg_color=` does) and re-resolved at paint, never cached — same rules as
1784
- `D-theme-ref`, including riding the invalidate-everything pass on `theme=`.
1785
- - **This licenses no global bg/fg token.** `D-bg-inherit` stands: a slot's
1789
+ `D_theme_ref`, including riding the invalidate-everything pass on `theme=`.
1790
+ - **This licenses no global bg/fg token.** `D_bg_inherit` stands: a slot's
1786
1791
  `Ref` can only point at a color the theme *already* carries.
1787
1792
 
1788
1793
  ---
1789
1794
 
1790
- ## D-progress-bar — A value that is not a field; no text on the bar (2026-08-01)
1795
+ ## D_progress_bar — A value that is not a field; no text on the bar (2026-08-01)
1791
1796
 
1792
1797
  **Status:** Accepted; `Component::ProgressBar` implemented 2026-08-02, demoed in
1793
- the sampler. Color is `D-color-slots`; the glyph pair rides `D-ambiguous-width`;
1794
- the ticker rides `D-attach-hooks`. What this entry owns is the *shape*.
1798
+ the sampler. Color is `D_color_slots`; the glyph pair rides `D_ambiguous_width`;
1799
+ the ticker rides `D_attach_hooks`. What this entry owns is the *shape*.
1795
1800
 
1796
1801
  **Context.** The first component with a `value` that is emphatically **not** an
1797
1802
  input: nothing focuses it, nothing types into it, and its number comes from the
@@ -1879,11 +1884,11 @@ paint over").
1879
1884
 
1880
1885
  ---
1881
1886
 
1882
- ## D-cluster-caret — The caret is boundary-locked; edits step by cluster (2026-08-02)
1887
+ ## D_cluster_caret — The caret is boundary-locked; edits step by cluster (2026-08-02)
1883
1888
 
1884
1889
  **Status:** Accepted; implemented 2026-08-02 in `AbstractStringField`, so it
1885
1890
  landed on `TextField`, `PasswordField` and `TextArea` at once. Closes the gap
1886
- `D-text-field-axes` / `D-text-area-columns` / `D-cluster-width` each recorded as
1891
+ `D_text_field_axes` / `D_text_area_columns` / `D_cluster_width` each recorded as
1887
1892
  open.
1888
1893
 
1889
1894
  **Context.** `@caret` indexed **codepoints** while the terminal draws **grapheme
@@ -1941,7 +1946,7 @@ makes the orphan bug unreachable.
1941
1946
  lines. Three concrete costs: (1) **it moves the axis, so every
1942
1947
  `caret = <something>.length` breaks silently** — five sites in `lib/` plus
1943
1948
  `examples/sampler.rb`'s `area.caret = start + command.length + 1`, all correct
1944
- for ASCII and wrong otherwise, which is the failure mode `D-text-field-axes`
1949
+ for ASCII and wrong otherwise, which is the failure mode `D_text_field_axes`
1945
1950
  deleted, relocated from the framework to its callers; it then forced an open
1946
1951
  question about a loud rename migration purely to convert those silent breaks
1947
1952
  into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
@@ -1961,7 +1966,7 @@ makes the orphan bug unreachable.
1961
1966
  repeated at every reader, and `caret=` already adjusts by clamping, so
1962
1967
  snapping there is not a new kind of surprise.
1963
1968
  - **Move `max_text_length` to counting clusters** alongside this. Deliberately
1964
- not bundled: it stays character-counting and stays `D-text-field-axes`'s
1969
+ not bundled: it stays character-counting and stays `D_text_field_axes`'s
1965
1970
  decision. Now a knowing choice rather than an untouched default — a decomposed
1966
1971
  `é` burns 2 of 10, and a field at its cap refuses an accent on its last letter
1967
1972
  because `insert`'s check fires before the mark can merge.
@@ -1977,10 +1982,10 @@ an empty field, which is input validation, not an axis question.
1977
1982
 
1978
1983
  ---
1979
1984
 
1980
- ## D-float-field — `FloatField`: named for its Ruby type, and a deliberate copy of `IntegerField` (2026-08-07)
1985
+ ## D_float_field — `FloatField`: named for its Ruby type, and a deliberate copy of `IntegerField` (2026-08-07)
1981
1986
 
1982
1987
  **Status:** Accepted; implemented 2026-08-07 (`Component::FloatField`). The
1983
- `Float` half of `D-integer-field`'s "derived parse" case — same wrapper shape,
1988
+ `Float` half of `D_integer_field`'s "derived parse" case — same wrapper shape,
1984
1989
  same taxonomy slot, so only what *differs* is recorded here.
1985
1990
 
1986
1991
  **Context.** Vaadin calls this a *Number Field*; the survey in
@@ -2001,7 +2006,7 @@ category, not this field's value, and it would force the eventual sibling to be
2001
2006
  ~90% of their body (the `HasContent` shell, the `on_key` filter interceptor, the
2002
2007
  `fire_if_changed` guard) and differ in exactly the three places that matter: the
2003
2008
  filter, the parse, and the format. An `AbstractNumericField` with abstract
2004
- `parse`/`format` hooks **is** the converter strategy `D-integer-field` kept out,
2009
+ `parse`/`format` hooks **is** the converter strategy `D_integer_field` kept out,
2005
2010
  reached through inheritance instead of a setter — and the `cop` rule is to
2006
2011
  duplicate rather than fold a shallow commonality into a base. The duplication is
2007
2012
  visible and boring; the base would be machinery.
@@ -2036,7 +2041,7 @@ spinner as `IntegerField`. A settable step is not free on a binary float:
2036
2041
  stepping by `0.1` accumulates `0.30000000000000004` straight into the visible
2037
2042
  buffer, so the knob would need a rounding policy (decimals? significant
2038
2043
  digits?), and rounding is formatting — a forms concern, parked with `min`/`max`
2039
- in `D-integer-field`.
2044
+ in `D_integer_field`.
2040
2045
 
2041
2046
  **Alternatives rejected.**
2042
2047
  - *`BigDecimal` as the value type:* correct for money, but it needs the
@@ -2051,14 +2056,14 @@ in `D-integer-field`.
2051
2056
 
2052
2057
  ---
2053
2058
 
2054
- ## D-bigdecimal-field — `BigDecimalField`, and Tuile's first optional dependency (2026-08-07)
2059
+ ## D_bigdecimal_field — `BigDecimalField`, and Tuile's first optional dependency (2026-08-07)
2055
2060
 
2056
2061
  **Status:** Accepted; implemented 2026-08-07 (`Component::BigDecimalField`).
2057
- The third numeric field, so it inherits `D-float-field` wholesale (named for
2062
+ The third numeric field, so it inherits `D_float_field` wholesale (named for
2058
2063
  its Ruby value type, a deliberate copy rather than a shared base) — only the
2059
2064
  two things that are new are recorded here: exactness, and the packaging.
2060
2065
 
2061
- **Context.** `D-float-field` closes with "the wrong field for money — hold that
2066
+ **Context.** `D_float_field` closes with "the wrong field for money — hold that
2062
2067
  as `Integer` cents"; this is the field that makes the honest answer available.
2063
2068
  `BigDecimal`, though, is not a language built-in: it was a *default* gem
2064
2069
  through Ruby 3.3 and became a **bundled** gem in 3.4, so from 3.4 on a Bundler
@@ -2124,16 +2129,16 @@ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
2124
2129
  wanted, it belongs at the call site, where it is visible.
2125
2130
  - *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
2126
2131
  the buffer under the caret while typing (`19.9` → `19.90` mid-edit), which
2127
- needs a blur/commit point a TUI lacks — the same reason `D-integer-field`
2132
+ needs a blur/commit point a TUI lacks — the same reason `D_integer_field`
2128
2133
  gave for not normalizing.
2129
- - *A settable `step=`:* `D-float-field` rejected it over binary-float noise,
2134
+ - *A settable `step=`:* `D_float_field` rejected it over binary-float noise,
2130
2135
  which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
2131
2136
  anyway, so the three numeric fields stay one shape; this is the field to
2132
2137
  revisit first if the knob is ever wanted.
2133
2138
 
2134
2139
  ---
2135
2140
 
2136
- ## D-box-layouts — `Vertical` / `Horizontal`: declarative sugar with no `Auto` (2026-08-07)
2141
+ ## D_box_layouts — `Vertical` / `Horizontal`: declarative sugar with no `Auto` (2026-08-07)
2137
2142
 
2138
2143
  **Status:** Accepted; implemented 2026-08-07 (`Component::Layout::Box`,
2139
2144
  `::Vertical`, `::Horizontal`, and the `Fixed` / `Percent` / `Expand` / `Insets`
@@ -2229,7 +2234,7 @@ override, so both class methods carry the guard (found by the spec, not by
2229
2234
  reading).
2230
2235
 
2231
2236
  **Decision — `Box` is a shared base, against the duplicate-don't-DRY rule.**
2232
- `D-float-field` says duplicate rather than fold a *shallow* commonality into a
2237
+ `D_float_field` says duplicate rather than fold a *shallow* commonality into a
2233
2238
  base. This isn't shallow: the greedy pass is substantial and byte-for-byte
2234
2239
  identical except for which of `(left, top)` / `(width, height)` it reads, so
2235
2240
  `Box` parameterizes it behind two private hooks and `Vertical` / `Horizontal`
@@ -2308,7 +2313,7 @@ are ~10-line concretes. That is the sanctioned cohesive base
2308
2313
 
2309
2314
  ---
2310
2315
 
2311
- ## D-wrap-leading-space — An indent is content; no flag, and no hanging indent (2026-08-12)
2316
+ ## D_wrap_leading_space — An indent is content; no flag, and no hanging indent (2026-08-12)
2312
2317
 
2313
2318
  **Status:** Accepted; implemented 2026-08-12 (`StyledString#wrap_one`). Fixes
2314
2319
  [issue #2](https://github.com/mvysny/tuile/issues/2). The continuation half —
@@ -2377,17 +2382,17 @@ so it is a no-op there, and the indented case is the only one with an opinion.
2377
2382
 
2378
2383
  ---
2379
2384
 
2380
- ## D-select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
2385
+ ## D_select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
2381
2386
 
2382
2387
  **Status:** Accepted; `Component::Select` implemented 2026-08-12, demoed in the
2383
- sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split and the
2384
- resolve-don't-store-an-index rule, both adopted verbatim), `D-radio-group` (the
2385
- cursor-is-chrome rule) and `D-ambiguous-width`.
2388
+ sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split and the
2389
+ resolve-don't-store-an-index rule, both adopted verbatim), `D_radio_group` (the
2390
+ cursor-is-chrome rule) and `D_ambiguous_width`.
2386
2391
 
2387
2392
  **Context.** A one-row closed-choice field: a face showing the selected item's
2388
- label plus a `▾`, dropping open a `ListDropdown` of the options. `D-combobox`
2393
+ label plus a `▾`, dropping open a `ListDropdown` of the options. `D_combobox`
2389
2394
  deferred it once ("filterable first"), on the assumption that it needed the
2390
- read-only-field axis `D-has-value` parked for the forms layer. That assumption
2395
+ read-only-field axis `D_has_value` parked for the forms layer. That assumption
2391
2396
  was an artifact of picturing a read-only `TextField` as the face; nothing gates
2392
2397
  this component.
2393
2398
 
@@ -2448,18 +2453,18 @@ dropdown as an overlay. Two consequences worth naming:
2448
2453
  having the child removes the wrinkle instead of documenting it.
2449
2454
 
2450
2455
  **Decision — promote `ComboBox#anchor` to `ListDropdown#anchor_to`.** Select needs
2451
- byte-identical vertical geometry, and `D-float-field`'s duplicate-don't-DRY rule
2456
+ byte-identical vertical geometry, and `D_float_field`'s duplicate-don't-DRY rule
2452
2457
  **does not apply**: that licensed copying a *shell* around three genuine
2453
2458
  differences, whereas this is the same computation with zero differences, so a
2454
2459
  later fix to the flip rule would land in one copy and silently not the other —
2455
2460
  and the symptom appears only near a screen edge, which is invisible under test.
2456
- The promotion threshold is the project's existing one (`D-color-slots`: "a
2461
+ The promotion threshold is the project's existing one (`D_color_slots`: "a
2457
2462
  *second* built-in needing the same thing"). Two rulings ride along:
2458
2463
 
2459
2464
  - **Width stays a caller-supplied parameter** (defaulting to the anchor's), so
2460
2465
  `ComboBox` keeps its lines-up-with-the-field policy and Select keeps its
2461
2466
  measured one, and `anchor_to` never measures content itself. Same shape as
2462
- `D-box-layouts`' "`align:` is legal only because the cross extent is
2467
+ `D_box_layouts`' "`align:` is legal only because the cross extent is
2463
2468
  caller-supplied", and it keeps Select's measuring within the top-down re-grow
2464
2469
  rule: an optional, caller-side query feeding a rect the caller then assigns.
2465
2470
  - **Horizontally we slide, vertically we flip.** Covering the driver would hide
@@ -2507,7 +2512,7 @@ two callers that already know the answer.
2507
2512
  discover them one at a time by cycling, with no way to see the set or know how
2508
2513
  many there are. The dropdown is better at every item count, so the
2509
2514
  `ListDropdown` face is the only face, and the vocabulary does not grow a fourth
2510
- closed-choice widget (cf. `D-box-layouts`' "there is no `Auto`"). *Re-grow
2515
+ closed-choice widget (cf. `D_box_layouts`' "there is no `Auto`"). *Re-grow
2511
2516
  rule:* if it returns it is a **face** on this component (a `dropdown: false`
2512
2517
  knob over the identical value seam), never a separate component, and it needs a
2513
2518
  real argument about visibility rather than a row-budget one.
@@ -2518,7 +2523,7 @@ two callers that already know the answer.
2518
2523
  one row itself.
2519
2524
  - *A shared base with `RadioGroup`* (`AbstractClosedChoiceField`): the ~15-line
2520
2525
  `items=` / `item_label=` / `label_for` shell is duplicated instead, per
2521
- `D-float-field`. The test is whether the commonality is a *shell around genuine
2526
+ `D_float_field`. The test is whether the commonality is a *shell around genuine
2522
2527
  differences* or the *same computation* — `anchor_to` is the latter (extract), the
2523
2528
  items shell is the former (duplicate). The three differences a base would have
2524
2529
  to paper over with hooks: row rendering (`(*) label` glyphs vs. a bare label,
@@ -2526,7 +2531,7 @@ two callers that already know the answer.
2526
2531
  Space commits the row it's on, vs. the highlight *being* the pending selection),
2527
2532
  and where the rows live (always, in the component's own rect, vs. only while
2528
2533
  open, in a `Popup`'s). Three hooks over fifteen lines, reached through
2529
- inheritance, is the converter-strategy-by-inheritance shape `D-float-field`
2534
+ inheritance, is the converter-strategy-by-inheritance shape `D_float_field`
2530
2535
  rejected — and it would couple two widgets that should stay free to diverge.
2531
2536
  This is the third copy of that shell, the same count `IntegerField` /
2532
2537
  `FloatField` / `BigDecimalField` reached; a *fourth* is when to re-argue it.
@@ -2544,7 +2549,7 @@ two callers that already know the answer.
2544
2549
  attempt is keystroke-driven, so it would flood a host's log on autorepeat, and
2545
2550
  an app may legitimately pass through item-less while loading. Enter/Space/Down
2546
2551
  are claimed either way — one rule, no branch. (An item-less Select is arguably
2547
- a *disabled* field, which touches the read-only/disabled axis `D-has-value`
2552
+ a *disabled* field, which touches the read-only/disabled axis `D_has_value`
2548
2553
  parked for the forms layer. Not designed here, not foreclosed either.)
2549
2554
  - The dropdown is measured to the widest label plus `List`'s **two** row gutters
2550
2555
  (`pad_to_row` ellipsizes to `content_width - 2`, one leading and one trailing
@@ -2564,13 +2569,13 @@ two callers that already know the answer.
2564
2569
  `ComboBox#active=` leans on, and filtering / row rendering / the commit action
2565
2570
  really do vary.
2566
2571
 
2567
- ## D-list-items — `List` takes items + a renderer, rendered lazily (2026-08-14)
2572
+ ## D_list_items — `List` takes items + a renderer, rendered lazily (2026-08-14)
2568
2573
 
2569
2574
  **Status:** Accepted; implemented 2026-08-14, with the five composers folded onto
2570
- it in the same series. Builds on `D-has-value` (typed, not stringly),
2571
- `D-combobox` (resolve an index, never store one), `D-float-field` (duplicate
2575
+ it in the same series. Builds on `D_has_value` (typed, not stringly),
2576
+ `D_combobox` (resolve an index, never store one), `D_float_field` (duplicate
2572
2577
  rather than fold a shallow commonality) and the top-down layout rule
2573
- (`D-box-layouts`). Delivers the first half of the "typed items + data provider on
2578
+ (`D_box_layouts`). Delivers the first half of the "typed items + data provider on
2574
2579
  `List`" item that gated List Box, Grid and Virtual List.
2575
2580
 
2576
2581
  **Context.** `List` took pre-rendered rows: `lines=` stored `Array<StyledString>`
@@ -2582,7 +2587,7 @@ seam:
2582
2587
  because the framework handed back a string.
2583
2588
  - Four components (`ComboBox`, `Select`, `RadioGroup`, `CheckboxGroup`) kept a
2584
2589
  private copy of the `@items` / `@item_label` / `label_for` / `rebuild_rows`
2585
- shell. `D-select` set the trigger for re-arguing a shared base at the *fourth*
2590
+ shell. `D_select` set the trigger for re-arguing a shared base at the *fourth*
2586
2591
  copy; this is it.
2587
2592
 
2588
2593
  **Decision — externalize rendering on the generic component.** `List` holds
@@ -2593,7 +2598,7 @@ follows elsewhere — a domain component takes data, a generic one takes strateg
2593
2598
 
2594
2599
  **Not a shared base class.** The alternative reading of four duplicated shells is
2595
2600
  "extract `AbstractItemsComponent`". That is exactly the `parse`/`format`-hook base
2596
- `D-float-field` rejected, one level up: it would need a render hook, a
2601
+ `D_float_field` rejected, one level up: it would need a render hook, a
2597
2602
  commit-gesture hook and a where-do-rows-live hook to span a dropdown driver and a
2598
2603
  row-per-item group. The duplication was a symptom of a missing *seam*, not of a
2599
2604
  missing *ancestor*, and adding the seam deleted the duplication that actually
@@ -2703,12 +2708,12 @@ having `value=` rebuild every row is the O(n) pass this decision just deleted.
2703
2708
  rendering — is the shape that makes the bug unsayable, and the PTY test now
2704
2709
  presses Enter.
2705
2710
 
2706
- ## D-scroll-nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
2711
+ ## D_scroll_nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
2707
2712
 
2708
- **Status:** Accepted; implemented 2026-08-14. Builds on `D-list-items` (which
2709
- made the item vocabulary real), `D-text-area-columns` and `D-text-field-axes`
2713
+ **Status:** Accepted; implemented 2026-08-14. Builds on `D_list_items` (which
2714
+ made the item vocabulary real), `D_text_area_columns` and `D_text_field_axes`
2710
2715
  (which named the index-vs-column axes inside the inputs) and
2711
- `D-ambiguous-width` (whose `display_width` is the column authority).
2716
+ `D_ambiguous_width` (whose `display_width` is the column authority).
2712
2717
 
2713
2718
  **Context.** Three scrolling components had grown three vocabularies for the
2714
2719
  same four concepts — a content unit, a wrapped unit, a viewport-relative row,
@@ -2786,7 +2791,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
2786
2791
  stop using row-words loosely. Naming no row is not an exception to "row
2787
2792
  everywhere".
2788
2793
  - **`List#items` → `List#rows`,** which a List item arguably is. Rejected:
2789
- `items` is where `cop` wants the domain-object noun (`D-list-items` had just
2794
+ `items` is where `cop` wants the domain-object noun (`D_list_items` had just
2790
2795
  landed it), and it is the word the enum widgets above `List` already use.
2791
2796
  - **`scroll_top`** (CSS's `scrollTop`, shorter). Rejected for `scroll_top_row`:
2792
2797
  it names no unit, and `list.scroll_top` reads as an imperative — *scroll to
@@ -2814,7 +2819,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
2814
2819
  wrong.
2815
2820
  - **`row_count` was reserved here, then created separately.** Making it a public
2816
2821
  reader was held to be a behavioural addition needing its own argument; that
2817
- argument is `D-text-area-rows`, which granted it on `TextArea` only. The point
2822
+ argument is `D_text_area_rows`, which granted it on `TextArea` only. The point
2818
2823
  this entry settled — that the *name* is already taken, so the addition need not
2819
2824
  re-litigate its spelling — held.
2820
2825
  - **`CHANGELOG.md` was not swept.** Its 0.4.0 entry announcing the `set_line` /
@@ -2822,10 +2827,10 @@ which this scheme makes unwriteable — `line` is never a coordinate.
2822
2827
  and describes what shipped *then*, so retro-editing it would make a released
2823
2828
  migration note reference a method that release did not have.
2824
2829
 
2825
- ## D-text-area-rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
2830
+ ## D_text_area_rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
2826
2831
 
2827
2832
  **Status:** Accepted; implemented 2026-08-15. Grants the reader
2828
- `D-scroll-nomenclature` reserved the name for. Answers
2833
+ `D_scroll_nomenclature` reserved the name for. Answers
2829
2834
  [#3](https://github.com/mvysny/tuile/issues/3).
2830
2835
 
2831
2836
  **Context.** Shell-style prompt-history recall in a `TextArea`: Up recalls the
@@ -2865,7 +2870,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2865
2870
  0.9.0 layout re-grow rule explicitly sanctions the opposite — capability
2866
2871
  returning as "an *optional, read-only, caller-side query* … never as an
2867
2872
  automatic channel the framework consults" — which is also why
2868
- `D-scroll-nomenclature` rejected a general `Component` scroll seam. It serves
2873
+ `D_scroll_nomenclature` rejected a general `Component` scroll seam. It serves
2869
2874
  one question, in one direction, at one moment, where the readers also serve the
2870
2875
  prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
2871
2876
  subclass, where the readers serve `on_key` too. In COP terms it is neither a
@@ -2897,7 +2902,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2897
2902
  responsible for keeping them consistent. **(4) It flips a written invariant for
2898
2903
  no argued caller** — AGENTS.md holds the class private "until a second caller
2899
2904
  actually exists", and nobody has asked for `row_text` / `index_at` from
2900
- outside. Forwarders grow on demand at one line each; `D-float-field`'s
2905
+ outside. Forwarders grow on demand at one line each; `D_float_field`'s
2901
2906
  temperament ("a fourth copy is when to re-argue it") applies.
2902
2907
  - **A `wrapped_text` method documented "do not store".** Same staleness, renamed.
2903
2908
  - **A validity token on `WrappedText`,** so a holder can detect a stale snapshot.
@@ -2921,7 +2926,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2921
2926
  which is the whole reason the object stays private. Specs pin that both track a
2922
2927
  text change and a width change.
2923
2928
 
2924
- ## D-text-view-scroll-verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
2929
+ ## D_text_view_scroll_verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
2925
2930
 
2926
2931
  **Status:** Accepted; implemented 2026-08-15. Amended 2026-08-23: the
2927
2932
  `active?` guard this was originally argued *from* turned out to be dead code
@@ -2966,7 +2971,7 @@ it never learns the row count, never clamps, and never touches focus.
2966
2971
  "Page" should page, it would break `Ctrl+U`/`Ctrl+D`'s reason to exist, and it
2967
2972
  fixes nothing anyway: an unfocused view still sees no keys.
2968
2973
  - **Ship the whole ladder as verbs** (full page, top, bottom, by-row). No caller
2969
- yet; `D-text-area-rows`'s temperament applies — a future caller argues its own
2974
+ yet; `D_text_area_rows`'s temperament applies — a future caller argues its own
2970
2975
  case, and these two settle the spelling for the rest.
2971
2976
 
2972
2977
  **Consequences.**
@@ -2999,10 +3004,10 @@ widget now obeys the framework-wide rule (AGENTS.md, book ch5) that a
2999
3004
  key to an unfocused view now scrolls it, which is what every other widget in the
3000
3005
  gem already did (`examples/sampler.rb`'s unfocused `List` is the house idiom).
3001
3006
 
3002
- ## D-notification — One corner toast, N messages, one ticker draining them (2026-08-17)
3007
+ ## D_notification — One corner toast, N messages, one ticker draining them (2026-08-17)
3003
3008
 
3004
3009
  **Status:** Accepted and implemented, `Component::Notification`. Builds on
3005
- `D-attach-hooks` (the synced-from-an-invariant ticker), `D-color-slots` (the
3010
+ `D_attach_hooks` (the synced-from-an-invariant ticker), `D_color_slots` (the
3006
3011
  per-message color), and Tier 1 of the component survey. Book ch7 "Notifications"
3007
3012
  is the user-facing half; the rdoc owns the per-symbol contract. What this entry
3008
3013
  owns is *why each choice*, and the alternatives that looked right first.
@@ -3094,7 +3099,7 @@ makes "at most one" true.
3094
3099
  - Corollary for a future factory: `self.show` calls bare `new`, never
3095
3100
  `Notification.new`, so a subclass's `show` builds the subclass.
3096
3101
  - This widget is what surfaced `Popup.self.open` as a subclass trap (it had to be
3097
- privatized here too, until the factory was deleted outright — `D-popup-open`).
3102
+ privatized here too, until the factory was deleted outright — `D_popup_open`).
3098
3103
 
3099
3104
  ### The singleton lives in the popups stack, never in a class ivar
3100
3105
 
@@ -3212,11 +3217,11 @@ second *kind* of anchoring that would unlock it (per the component survey), but
3212
3217
  `Notification` ships its own `reposition` first so the extraction is judged with
3213
3218
  two real implementations rather than one and a guess.
3214
3219
 
3215
- ## D-popup-open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
3220
+ ## D_popup_open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
3216
3221
 
3217
3222
  **Status:** Accepted and implemented; `Component::Popup.open` **removed**, and
3218
3223
  `Popup#open` now returns `self`. Surfaced while building
3219
- {Tuile::Component::Notification} (`D-notification`), which had to privatize the
3224
+ {Tuile::Component::Notification} (`D_notification`), which had to privatize the
3220
3225
  inherited factory to stop it undermining a private constructor.
3221
3226
 
3222
3227
  **Context.** `Popup.open(content:, modal:, size:)` was one-line sugar for
@@ -3257,7 +3262,7 @@ own arguments, and wraps the popup rather than *being* one — none of them is a
3257
3262
  inherited factory, so the trap does not apply. `popup_spec` asserts that neither
3258
3263
  `Popup` nor `ListDropdown` responds to `open` at the class level.
3259
3264
 
3260
- ## D-bracketed-paste — A paste is its own event, not a burst of keys (2026-08-23)
3265
+ ## D_bracketed_paste — A paste is its own event, not a burst of keys (2026-08-23)
3261
3266
 
3262
3267
  **Status:** Accepted and implemented in `Keys` (`BRACKETED_PASTE_ON`,
3263
3268
  `PASTE_START`, `read_paste`, `normalize_paste`), `EventQueue::PasteEvent`,
@@ -3277,7 +3282,7 @@ gone before the second arrived.
3277
3282
 
3278
3283
  Nothing downstream can repair that. By the time `handle_key("\r")` runs, "the
3279
3284
  user pressed Enter" and "the clipboard held a line break" are the same event.
3280
- The only downstream lever is inter-keystroke timing, which `D-select` already
3285
+ The only downstream lever is inter-keystroke timing, which `D_select` already
3281
3286
  rejected for type-ahead on exactly this ground: a terminal degrades that signal
3282
3287
  (bytes in one read burst merge into a single key) and a paste has no gaps at all.
3283
3288
  The information exists only at the layer that talks to the terminal, which is
@@ -3308,7 +3313,7 @@ mechanism.
3308
3313
  thread posts one event carrying the whole payload; `Screen#event_loop` routes it
3309
3314
  to `handle_paste` down the focus chain, with the same modal scoping as a key and
3310
3315
  no other rung. *Rejected: reusing `KeyEvent` with a flag*, which would put a
3311
- `pasted?` predicate on the ladder and re-create the runtime gate `D-key-dispatch`
3316
+ `pasted?` predicate on the ladder and re-create the runtime gate `D_key_dispatch`
3312
3317
  deleted — every `handle_key` would have to remember to check it, and the ones
3313
3318
  that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
3314
3319
  as individual keys.* It reads like graceful degradation and is the ambiguity
@@ -3356,7 +3361,7 @@ as `\n` and a typed one as `\r`, which is backwards and read as though multi-lin
3356
3361
  paste already worked. Accepting {Keys::CTRL_J} is still right, but its
3357
3362
  justification is now the honest one: that is the byte a *typed* Ctrl+J sends.
3358
3363
 
3359
- ## D-repaint-cascade — the repaint cascade skips the clear, never the invalidate (2026-08-23)
3364
+ ## D_repaint_cascade — the repaint cascade skips the clear, never the invalidate (2026-08-23)
3360
3365
 
3361
3366
  **Status:** Accepted and implemented in {Tuile::Component#repaint}. Found while
3362
3367
  building {Tuile::Component::TabSheet}, but the bug predates it and was already
@@ -3396,7 +3401,7 @@ the invalidation set and the buffer were both self-consistent.
3396
3401
  A container that paints nothing of its own can only redraw its area *through* its
3397
3402
  children, so being invalidated has to mean invalidating them. The tiling test
3398
3403
  keeps doing the one job it is good for — deciding whether there is a gap worth
3399
- blanking, which is what `D-progress-bar`'s "never blank a cell you are about to
3404
+ blanking, which is what `D_progress_bar`'s "never blank a cell you are about to
3400
3405
  paint over" cares about.
3401
3406
 
3402
3407
  **Why the extra invalidation is not a cost.** It is a repaint of a subtree that
@@ -3424,15 +3429,15 @@ moves, and only on the frames where an ancestor cleared.
3424
3429
  the tree below it is none of its business. Each container forwarding one hop is
3425
3430
  the local rule that composes.
3426
3431
 
3427
- ## D-tabs — `Tabs` / `TabSheet`: a strip, and a strip that swaps panes (2026-08-23)
3432
+ ## D_tabs — `Tabs` / `TabSheet`: a strip, and a strip that swaps panes (2026-08-23)
3428
3433
 
3429
3434
  **Status:** Accepted; `Component::Tabs` (with `Tabs::Tab`) and
3430
3435
  `Component::TabSheet` implemented 2026-08-23, demoed in the sampler, taught in
3431
3436
  book ch7 ("Switching between views"). Brainstormed in `ideas/tabs.md`, now
3432
- retired. Leans on `D-has-value` (the seam it declines), `D-progress-bar` (the
3433
- precedent for a selection kept *out* of that seam), `D-list-items` (items vs.
3434
- identities), `D-select` (claim the minimum), `D-ambiguous-width` (the separator
3435
- glyph), `D-tree-api` (the slot-swap recipe) and `D-attach-hooks` (what
3437
+ retired. Leans on `D_has_value` (the seam it declines), `D_progress_bar` (the
3438
+ precedent for a selection kept *out* of that seam), `D_list_items` (items vs.
3439
+ identities), `D_select` (claim the minimum), `D_ambiguous_width` (the separator
3440
+ glyph), `D_tree_api` (the slot-swap recipe) and `D_attach_hooks` (what
3436
3441
  detachment fires).
3437
3442
 
3438
3443
  **Context.** Several views, one visible at a time, and a one-row strip of
@@ -3446,7 +3451,7 @@ goes with it.
3446
3451
  test that decides it, and it generalizes: **would a form save it?** A
3447
3452
  `RadioGroup`'s selection *is* the datum being edited, so it is a value; a tab's
3448
3453
  selection is where the user is looking — nothing saves it, nothing validates it,
3449
- and a forms layer iterating fields must never find it. `D-progress-bar` made the
3454
+ and a forms layer iterating fields must never find it. `D_progress_bar` made the
3450
3455
  same call one step further out (a `value` that is a read-only report), and
3451
3456
  `List` has held a cursor and an `on_item_chosen` without being a field since it
3452
3457
  existed. External corroboration: **Vaadin's `Tabs` is not a field either** — it
@@ -3497,7 +3502,7 @@ smuggled in under one component. AGENTS.md carries the one-line invariant.
3497
3502
 
3498
3503
  **Decision — one tab stop for the whole strip, and arrows activate
3499
3504
  immediately.** Three arguments against a component per tab, in order of force:
3500
- "exactly one stop per widget" (`D-has-value`), and n tabs would mean n Tab
3505
+ "exactly one stop per widget" (`D_has_value`), and n tabs would mean n Tab
3501
3506
  presses before the content is reachable; making the *Tab key* walk between
3502
3507
  *tabs* is the one thing the key ladder forbids by construction (Tab is claimed
3503
3508
  above everything and means "leave this widget"), so it would read as a feature
@@ -3514,7 +3519,7 @@ highlighted.** Manual activation needs two states on one row — the selection a
3514
3519
  the roamed-to tab — and therefore two visual channels to separate them, on a
3515
3520
  strip that spends both on the selection alone (below). `RadioGroup` could afford
3516
3521
  that split vertically because each row has a glyph column of its own
3517
- (`D-radio-group`); a one-row strip cannot, and two highlights side by side read
3522
+ (`D_radio_group`); a one-row strip cannot, and two highlights side by side read
3518
3523
  as noise rather than as two kinds of state. Auto-activation deletes the
3519
3524
  distinction instead of styling it, and every code path — paint, hit test,
3520
3525
  callback — has one index to consult. Consequence, and it runs the opposite way
@@ -3524,7 +3529,7 @@ that owes its own answer (a cheap placeholder, or building on a settle delay),
3524
3529
  not a return to Enter-to-activate.
3525
3530
 
3526
3531
  **Decision — the strip claims LEFT / RIGHT and the mouse, and nothing else.**
3527
- `D-select`'s contract restated: Enter and Space have nothing to do once arrows
3532
+ `D_select`'s contract restated: Enter and Space have nothing to do once arrows
3528
3533
  activate, and declining them keeps a form's default button and the app's keys
3529
3534
  alive. UP / DOWN are declined so a future arrow-navigating layout can move focus
3530
3535
  *out* of the strip on the axis the strip doesn't use. HOME / END are declined
@@ -3557,7 +3562,7 @@ in the gem had used bold and a caption is a `StyledString` that may carry its ow
3557
3562
  colors.
3558
3563
 
3559
3564
  **Decision — the separator is `│`, the glyph `Window` paints its borders with,
3560
- not ASCII `|`.** This inverts `D-ambiguous-width`'s "a new component defaults to
3565
+ not ASCII `|`.** This inverts `D_ambiguous_width`'s "a new component defaults to
3561
3566
  ASCII when the pretty glyph is Ambiguous", and the inversion is the point: that
3562
3567
  rule exists to keep the Ambiguous inventory small and enumerable, and `│` is
3563
3568
  already *in* the inventory — `window.rb` paints it on every window, and nothing
@@ -3573,7 +3578,7 @@ separator column. Every segment has the same shape including the first and last
3573
3578
  covers the padding, because one that stopped at the glyphs would read as a
3574
3579
  ragged smear; and a click on a padding column selects that tab, while the
3575
3580
  separator column selects nothing — same rule as the blank tail past `extent`,
3576
- which focuses without selecting (`D-boolean-fields`). One private `segments`
3581
+ which focuses without selecting (`D_boolean_fields`). One private `segments`
3577
3582
  method is the sole source of that arithmetic, read by *both* the paint and the
3578
3583
  hit test, and derived from the captions on each call rather than recorded during
3579
3584
  the last paint — so a hit test is correct before the first paint and after a
@@ -3598,12 +3603,12 @@ Rejected — *segment-aligned scrolling* (the offset always a segment start): it
3598
3603
  buys clean edges and needs no glyph snapping, but wastes up to a segment of width
3599
3604
  at the right edge, and a strip this narrow is exactly where columns are scarce.
3600
3605
  Rejected — *reserved cue columns*: reserving two columns makes the window width a
3601
- function of the scroll state that is computed from it, which is `D-select`'s
3606
+ function of the scroll state that is computed from it, which is `D_select`'s
3602
3607
  `:auto`-scrollbar circularity, and shifts the whole strip sideways when a caption
3603
3608
  is edited. The cues are **overlaid** on the edge columns instead, keeping the
3604
3609
  style of the cell they cover so one landing on the selected segment doesn't punch
3605
3610
  a hole in its highlight, and they are ASCII `<` / `>` — `‹ ›` are Ambiguous-width
3606
- (`D-ambiguous-width`), and a `cue_glyphs=` knob with no caller is a knob to argue
3611
+ (`D_ambiguous_width`), and a `cue_glyphs=` knob with no caller is a knob to argue
3607
3612
  about later. They stay chrome, not buttons: a click on a cue falls through to the
3608
3613
  half-visible segment under it, which selects it and reveals it — the direction the
3609
3614
  cue pointed anyway — where a clickable cue would need the column to hit-test
@@ -3629,12 +3634,12 @@ returns a `Tabs::Tab`, Vaadin-style. The test that separates the two is sharper
3629
3634
  than "items feel wrong": **an item is an element of a collection someone else
3630
3635
  owns** — assignment is whole-collection, and an item carries no per-element
3631
3636
  state, the renderer deriving everything from the object each paint (which is why
3632
- `D-list-items` *removed* the appenders). **A tab is identity plus per-element
3637
+ `D_list_items` *removed* the appenders). **A tab is identity plus per-element
3633
3638
  mutable state**, minted by the widget and living as long as it, and re-assigning
3634
3639
  the whole set — the operation an items API is built around — is precisely what a
3635
3640
  strip must never offer: it would destroy tab identity and with it `TabSheet`'s
3636
3641
  pane mapping. Two corollaries make the ruling durable: the unbuilt half of
3637
- `D-list-items` is a *data provider* behind `items`, and a provider cannot own
3642
+ `D_list_items` is a *data provider* behind `items`, and a provider cannot own
3638
3643
  per-tab state, so `HasItems` would arrive carrying a promise Tabs must refuse
3639
3644
  (paging tabs is meaningless — a million tabs is not a UI); and the growth path
3640
3645
  here is per-element *attributes* (hidden, disabled, closeable), which items have
@@ -3694,7 +3699,7 @@ which is misleading (the pane is *derived* from the selection, not assignable);
3694
3699
  see a click; and `HasContent#on_focus` forwards focus into the content, which is
3695
3700
  the behavior this design rejects (switching a tab must not move focus into the
3696
3701
  new pane — browser and Vaadin behavior). What *is* reused is the slot-swap
3697
- recipe `D-tree-api` specifies for `Window`: detach without notifying, rewire,
3702
+ recipe `D_tree_api` specifies for `Window`: detach without notifying, rewire,
3698
3703
  then `on_child_removed` last, so the focus repair cascades into the *new*
3699
3704
  occupant. `TabSheet` overrides that hook to land focus on **the strip** rather
3700
3705
  than on itself, which is not focusable; the other candidate (the new pane's
@@ -3778,7 +3783,7 @@ across caption edits and reorderings of nothing else. And a starved strip stays
3778
3783
  wholly reachable, at the cost of a scroll offset that every future paint-time or
3779
3784
  hit-test change has to keep threading through one place.
3780
3785
 
3781
- ## D-menu-bar — `MenuBar`: a focused strip driving a cascade of `ListDropdown`s (2026-08-24)
3786
+ ## D_menu_bar — `MenuBar`: a focused strip driving a cascade of `ListDropdown`s (2026-08-24)
3782
3787
 
3783
3788
  **Status:** Accepted; v1 (`Component::MenuBar` with `MenuBar::Item` and the
3784
3789
  private `MenuBar::Cascade`) implemented 2026-08-24, demoed in the sampler, taught
@@ -3787,7 +3792,7 @@ in book ch7 ("Menus"); v2 (mnemonics) the same day. Designed in a since-retired
3787
3792
  Terminal.Gui, notcurses, MC, and the frameworks that have no menu) this entry
3788
3793
  only summarizes.
3789
3794
 
3790
- **Update 2026-08-24: a narrow bar scrolls**, on `D-tabs`' scrolling decision,
3795
+ **Update 2026-08-24: a narrow bar scrolls**, on `D_tabs`' scrolling decision,
3791
3796
  which both strips implement identically (one private `left_column`, one
3792
3797
  `adjust_left_column` as its sole writer, ASCII cues overlaid on the edge
3793
3798
  columns). `MenuBar`'s share of it: one private `highlight=` funnels the arrow,
@@ -3802,7 +3807,7 @@ placement*, not a second kind of overlay.
3802
3807
  the open menus are non-modal `ListDropdown`s mounted on the `ScreenPane` — owned
3803
3808
  by the bar, parented by nobody — so every key arrives at `MenuBar#handle_key`,
3804
3809
  which offers it to a `Cascade` first. That is `Select`'s architecture
3805
- (`D-select`) extended to N levels, which is why **nothing in the key-dispatch
3810
+ (`D_select`) extended to N levels, which is why **nothing in the key-dispatch
3806
3811
  ladder changes** and why the whole widget is additive: two new placement helpers
3807
3812
  on `ListDropdown`, one callback pass-through, and no change to `Popup`,
3808
3813
  `ScreenPane` or `Component`.
@@ -3838,7 +3843,7 @@ error.
3838
3843
  trigger is the first non-`List` content wanting anchoring (Tooltip, a
3839
3844
  date-picker grid). It originally had a second half — a third placement method
3840
3845
  on `ListDropdown`, from `ContextMenu`'s `anchor_at(point)` — which went dormant
3841
- when that widget was iced (`D-no-context-menu`).
3846
+ when that widget was iced (`D_no_context_menu`).
3842
3847
  - **A command-code bus** (Turbo Vision's `cmOpen` + `handleEvent`) instead of
3843
3848
  per-item callables. Rejected: Ruby has closures, and Vaadin, Terminal.Gui and
3844
3849
  ratatui's `tui-menu` all landed on per-item listeners.
@@ -3897,12 +3902,12 @@ extraction.
3897
3902
  - **So does detaching**, from `on_detached`: the panels are the pane's children,
3898
3903
  not the bar's, so nothing else would take them down.
3899
3904
  - **An open menu swallows keys; a closed strip does not.** The one deliberate
3900
- divergence from `D-select`'s claim-the-minimum rule, and the honest reading of
3905
+ divergence from `D_select`'s claim-the-minimum rule, and the honest reading of
3901
3906
  what a menu is — an app key firing behind a visible panel is worse than a dead
3902
3907
  keystroke.
3903
3908
  - **A click outside an open cascade is not blocked**, because non-modal overlays
3904
3909
  block nothing — but it does *dismiss*. The framework-level fix this entry
3905
- called for (and declined to invent here) shipped as `D-outside-click`: the
3910
+ called for (and declined to invent here) shipped as `D_outside_click`: the
3906
3911
  pane closes every popup a left click missed, and `Cascade` reconciles its level
3907
3912
  stack from each panel's `Popup#on_close`. The click itself still reaches
3908
3913
  whatever is beneath.
@@ -3912,13 +3917,13 @@ extraction.
3912
3917
  `open_below` / `handle_key` / `close` / `open?` it is a boundary; if it grows
3913
3918
  accessors that expose the level stack, the "class" was only ever a seam and it
3914
3919
  folds back in.
3915
- - **Widths are measured per level, caller-side**, third repeat of the `D-select`
3920
+ - **Widths are measured per level, caller-side**, third repeat of the `D_select`
3916
3921
  pattern (`anchor_to` and `anchor_beside` measure nothing). The submenu arrows
3917
3922
  right-align against the level's *widest label*, a number the cascade already
3918
3923
  has, so they line up without asking the `List` how wide it ended up.
3919
3924
  - **The `▸` is Neutral, not Ambiguous** — verified, like `Select`'s `▾`. The
3920
3925
  obvious `▶` / `▼` are Ambiguous and would have needed an ASCII opt-in under
3921
- `D-ambiguous-width`.
3926
+ `D_ambiguous_width`.
3922
3927
 
3923
3928
  **Mnemonics (v2), and why they are legal.** `add_item(caption, mnemonic: "f")`
3924
3929
  at *every* depth. AGENTS.md deleted `Component#key_shortcut` and the capture
@@ -3983,17 +3988,17 @@ reordering, dynamically computed items, open-on-hover
3983
3988
  collapse-into-an-overflow-menu.
3984
3989
 
3985
3990
  **Update 2026-08-24: `ContextMenu` is iced indefinitely** — designed, priced and
3986
- declined the same day, in `D-no-context-menu`. It would have reused `Cascade` and
3991
+ declined the same day, in `D_no_context_menu`. It would have reused `Cascade` and
3987
3992
  `Item` verbatim, which is why the two consequences above are worded the way they
3988
3993
  are: the nested `Item` name is *settled* rather than deferred, and the `Popover`
3989
3994
  extraction trigger keeps only its "first non-`List` content" half.
3990
3995
 
3991
- ## D-outside-click — An outside click dismisses a popup, by flag not by notice (2026-08-24)
3996
+ ## D_outside_click — An outside click dismisses a popup, by flag not by notice (2026-08-24)
3992
3997
 
3993
3998
  **Status:** Decided and implemented 2026-08-24. Designed in a since-retired
3994
3999
  `ideas/outside-click-dismiss.md`, itself split out of the declined `ContextMenu`
3995
- (`D-no-context-menu`), so this entry is the whole record. Supersedes the wart
3996
- `D-menu-bar` recorded without fixing.
4000
+ (`D_no_context_menu`), so this entry is the whole record. Supersedes the wart
4001
+ `D_menu_bar` recorded without fixing.
3997
4002
 
3998
4003
  **Context.** Whether an open overlay closed when you clicked elsewhere depended
3999
4004
  on what you happened to click *on*. A click on a focusable widget moved focus,
@@ -4113,13 +4118,13 @@ back would leave `depth` / `deepest` / `highlighted` all lying. It wires an
4113
4118
  identity-keyed, idempotent delete — idempotent because the same notice also
4114
4119
  arrives from its own `truncate` (which has already popped the entry) and from
4115
4120
  teardown, in no guaranteed order. That is the shape the house rules ask for:
4116
- `@levels` is a `D-tree-api`-style second copy of a list slot, and hook-owned
4117
- state is *synced from an invariant*, not toggled by the hooks (`D-progress-bar`'s
4121
+ `@levels` is a `D_tree_api`-style second copy of a list slot, and hook-owned
4122
+ state is *synced from an invariant*, not toggled by the hooks (`D_progress_bar`'s
4118
4123
  `sync_ticker`). Per-level truncate closures wired at `push` are the toggle
4119
4124
  version.
4120
4125
 
4121
4126
  **Left button only.** `MouseEvent` is X10 press-only (no release, no motion), so
4122
- there is no drag case. Excluding scroll is `D-notification`'s stray-spin lesson;
4127
+ there is no drag case. Excluding scroll is `D_notification`'s stray-spin lesson;
4123
4128
  excluding `:right` keeps a future context action from nuking an open dropdown.
4124
4129
 
4125
4130
  **Vaadin, verified against the 24 docs.** "Modal dialogs are closable in three
@@ -4161,7 +4166,7 @@ or a cleared flag.
4161
4166
  about it; app modals keep `true` and opt out per dialog. The one accepted risk is
4162
4167
  a stray click discarding a half-filled form dialog.
4163
4168
 
4164
- ## D-no-context-menu — No `ContextMenu`: designed, priced and declined (2026-08-24)
4169
+ ## D_no_context_menu — No `ContextMenu`: designed, priced and declined (2026-08-24)
4165
4170
 
4166
4171
  **Status:** Decided 2026-08-24 — **not building it**, indefinitely. Designed in a
4167
4172
  since-retired `ideas/context-menu.md` (opened and graduated the same day), so
@@ -4252,10 +4257,10 @@ to stay available as a mnemonic — and for `keyboard_hint`.
4252
4257
  component along the rect chain, ancestor first and deepest last, so "which
4253
4258
  target owns this click" has no answer. (What that ordering *would* give free is
4254
4259
  deepest-wins, if a revival adds "opening one closes any other open context
4255
- menu" — the `D-notification` shape, found by scanning the popups stack rather
4260
+ menu" — the `D_notification` shape, found by scanning the popups stack rather
4256
4261
  than a class ivar.)
4257
4262
  - **Type-ahead search inside an open menu**, which `List#select_next` makes nearly
4258
- free. Same rejection as in `D-menu-bar`: it competes with explicit mnemonics for
4263
+ free. Same rejection as in `D_menu_bar`: it competes with explicit mnemonics for
4259
4264
  the same keystroke and owes a precedence rule.
4260
4265
 
4261
4266
  **Two gaps it surfaced that outlive it.**
@@ -4263,17 +4268,17 @@ to stay available as a mnemonic — and for `keyboard_hint`.
4263
4268
  - **An outside click on an open overlay notified nobody.** `Select`, `MenuBar`
4264
4269
  and the sampler's slash menu all lingered on a click that landed on decoration,
4265
4270
  and a modal popup could not dismiss on an outside click at all. **Closed**
4266
- 2026-08-24 by `D-outside-click`, which also dissolved the modal/non-modal split
4271
+ 2026-08-24 by `D_outside_click`, which also dissolved the modal/non-modal split
4267
4272
  the gap was framed around.
4268
4273
  - **A right-click does not move a `List` cursor.** `List::Cursor#handle_mouse`
4269
4274
  acts on `:left` only (specced), and there is no public `item_index_at(point)`,
4270
4275
  so "act on the row I clicked" is unsayable unless the app computes
4271
4276
  `event.y - rect.top + scroll_top_row` itself. Nothing needs it today; it is the
4272
- same shape of hole as the `List#select(index)` gap `D-menu-bar` had to fill.
4277
+ same shape of hole as the `List#select(index)` gap `D_menu_bar` had to fill.
4273
4278
 
4274
4279
  ---
4275
4280
 
4276
- ## D-status-bar — Delete the framework status bar; the app owns its bottom row (2026-08-25)
4281
+ ## D_status_bar — Delete the framework status bar; the app owns its bottom row (2026-08-25)
4277
4282
 
4278
4283
  **Status:** Accepted 2026-08-25; unimplemented. Supersedes the shipped
4279
4284
  `ScreenPane#status_bar` slot and the `Component#keyboard_hint` channel that fed
@@ -4385,7 +4390,7 @@ screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
4385
4390
  claimed the bar was "driven by focus" and showed "the focused context's own
4386
4391
  advertised hint" — behavior that never existed; focus only triggered the
4387
4392
  rebuild. Deleting it removes a documented promise the code never kept.
4388
- - **`D-boolean-fields`' aside is retired**, not overruled: "hints are a
4393
+ - **`D_boolean_fields`' aside is retired**, not overruled: "hints are a
4389
4394
  window/popup-level affordance; per-field hints would drown the status bar" was
4390
4395
  an argument about where a hint belongs, and there is no longer a framework
4391
4396
  hint to place.
@@ -4405,7 +4410,7 @@ screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
4405
4410
  same baked app policy as the `"q quit"` string, but it is *dispatch*, not
4406
4411
  presentation, and it is separable — deleting it would make every example and
4407
4412
  both downstream apps grow a quit handler in the same breath as an unrelated
4408
- change. **Ruled 2026-08-25 by `D-quit-key`: it stays, unadvertised**, on the
4413
+ change. **Ruled 2026-08-25 by `D_quit_key`: it stays, unadvertised**, on the
4409
4414
  same convention argument as the popup's lost `q Close` above.
4410
4415
 
4411
4416
  **There is no app-facing `keyboard_hint` convention, and the book must not
@@ -4428,7 +4433,7 @@ framework-owned row. Textual is the shape to copy if it does: its `Footer` is a
4428
4433
  widget the app mounts in `compose()`, reading from the `BINDINGS` table the
4429
4434
  framework owns ⚠. That splits ownership at the right seam — the app decides
4430
4435
  whether a bar exists and where, the widget declares its keys — and it is already
4431
- on record as steal-candidate #1 in `D-key-dispatch`. Bringing back a bar the
4436
+ on record as steal-candidate #1 in `D_key_dispatch`. Bringing back a bar the
4432
4437
  framework *places* reopens this entry.
4433
4438
 
4434
4439
  **Prior art** (surveyed 2026-08-25; ⚠ marks memory-based claims worth checking
@@ -4460,17 +4465,17 @@ this one — and no app ever invoked it.
4460
4465
 
4461
4466
  ---
4462
4467
 
4463
- ## D-quit-key — `q` / ESC quit the loop, unadvertised, as a Tuile quirk (2026-08-25)
4468
+ ## D_quit_key — `q` / ESC quit the loop, unadvertised, as a Tuile quirk (2026-08-25)
4464
4469
 
4465
4470
  **Status:** Accepted 2026-08-25; no code change — this records a decision to
4466
- *keep* what ships. Closes the question `D-status-bar` deferred.
4471
+ *keep* what ships. Closes the question `D_status_bar` deferred.
4467
4472
 
4468
4473
  **Context.** `Screen#event_loop` ends with
4469
4474
  `@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)` — after the
4470
4475
  three-rung ladder has declined a key, bare `q` or ESC stops the loop and the
4471
4476
  app exits. It is app policy the framework enforces, and no app opted into it.
4472
4477
 
4473
- `D-status-bar` deleted the framework status bar and with it the hardcoded
4478
+ `D_status_bar` deleted the framework status bar and with it the hardcoded
4474
4479
  `"q quit"` prefix that was this fallback's only advertisement, deliberately
4475
4480
  leaving the behavior alone as a separate question. That left the least coherent
4476
4481
  state of the three: a hardcoded quit key with nothing anywhere surfacing it.
@@ -4481,10 +4486,10 @@ open question.
4481
4486
  - **It is a convention, not an invention.** `q` quits `less`, `man`, `top`,
4482
4487
  `htop` and every pager git shells out to; ESC dismisses. A user arriving at a
4483
4488
  full-screen terminal app already tries both. That is the same argument that
4484
- settled the popup's lost `q Close` hint in `D-status-bar` — a convention the
4489
+ settled the popup's lost `q Close` hint in `D_status_bar` — a convention the
4485
4490
  user brings is not something each app must teach.
4486
4491
  - **The escape hatch already exists and needs no new surface.** A component
4487
- keeps `q` by consuming it, which is the whole of `D-key-dispatch`'s
4492
+ keeps `q` by consuming it, which is the whole of `D_key_dispatch`'s
4488
4493
  delivery rung: a focused {Component::TextField} does it for free (`q` is
4489
4494
  printable — this is why pikuri-tui's shells never quit on a typed `q`), and an
4490
4495
  app wanting `q` as a command binds it in the scope root's `handle_key`. ESC
@@ -4520,3 +4525,898 @@ open question.
4520
4525
  - **What would reopen it:** a real app that needs bare `q` at the scope root and
4521
4526
  finds consuming it awkward, or a second key wanting the same treatment (which
4522
4527
  would make this a *list*, and a list wants a knob).
4528
+
4529
+ ---
4530
+
4531
+ ## D_hook_visibility — A framework-invoked hook is protected, reached with `__send__` (2026-08-30)
4532
+
4533
+ **Status:** Accepted and implemented for {Component#on_theme_changed}
4534
+ (protected; `Screen#theme=` fans it out through `__send__`). `Component#on_focus`
4535
+ is the one framework-invoked hook still public — see *Consequences*.
4536
+
4537
+ **Context — the field report.** virtui crashed on an OS appearance flip:
4538
+
4539
+ ```
4540
+ NoMethodError: protected method `on_theme_changed' called for an instance of UI::VMWindow
4541
+ ```
4542
+
4543
+ Three of its `Window` subclasses group their overrides together —
4544
+ `on_width_changed`, `on_theme_changed`, `repaint_border` — under one `protected`
4545
+ keyword. Two of those three are protected in Tuile; the third was **public**,
4546
+ because `Screen#theme=` fanned it out as `@pane&.on_tree(&:on_theme_changed)`, an
4547
+ explicit-receiver send. Ruby lets a subclass *narrow* an inherited method, so the
4548
+ natural grouping silently broke the walk.
4549
+
4550
+ The failure is worse than one exception. `theme=` assigns `@theme` *before* the
4551
+ walk, so the theme really does swap; the walk then dies at the first offender in
4552
+ pre-order, every component after it never hears the hook, and the closing
4553
+ `needs_full_repaint` never runs — the new theme is live under content painted
4554
+ for the old one, until something unrelated invalidates. And nothing catches it in
4555
+ a test suite that never flips the theme.
4556
+
4557
+ **Decision.** A hook the *framework* calls on a component is plumbing an app
4558
+ overrides and never invokes, so it is `protected`, and the framework reaches it
4559
+ with `__send__`:
4560
+
4561
+ ```ruby
4562
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
4563
+ ```
4564
+
4565
+ `__send__` is the point, not a workaround for the visibility change: it ignores
4566
+ visibility, so an override may be public, protected or private and the walk can
4567
+ no longer be broken from an app at all. The public half of the seam is the
4568
+ *listener* (`on_theme_changed=`), which is what an app assembling stock
4569
+ components actually calls.
4570
+
4571
+ **Why the visibility can't just be finessed by *who* does the walking.** Ruby
4572
+ checks a protected call against the class the method is *defined in*, relative to
4573
+ the caller's `self` — so an override defined in a subclass is unreachable by
4574
+ explicit receiver from anywhere else, including a sibling component and even the
4575
+ base class:
4576
+
4577
+ ```ruby
4578
+ class Base; def fan(o) = o.hook; protected; def hook = "base"; end
4579
+ class Sub < Base; protected; def hook = "sub"; end
4580
+ class Other < Base; end
4581
+ Other.new.fan(Sub.new) # NoMethodError
4582
+ Base.new.fan(Sub.new) # NoMethodError — same reason
4583
+ ```
4584
+
4585
+ That leaves exactly two workable shapes: `__send__`, or an *implicit* receiver.
4586
+ {Component#fire_lifecycle} is the implicit-receiver one — it recurses through a
4587
+ `Component`-defined method and calls `on_attached` / `on_detached` on `self`,
4588
+ which is why those two have been quietly protected all along and why this class
4589
+ of bug never reached them.
4590
+
4591
+ **Alternatives rejected.**
4592
+
4593
+ - *Keep the hook public and document "don't narrow it".* The documentation
4594
+ nobody reads, guarding a trap the natural code layout walks straight into —
4595
+ virtui's three sites are the proof, and grouping hooks under one `protected` is
4596
+ good Ruby, not a mistake to correct. A rule that fires an exception in
4597
+ production, at OS-flip time, in a path no suite exercises, is not a rule; it is
4598
+ a landmine.
4599
+ - *A public `Component#fire_theme_changed` walker* — the `fire_lifecycle` shape,
4600
+ hoisted to public so `Screen` can start it, calling the protected hook on
4601
+ `self` at each node. It genuinely works and needs no `__send__`. Rejected on
4602
+ surface: it puts a second public method on every component (in the rdoc, in
4603
+ `sig/tuile.rbs`, callable by apps) and duplicates {Component#on_tree}, to avoid
4604
+ one `__send__` at one call site. It also only relocates the hazard — the
4605
+ *walker* becomes the method that must not be narrowed.
4606
+ - *Rescue `NoMethodError` around the walk.* Swallows real bugs inside app hooks
4607
+ and leaves the restyle half-applied, which is the symptom being fixed.
4608
+ - *Make the call tolerant but leave the hook public.* Fixes today's crash and
4609
+ keeps the trap armed for the next contributor who writes `&:some_hook`; the
4610
+ hook's visibility is what states the intent.
4611
+
4612
+ **Consequences.**
4613
+
4614
+ - **The `attr_writer` stays public**, and the reader is now protected — an
4615
+ asymmetric accessor pair, deliberately: assigning a listener is app-facing,
4616
+ firing it is not.
4617
+ - **A new framework-invoked hook copies this shape**: protected, `__send__` at
4618
+ the fan-out, listener writer public if it has one. Never `&:hook`.
4619
+ - **`on_focus` stays public, deliberately** — it is not plumbing in the same
4620
+ sense. {Component::HasContent} / {Component::Layout} / {Component::TabSheet}
4621
+ each override it to forward focus into their content, so it reads as part of
4622
+ the composition seam a mixin publishes rather than as a private notification.
4623
+ It carries the same theoretical hazard (an app narrowing its override would
4624
+ break `Screen#focused=`), and that is accepted: nothing has hit it, and
4625
+ protecting a method three mixins present as interface would cost more clarity
4626
+ than it buys.
4627
+ - **Specs call the hook with `send`**, and two guards exist: `component_spec`
4628
+ asserts the visibility pair, `screen_spec` asserts that a subclass declaring a
4629
+ `protected` override is still fired *and* that the walk continues past it.
4630
+
4631
+ ## D_overlay — Extract `Overlay`; `Popup` becomes unconditionally modal (2026-08-30)
4632
+
4633
+ **Decision.** Split {Component::Popup} in two. `Overlay < Component` is the bare
4634
+ floating layer — the mount/dismiss lifecycle, `owner`, `on_close`,
4635
+ `close_on_outside_click`, a no-op `reposition`, and the full-repaint escalation
4636
+ in `rect=` — and `Popup < Overlay` adds the modal dialog on top: a declared
4637
+ `size`, self-centering, `focusable?`, and ESC/`q`. `Popup.new(modal: false)` is
4638
+ gone; the `@modal` ivar with it, since `modal?` is now a constant on each class.
4639
+ `Notification` and `ListDropdown` both reparent onto `Overlay`.
4640
+
4641
+ **Why.** The cut line was not invented — it is exactly what `ScreenPane` calls on
4642
+ a member of `@popups` (`rect`, `modal?`, `reposition`, `owner`,
4643
+ `close_on_outside_click?`, `close`, `on_tree`), so `Overlay` makes an interface
4644
+ that already existed implicitly into a class. What forced it was the tally: both
4645
+ non-modal subclasses *rejected* most of `Popup`. `Notification` overrode
4646
+ `focusable?`, `tab_stop?`, `reposition` and `handle_mouse`, and had to **raise**
4647
+ from `size=` to fight off an inherited feature; `ListDropdown` called `self.size
4648
+ =` only to stop `Popup#reposition` stomping its anchored placement. A base whose
4649
+ contract is "remember to switch four inherited behaviours off" fails the `cop`
4650
+ skill's *the base must earn its place — it permits, it doesn't mandate*, and this
4651
+ file's own AGENTS.md section "Non-modal overlays — two traps a new one will hit"
4652
+ was that fragile-base-class tax written down in prose because it could not be
4653
+ written in types.
4654
+
4655
+ **What it bought, beyond tidiness.**
4656
+ - *A latent bug, fixed by construction.* `ListDropdown` declared
4657
+ `focusable?`/`tab_stop?` on its inner `Menu` and never on itself, so it
4658
+ inherited `Popup#focusable? == true`. It survived only because `layout` makes
4659
+ the content cover the whole rect, so `HasContent#handle_mouse` forwarded every
4660
+ in-rect click before `Component#handle_mouse` could assign focus — safe by
4661
+ *geometry*, not by declaration. A border or an inset would have landed focus
4662
+ outside the key scope and killed every keystroke until Tab.
4663
+ - *`Notification`'s raising `size=` is deleted, not renamed.* An `Overlay` has no
4664
+ declared box, so there is nothing to refuse.
4665
+ - *`anchor_to` stops double-assigning `rect`.* The `self.size =` call ran through
4666
+ `Popup#reposition`, which assigned an intermediate rect at the old origin —
4667
+ reintroducing exactly what `reposition`'s own rdoc says it avoids, on
4668
+ `ComboBox`'s per-keystroke re-anchor path.
4669
+
4670
+ **Alternatives rejected.**
4671
+ - *The inverse cut — base stays `Popup`, the modal one becomes `Dialog`.* It
4672
+ would have kept `ScreenPane#popups` accurate at zero renaming cost, but every
4673
+ existing `Popup.new(content: w).open` would **silently** become non-modal,
4674
+ uncentered and un-ESC-able. A loud `ArgumentError` on a removed `modal: false`
4675
+ beats a silent behaviour change.
4676
+ - *Renaming the pane/screen vocabulary to `overlays` / `add_overlay`.* Considered
4677
+ and declined: the break reaches `Screen#add_popup`, which apps call, and
4678
+ "popup stack" remains a defensible name for the stack. The `@param` types and
4679
+ rdoc say `Overlay` instead, which is what a caller actually needs to know.
4680
+ - *Keeping `modal:` as a kwarg on `Popup` alongside `Overlay`.* Two ways to build
4681
+ the same thing, with the trap-laden one still reachable. The whole point is
4682
+ that the inert defaults are the ones you get by default.
4683
+
4684
+ **Consequences.**
4685
+ - `focusable?` and `modal?` are now coupled: flip both or neither. A *focusable
4686
+ non-modal* overlay is the one combination that must never ship — it holds focus
4687
+ outside the key scope, where `bubble_key` reaches nobody. The `Overlay` rdoc
4688
+ states this as the coupling rather than as a ban on overriding, because `Popup`
4689
+ overrides both.
4690
+ - `Overlay#reposition` is a no-op, so a subclass with a *derived* position owns
4691
+ its own override (`Notification`), and one placed by a driver simply keeps the
4692
+ rect it was given (`ListDropdown`, re-anchored from its driver's `rect=`).
4693
+ - **Still open:** where `anchor_to` / `anchor_beside` belong. They stay on
4694
+ `ListDropdown` for now. The `Popover` extraction (`D_select`, `D_menu_bar`) is
4695
+ *cheaper* after this change, since `Overlay` — not the modality-carrying
4696
+ `Popup` — is the right parent for a generically anchored layer; the trigger is
4697
+ unchanged, the first non-`List` content wanting anchoring.
4698
+
4699
+ ## D_declared_size — `Popup#size` is `#declared_size` (2026-08-30)
4700
+
4701
+ **Decision.** Rename `Popup#size` / `#size=` — and the `Popup.new` and
4702
+ `InfoWindow.open` keyword — to `declared_size`. Nothing else changes: it is still
4703
+ a `Size | Fraction`, still re-resolved against the screen on every layout pass,
4704
+ still authoritative.
4705
+
4706
+ **Why.** `size` on a component is reasonably expected to mean `rect.size`, and
4707
+ the name was already spoken for by a different concept: an *input* the popup is
4708
+ re-read from on every layout pass, not a *report* of current geometry. Leaving it
4709
+ would have created tension the first time anyone added `Component#size`, and the
4710
+ types disagree too — `rect.size` is a `Size`, this is a `Size | Fraction`, so a
4711
+ `Component#size` reader would have been a Liskov break on the one class most
4712
+ likely to be handled polymorphically. `declared_size` is the word `popup.rb`'s
4713
+ own rdoc already used ("its box is *declared* by"), and it contrasts correctly
4714
+ with `preferred` / `requested`, which the same rdoc explicitly disclaims: the
4715
+ screen applies exactly what you ask for, with no negotiation.
4716
+
4717
+ **Alternatives rejected.**
4718
+ - *`auto_size`* (the first proposal). Two problems. "Auto-size" conventionally
4719
+ means shrink-to-fit-content — Swing's `pack()`, WPF's `SizeToContent` — which
4720
+ is precisely the eager bottom-up `content_size` channel deleted in 0.9.0, and
4721
+ which AGENTS.md already spends a rule keeping out of the vocabulary
4722
+ (`D_box_layouts`: "there is no `Auto`"). And it read as a contradiction on the
4723
+ one subclass that genuinely does size itself: `Notification#auto_size=` raising
4724
+ "sizes itself from its messages". (That override is gone under `D_overlay`, but
4725
+ the naming argument stands for the next such subclass.)
4726
+ - *`auto_center_with_size(x)`*, a command rather than a property. It has real
4727
+ merit — Tuile already has imperative geometry methods (`center`, `reposition`,
4728
+ `anchor_to`) and a command is honest about the side effect that a bare setter
4729
+ hides. Rejected on three counts: it collides with the existing `Popup#center`,
4730
+ giving two near-synonymous centering verbs; after `D_overlay` made `Popup`
4731
+ unconditionally modal, "auto center" names the class *invariant* rather than
4732
+ the varying member, so it carries no information at the call site; and the
4733
+ member is *state*, not an action — `reposition` re-reads it forever, so a
4734
+ `Fraction` means "stay half the screen through every SIGWINCH", which a command
4735
+ name hides. A rejected refinement, folding it into `center(size = nil)`, keeps
4736
+ one verb but still hides the persistence.
4737
+ - *Keeping `size` and never adding `Component#size`* — settling that a
4738
+ component's size is spelled `component.rect.size` forever. Cheapest (no
4739
+ breaking change), and declined because it preserves the trap rather than
4740
+ removing it.
4741
+
4742
+ **Consequence, taken up the same day.** `size` being free on `Component` was the
4743
+ point, and `Component#size` / `#width` / `#height` were added straight after as
4744
+ pure readers of `rect` — partly to *squat* the names, so no component can later
4745
+ claim `size` for a content-derived measurement. They are reports, never requests:
4746
+ no writer, and no container consults them when dividing space. The top-down
4747
+ re-grow rule still governs, and a second component wanting a *declared* box
4748
+ copies `Popup`'s naming rather than overloading `size`.
4749
+
4750
+ ## D_extent — `extent` on `Component`: the rect is what you were given, not what you paint (2026-08-30)
4751
+
4752
+ **Decision.** Promote `extent` from a per-widget convention to a `Component`
4753
+ member defaulting to `rect`, and give it a paired `clear_outside_extent`. A
4754
+ widget that paints less than its rect narrows the extent and then uses it in the
4755
+ three places that care — clearing, hit-testing, anchoring. **`rect` keeps meaning
4756
+ exactly what the parent assigned**; nothing about `extent` flows upward.
4757
+
4758
+ **Why now.** The concept had leaked six times (`Button`, `Checkbox`, `Tabs`,
4759
+ `MenuBar`, `Select`, `ComboBox`) and produced two shipped bugs in one session: a
4760
+ `Select` in a single-slot container opened its dropdown from a click 20 rows
4761
+ below its face, and `ListDropdown#anchor_to` placed the panel using a rect the
4762
+ driver did not occupy. Both were "the widget forgot to consult `extent`", in
4763
+ different places. It was one concept with no name and no home.
4764
+
4765
+ **Rejected: the component clamps its own `rect`.** The tempting inverse — layout
4766
+ offers 10 rows, the widget writes back 1, the parent's arithmetic unaffected and
4767
+ the gap simply showing through. It is *not* the deleted bottom-up `content_size`
4768
+ channel (no parent consults anything, no re-layout is triggered), and
4769
+ `children_tile_rect?` already handles the resulting gap, so it would have worked.
4770
+ Rejected on the invariant it costs: **`rect=` would lie.** `c.rect = r; c.rect ==
4771
+ r` becomes false, and `f.add(checkbox, Fixed[10])` silently yields a one-row
4772
+ checkbox, so the declared *constraint* is a lie too, invisible at the call site.
4773
+ In a top-down system the highest-value property is that a container's arithmetic
4774
+ can be verified by reading the container alone; once any component may rewrite
4775
+ its rect, you cannot reason about a `Box` without knowing which of its children
4776
+ clamp. Two lesser counts: clamping spreads the surprise to every reader of any
4777
+ `rect` (with `extent` only the six widgets that *have* a quirk carry it), and it
4778
+ solves only the height axis — every width clamp here is content-derived
4779
+ (`caption.display_width + 4`), so it would need the parent told to re-lay-out on
4780
+ `caption=`, which is `on_child_content_size_changed`, deleted in 0.9.0. Vaadin 8's
4781
+ slot negotiation is the prior art, and Vaadin 10 dropped it for CSS.
4782
+
4783
+ **The default is `nil`, not `rect.size`, and that is what lets `repaint` decide.**
4784
+ The first cut defaulted to `rect.size` and had the base branch on `extent ==
4785
+ rect`; it was implemented and backed out, because a one-row `Select` in a one-row
4786
+ rect satisfies that test while genuinely painting its extent in full — the base
4787
+ would blank the row and `Select` would repaint it, every cell dirty, the row
4788
+ re-emitted (`D_progress_bar`). The two states the base must tell apart are "no
4789
+ declaration, so clear everything" (a `Label` with short text) and "declared, so
4790
+ leave it alone", and they are *not* distinguishable from the value: they are
4791
+ distinguishable by whether there is a value. Hence `nil`.
4792
+
4793
+ The payoff is that widgets keep the ordinary `super`-then-paint shape — no widget
4794
+ has to remember to call a helper, and forgetting to declare an extent degrades to
4795
+ today's behaviour rather than to stale glyphs.
4796
+
4797
+ **`extent` is a `Size | nil`, not a `Rect`.** It always sits at the rect's top-left, so
4798
+ a `Rect` would carry two fields that must equal `rect.left` / `rect.top` and
4799
+ could be set not to — the invariant would live in a doc sentence rather than in
4800
+ the type. `Component#extent_rect` places it for the two consumers that need
4801
+ coordinates (`handle_mouse` hit-testing, `ListDropdown#anchor_to`). Member count
4802
+ is a wash; what is bought is that an offset extent cannot be written.
4803
+
4804
+ **Consequences.**
4805
+ - Four widgets that called `super` now clear only outside the extent, which is
4806
+ strictly less blanking: an unchanged `Checkbox` repaint went from 48 to 22
4807
+ emitted bytes and stopped re-emitting its caption.
4808
+ - `Select`'s hand-rolled tail arithmetic is deleted; its `repaint` is the same
4809
+ shape as the other four.
4810
+ - Hit-testing stays a per-widget one-liner, because the *action* differs
4811
+ (toggle / open / click) and click-to-focus is deliberately ungated by geometry.
4812
+ - **Not** a licence for a parent to consult `extent`. If a container ever wants
4813
+ to, that is the bottom-up channel again and needs its own argument.
4814
+
4815
+ ## D_final_tree — `children` and `parent` are final; no shadow tree (2026-08-30)
4816
+
4817
+ **Decision.** `children`, `parent`, `parent=`, `add_child`, `remove_child` and
4818
+ `detach_child` may not be overridden. `Component.verify_final!` resolves each
4819
+ one and compares its `owner`, raising `Tuile::Error` from `Component#initialize`
4820
+ when a subclass has taken any of them. Checked once per class and memoized.
4821
+
4822
+ **Why a runtime check rather than the existing prose.** `D_tree_api` already
4823
+ said "never override `children`" and `component_spec` already walked a tree of
4824
+ every container kind asserting the array and the pointers agree. Both are
4825
+ in-repo guards; neither reaches an *app* subclassing `Component`, which is where
4826
+ the mistake is most likely and least visible. The failure mode is nasty and
4827
+ silent in a specific way: `attached?` walks the **parent chain** while every
4828
+ subtree walk uses **`children`**, so a derived `children` yields a component that
4829
+ is attached but never painted, a lifecycle hook fired for the wrong set, and a
4830
+ click that never reaches a widget the tree still lists. Nothing raises; the
4831
+ widget is just dead.
4832
+
4833
+ The check earned itself immediately: `component_spec`'s own `container_with`
4834
+ helper built its fixtures with `define_method(:children) { kids }`, i.e. the gem's
4835
+ test suite was faking the tree it was asserting about. That is now real
4836
+ `add_child` wiring.
4837
+
4838
+ **Why the check is at instantiation, not at definition.** A
4839
+ `Component.method_added` hook fires at load time, which is nicer, but only sees
4840
+ a literal `def` in the subclass body — it misses an override arriving through an
4841
+ `include` or a `prepend`. Resolving `instance_method(...).owner` at the first
4842
+ `new` catches all four routes uniformly, in three lines, at the cost of firing a
4843
+ moment later. It does not stop `instance_variable_set(:@children, …)`, and it
4844
+ isn't meant to: the goal is catching the accident, not defeating an adversary.
4845
+
4846
+ **Rejected: `Screen` holds a private tree of its own**, authoritative regardless
4847
+ of what `parent` / `children` say. Two sources of truth that can drift is
4848
+ strictly worse than one that can be lied about, and this is `D_tree_api`'s
4849
+ slot-desync rule raised to framework scale — `ScreenPane#popups` needed an
4850
+ explicit carve-out and a drift assertion just to duplicate *ordering* for one
4851
+ list. Every operation would also have to pick a tree, and the right pick differs
4852
+ per operation (paint and focus want the physical one, a named slot wants the
4853
+ logical one), so each choice becomes a new bug surface. And it would not even
4854
+ fix the case that prompted this: routing is per-component methods, so a shadow
4855
+ tree does nothing about a `handle_mouse` that ignores half its children.
4856
+
4857
+ **The logical/physical axis is real, and it is served by composition instead.**
4858
+ A container whose regions are app-swappable holds a {Component::Slot} per region
4859
+ — a logical view implemented *over* the physical tree, never beside it. See
4860
+ `D_slots`.
4861
+
4862
+ ## D_slots — `Slot`, a one-child region; `HasContent` stops meaning "one child" (2026-08-30)
4863
+
4864
+ **Decision.** Add `Component::Slot`: a `Component` that includes `HasContent` and
4865
+ sizes its occupant to its own rect. A container with several regions gives each
4866
+ one a `Slot`, wired once at construction. `HasContent` keeps its implementation
4867
+ but is re-scoped to mean *I own exactly one child directly*, and loses
4868
+ `handle_mouse` to `Component`.
4869
+
4870
+ **The problem.** `HasContent`'s rdoc said "a component with one child tops",
4871
+ which `Window` has falsified since it grew a footer — and `Window` paid for it:
4872
+ `footer=` was a 20-line hand-copy of `content=` including the notify-last
4873
+ ordering rule, plus a `handle_mouse` and a `rect=` patch. That is O(slots)
4874
+ boilerplate, each copy a chance to get the order wrong. A three-region dialog
4875
+ would have been a third copy, or — worse, and the shape actually proposed — would
4876
+ have included `HasContent` and silently mis-routed: `HasContent#handle_mouse`
4877
+ forwarded only to `content`, so its buttons would have been unclickable.
4878
+
4879
+ **Why a component and not a slot mechanism.** The alternative designed first was
4880
+ a declared slot order (`SLOTS = %i[content footer]`) plus a `swap_slot` helper
4881
+ computing the insert index as "populated slots declared before me". It works, and
4882
+ it makes the ordering rule executable. It was dropped because it answers "why not
4883
+ on `Component`, then?" badly: it is a new framework mechanism — a class-constant
4884
+ convention, ivar reflection to write the backing store in the right order, a
4885
+ doc entry — solving a problem composition already solves. `Slot` adds one small
4886
+ class and no new concepts, and the nil case that motivated the whole thing stops
4887
+ existing rather than being computed: inside a `Slot` the only insert index is 0.
4888
+
4889
+ **Rejected: placeholder components.** Keeping a fixed-arity `@children` by
4890
+ parking an inert object in every empty slot. This is a shadow tree at 1/10 scale
4891
+ (`D_final_tree`): `children.size` stops meaning what it says, `on_tree` visits
4892
+ things that aren't UI, and every generic walk must tolerate ghosts forever — all
4893
+ to buy index arithmetic. A `Slot` is not a placeholder: it has a rect, it clears
4894
+ it, and it routes.
4895
+
4896
+ **Rejected: holder sub-containers built from `Layout`.** Wrapping each region in
4897
+ a `Layout::Absolute` needs no framework change at all, which is exactly the tell
4898
+ — an app can already do it. It costs a tree level *and* rect plumbing per region,
4899
+ and it is a placeholder with geometry.
4900
+
4901
+ **An empty slot does not collapse.** It keeps the rect it was assigned and clears
4902
+ it, so a dialog with no message shows the hole — consistent with the same dialog
4903
+ given an empty message string. Closing the gap is the *parent's* arithmetic (a
4904
+ zero extent), which is what top-down layout already demands; detaching the slot
4905
+ instead would put the index problem straight back, and neither `Layout#add` nor
4906
+ `Box#add` takes an index to re-insert at. `Window` uses the degenerate form: an
4907
+ absent footer gets an empty rect, because a `Slot` clears what it is given and
4908
+ would otherwise blank the bottom border.
4909
+
4910
+ **A slot is transparent, in all three channels.** Not `focusable?`, so the focus
4911
+ cascades walk past it; `Component#handle_mouse` descends through it; and
4912
+ `on_child_removed` is *forwarded to the parent*, because the default repair moves
4913
+ focus to `self` and a slot is inert — no cursor, no keys, nothing to bubble from.
4914
+ That last one is not theoretical: it broke `window_spec`'s "repairs focus when a
4915
+ focused footer is removed" the moment the footer moved into a slot.
4916
+
4917
+ **The cost, paid knowingly.** `Window#children` now always holds the footer slot,
4918
+ and a footer's `parent` is that slot rather than the window. Both are honest —
4919
+ the region genuinely exists whether or not it is occupied — and one thing gets
4920
+ *stronger*: content-then-footer ordering used to depend on `content=` inserting
4921
+ at 0 and `footer=` appending, and now holds structurally because the slot is
4922
+ wired at construction.
4923
+
4924
+ **`HasContent` survives, re-scoped.** A marker mixin with no implementation was
4925
+ considered and rejected twice over: the swap dance has to live somewhere or every
4926
+ includer hand-writes it again (the duplication this deletes), and a `content=`
4927
+ meaning "put this in my Slot" is circular, since `Slot` *is* a `HasContent`. So
4928
+ it keeps its body and gains a rule for which shape to use — permanent, integral
4929
+ content includes it (a typed field's inner `TextField`, `Slot` itself);
4930
+ app-swappable regions hold a `Slot`. It stays a mixin rather than per-class
4931
+ accessors so a tree walk can find content via `is_a?(HasContent)`, the same
4932
+ reason `HasCaption` is one.
4933
+
4934
+ **`handle_mouse` folds into `Component`.** The child-walk existed three times —
4935
+ `Layout`, `TabSheet` (verbatim) and, narrowed to one child, `HasContent`, with
4936
+ `Window` patching a footer branch on top. It is now the base implementation:
4937
+ focus self if focusable, then hand the event to every child whose rect contains
4938
+ the point. Widgets that resolve clicks inside their own rect (`List`, `Select`,
4939
+ `MenuBar`, `Notification`) already override without `super` and are unaffected.
4940
+ One behavior change falls out and is a fix: a click on `Window` chrome now lands
4941
+ focus on the window, which `AGENTS.md` has described all along.
4942
+
4943
+ **Not folded: `on_focus`.** The obvious symmetry — promote `Layout#on_focus`'s
4944
+ first-tab-stop walk to `Component` and delete `HasContent#on_focus` — was
4945
+ implemented in design and dropped. Unlike the mouse walk these are not
4946
+ duplicates: `HasContent` forwards to *its content*, `Layout` searches for the
4947
+ first `tab_stop?` descendant, and they disagree whenever content is focusable but
4948
+ not a tab stop (a `Popup` wrapping a `Window`). More decisively, `on_focus` is a
4949
+ *public app-facing hook* whose default is deliberately "do nothing"
4950
+ (`D_hook_visibility`); giving it default behavior changes what `super` means in
4951
+ every app override. The mouse walk carries no such risk — its default already
4952
+ does something.
4953
+
4954
+ ## D_confirm_window — `ConfirmWindow`: the component is the builder; every button dismisses (2026-08-31)
4955
+
4956
+ **Decision.** Add `Component::ConfirmWindow < Window`: a caption, a prose
4957
+ message and a centered row of `Button`s, opened as a content-measured modal
4958
+ popup. The component itself is the builder — `#button(caption, mnemonic:, &action)`
4959
+ declares any button set; three class factories (`alert`, `confirm`, `yes_no`)
4960
+ cover the common shapes — and **every button closes the dialog**. Book ch7
4961
+ ("The confirm dialog") carries the user-facing story.
4962
+
4963
+ **Callback-only, because blocking is impossible.** The shape everyone reaches
4964
+ for first — `JOptionPane`, tkinter `askyesno`, `tty-prompt`'s `yes?`, GTK3
4965
+ `dialog.run` — *blocks* and returns the answer. Tuile is single-threaded:
4966
+ `Screen#run_event_loop` is `$stdin.raw { event_loop }` with the key thread
4967
+ already running, so a value-returning modal would need a nested loop
4968
+ re-entering raw mode. This is the first thing a contributor will try to "fix";
4969
+ it cannot work here.
4970
+
4971
+ **One dismissal channel, N action channels.** A button with a block fires it; a
4972
+ button without one is a Cancel. ESC, `q`, an outside click and a Cancel button
4973
+ are all one event — `on_dismiss`, fired exactly once and only when no action
4974
+ button was chosen. `Overlay#on_close` (which fires on *every* departure) is the
4975
+ hook, gated by one bool. This is Vaadin's "ESC triggers the Cancel action"
4976
+ minus its `cancelable`/`rejectable` booleans: Ruby can say *absent argument =
4977
+ absent button*, which deletes the boolean surface a Java API pays for.
4978
+
4979
+ **Every button dismisses, unconditionally — no keep-open knob.** The
4980
+ counter-case was hunted and doesn't exist: a dialog staying open after a press
4981
+ is either collecting input (excluded below) or chaining — "Copy files" → a
4982
+ copy-progress window — and chaining is the callback's job: it opens the *next*
4983
+ window. Activation order is **mark chosen → close → fire**: the bool set before
4984
+ `close` is what makes `on_close` skip the dismissal, and the block firing after
4985
+ close sees clean focus-repair state (the dialog is already out of `@popups`),
4986
+ so a callback opening a follow-up popup snapshots the right prior focus and a
4987
+ raising block can't strand a half-open dialog.
4988
+
4989
+ **The component is the builder — roads not taken.** Prior art: ~20 blocking
4990
+ overloads (Swing), setters plus booleans (Vaadin), a builder object (Android),
4991
+ flags plus an `addButton` escape hatch (Qt), buttons-as-data with a result
4992
+ index (Electron, Turbo Vision, prompt_toolkit), or no dialog component at all
4993
+ (Textual). Against `X.new.tap { … }` already being the house idiom, three
4994
+ candidates lost:
4995
+
4996
+ - *Kwargs only* dies at button 4, and each new knob is a constructor parameter
4997
+ forever. It survives as the **factory** shape, pinned at one or two buttons,
4998
+ where those costs never fire.
4999
+ - *Buttons as data + one `case` callback* dissolves once the component is the
5000
+ builder: its one advantage (N buttons with zero API growth) is the
5001
+ mechanism's job now, and it invents a `[symbol, label]` vocabulary next to
5002
+ `Button.new("Save") { save! }`, which already is caption-plus-action.
5003
+ - *A separate builder object* is a second class whose only job is to be a
5004
+ half-built dialog, while Tuile components are already mutable.
5005
+
5006
+ New capability lands as a `#button` kwarg or a method, never a constructor
5007
+ parameter — the exponential growth never starts. And `#button` takes a caption
5008
+ plus kwargs plus block, never a prebuilt `Button`: the dialog must restyle the
5009
+ caption (the mnemonic underline) and wrap the action (close-then-fire), and
5010
+ doing either to a caller's `Button` is spooky mutation.
5011
+
5012
+ **Three factories, and no more.** The sets toolkits ship: OK · OK/Cancel ·
5013
+ Yes/No · Yes/No/Cancel · Retry/Cancel · Abort/Retry/Ignore. `alert`
5014
+ (acknowledge), `confirm` (its labels are kwargs, so it *is* OK/Cancel and
5015
+ Delete/Cancel) and `yes_no` (one line over `confirm`) cover them; everything
5016
+ further is a label respelling or five lines of the mechanism. Windows'
5017
+ six-value `MessageBoxButtons` enum is the tripwire this rule exists to avoid.
5018
+
5019
+ **No content slot; `message=` changes kind and stores as given.** The body is
5020
+ prose rendered by a `TextView` the dialog owns. Three buys: owning the body is
5021
+ what makes scrolling *reachable* (`TextView#handle_key` acts on the key alone,
5022
+ so the dialog hand-feeds scroll keys while a button keeps focus); the sizing
5023
+ rule has one mode (the dialog always measures content it owns,
5024
+ `Notification`-style, rather than "measure unless the caller assigned
5025
+ content"); and `StyledString` already covers icons, color and emphasis on a
5026
+ TTY. The casualty, priced: exactly the case Vaadin's docs carve out — the
5027
+ don't-ask-again checkbox. Everything else people put in a dialog body isn't a
5028
+ confirm dialog, and `Popup.new(content: your_layout)` remains the escape
5029
+ hatch. **Re-grow rule:** don't-ask-again returns as a named `remember:` seam
5030
+ whose state reaches the callback, never as a reopened content slot.
5031
+
5032
+ The accessor rule worth keeping: a setter that **normalizes within a kind**
5033
+ stores the normalized form (`Label#text`: `String` → `StyledString`, text
5034
+ stays text, round-trip pinned); a setter that **changes the kind** stores the
5035
+ input and derives the rendering (`String` → a component — handing back the
5036
+ `TextView` would hand back machinery). Hence `#message` returns what was
5037
+ assigned. The slot occupant is *derived* from the raw value through a single
5038
+ write path, so the two stored values cannot disagree (`D_tree_api`'s desync
5039
+ rule is about a second copy of tree *structure*). Coercion lives on the
5040
+ dialog, not on `Slot#content=`: the right wrapper differs per region — a
5041
+ caption-ish line wants a `Label` (ellipsizes), a message wants a `TextView`
5042
+ (wraps) — so one `Slot`-level answer would be wrong half the time in this very
5043
+ component. A `Component` message is mounted as-is, and the dialog — which may
5044
+ measure only content it *owns* (the v0.9.0 re-grow rule's caller-side query,
5045
+ here `#measured_size`) — then takes the full half-screen box.
5046
+
5047
+ **Mnemonics: MenuBar's shape; `q`, `g`, `G` reserved.** Local sugar over the
5048
+ window's own `handle_key` per `D_key_dispatch`'s re-grow rule — never a
5049
+ dispatch phase — with the letter underlined in the caption: Tuile has no
5050
+ status bar to advertise keys in (`D_status_bar`), so an unadvertised mnemonic
5051
+ is a hidden feature. `mnemonic: :auto` (the default) derives the caption's
5052
+ first letter and is *silently skipped* when reserved, taken or unusable; an
5053
+ explicit letter raises at registration, exactly where `MenuBar#add_item` puts
5054
+ every rule that has no sane answer at keypress time. Two-tier on purpose:
5055
+ best-effort for a derivation the caller never chose, strict for a promise they
5056
+ spelled out. Reserved: `q` — the do-nothing route out of *any* confirm dialog,
5057
+ including one that thinks it forces a choice (the user can always Ctrl+C, and
5058
+ pretending there is no escape route just trains them to reach for it); `g`/`G`
5059
+ — message scrolling; Space — it presses the focused button. The hand-fed
5060
+ scroll set (`BODY_SCROLL_KEYS`) is deliberately **not**
5061
+ `Keys::UP_ARROWS`/`DOWN_ARROWS`: their vi aliases `j`/`k` stay available as
5062
+ mnemonics ("Keep"), and the body still honors them when focused itself.
5063
+ Reservation-at-registration is what keeps keypress time free of shadowing
5064
+ rules.
5065
+
5066
+ **The body is a tab stop; focus opens on the first button.** A plain
5067
+ `TextView`, no `tab_stop?`-suppressing subclass: the arrows reach the prose
5068
+ either way, but the stop makes overflowing prose *visibly* reachable
5069
+ (Shift+Tab) rather than secretly scrollable, and it deletes a nested class.
5070
+ `on_focus` lands on the first-declared button — which, since Enter presses the
5071
+ focused button, is the default button. A safe-default knob (destructive
5072
+ confirms focusing Cancel) can land later as a `#button` kwarg.
5073
+
5074
+ **Buttons live inside the window, not in `Window#footer`.** A `Horizontal` as
5075
+ the bottom row of the inner `Vertical` (body `Expand[1]`, row `Fixed[1]`,
5076
+ spacing 1): the footer paints *over the bottom border row*, and `[ Delete ]`
5077
+ glyphs embedded in the border look wrong — the border stays clean chrome.
5078
+ Centering rides `cross: Fixed[row width], align: :center`, re-declared on each
5079
+ `#button` call, because a `Box` constraint changes only by remove-and-re-add.
5080
+
5081
+ **Sizing: measured, capped at half the screen.** The private `MeasuredPopup`
5082
+ derives `declared_size` from `#measured_size` on every `reposition`, so a
5083
+ message change and a SIGWINCH both re-measure against the current screen — the
5084
+ `Overlay` "derived position needs its own `reposition`" rule applied to a
5085
+ derived *size*. The cap is `Fraction::HALF`, `Popup`'s own default, so the
5086
+ dialog only ever *shrinks below* the default popup box. It re-measures freely
5087
+ rather than grow-only like `Notification` — a dialog's text changes far less
5088
+ often than a toast's. No floor for now; the risk a floor would hedge (a tiny
5089
+ yes/no box going unnoticed over a busy screen) is really a backdrop problem —
5090
+ `ideas/modal-backdrop.md`.
5091
+
5092
+ **Named `ConfirmWindow`, not `ConfirmDialog`.** It sits in the `*Window`
5093
+ family — a `Window` subclass, tiled-or-popup for free — and obeys the
5094
+ widget-suffix rule. `ConfirmDialog` is what a searcher will type, but Tuile
5095
+ already calls `Popup` "the modal dialog", so that name would imply `< Popup`;
5096
+ the rdoc and README say "the confirm dialog" in prose, so the search still
5097
+ lands.
5098
+
5099
+ **Two seams deliberately not added.** No `header=`: it would be the title, and
5100
+ `HasCaption#caption` already is — two accessors for one thing, one storing
5101
+ as-given and one coercing, is exactly the wart the store-as-given rule above
5102
+ exists to prevent. A *rich* header (an icon beside the text) would come back
5103
+ as a region distinct from the title, never as a second name for it. And not a
5104
+ `HasValue`: a dialog outcome is not a field value — the same reasoning that
5105
+ keeps `ProgressBar` out of the mixin (`D_progress_bar`).
5106
+
5107
+ **An alert keeps its OK button** even though ESC/`q`/outside-click already
5108
+ close it: Tuile deliberately advertises no quit key (`D_quit_key`,
5109
+ `D_status_bar`), so the button *is* the discoverability affordance — and it is
5110
+ clickable, which the keys are not.
5111
+
5112
+ **Rejected: folding `PickerWindow` in.** A keystroke-addressed, scrollable
5113
+ `List` of options with a cursor vs. a short focusable row of buttons with a
5114
+ default and a dismissal: one widget with a mode flag would disagree with
5115
+ itself on every question that matters — does the cursor roam, is there a
5116
+ default, what does ESC mean, does a pick close. What the two share is API
5117
+ *shape* — a caption, a set of choices, one callback — not code.
5118
+
5119
+ ## D_info_window_body — `InfoWindow`: two body presentations, prose wraps and rows don't (2026-08-31)
5120
+
5121
+ **Decision.** `InfoWindow` gains `ConfirmWindow`'s body seam — `message=`
5122
+ accepting `Component | String | StyledString | nil`, text rendered by a
5123
+ wrapping, scrollable `TextView`, the reader returning what was assigned
5124
+ (`D_confirm_window`'s store-as-given rule) — and **keeps `lines=`**, rebuilt
5125
+ as sugar that mounts a `List` through the same slot (delegating to
5126
+ `List#lines=`, so the coerce/split/rstrip semantics stay one implementation).
5127
+ The constructor and `.open` take one body positional and dispatch by type:
5128
+ `Array` → rows, anything else → `message=`. Last writer wins; after `lines=`,
5129
+ `message` reads the mounted `List` back — store-as-given holds, because the
5130
+ sugar *assigns a Component*.
5131
+
5132
+ **Truncation is a presentation, not a bug.** The itch was that `InfoWindow`
5133
+ (born before `TextView` existed) truncated long lines while
5134
+ `ConfirmWindow.alert` wrapped them — two "here's some information" paths
5135
+ diverging on a long sentence. But the fix is not to kill the `List` body:
5136
+ `TextView` has no truncate mode, so a `List` is the only way to show columnar
5137
+ output (a file listing, aligned key-value rows) where a wrap destroys the
5138
+ alignment. The actual bug was that wrap-vs-truncate was **accidental** —
5139
+ decided by which class you reached for. Two named setters on one class make it
5140
+ chosen: `message=` is *prose*, `lines=` is *rows*. The docs demote `lines` to
5141
+ second billing on purpose — book ch7 and the README row lead with `message=`,
5142
+ or everyone keeps reaching for the truncating path out of habit.
5143
+
5144
+ **Non-breaking by choice, not necessity.** Neither virtui nor pikuri-tui used
5145
+ `InfoWindow` (or `ConfirmWindow`) at all when this shipped, so the breaking
5146
+ design first sketched — delete the lines API outright — would have cost
5147
+ nothing downstream. `lines=` survives because the rows presentation earns it,
5148
+ not for compatibility. Which seam downstream apps actually reach for remains
5149
+ worth watching; if `lines=` goes unused for a few releases, *that* is the
5150
+ evidence for retiring it. One observable change shipped anyway: a bare
5151
+ `InfoWindow.new` now has no body (`content` is `nil`) where it used to mount
5152
+ an empty `List`.
5153
+
5154
+ **`ConfirmWindow#message=` deliberately does not learn Array→List.** A
5155
+ confirm dialog's body is prose by nature; the asymmetry is a decision, not an
5156
+ oversight. If a caller ever appears: a `List` of lines is measurable —
5157
+ widest-line × row-count, no wrap pass — so it would not fall into
5158
+ `#measured_size`'s "Component body ⇒ full half-screen box" hole.
5159
+
5160
+ **The coercion is a duplicate, on purpose.** This is copy two of
5161
+ `ConfirmWindow`'s `message=` case (per `D_float_field`'s shallow-shell rule —
5162
+ fold at four, not two): the classes want different wrappers around the same
5163
+ five-line dispatch, and `InfoWindow`'s has no popup to re-measure.
5164
+
5165
+ **`InfoWindow` keeps its place next to `ConfirmWindow.alert`:** tiled use, a
5166
+ buttonless popup, `declared_size:` control, and the rows presentation — none
5167
+ of which the alert offers.
5168
+
5169
+ ## D_inverse — `Style#inverse`: model SGR 7, don't fake it with colors (2026-08-31)
5170
+
5171
+ **Decision.** `StyledString::Style` gains a seventh attribute, `inverse`
5172
+ (SGR 7 on / 27 off), plumbed everywhere a style attribute lives: the
5173
+ `Data` member, `sgr_to`'s minimal diff, strict `parse`, and a whole-string
5174
+ `StyledString#with_inverse` beside `with_bold` / `with_underline`. One
5175
+ special ruling rides along: **`under_bg` treats an inverse span as already
5176
+ backgrounded** and skips it, exactly like a span with an explicit bg.
5177
+
5178
+ **Why an attribute, not a color pair.** The motivating use is the
5179
+ inverted focus chip (`[1]-VMs`, LazyVim-mode-segment style). Inverse swaps
5180
+ whatever fg/bg are *actually in effect* at the cell — terminal defaults
5181
+ included, which `fg:`/`bg:` cannot name — so a chip built with it is
5182
+ legible on any terminal palette with zero color decisions. The faked
5183
+ version (explicit `fg: :black, bg: <accent>` per theme variant) works but
5184
+ re-litigates contrast per theme and still guesses wrong on user-customized
5185
+ palettes. That asymmetry — the terminal knows its own default pair, the
5186
+ app never does — is the whole case for modeling the attribute.
5187
+
5188
+ **The `under_bg` ruling.** `under_bg` fills bg only into spans that have
5189
+ none; an inverse span's bg member is nil, but filling it would backfire —
5190
+ SGR 7 swaps the effective pair, so the filled tint becomes the chip's
5191
+ *glyph* color while its visual background stays the terminal's default fg.
5192
+ Skipping keeps an inverted chip looking identical on a plain and a tinted
5193
+ panel, which is what "terminal-theme-proof" has to mean. `with_bg` is
5194
+ untouched: it is override-all by contract, and a caller explicitly
5195
+ assigning a bg to an inverse span gets exactly that (the swap then applies
5196
+ to the explicit pair).
5197
+
5198
+ **Alternatives rejected.**
5199
+ - *The theme-token workaround* (a `fg`+`bg` pair per variant) — see above;
5200
+ it also puts a per-widget contrast decision into every app theme.
5201
+ - *Also modeling blink/conceal/dim while in there.* Declined: each
5202
+ attribute costs a code pair in three places plus round-trip and lenient
5203
+ surface, and none has a component waiting. The strict parser keeps
5204
+ raising on them, which is the round-trip contract doing its job — model
5205
+ an attribute when a consumer appears, not for SGR completeness.
5206
+ - *Naming it `reverse`* (ECMA-48 says "negative image", terminfo says
5207
+ `rev`). `inverse` is what CSS (`filter`), xterm docs and most modern
5208
+ terminal emulators call it, and `reverse` collides with Ruby's
5209
+ `String#reverse` / `Array#reverse` at the reference site.
5210
+
5211
+ ## D_background_rgb — `detect` yields a `Result`; the flip re-probes for the RGB (2026-08-31)
5212
+
5213
+ **Decision.** `TerminalBackground.detect` returns a `Result(scheme:, color:)`
5214
+ instead of a bare `Symbol`, and `Screen#background_color` exposes the color half
5215
+ as a `Color` (nil when nothing reported one). It stays current across OS
5216
+ appearance flips: `Screen#on_color_scheme` writes `TerminalBackground::QUERY`
5217
+ from the event-loop thread, the key thread reads the reply back through
5218
+ `Keys.getkey`'s new `\e]` drain, and `EventQueue::BackgroundColorEvent` carries
5219
+ it up to `Screen#on_background_color`. A changed color fires
5220
+ `Component#on_theme_changed` across the tree, the same fan-out a theme swap uses.
5221
+
5222
+ **Why expose it at all.** A theme picks colors to sit *against* the background;
5223
+ the borderless-panes idiom (LazyVim's editor-vs-explorer split, virtui's ask)
5224
+ derives one *from* it — a secondary pane at ±4–5% luminance, same hue, with the
5225
+ primary pane left at the terminal default. That needs the actual RGB, and the
5226
+ OSC 11 reply already carried it: `REPLY` captured three components and the
5227
+ private `classify` collapsed them to `:light`/`:dark` and dropped the rest. The
5228
+ workaround is a fixed near-neutral per variant, which looks right only near the
5229
+ background it was tuned on.
5230
+
5231
+ **Why a `Result`, not a second entry point.** One OSC 11 exchange yields both
5232
+ facts, so one method returns both. `detect` + a sibling `detect_color` would
5233
+ mean either a second round trip on a probe that is already timing-constrained,
5234
+ or a stashed module ivar that lies the moment `detect` is called twice with
5235
+ different IOs — the issue's own second suggestion, declined for that. The
5236
+ breaking return type costs exactly one internal caller.
5237
+
5238
+ **Why the live re-probe, rather than a documented startup snapshot.** Mode 2031
5239
+ reports light/dark and no RGB, so an unrefreshed value survives a flip pointing
5240
+ at the *old* background — a dark-derived tint on a now-light terminal, silently,
5241
+ in the one situation the theme machinery otherwise handles perfectly. The
5242
+ startup-only timing constraint (the reply lands on stdin, which the key thread
5243
+ owns once the loop runs) dissolves once the key thread is the one reading it.
5244
+
5245
+ Three placements make that safe, and each is load-bearing:
5246
+
5247
+ - **The query is written from the event-loop thread**, not the key thread. That
5248
+ thread also owns `emit`, so the query's bytes can never land inside a frame's
5249
+ synchronized-output batch. A key thread writing its own query would race
5250
+ every repaint.
5251
+ - **The reply is drained a byte at a time** in `getkey`, like `read_paste` and
5252
+ for a sharper reason: an OSC reply may end in ST, which *is* `\e\\` — a
5253
+ gulping read would swallow the terminator plus whatever was typed behind it.
5254
+ `\e]` can't collide with a keyboard sequence, so the drain eats no real key.
5255
+ - **`print` now flushes.** It never did, and every existing caller got away with
5256
+ it because a frame's `emit` flushed moments later. A *query* whose reply the
5257
+ app is waiting on cannot rely on that.
5258
+
5259
+ **The stale value is kept, never blanked.** Nil-ing `@background_color` on the
5260
+ flip and refilling it on the reply is the honest-looking option and is wrong: a
5261
+ terminal that reports 2031 flips but not OSC 11 would lose, permanently, the
5262
+ color it gave us at startup. Holding the old value costs one frame of a slightly
5263
+ wrong tint on terminals that *do* answer, and costs nothing on those that don't.
5264
+
5265
+ **Why `on_theme_changed` and not a new callback.** The hook's contract is
5266
+ "rebuild the colors you derived from the theme"; a background-derived tint is one
5267
+ of those, and its inputs just moved. A dedicated `on_background_color_changed=`
5268
+ would be a second channel firing microseconds after the first, for an app that
5269
+ must handle both identically. The cost is one extra full repaint per OS
5270
+ appearance flip, which is a rare event with a full repaint already in it.
5271
+
5272
+ **Alternatives rejected.**
5273
+ - *A module-level `TerminalBackground.background_color` accessor* — mutable
5274
+ module state, stale by construction. See above.
5275
+ - *Tuile computing the tint* (a `Theme#tinted` or a `Color#lighten`). Out of
5276
+ scope: how far to step, in which direction, and whether to step at all is the
5277
+ app's design decision, and Tuile has no component that wants it. Exposing the
5278
+ fact is the framework's job.
5279
+ - *Deriving the scheme from the re-probe's RGB* instead of trusting the 2031
5280
+ report. They agree unless the terminal is buggy, and the report is the thing
5281
+ that actually said "the user flipped their OS appearance" — so the event
5282
+ carries the color alone.
5283
+ - *A `FakeScreen` pinning a plausible dark RGB* rather than nil. Nil is what a
5284
+ non-answering terminal reports, which is the branch app code most needs
5285
+ exercised; a spec that wants a color assigns one through
5286
+ `FakeScreen#background_color=`, which takes the same path a real reply does.
5287
+
5288
+ ## D_color_depth — Detect the depth; degrade RGB at the wire (2026-08-31)
5289
+
5290
+ **Decision.** `ColorDepth.detect` reads the terminal's color depth from the
5291
+ environment (`:truecolor` / `:palette256` / `:ansi16`), `Screen#color_depth`
5292
+ holds it for the session, `Color#quantize(depth)` maps a color to the nearest
5293
+ one that depth can show, and `Buffer#flush` applies that to every color on its
5294
+ way to the wire. Upstream issue #8.
5295
+
5296
+ **Why it became necessary.** `Color#sgr_codes` emits `48;2;R;G;B` for every RGB
5297
+ color unconditionally, which was fine while RGB only ever came from a
5298
+ declaration site — a human picking a theme constant for a terminal they were
5299
+ looking at. `D_background_rgb` changed that: an app can now *read* the
5300
+ background and *derive* a color from it (virtui's borderless-pane tint steps the
5301
+ reported background toward its own pole), so Tuile hands out an RGB value the
5302
+ app has no safe way to write back out. Under a 256-color terminal, or tmux
5303
+ without `terminal-features "*:RGB"`, that computed `48;2;…` is mangled or
5304
+ silently approximated.
5305
+
5306
+ **Why the downgrade is automatic, and why it lives in `Buffer#flush`.** The
5307
+ deciding argument is that *not all RGB has a call site to opt in at*. RGB enters
5308
+ an app three ways: **declared** (a `Color.hex` theme token), **computed** (the
5309
+ tint), and **parsed** — `StyledString.parse` ingests ANSI produced by other
5310
+ programs, e.g. a `Component::LogTextView` fed a tool's colored output. Parsed
5311
+ colors arrive as *data*, with no declaration site an app could quantize at, and
5312
+ `StyledString` must stay depth-unaware (it is a frozen value type with a
5313
+ `parse(to_ansi(x)) == x` round-trip and no `Screen` dependency — the same rule
5314
+ that keeps it theme-unaware). Only a choke point on the wire catches that case.
5315
+
5316
+ `flush` is that choke point: it is where logical cells become bytes, the same
5317
+ role `draw_text` plays for backgrounds. Quantization happens *before* the
5318
+ `Style#sgr_to` diff, so two RGBs landing on one palette cell emit a single SGR
5319
+ instead of two. The "but then `sgr_codes` is dishonest" objection dissolves at
5320
+ this placement — `Color` still emits exactly what it was given, and `flush`
5321
+ already doesn't emit what you wrote (it skips unchanged cells and wraps frames
5322
+ in sync batches). Adapting a logical frame to a physical terminal *is* the
5323
+ buffer's job. Nor does this reopen the framework's allergy to automatic
5324
+ channels: the deleted ones (`content_size`, `keyboard_hint`) were semantic
5325
+ queries the framework made *of components*; this consults nobody, adds no
5326
+ `Component` API, and is one field on `Buffer`.
5327
+
5328
+ **Roads not taken:**
5329
+
5330
+ - *Opt-in `quantize` only*, leaving apps to call it. Serves the computed case
5331
+ and nothing else — see the parsed case above. `quantize` stays public anyway,
5332
+ for an app that wants to *know* what a color becomes on the wire (checking a
5333
+ computed tint still contrasts with the background after both round to palette
5334
+ cells) without changing what it stores.
5335
+ - *Pre-quantizing at theme definition or at tint derivation.* Redundant (flush
5336
+ catches those anyway) and actively harmful: it bakes depth into stored state,
5337
+ the cache-in-an-ivar failure the theme and `bg_color` rules forbid. A stored
5338
+ `Color.palette(237)` has forgotten it was `#3a3a3a`, so any later contrast
5339
+ check or derivation works from the lossy copy. `ThemeDef.default` is built at
5340
+ load time anyway, before a `Screen` exists to supply a depth. The model is:
5341
+ **logical layer always truecolor, wire layer always terminal-native, one
5342
+ conversion at the boundary.**
5343
+ - *Raising at render on an unrepresentable color*, leaving apps to supply only
5344
+ representable ones. The fail-fast instinct matches the house "raise at
5345
+ registration, not gate at runtime" pattern, but that pattern works because it
5346
+ raises *at the write site, deterministically, on the developer's machine*.
5347
+ This inverts both: it fires at the read site, far from the assignment, and
5348
+ only on the *end user's* terminal — a developer's truecolor terminal and
5349
+ `FakeScreen`'s pinned `:truecolor` never see it, so it ships and crashes on
5350
+ tmux. Breaks-at-a-distance and silent-under-test, by design. It also converts
5351
+ "coarser shade" — which the terminal already approximates on its own — into
5352
+ "app dies mid-repaint", and no peer framework does it (Rich, Textual, tcell,
5353
+ chalk and notcurses all degrade).
5354
+ - *A keyed cache — memo or LRU — in front of `quantize`.* Measured and
5355
+ rejected (`benchmark/quantize.rb`, 1M calls, ruby 3.3.8): compute ~360
5356
+ ns/call on both workloads; an unbounded memo 158 ns (typical) / 217 ns
5357
+ (gradient, having grown to 100k entries); a 256-entry LRU 183 ns (typical)
5358
+ but **648 ns** on the gradient — 1.8× *slower* than just computing, because
5359
+ every miss pays lookup + compute + eviction. The bounded cache only wins the
5360
+ workload that needed no help, and the unbounded one is keyed on a 16.7M-entry
5361
+ input space with the parsed-ANSI case as its adversary.
5362
+ `Buffer::WIDTH_CACHE` is unbounded for reasons that don't transfer: distinct
5363
+ graphemes are bounded by fonts and languages, and each avoided gem call costs
5364
+ ~20×. What is exploited instead is the *output* space — 240 palette cells and
5365
+ 16 names — so frozen tables let `quantize` be pure arithmetic returning a
5366
+ shared instance, allocating nothing.
5367
+
5368
+ - *A `Screen#color_depth=` setter.* Detection runs once and the depth cannot
5369
+ change mid-session, `ColorDepth::OVERRIDE_ENV` already covers a terminal that
5370
+ misreports, and a setter drags in a real bug: `Buffer::Cell#set` flips dirty
5371
+ only on a *style* change, but a depth change alters the *bytes* an unchanged
5372
+ style emits — the minimal diff has nothing to notice, so the setter would owe
5373
+ a buffer-wide `mark_all_dirty`. Deleting the setter deletes the wrinkle.
5374
+ - *Consulting terminfo* (`RGB`, `colors#0x1000000`) as issue #8 suggested. Tuile
5375
+ has no terminfo access — tty-screen does geometry, not capabilities — so this
5376
+ means shelling out to `tput`/`infocmp` at every startup. The env ladder plus
5377
+ the override covers the real matrix, and its failure mode is *conservative*:
5378
+ tmux and ssh (which drops `COLORTERM`, not being in the default `SendEnv`
5379
+ set) under-report, which renders coarser but never mangled.
5380
+ - *Two-stepping RGB → 256 → 16* under `:ansi16`, reusing the palette quantizer.
5381
+ Compounds the rounding: `rgb(0, 0, 195)` is nearer bright blue than blue, but
5382
+ rounds to cube cell 19 `(0,0,175)` first and then picks blue off *that*.
5383
+ Direct nearest-of-16 costs one more table and is pinned by a spec.
5384
+ - *A "needs translation" predicate or form sugar beside `quantize`.* The need
5385
+ is a function of *(form, depth)* — a bare `from_palette?` is ambiguous
5386
+ between the 256 and 16 targets, and `full_rgb?` misses the palette→16 case —
5387
+ which is exactly the case analysis `quantize` already performs, so a separate
5388
+ `needs_quantize?(depth)` would restate it as a boolean and drift from it.
5389
+ Identity-return makes the predicate free instead:
5390
+ `color.quantize(depth).equal?(color)`. Form introspection (`named?` /
5391
+ `palette?` / `rgb?`, matching the factory names) is likewise left out until
5392
+ an app asks — `color.value` answers it. Same shape one level up: a
5393
+ `Screen#truecolor?` boolean instead of the three-valued symbol would leave
5394
+ `:ansi16` inexpressible.
5395
+ - *A perceptual distance metric.* Plain squared-Euclidean RGB. The place a
5396
+ perceptual weight would show is the cube-vs-grey-ramp tiebreak on near-greys
5397
+ — exactly what a background-derived tint produces — so `color_spec` pins a
5398
+ real stepped tint (`rgb(30,30,34)` → `palette(234)`), and the metric gets
5399
+ revisited only if that cell ever looks wrong on an actual screen.
5400
+
5401
+ **One memo *is* needed, and finding that took measuring the right thing.**
5402
+ `Buffer#quantized_style` runs per dirty **cell**, not per style transition —
5403
+ easy to get wrong from the design sketch, since `sgr_to` is what runs per
5404
+ transition. So a full-screen repaint of RGB-styled content paid the arithmetic
5405
+ 8000 times for what was one span, and measured **51 ms against 15 ms** at
5406
+ `:truecolor`: a 3.4× regression landing squarely on the app the feature exists
5407
+ for, whose panes are painted in a computed tint. The fix is a *one-slot* memo —
5408
+ remember the last `(style → quantized style)` answer and compare by identity,
5409
+ sound because a `Style` is frozen. That collapses the work back onto genuine
5410
+ transitions and makes depth nearly free (10.7 ms vs 9.9 ms at `:truecolor`;
5411
+ `:ansi16` likewise). It is not the rejected cache in miniature: no key space, no
5412
+ eviction, two ivars, and it exploits run locality within a row rather than value
5413
+ recurrence across a session. Two supporting micro-optimizations in
5414
+ `nearest_palette` came out of the same pass — destructuring rather than
5415
+ splatting the triple, and `x * x` rather than `x**2`, together ~2× — which is
5416
+ why that method is written flat instead of tidy.
5417
+
5418
+ **A known-lossy corner, accepted.** `:ansi16` matching uses xterm's default RGBs
5419
+ for the 16 named colors, which a terminal's own scheme may redefine — the one
5420
+ mapping here that can be honestly wrong. It is documented on `Color#quantize`,
5421
+ and the result is a *named* color (SGR `30..37`/`90..97`), so the user's scheme
5422
+ still decides what is finally drawn. `TERM=linux` is about the only consumer.