tuile 0.14.0 → 0.15.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/DECISIONS.md +3181 -41
  4. data/README.md +25 -5
  5. data/TERMINOLOGY.md +17 -3
  6. data/book/05-focus.md +63 -2
  7. data/book/06-theming.md +55 -7
  8. data/book/07-components.md +474 -48
  9. data/book/08-testing.md +78 -0
  10. data/book/10-locale.md +216 -0
  11. data/book/README.md +14 -5
  12. data/examples/sampler.rb +265 -25
  13. data/ideas/binder.md +177 -0
  14. data/ideas/composite-field.md +77 -0
  15. data/ideas/focus-accent.md +116 -0
  16. data/ideas/form-layout.md +151 -0
  17. data/ideas/hover/probe.rb +241 -0
  18. data/ideas/hover/probe_spec.rb +82 -0
  19. data/ideas/hover.md +909 -0
  20. data/ideas/new-components.md +26 -6
  21. data/lib/tuile/component/abstract_string_field.rb +106 -58
  22. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  23. data/lib/tuile/component/big_decimal_field.rb +52 -79
  24. data/lib/tuile/component/checkbox_group.rb +36 -20
  25. data/lib/tuile/component/combo_box.rb +59 -31
  26. data/lib/tuile/component/date_field.rb +322 -0
  27. data/lib/tuile/component/float_field.rb +57 -82
  28. data/lib/tuile/component/has_bad_input.rb +88 -0
  29. data/lib/tuile/component/has_caption.rb +8 -0
  30. data/lib/tuile/component/has_content.rb +29 -10
  31. data/lib/tuile/component/has_placeholder.rb +62 -0
  32. data/lib/tuile/component/has_validation.rb +115 -0
  33. data/lib/tuile/component/has_value.rb +27 -0
  34. data/lib/tuile/component/integer_field.rb +51 -78
  35. data/lib/tuile/component/label.rb +6 -38
  36. data/lib/tuile/component/layout/box.rb +87 -19
  37. data/lib/tuile/component/layout.rb +13 -3
  38. data/lib/tuile/component/list.rb +11 -6
  39. data/lib/tuile/component/list_dropdown.rb +4 -0
  40. data/lib/tuile/component/overlay.rb +17 -0
  41. data/lib/tuile/component/radio_group.rb +39 -22
  42. data/lib/tuile/component/select.rb +12 -4
  43. data/lib/tuile/component/text_area.rb +14 -8
  44. data/lib/tuile/component/text_field.rb +42 -15
  45. data/lib/tuile/component/text_view.rb +25 -8
  46. data/lib/tuile/component/time_field.rb +454 -0
  47. data/lib/tuile/component/window.rb +26 -13
  48. data/lib/tuile/component.rb +469 -73
  49. data/lib/tuile/fake_screen.rb +11 -1
  50. data/lib/tuile/final.rb +75 -0
  51. data/lib/tuile/locale.rb +851 -0
  52. data/lib/tuile/screen.rb +131 -17
  53. data/lib/tuile/screen_pane.rb +13 -9
  54. data/lib/tuile/testing.rb +198 -0
  55. data/lib/tuile/theme.rb +100 -10
  56. data/lib/tuile/version.rb +1 -1
  57. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  58. data/lib/tuile.rb +1 -0
  59. data/sig/tuile.rbs +3398 -412
  60. metadata +18 -1
data/DECISIONS.md CHANGED
@@ -117,9 +117,10 @@ chain. Self-painters route the effective bg through a single choke point,
117
117
  (`ideas/background-fill-color.md`) is retired; its invariants graduated to
118
118
  AGENTS.md ("Background color") and its reader-half to book ch6 ("Backgrounds
119
119
  are opt-in"). {Component::Label} already carried its own `#bg` (override-all
120
- via `with_bg`); it composes with `bg_color` (explicit span bgs survive
121
- `under_bg`, so `#bg` wins locally), but the two-knob overlap is a wart
122
- flagged for a later consolidation decision. The theme-token variant that
120
+ via `with_bg`); it composed with `bg_color` (explicit span bgs survive
121
+ `under_bg`, so `#bg` won locally), and the two-knob overlap was flagged here as
122
+ a wart pending a consolidation decision — taken in `D_bg_surface`, which
123
+ deleted it. The theme-token variant that
123
124
  surfaced during design landed separately — see `D_theme_ref`.
124
125
 
125
126
  ---
@@ -254,7 +255,7 @@ values *and* a uniform seam for free.
254
255
 
255
256
  **Alternatives rejected.**
256
257
  - *String-only value on every input:* fails "pick a domain object, get the
257
- object," and bakes a `String` assumption a future `IntegerField`/`DatePicker`
258
+ object," and bakes a `String` assumption a future `IntegerField`/`DateField`
258
259
  would fight. Kept only as a theoretical fallback.
259
260
  - *A full Vaadin-shaped `HasValue`* (read-only, required-indicator,
260
261
  old-value/`isFromClient` event payload, converters/validators): every one of
@@ -398,12 +399,17 @@ moment to settle the input taxonomy while still pre-1.0.
398
399
  - **Value is a derived parse, fired eagerly.** `value` is recomputed from the
399
400
  buffer on read; `on_value_change` fires per keystroke but only on a real
400
401
  *value* change (`"7"`→`"07"` is silent). No normalization in v1 (`"007"`
401
- shows as typed); canonicalizing needs a blur/commit point a TUI lacks.
402
+ shows as typed): rewriting the buffer under the caret while typing is worse
403
+ than an ugly buffer, so it would have to wait for a commit point. `on_blur`
404
+ is now that point (`D_on_blur`), which makes this re-openable on the merits —
405
+ it is no longer blocked on a missing hook.
402
406
  - **Up/Down are a built-in ±1 spinner**, treating an empty/un-parseable field
403
- as `0`, handled inside the field's `on_key` interceptor. `IntegerField`
404
- therefore does *not* expose `on_key_up`/`on_key_down` (`on_enter`, a submit
405
- hook, stays delegated) — on a numeric field the arrows have a native meaning,
406
- so surfacing them as app callbacks would fight the spinner.
407
+ as `0`. (Wired to the inner field's `on_key` interceptor originally; to its
408
+ `on_key_up`/`on_key_down` since `D_no_key_interceptor`.) `IntegerField`
409
+ therefore does *not* expose `on_key_up`/`on_key_down` *on its own face*
410
+ (`on_enter`, a submit hook, stays delegated) — on a numeric field the arrows
411
+ have a native meaning, so surfacing them as app callbacks would fight the
412
+ spinner.
407
413
  - **Both composed fields include `HasContent`.** `ComboBox` and `IntegerField`
408
414
  hold their inner `TextField` as their single `HasContent` child rather than
409
415
  hand-rolling `children`/`rect=`/`on_focus`. This reuses an *existing* mixin
@@ -442,8 +448,9 @@ what Java needs a class for, and `is_a?(HasValue)` is the Binder's marker.
442
448
  - `content`/`content=` are public on `ComboBox`/`IntegerField` (from
443
449
  `HasContent`) — a structural accessor, distinct from the typed `value` seam
444
450
  that stays the intended domain API.
445
- - The digit filter is the inner field's `on_key`, consulted *before* insertion,
446
- so a rejected key never moves the caret.
451
+ - The digit filter is the inner field's `insert_text` (originally its `on_key`,
452
+ which a paste bypassed — `D_input_filters`), so a rejected key never moves the
453
+ caret and a rejected paste lands nothing.
447
454
  - Empty is per-component: `nil` for `IntegerField`, `""` for a text input.
448
455
 
449
456
  ---
@@ -949,7 +956,8 @@ much of `List` to reuse and what the value should be. (A single-select group
949
956
 
950
957
  **Decision.**
951
958
  - **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
952
- `HasContent` child, which supplies the cursor, scrolling, the scrollbar and
959
+ child — read-only as `list` since 0.15.0 (`D_wrapping_field`), an app tunes it
960
+ but never supplies it — which supplies the cursor, scrolling, the scrollbar and
953
961
  per-row hit-testing. The group's own code is four lines of wiring: rebuild
954
962
  `lines=` on any change to items/labels/selection, claim **Space** in
955
963
  `handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
@@ -2003,7 +2011,7 @@ category, not this field's value, and it would force the eventual sibling to be
2003
2011
  "the other number field."
2004
2012
 
2005
2013
  **Decision — duplicate `IntegerField` rather than grow a base.** The two share
2006
- ~90% of their body (the `HasContent` shell, the `on_key` filter interceptor, the
2014
+ ~90% of their body (the `HasContent` shell, the nested filtering `Field`, the
2007
2015
  `fire_if_changed` guard) and differ in exactly the three places that matter: the
2008
2016
  filter, the parse, and the format. An `AbstractNumericField` with abstract
2009
2017
  `parse`/`format` hooks **is** the converter strategy `D_integer_field` kept out,
@@ -2048,11 +2056,20 @@ in `D_integer_field`.
2048
2056
  `bigdecimal` gem, a decimals/scale policy, and `"0.1"` → `BigDecimal("0.1")`
2049
2057
  string-round-tripping — a different field with a different name, not this one.
2050
2058
  - *Normalize the buffer on parse (`"007"` → `"7"`, `".5"` → `"0.5"`):*
2051
- rejected for the same reason as in `IntegerField` — canonicalizing needs a
2052
- blur/commit point a TUI lacks, and rewriting the buffer under the caret while
2053
- typing is worse than an ugly buffer.
2059
+ rejected for the same reason as in `IntegerField` — rewriting the buffer under
2060
+ the caret while typing is worse than an ugly buffer, so it belongs at a commit
2061
+ point, which `on_blur` has since become (`D_on_blur`).
2054
2062
  - *A locale decimal comma:* no locale seam exists in Tuile, and inventing one
2055
2063
  for a single field would put i18n in the wrong layer.
2064
+ **Amended 2026-09-04 (`D_locale`):** the seam now exists, and
2065
+ `Locale#decimal_separator` is detected and exposed — but the field still does
2066
+ not read it, because that is field-side work this entry's own reasoning
2067
+ constrains: `TYPEABLE` is an `insert_text` filter, so admitting `,` changes
2068
+ what a pasted `"1,5"` does (today it lands nothing, deliberately, rather than
2069
+ sieving to `"15"`), and `value=` writes through `to_s`, which is always a dot.
2070
+ A comma grammar is still prefix-closed, so `D_input_filters` holds and the
2071
+ work is tractable; it is simply not done. The member is one of the six
2072
+ `D_locale` shipped ahead of its consumer.
2056
2073
 
2057
2074
  ---
2058
2075
 
@@ -2128,9 +2145,10 @@ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
2128
2145
  ("shortest decimal that round-trips") hidden inside a setter. If it is ever
2129
2146
  wanted, it belongs at the call site, where it is visible.
2130
2147
  - *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
2131
- the buffer under the caret while typing (`19.9` → `19.90` mid-edit), which
2132
- needs a blur/commit point a TUI lacks — the same reason `D_integer_field`
2133
- gave for not normalizing.
2148
+ the buffer under the caret while typing (`19.9` → `19.90` mid-edit), so it
2149
+ belongs at a commit point — the same reason `D_integer_field` gave for not
2150
+ normalizing, and re-openable on the same terms now `on_blur` exists
2151
+ (`D_on_blur`).
2134
2152
  - *A settable `step=`:* `D_float_field` rejected it over binary-float noise,
2135
2153
  which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
2136
2154
  anyway, so the three numeric fields stay one shape; this is the field to
@@ -2146,6 +2164,12 @@ value types on `Layout`). Book ch3 pre-approved the shape and named the
2146
2164
  acceptance criterion — "added if and when the convenience pays for itself" —
2147
2165
  so what this entry records is that it did, and every choice inside it.
2148
2166
 
2167
+ **Update 2026-09-04: an empty rect propagates, and a child can be re-placed.**
2168
+ `#relayout` no longer returns early on an empty rect of its own, `#add` takes
2169
+ `at:` and `#constrain` re-constrains a child already added — so hiding a pane by
2170
+ `#remove` is reversible, which is what keeps `Fixed[0]` a *collapse* rather than
2171
+ Tuile's answer to hiding. See `D_empty_ancestor`.
2172
+
2149
2173
  **Context.** `Layout::Absolute` was the only container: you override `rect=`
2150
2174
  and compute each child's rectangle. That is right for genuinely
2151
2175
  two-dimensional geometry and tedious for a stack. `examples/sampler.rb` carried
@@ -2568,6 +2592,10 @@ two callers that already know the answer.
2568
2592
  non-focusable `Menu` really does give the same re-entrancy safety
2569
2593
  `ComboBox#active=` leans on, and filtering / row rendering / the commit action
2570
2594
  really do vary.
2595
+ - **`D_mouse`'s keyboard-first ranking leaves this entry untouched.** The
2596
+ dropdown is *enumeration* — options the user cannot type without seeing — not
2597
+ a mouse affordance, and the no-printable claim is a keyboard argument; nothing
2598
+ there demotes Select relative to `ComboBox`.
2571
2599
 
2572
2600
  ## D_list_items — `List` takes items + a renderer, rendered lazily (2026-08-14)
2573
2601
 
@@ -2845,10 +2873,11 @@ it: it snaps to the absolute start/end of the text.
2845
2873
 
2846
2874
  **Decision — two public readers on `TextArea`, forwarding to the private wrap.**
2847
2875
  `caret_row` and `row_count`, one line each. The caller claims the key in a seam
2848
- that already exists — `handle_text_input_key` in a subclass, or the `on_key`
2849
- interceptor for app code that would rather not subclass — and delegates to
2876
+ that already exists — `handle_text_input_key` in a subclass — and delegates to
2850
2877
  `super` everywhere else, which leaves the edge snap intact for anyone who
2851
- doesn't claim it. The recipe lives in the `TextArea` rdoc.
2878
+ doesn't claim it. The recipe lives in the `TextArea` rdoc. (The entry originally
2879
+ offered the `on_key` interceptor as a no-subclass alternative; it is gone, and
2880
+ the readers are public, so a subclass is the one route — `D_no_key_interceptor`.)
2852
2881
 
2853
2882
  Both readers are needed and neither is redundant: history recall uses both, and
2854
2883
  the auto-growing prompt strip — the case the name was reserved for — uses
@@ -2859,9 +2888,10 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2859
2888
  - **A protected `on_caret_vertical_overflow(delta)` hook**, consulted inside
2860
2889
  `move_caret_vertical` before the snap. This was the issue's own preferred
2861
2890
  shape, on the grounds that it avoids re-deriving a decision `TextArea` already
2862
- makes. Rejected on five counts. It would be a *fourth* key-interception
2863
- mechanism in a class that already has three (`on_key`,
2864
- `handle_text_input_key`, the rung-3 ancestor bubble), where the house style is
2891
+ makes. Rejected on five counts. It would be a *third* key-interception
2892
+ mechanism in a class that already has two (`handle_text_input_key` and the
2893
+ rung-3 ancestor bubble; `on_key` was a third until
2894
+ `D_no_key_interceptor`), where the house style is
2865
2895
  "claim the key, or decline it". It names an implementation *moment* rather than
2866
2896
  an event — one point inside a private method, after a clamp — so a later branch
2867
2897
  in the Up path (desired-column memory, say) would shift its firing condition
@@ -2873,7 +2903,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2873
2903
  `D_scroll_nomenclature` rejected a general `Component` scroll seam. It serves
2874
2904
  one question, in one direction, at one moment, where the readers also serve the
2875
2905
  prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
2876
- subclass, where the readers serve `on_key` too. In COP terms it is neither a
2906
+ subclass, where the readers serve any caller. In COP terms it is neither a
2877
2907
  listener (nothing changed) nor a provider (no data pulled) — a template-method
2878
2908
  escape valve where two COP-shaped seams already exist. As for the
2879
2909
  re-derivation it was meant to avoid: the decision is literally
@@ -3311,8 +3341,8 @@ mechanism.
3311
3341
 
3312
3342
  **Decision — a `PasteEvent`, and it never touches the key ladder.** The key
3313
3343
  thread posts one event carrying the whole payload; `Screen#event_loop` routes it
3314
- to `handle_paste` down the focus chain, with the same modal scoping as a key and
3315
- no other rung. *Rejected: reusing `KeyEvent` with a flag*, which would put a
3344
+ to `handle_paste`, with the same modal scoping as a key and no other rung.
3345
+ *Rejected: reusing `KeyEvent` with a flag*, which would put a
3316
3346
  `pasted?` predicate on the ladder and re-create the runtime gate `D_key_dispatch`
3317
3347
  deleted — every `handle_key` would have to remember to check it, and the ones
3318
3348
  that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
@@ -3320,6 +3350,21 @@ as individual keys.* It reads like graceful degradation and is the ambiguity
3320
3350
  walking back in through the fallback: a component that declines a paste would
3321
3351
  still get eight ENTERs. Unhandled text is dropped.
3322
3352
 
3353
+ **Amended 2026-09-03 — delivery is to the focused component, and stops there.**
3354
+ `ScreenPane#handle_paste` originally walked the focus chain the way
3355
+ `bubble_key` does, offering the text to each ancestor in turn. That was
3356
+ symmetry for its own sake. The three reasons a *key* bubbles (`D_key_dispatch`)
3357
+ are all about a scope-wide **binding** — a form's default button, a layout's
3358
+ one-key jumps, and the modality that falls out of stopping at the scope root —
3359
+ and none of them has a paste analogue: "the ancestor gets the clipboard the
3360
+ field declined" is not a feature, and no component in the gem but
3361
+ `AbstractStringField` overrides `handle_paste`, which is always the innermost
3362
+ component on the chain when it matters. The scoping is kept (focus outside the
3363
+ modal scope still receives nothing, so a modal stays modal); only the walk is
3364
+ gone. This is a **narrowing**, so the re-grow bar is low if a real ancestor-level
3365
+ paste consumer ever appears — but the replay-as-keys fallback rejected above
3366
+ stays rejected, which is the part that would actually hurt.
3367
+
3323
3368
  **Decision — the field inserts it as one mutation.**
3324
3369
  `AbstractStringField#handle_paste` inserts at the caret in a single `text=`, so
3325
3370
  `on_change` fires once for the paste rather than once per character. That is what
@@ -3338,8 +3383,8 @@ layer. What a **text buffer** may hold is the field's call:
3338
3383
  `AbstractStringField#preprocess_paste` drops the C0 controls (a raw `\e` or `\t`
3339
3384
  reaching {Tuile::Buffer} would move the real cursor mid-frame), keeps `\n`, and
3340
3385
  turns a tab into one space rather than inventing a tab width;
3341
- `TextField#preprocess_paste` narrows further — newlines to spaces, since a
3342
- one-row field holds no line break, and a trim to `max_text_length` rather than a
3386
+ `TextField#preprocess_paste` narrows further — the newline ruling is its own
3387
+ entry (`D_paste_newlines`), and a trim to `max_text_length` rather than a
3343
3388
  rejection, because that is what typing the same characters would have done. An
3344
3389
  app wanting tab *expansion* or a `[Pasted 230 lines]` placeholder overrides
3345
3390
  `handle_paste`, which is the seam that exists for it.
@@ -3395,8 +3440,14 @@ the invalidation set and the buffer were both self-consistent.
3395
3440
 
3396
3441
  **Decision.** Make the *clear* conditional and the *invalidate* unconditional:
3397
3442
 
3398
- clear_background unless children.any? && children_tile_rect?
3399
- children.each { |c| screen.invalidate(c) }
3443
+ clear_outside_extent unless children.any? && children_tile_rect?
3444
+ invalidate_children
3445
+
3446
+ **Update 2026-09-04:** the second line is a named protected method rather than
3447
+ an inline `children.each`, so a container that skips `super` to paint its own
3448
+ rect has something to call — `Component::Window` skipped the clear it did not
3449
+ need and lost the cascade in the same edit, twice, because the cascade had no
3450
+ name (`D_component_contract`).
3400
3451
 
3401
3452
  A container that paints nothing of its own can only redraw its area *through* its
3402
3453
  children, so being invalidated has to mean invalidating them. The tiling test
@@ -3440,6 +3491,13 @@ identities), `D_select` (claim the minimum), `D_ambiguous_width` (the separator
3440
3491
  glyph), `D_tree_api` (the slot-swap recipe) and `D_attach_hooks` (what
3441
3492
  detachment fires).
3442
3493
 
3494
+ **Update 2026-09-05: the re-grow rule below has been met.** `D_visibility`
3495
+ brings `Component#visible=` back as the full focus-and-paint gate this entry
3496
+ asked for, with the `Box` arithmetic ruled, on the conditional form field as the
3497
+ second consumer. `TabSheet` keeps detaching regardless — the lifecycle hooks on
3498
+ switch are a feature — so the *decision* here stands; only the "Tuile grows no
3499
+ visibility flag" clause is superseded.
3500
+
3443
3501
  **Context.** Several views, one visible at a time, and a one-row strip of
3444
3502
  captions to pick between them. Two components, because the strip is useful
3445
3503
  alone — Vaadin documents that case explicitly ("content switching without Tab
@@ -4531,8 +4589,9 @@ open question.
4531
4589
  ## D_hook_visibility — A framework-invoked hook is protected, reached with `__send__` (2026-08-30)
4532
4590
 
4533
4591
  **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*.
4592
+ (protected; `Screen#theme=` fans it out through `__send__`) and for
4593
+ {Component#on_blur} (`D_on_blur`). `Component#on_focus` is the one
4594
+ framework-invoked hook still public — see *Consequences*.
4536
4595
 
4537
4596
  **Context — the field report.** virtui crashed on an OS appearance flip:
4538
4597
 
@@ -4620,10 +4679,11 @@ of bug never reached them.
4620
4679
  sense. {Component::HasContent} / {Component::Layout} / {Component::TabSheet}
4621
4680
  each override it to forward focus into their content, so it reads as part of
4622
4681
  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.
4682
+ Its narrowing hazard is nevertheless **gone**: `Screen#focused=` sends it with
4683
+ `__send__` since `D_on_blur`, because a *protected* `on_blur` beside it makes
4684
+ the fatal grouping likely rather than theoretical. Public-and-`__send__`-ed is
4685
+ the combination for a hook that is genuinely interface; the rule above is for
4686
+ the rest.
4627
4687
  - **Specs call the hook with `send`**, and two guards exist: `component_spec`
4628
4688
  asserts the visibility pair, `screen_spec` asserts that a subclass declaring a
4629
4689
  `protected` override is still fired *and* that the walk continues past it.
@@ -4815,9 +4875,10 @@ is a wash; what is bought is that an offset extent cannot be written.
4815
4875
  ## D_final_tree — `children` and `parent` are final; no shadow tree (2026-08-30)
4816
4876
 
4817
4877
  **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.
4878
+ `detach_child` may not be overridden. `Component` declares them through
4879
+ `Tuile::Final`, whose `verify_final!` resolves each one and compares its
4880
+ `owner`, raising `Tuile::Error` from `Component#initialize` when a subclass has
4881
+ taken any of them. Checked once per class and memoized.
4821
4882
 
4822
4883
  **Why a runtime check rather than the existing prose.** `D_tree_api` already
4823
4884
  said "never override `children`" and `component_spec` already walked a tree of
@@ -4830,6 +4891,25 @@ is attached but never painted, a lifecycle hook fired for the wrong set, and a
4830
4891
  click that never reaches a widget the tree still lists. Nothing raises; the
4831
4892
  widget is just dead.
4832
4893
 
4894
+ **Amendment (2026-09-01): extracted to `Tuile::Final`.** The first cut fused the
4895
+ mechanism with this one rationale — a `FINAL_METHODS` constant plus a
4896
+ `verify_final!` whose raise recited three sentences about `attached?` and the
4897
+ parent chain. That message is nonsense printed for any *other* final method, and
4898
+ the fusion was noticed while weighing a second group (`bg_color` /
4899
+ `effective_bg_color`, so an app can't override the reader the framework reads).
4900
+ So the mechanism is now Ruby's missing `final` keyword and nothing more: a class
4901
+ `extend`s `Tuile::Final`, marks its methods, and the raise points at the
4902
+ offending method's own rdoc, which is where each *why* lives. Enforcement is
4903
+ unchanged — the resolved-`owner` check from `initialize`, memoized per class.
4904
+
4905
+ *Declared in one call, not on each `def`.* `final def foo` parses (a `def` hands
4906
+ back its name) and reads like Java, but YARD has no handler for the macro, so
4907
+ the decorated `def` loses its parameter list and sord generates
4908
+ `def foo: () -> void` into `sig/tuile.rbs` — measured, not feared: it dropped
4909
+ `add_child`'s two parameters and both `attr_reader`s outright. CI's `sig/` drift
4910
+ gate catches the *change*, but a newly-added `final def` would just be committed
4911
+ with an empty signature. So the names are listed once near the top of the class.
4912
+
4833
4913
  The check earned itself immediately: `component_spec`'s own `container_with`
4834
4914
  helper built its fixtures with `define_method(:children) { kids }`, i.e. the gem's
4835
4915
  test suite was faking the tree it was asserting about. That is now real
@@ -5420,3 +5500,3063 @@ for the 16 named colors, which a terminal's own scheme may redefine — the one
5420
5500
  mapping here that can be honestly wrong. It is documented on `Color#quantize`,
5421
5501
  and the result is a *named* color (SGR `30..37`/`90..97`), so the user's scheme
5422
5502
  still decides what is finally drawn. `TERM=linux` is about the only consumer.
5503
+
5504
+ ---
5505
+
5506
+ ## D_scrollbar_reserve — `TextView` reserves a blank column beside the bar; no knob, and not called a gutter (2026-09-01)
5507
+
5508
+ **Status:** Accepted; implemented 2026-09-01 (`TextView#scrollbar_columns`).
5509
+ Fixes [issue #9](https://github.com/mvysny/tuile/issues/9).
5510
+
5511
+ **Context.** With `D_status_bar`'s borderless panes, a `TextView` scrollbar sits
5512
+ at the pane's outermost column and text runs straight into it: `wrap_width` was
5513
+ `rect.width - 1`, `rewrap` padded every row to exactly that, and `paintable_row`
5514
+ concatenated the glyph onto the padded row, so a row wrapping at the full width
5515
+ put its last character in the column immediately left of `█`:
5516
+
5517
+ ```
5518
+ enough that it must wrap against the pane edge to show the█
5519
+ ```
5520
+
5521
+ Inside a `Window` the gap came free from the border, which is why this survived
5522
+ to 0.14.0 unnoticed — and why no spec caught it: every scrollbar example in
5523
+ `text_view_spec` used content far shorter than the viewport.
5524
+
5525
+ **The deciding fact: `List` already reserved it.** `List#pad_to_row` ellipsizes
5526
+ the body to `content_width - 2` and pads `" " + body + " " * (fill + 1)` — the
5527
+ "two row gutters" `D_select` measures a dropdown against — so a `List` row has
5528
+ never touched the bar. The two components only look alike at `paintable_row`.
5529
+ That reframed the request from "a new option on two components" to "`TextView`
5530
+ is inconsistent with `List`", in the direction the reporter already preferred.
5531
+
5532
+ **Decision — always reserve, no option.** `scrollbar_columns` returns the
5533
+ columns the bar claims (`0` hidden, else `2`), `wrap_width` subtracts it and
5534
+ `paintable_row` emits the blank before the glyph. An `Integer` knob defaulting
5535
+ to `0` was the issue's own first proposal and was rejected: it would only ever
5536
+ hold `0` or `1` (a boolean wearing a number), it has no defensible default once
5537
+ `List` is known to reserve unconditionally, and it is the per-child tuple growth
5538
+ `D_box_layouts` refuses — a gap between two things is a property of the pair,
5539
+ not a parameter of one. Both affected methods are private, so nothing public
5540
+ changes; existing apps rewrap one column narrower wherever a bar is visible,
5541
+ which is invisible inside a `Window` and is the fix everywhere else.
5542
+
5543
+ **Decision — the name `gutter` is unavailable.** The word was already spent
5544
+ twice in this tree, in the two incompatible industry senses: `List`'s
5545
+ "one-column gutter" is the blank pad (the CSS-Grid/Bootstrap *gap* sense), while
5546
+ `list.rb`/`text_view.rb` said "minus the scrollbar gutter" for the bar's own
5547
+ column (the CSS `scrollbar-gutter: stable` sense, where the gutter *is* where
5548
+ the bar goes; editors add a third — VS Code's gutter *contains* line numbers).
5549
+ So `scrollbar_gutter` would have been a third meaning contradicting a rdoc
5550
+ sentence six lines above it, which `D_scroll_nomenclature`'s one-word-per-concept
5551
+ rule forbids. The rdoc uses of the bar-column sense were reworded to "the
5552
+ scrollbar column", leaving the blank-pad sense as the only surviving one. Had an
5553
+ option shipped, the name would have been `scrollbar_spacing` — `spacing` already
5554
+ means "gap between two things" here (`Box#spacing`).
5555
+
5556
+ **The reserve drops below width 3.** At `rect.width == 2` a bar plus a blank
5557
+ leaves no column for text at all, so `scrollbar_columns` returns `1` there and
5558
+ at width 1. That keeps `paintable_row`'s "exactly `rect.width` columns"
5559
+ contract — the thing the whole paint path rests on — true at every width, which
5560
+ is the part a naive `- 2` would break silently.
5561
+
5562
+ **Not an AGENTS.md invariant.** The reserve lives entirely inside
5563
+ `text_view.rb`; no contributor can break it from another file, so it stays in
5564
+ that file's rdoc under the gate at the top of AGENTS.md.
5565
+
5566
+ ---
5567
+
5568
+ ## D_bg_surface — A component's own background: `default_bg_color`, keyed by state (2026-09-01)
5569
+
5570
+ **Status:** Accepted; implemented 2026-09-01. Closes
5571
+ [issue #11](https://github.com/mvysny/tuile/issues/11). Extends `D_bg_inherit`
5572
+ and `D_theme_ref`, which built the inheritance chain but left no level for a
5573
+ widget's *own* surface.
5574
+
5575
+ **Context.** `Component#bg_color=` documented itself as tinting a component and
5576
+ its subtree, and `#effective_bg_color` as "the background actually painted".
5577
+ Neither held for an editable field: `AbstractStringField#background` reached
5578
+ past the chain to `screen.theme` directly, so a `TextField` ignored an inherited
5579
+ tint *and* ignored a `bg_color` set on itself — the value was never read on the
5580
+ paint path. Six widgets did some version of that reach-around
5581
+ (`AbstractStringField`, `Select`, `ComboBox`'s `▾`, and the focus accent in
5582
+ `Button` / `Checkbox` / `Tabs`), which is why AGENTS.md had to describe "three
5583
+ camps, don't mix them" with a standing prohibition — *inherent-bg widgets must
5584
+ not set `bg_color`* — on the third.
5585
+
5586
+ Diagnosis: the chain was missing a level. There were three questions and only
5587
+ two names. *What's behind me?* was `effective_bg_color`; *does the app want to
5588
+ override it?* was `bg_color`; **do I paint an opaque surface of my own, and in
5589
+ what color?** had no name, so every widget that needed one reached *around* the
5590
+ chain instead of contributing *to* it.
5591
+
5592
+ **Decision.** Add the missing level as a protected hook, and let the app's
5593
+ answer at any level be keyed by component state.
5594
+
5595
+ ```
5596
+ effective_bg_color = @bg_color || default_bg_color || parent.effective_bg_color
5597
+ ```
5598
+
5599
+ - `default_bg_color` is protected, `nil` by default ("no surface of my own"),
5600
+ and overridden by the widgets that paint one. A non-nil answer terminates
5601
+ inheritance, which is what keeps a form's fields looking like fields inside a
5602
+ tinted panel.
5603
+ - `bg_color` (and `default_bg_color`) accept a `Hash` keyed by `BG_STATES`
5604
+ (`:normal`, `:active`) beside a `Color` / `Theme::Ref`. A missing key is not
5605
+ answered at that level and falls through, so `{ active: blue }` means "keep my
5606
+ own well, override the focus shade" and a flat `Color` means flat in every
5607
+ state.
5608
+ - `effective_bg_color` becomes protected and final; `bg_color` stays the one
5609
+ public knob.
5610
+ - `clear_outside_extent` blanks with a private `ambient_bg_color`
5611
+ (`@bg_color`, else the parent's `effective_bg_color`), skipping the widget's
5612
+ own default: outside its extent the widget is not there.
5613
+ - `AbstractStringField#background` is deleted; the text fields paint through
5614
+ `draw_text` like every other self-painter.
5615
+
5616
+ **Why a state map rather than picking one focus behavior.** The narrower
5617
+ question was where a field's focus highlight lives: *inside* the hook
5618
+ (`active? ? active_bg : input_bg`, so an app tint replaces the shade too) or
5619
+ *outside* it, as a `with_bg` layer over whatever resolved (so the shade
5620
+ survives a tint). Each solves half the problem. Inside, `select.bg_color = X`
5621
+ silently removes the *only* focus indicator a `Select` has — it paints no
5622
+ caret. Outside, an app can never produce the flat, focus-invariant surface that
5623
+ motivated the issue, and the mechanism covers only the widgets that happen to
5624
+ paint a well.
5625
+
5626
+ The state map subsumes both, and turns the hole in the first option into a
5627
+ choice: pass a flat color and you get a flat surface; pass a pair and you keep a
5628
+ shade of your own choosing. It is also the shape the hook already wanted — one
5629
+ channel answering "what color for the state I am in" — where the layer variant
5630
+ needs a second, un-settable channel beside it.
5631
+
5632
+ **Why this is not the CSS road.** The reflex objection was that state keys are
5633
+ pseudo-classes. They are not: what makes CSS CSS is selectors matching across
5634
+ the tree, plus specificity, plus the cascade. A small closed set of states,
5635
+ resolved on the component itself, is **Android's `ColorStateList`** — a bounded,
5636
+ well-regarded design. The guardrails that keep it there, all enforced or
5637
+ recorded: the key set is framework-defined and the setter raises on anything
5638
+ else; a key is added only when Tuile grows the *state* (hence no `:disabled`
5639
+ today — there is no disabled state, no `enabled?`, no focus-skipping and no
5640
+ theme token, and a key promising one would be a lie); and a `Hash` resolves
5641
+ against **its owner's** state only, never a descendant's.
5642
+
5643
+ **Naming.** `normal:` over `inactive:` — a state set names its base state
5644
+ positively, or the day `disabled:` arrives `inactive` reads as a superset of it.
5645
+ `default:` was unavailable: "terminal default" is load-bearing vocabulary in
5646
+ exactly this area.
5647
+
5648
+ **Alternatives rejected.**
5649
+ - *Override the `bg_color` **reader*** in `AbstractStringField` (`super || well`)
5650
+ — a two-line fix needing no new API. Rejected on three counts: the reader
5651
+ stops meaning "what the app set", which is the test the framework needs to
5652
+ distinguish an app tint from a widget well; that distinction is exactly what
5653
+ the dead-tail rule requires, so recovering it means reading `@bg_color` around
5654
+ your own accessor; and a widget's private well silently becomes a subtree tint.
5655
+ `bg_color` / `bg_color=` / `effective_bg_color` are marked final partly to
5656
+ foreclose this route.
5657
+ - *A public `opaque=` flag* (default false, true on fields, app-flippable). It
5658
+ found something real — a per-instance opt-out, unreachable today without
5659
+ subclassing — but the name imports the compositing model `D_bg_inherit`
5660
+ refused, and collides head-on with the standing "terminal cells are opaque".
5661
+ It is also a no-op on any component with no `default_bg_color`, and dead in
5662
+ combination with a set `bg_color`. The capability itself was real, and landed
5663
+ the same day as the sentinel this note called for — see the amendment below.
5664
+ - *A `bg_color=` forwarded from a composed field to its inner one*
5665
+ (`super; content.bg_color = color`) — illegal, since the setter is final, and
5666
+ unnecessary: the composer owns the well and marks its face `BG_INHERIT`
5667
+ instead, which also deletes the duplicated `active? ? … : …` the `ComboBox`
5668
+ `▾` was carrying.
5669
+ - *Migrating `Button` / `Checkbox` / `Tabs` / `MenuBar` / `List` onto the hook.*
5670
+ Expressible — `{ active: active_bg_color }` with no `:normal` key is exactly
5671
+ their behavior — but deliberately not done here. Their accent is `with_bg`
5672
+ (override-all) where the chain is `under_bg` (fill-unset), so an app-styled
5673
+ caption span would start surviving the highlight; and for `Tabs` / `MenuBar` /
5674
+ `List` the accent covers a *segment or row*, not the component, which the
5675
+ per-component hook cannot express at all. A separate call, on its own merits.
5676
+
5677
+ **Consequences.**
5678
+ - AGENTS.md's "three camps" becomes two, and the prohibition on a well widget
5679
+ setting `bg_color` is gone — that is the bug, not the rule.
5680
+ - `Select#face_row` no longer stomps span backgrounds with `with_bg`, so an item
5681
+ label carrying its own background now keeps it.
5682
+ - A new composed field owes a `default_bg_color`, or its face paints untinted;
5683
+ a new widget with a well owes one plus an `extent`, or its dead tail lies.
5684
+ - The hook must not allocate: `TextArea` resolves the chain once per painted
5685
+ row, so a `Hash` built per call would put an allocation on the repaint path.
5686
+ Branch on `active?` and return one `Color`.
5687
+ **Amendment (2026-09-01): `BG_INHERIT`, and ownership is told, not inferred.**
5688
+ The first cut had `AbstractStringField#default_bg_color` return `nil` when
5689
+ `parent.is_a?(HasValue)` — the leaf working out for itself whether a composed
5690
+ field owned its surface. That was wrong in both directions, and it was
5691
+ positional where the question is structural:
5692
+
5693
+ - *False negative.* Anything inserted between composer and field — a `Layout`,
5694
+ a `Slot` — makes the parent something else, the field reclaims its well, and
5695
+ the composer's `bg_color` goes inert over the field's cells again. Issue #11
5696
+ resurrected by a refactor with nothing to do with backgrounds.
5697
+ - *False positive.* An app composite that happens to include `HasValue` and
5698
+ holds a `TextField` alongside other widgets silently loses a well it wanted,
5699
+ with nothing saying why.
5700
+
5701
+ So the owner says it out loud. `Component::BG_INHERIT` (the Symbol `:inherit`)
5702
+ assigned to `bg_color` means **skip my own `default_bg_color`, take what
5703
+ surrounds me** — CSS's `background: inherit`. Each composer marks its face
5704
+ (`field.bg_color = BG_INHERIT`) at construction and declares the well itself;
5705
+ `AbstractStringField#default_bg_color` is unconditional again. `spec` pins both
5706
+ halves, including a `Layout` inserted between composer and field, which is the
5707
+ case the old rule failed.
5708
+
5709
+ *Why the sentinel rather than a marker flag.* It is the opt-out the `opaque=`
5710
+ alternative above was groping for, and it serves a **second** caller: an app
5711
+ making a field sit flush in a tinted panel writes `field.bg_color = BG_INHERIT`
5712
+ rather than repeating the ancestor's `Theme::Ref` or subclassing (the pikuri
5713
+ prompt that opened issue #11). One property, no dead combinations, no no-op on
5714
+ components without a default. `nil` keeps its own distinct meaning — fall
5715
+ through to `default_bg_color` *first* — so the two are not redundant.
5716
+
5717
+ *What it costs.* `ambient_bg_color` owes the same `BG_INHERIT` check as
5718
+ `effective_bg_color`, or the dead tail paints the literal `:inherit`. And the
5719
+ mark and the override are a **pair**: measured on 2026-09-01, dropping the
5720
+ composer's `default_bg_color` while keeping the mark leaves the face with no
5721
+ well at all (`nil`, a ComboBox reads as plain text); dropping both puts the
5722
+ inner field's own well back and makes the composer's `bg_color` inert over it
5723
+ (`IntegerField` entirely so) with the `▾` alone taking the tint. Neither is
5724
+ caught by the numeric fields' specs, which assert nothing about their well.
5725
+
5726
+ *Named, not chosen:* `TRANSPARENT` — accurate about the effect, but it imports
5727
+ the compositing model `D_bg_inherit` refused and collides with the standing
5728
+ "terminal cells are opaque". `INHERIT` is the house word (this section is
5729
+ "Background color (opt-in, inherited)") and matches CSS. A bare Symbol is safe
5730
+ where `D_theme_ref` rejected one for `Theme::Ref`: that objection was
5731
+ ambiguity with `Color.coerce`'s ANSI colour *names*, and `:inherit` is not one.
5732
+
5733
+ - **`Label#bg` is deleted** (the wart `D_bg_inherit` flagged and parked). It
5734
+ predated the chain and did two things: fill behind the text, the trailing pad
5735
+ and the blank rows — which is exactly `bg_color` now, down to the padding,
5736
+ since `Label#repaint` routes every row through `draw_text` — and *stomp* a
5737
+ span's own background via `with_bg`. Only the second was unique, and it is a
5738
+ restyle of the text rather than a property of the component, so it belongs on
5739
+ the text: `label.text = text.with_bg(c)`. Migration is `label.bg = c` →
5740
+ `label.bg_color = c`, plus the `with_bg` above only if the text carries span
5741
+ backgrounds you meant to override. Keeping it would have left two spellings of
5742
+ "this label's background" that differ only in an edge case, one of them
5743
+ invisible to inheritance, `Theme::Ref` and the state map.
5744
+
5745
+ ---
5746
+
5747
+ ## D_scrollbar_ink — The scrollbar's ink: a theme token, and no handle when nothing scrolls (2026-09-01)
5748
+
5749
+ **Status:** Accepted; implemented 2026-09-01 (`Theme#scrollbar_color`,
5750
+ `VerticalScrollBar#scrollbar_char`, `VerticalScrollBar.handle_char=`). Fixes
5751
+ [issue #10](https://github.com/mvysny/tuile/issues/10). Sits beside
5752
+ `D_scrollbar_reserve`, which fixed the bar's *geometry* from the same borderless
5753
+ pane work; this fixes its *ink*.
5754
+
5755
+ **Context.** `scrollbar_char` returned a bare `█` / `░` and both call sites —
5756
+ `List#paintable_row` and `TextView#paintable_row` — wrapped it in
5757
+ `StyledString.plain`, so the bar painted in the terminal's **default
5758
+ foreground**: on a dark scheme, near-white. And it was loudest exactly when it
5759
+ said least, because `row_count <= height` sets `handle_height = height`: a
5760
+ full-height, 100%-ink column carrying no information at all. Inside a `Window`
5761
+ this read as chrome; in a borderless pane (`D_status_bar`'s panes, a
5762
+ `LogTextView`) it became the loudest thing on screen, with `scrollbar_visibility
5763
+ = :gone` — trading the whole indicator away — as the only lever.
5764
+
5765
+ The two halves are independent, and only one of them needs a theme.
5766
+
5767
+ **Decision — no handle when there is nothing to scroll, and that is an *ink*
5768
+ rule.** `scrollbar_char` returns the track glyph at every row when
5769
+ `row_count <= height`. The handle geometry readers still report the covering
5770
+ handle; what changed is what gets drawn. This matters because the issue asked
5771
+ for it as an `:auto` **visibility** mode, which `D_select` refuses and still
5772
+ refuses: making visibility a function of `rect.height` makes `content_width` /
5773
+ `wrap_width` a function of `rect.height` too, while the padded-row cache is
5774
+ rebuilt from the width-only `on_width_changed` — a height-only resize would
5775
+ leave every row one column off, silently. Going quiet inside the *glyph* touches
5776
+ none of that: `scrollbar_visible?` stays true, the column stays reserved, the
5777
+ wrap width never moves (pinned by a spec in each component). So the request is
5778
+ granted without reopening the ban, and the two must not be conflated later —
5779
+ `:auto` is still the wrong name and the wrong mechanism.
5780
+
5781
+ *Rejected: a third `scrollbar_visibility` value* (`:when_scrollable`) preserving
5782
+ the old look under `:visible`. It would spend API surface defending the exact
5783
+ behavior being complained about; a full-height solid handle has no defenders.
5784
+ The change is cosmetic and ships as a `Fix`.
5785
+
5786
+ *Rejected: painting blanks rather than `░`.* The track keeps the affordance —
5787
+ "a bar lives here, there is nothing to scroll" — where a blank column reads as a
5788
+ layout bug. And with `DARK`'s token the sparse `░` is already near-invisible,
5789
+ which is the quiet the issue asked for.
5790
+
5791
+ **Decision — one token, `Theme#scrollbar_color`, read at paint time.** The
5792
+ precedent is exact: `active_border_color` is framework-chrome *foreground*, and
5793
+ this is the same kind of thing. Both `paintable_row`s style the glyph span with
5794
+ `screen.theme.scrollbar_color`; `VerticalScrollBar` stays a pure geometry helper
5795
+ with no `Screen` dependency, which is why the token is read by the components and
5796
+ not by it. Two consequences fall out free: `CHROME_TOKENS` derives from
5797
+ `members`, so `Theme.ref(:scrollbar_color)` works the day it lands
5798
+ (`D_theme_ref`), and `Buffer#flush` quantizes at the wire, so nothing is
5799
+ pre-degraded (`D_color_depth`).
5800
+
5801
+ *Rejected: a second token for the empty track*, which the issue floats on the
5802
+ grounds that `░` and `█` want different weights against the same background. They
5803
+ already have them: `░` is ~25% ink against `█`'s 100%, so the glyph delivers the
5804
+ weight difference a second token would buy. And with the quiet-handle rule above,
5805
+ `░` is the *resting* state and `█` appears only when it means something. A second
5806
+ token stays purely additive if one proves flat.
5807
+
5808
+ *Rejected: reusing `hint_color`.* Tempting — it is the theme's "de-emphasized
5809
+ chrome" color — but since `D_status_bar` deleted the status bar in 0.13.0,
5810
+ `hint_color` is the one token **nothing in `lib/` paints with**: it is now
5811
+ app-facing. Loading framework chrome onto it means a theme author cannot retune
5812
+ their hints without retuning every scrollbar.
5813
+
5814
+ *Rejected: a per-component `scrollbar_color=` accessor.* The same rule
5815
+ `D_bg_surface` closes with — a component does not grow a private color accessor
5816
+ beside the theme channel. An app that wants one bar different from another styles
5817
+ the theme, or (per-slot, live-resolved) uses `Theme.ref`.
5818
+
5819
+ **Decision — the two glyphs are an app-global knob on the class.**
5820
+ `VerticalScrollBar.handle_char=` / `.track_char=`, so an app can ask for a
5821
+ lazygit-style bar (`▐` over a `│` rule) in two lines. Scope was the whole
5822
+ question, and app-global is right for the same reason `Theme` is: a scrollbar
5823
+ style is look-and-feel, which an app wants *uniform*, and per-component styling
5824
+ would make inconsistency the default. It is also the shape `D_ambiguous_width`
5825
+ already blessed — the pretty glyph offered as an opt-in knob, alongside
5826
+ `TextField#mask_char=` — and the precedent for a reassignable app-global lives
5827
+ next door in `ThemeDef.default`, whose spec-restore discipline this inherits.
5828
+ Both call sites already construct a bar per repaint, so nothing had to be
5829
+ plumbed through `List` / `TextView`.
5830
+
5831
+ *Rejected: passing the glyphs through the constructor, or a per-component
5832
+ `scrollbar_glyphs=`.* Two components, two more setters, two more things to keep
5833
+ in sync, to serve a choice an app makes once. If one component ever genuinely
5834
+ needs to differ, an instance override is additive on top of this.
5835
+
5836
+ **The knob validates at assignment: one grapheme cluster, one column.**
5837
+ `paintable_row` concatenates the glyph onto a row padded to fill the rest of the
5838
+ rect, so a two-column glyph pushes *every* painted row one column past
5839
+ `rect.width` — the "exactly `rect.width` columns" contract the whole paint path
5840
+ rests on, broken silently, with nothing in the diff to notice. That is the same
5841
+ silent-corruption class `D_select` refuses an `:auto` mode for, and it is why the
5842
+ check is at the writer rather than at paint, where the symptom is a corrupt frame
5843
+ with nothing to point at. Note this stays *inside* `D_ambiguous_width`'s bet
5844
+ rather than testing it: `█`, `░`, `│` and `▐` are all East-Asian Ambiguous and
5845
+ all measure 1 under the gem's policy.
5846
+
5847
+ **The token is required, not defaulted.** `Theme` is a `Data.define` with
5848
+ required kwargs, so a new member breaks an explicit `Theme.new(...)`. Defaulting
5849
+ it would have kept those callers working while baking a dark-tuned grey into
5850
+ light themes; `theme.rb`'s own rdoc says a theme "is declared once so the
5851
+ verbosity self-documents", and the class fail-fasts on every other input
5852
+ (`TypeError` on a non-`Color`, `KeyError` on a `custom` typo). So it ships as a
5853
+ **Breaking** changelog entry with a one-line migration. `Theme::DARK.with(...)`,
5854
+ the documented construction path, is unaffected either way.
5855
+
5856
+ **Defaults.** `DARK` reuses `GREY37` (59), the `active_bg_color` value, so the
5857
+ handle carries the same weight as the selection well — and the sparse track at
5858
+ that color is near-invisible against a #1d–#2d terminal background, which is the
5859
+ resting state we want. `LIGHT` inverts the reasoning rather than copying the
5860
+ token: a *foreground* on a pale background must be darker than it, so `GREY62`
5861
+ (247, ~#9e9e9e) sits a step below the light theme's highlights instead of
5862
+ matching them.
5863
+
5864
+ **Parked: focus-awareness.** The bar in the *focused* pane is the one the user
5865
+ can actually drive, so it arguably wants the brighter ink — the question
5866
+ `ideas/focus-accent.md` holds. Doing it here would mean importing `BG_STATES`-style
5867
+ state-keyed maps (`D_bg_surface`) into a foreground token for one widget's sake.
5868
+ Not built, not foreclosed.
5869
+
5870
+ ## D_paste_newlines — A one-line field keeps the paste's first line (2026-09-03)
5871
+
5872
+ **Status:** Accepted and implemented in `TextField#preprocess_paste`. Supersedes
5873
+ the flatten-to-spaces rule recorded in `D_bracketed_paste`.
5874
+
5875
+ **Context.** `TextField` holds one row, so a pasted `\n` has to go somewhere. It
5876
+ used to become a space. That is wrong on the *dominant* real paste: a whole line
5877
+ copied from an editor or a terminal carries a trailing newline, and flattening
5878
+ turned `"widget-3141\n"` into `"widget-3141 "` — an invisible trailing space that
5879
+ survives into whatever the app does with the value, and that no user can see to
5880
+ delete.
5881
+
5882
+ **Nobody agrees, so "what everyone does" was not available.** Measured against
5883
+ the neighbours rather than recalled:
5884
+
5885
+ | toolkit | `"a\nb"` pasted into its single-line input | mechanism |
5886
+ |---|---|---|
5887
+ | HTML `<input type=text>` | `"ab"` — **stripped** | the spec's value sanitization algorithm ("strip newlines") |
5888
+ | Textual `Input` (a TUI) | `"a"` — **first line** | `event.text.splitlines()[0]` in `_on_paste` |
5889
+ | GTK4 `GtkText` / `GtkEntry` | `"a"` with `truncate-multiline`, else all of it | the `truncate-multiline` property, default `FALSE` |
5890
+ | Swing `JTextField` | `"a b"` — **space** | `PlainDocument`'s `filterNewlines`, set true by `JTextField` |
5891
+ | Qt `QLineEdit` | value keeps `"a\nb"`; *displays* `"a b"` | `QWidgetLineControl::updateDisplayText` rewrites C0 to spaces at paint |
5892
+
5893
+ Three camps, and Qt is really a fourth: it never sanitizes the value at all, only
5894
+ the pixels, so `text()` hands back a string with a newline in it that the widget
5895
+ never showed. Tuile can't take that road — `Buffer` is a grid of cells and a `\n`
5896
+ reaching it corrupts the frame — but it is worth naming, because it is the road
5897
+ "just fix the paint" leads to.
5898
+
5899
+ **Decision — keep the first line, drop the rest.** `super[/\A[^\n]*/]`, before
5900
+ the `max_text_length` trim. Of the three real options it is the only one that
5901
+ never *invents* content: stripping fuses `"John Smith\nMain St"` into
5902
+ `"John SmithMain St"`, a token that was in nobody's clipboard, and spacing
5903
+ manufactures the trailing blank above. Truncation only ever discards, and it
5904
+ discards the part a one-row field could not have shown anyway. It also agrees
5905
+ with stripping on the case that actually happens (a trailing newline), so the
5906
+ difference between them is confined to pastes that were already never going to
5907
+ fit.
5908
+
5909
+ Textual is the precedent that counts here — same medium, same constraint, same
5910
+ ruling — over HTML's, which inherits a sanitization algorithm written for form
5911
+ submission rather than for editing.
5912
+
5913
+ *Rejected: a `truncate_multiline` knob*, GTK's answer. It buys the caller a
5914
+ choice between two lossy behaviours neither of which they can act on, and a
5915
+ field that keeps every line is a `TextArea`. *Rejected: rejecting the paste
5916
+ outright* — that is right for a field whose grammar the paste violates
5917
+ (`D_input_filters`) and wrong here, where the first line is perfectly good input.
5918
+
5919
+ ## D_input_filters — Filter input at `insert_text`, and only where the grammar is prefix-closed (2026-09-03)
5920
+
5921
+ **Status:** Accepted and implemented in `AbstractStringField#insert_text`
5922
+ (promoted to the documented seam), `TextField#insert` / `TextArea#insert_char`
5923
+ (both now route through it), and the nested `Field` subclasses of
5924
+ `IntegerField`, `FloatField` and `BigDecimalField`.
5925
+
5926
+ **Context — the filter was on the wrong event, and shipped broken for it.**
5927
+ The three numeric fields kept their "digits only" rule in a `field_key` proc
5928
+ wired as the inner field's `on_key`, and `on_key` is consulted only from
5929
+ `handle_key`. A paste is not a key (`D_bracketed_paste`), so it walked straight
5930
+ past. Measured on `master` before this entry:
5931
+
5932
+ ```
5933
+ typed "xyz" → text "" value nil # filter works
5934
+ pasted "xyz" → text "xyz" value nil # filter bypassed
5935
+ 42, pasted "abc" at 0 → text "abc42" value nil
5936
+ FloatField, "1,5" → text "1,5" value nil # a plausible real paste
5937
+ ```
5938
+
5939
+ So all three fields could be put in a state their own rdoc said was impossible,
5940
+ with one Ctrl-V. The deeper fault is one of *altitude*: the rule constrains the
5941
+ **buffer**, and it was written against the **event**, which is why there were two
5942
+ paths and only one of them was guarded.
5943
+
5944
+ **Decision — one seam, at the text mutation.** `insert_text` is now the sole
5945
+ insertion point for every string field: a typed character
5946
+ (`TextField#insert`), the ENTER newline (`TextArea#insert_char`) and a whole
5947
+ pasted clipboard all land there. A field constrains its contents by overriding
5948
+ it. There is no second thing to remember, and no way to guard typing and forget
5949
+ paste — the shape of the code makes that misfeature unwritable.
5950
+
5951
+ **Decision — judge the result, not the fragment.** The override tests the whole
5952
+ resulting buffer against a `TYPEABLE` regexp rather than sieving the inserted
5953
+ string character by character. Sieving is the seductive one — it "salvages" a
5954
+ messy paste — and it is how `"1,5"` becomes `"15"`: a plausible number, off by a
5955
+ factor of ten, that the user never copied and cannot see is wrong. Rejecting the
5956
+ paste whole is also exactly what typing the comma does, so the two paths stay
5957
+ indistinguishable to the user.
5958
+
5959
+ **Decision — prevention requires a prefix-closed grammar; otherwise report.**
5960
+ This is the criterion that says which mechanism a field gets, and it is the
5961
+ useful half of this entry:
5962
+
5963
+ - A grammar is **prefix-closed** when every valid value can be reached through
5964
+ valid intermediate states. An integer buffer is (`""`, `"-"`, `"-1"`), so is a
5965
+ decimal (`"1."` must be reachable, and is a member of `TYPEABLE` even though
5966
+ `value` reads it as `1`). Such a field can *prevent* bad input, and then it
5967
+ has none: `IntegerField#value` is now `nil` only for the two half-typed states,
5968
+ never for garbage.
5969
+ - A date is **not**: `"2020-13-45"` is well-formed at every character and denotes
5970
+ nothing, and month lengths and leap years are whole-string facts, so no filter
5971
+ over insertions can decide it. A field like that must accept the input and
5972
+ report it bad — the `HasBadInput` channel (`D_bad_input`).
5973
+
5974
+ The two are complements, not rivals: prevention where it is total, reporting
5975
+ where prevention is impossible. What must not happen is a *partial* filter, which
5976
+ is the worst of both — it looks like a guarantee, and isn't.
5977
+
5978
+ **Roads not taken, for keeping a value out of a field.**
5979
+
5980
+ - *Watch `on_change` and revert.* The callback has already fired for the state
5981
+ you are about to undo, so every other observer sees the bad value and acts on
5982
+ it; the revert fires a second round; and the caret has nowhere sensible to
5983
+ land. It also cannot distinguish a bad *user edit* from a bad programmatic
5984
+ `value=`.
5985
+ - *A veto seam on `HasValue`.* Wrong layer twice over. `HasValue` is deliberately
5986
+ thin (`D_has_value`), and for the composed fields `value` is a **derived
5987
+ parse** of the buffer (`D_integer_field`) — there is no "value is being set"
5988
+ moment to veto while the user types, so any veto would have to run on the
5989
+ buffer anyway, which is where it now is.
5990
+ - *Keep the per-key filter and add a parallel paste filter.* The two-seam design
5991
+ that caused this. A future field would have to remember both, and the one that
5992
+ forgot would fail silently and only under Ctrl-V.
5993
+ - *Filter in `text=`.* Too wide: it would police the programmatic `value=`, which
5994
+ legitimately writes shapes no key types (`FloatField`'s `"1.0e-05"`). Only
5995
+ *user input* is filtered, which is exactly what `insert_text` means.
5996
+
5997
+ **Consequence — `e` became typeable in a `FloatField`.** The old per-character
5998
+ rule admitted no `e`, while `value = 1e-5` writes `"1.0e-05"` into the buffer, so
5999
+ the field could display a value the user was then unable to edit — every
6000
+ insertion into it would now be rejected by a `TYPEABLE` without an exponent. The
6001
+ grammar therefore admits the exponent, which widens typing slightly and closes
6002
+ that hole. A displayed buffer should always be one the user can go on editing;
6003
+ that is a general rule for a field with a `TYPEABLE`.
6004
+
6005
+ ## D_no_key_interceptor — No `on_key` callback: a component that wants a key subclasses (2026-09-03)
6006
+
6007
+ **Status:** Accepted. `AbstractStringField#on_key` is **deleted**; its three
6008
+ consumers moved to `ComboBox#handle_key` + `on_escape`, the sampler's
6009
+ `SlashCommandTextArea`, and (downstream) a `TextArea` subclass in pikuri-tui's
6010
+ `ConfirmerPopup`.
6011
+
6012
+ **Context — it was a veto wearing a listener's clothes.** `on_key` was a proc
6013
+ consulted *before* the field's own key handling, with a truthy return consuming
6014
+ the key. Every sibling seam on the class either reports something
6015
+ (`on_change`, `on_value_change`) or claims **one named key** (`on_enter`,
6016
+ `on_escape`, `on_key_up`, `on_key_down`). `on_key` claimed *all* of them,
6017
+ pre-emptively. That is a behavior override, and COP's answer to a behavior
6018
+ override is the sanctioned inheritance carve-out — subclass the widget to *be*
6019
+ the component — not an injected proc.
6020
+
6021
+ **Decision — delete it; `handle_key` (and its `handle_text_input_key` hook) is
6022
+ the seam.** Three properties decided it:
6023
+
6024
+ - **It duplicated an existing, better seam.** `Component#handle_key` is already
6025
+ the per-component key hook, and it composes two ways `on_key` could not:
6026
+ through `super` (a subclass claims one key and inherits the rest) and through
6027
+ the rung-3 bubble (an ancestor sees what the focused component declined).
6028
+ `on_key` was one slot, no chaining.
6029
+ - **The slot was contended, and losing it was silent.** A composed field must
6030
+ claim its inner field's single `on_key` to do anything with keys — all four
6031
+ did — so an app writing `combo.content.on_key = mine` silently disabled the
6032
+ widget's own behavior, and the widget writing it silently disabled the app's.
6033
+ There is no such contention on a subclass.
6034
+ - **It sat at the wrong altitude for what people reached for it for.** The
6035
+ three numeric fields put their input filter there, and a paste walked past it
6036
+ for two releases (`D_input_filters`). By the end its own rdoc had to warn
6037
+ readers off the obvious use — a doc that says "don't use this API for the
6038
+ thing it looks like it's for" is the API being wrong, not the doc.
6039
+
6040
+ **And *not* promoted to `Component`.** The tempting generalization — if
6041
+ `handle_key` is on `Component`, why is `on_key` only on string fields? — points
6042
+ the other way. A universal pre-dispatch veto is a **fourth rung on the key
6043
+ ladder**: a per-component gate consulted before delivery, which is exactly the
6044
+ capture phase `D_key_dispatch` deleted in 0.10.0 and exactly what AGENTS.md's
6045
+ "no gate, no predicate and no mode flag anywhere in it" forbids. The right
6046
+ generalization was the one already there: `handle_key`.
6047
+
6048
+ **Each consumer got *better*, which is the evidence the seam was wrong.**
6049
+
6050
+ - **`ComboBox` needed no subclass at all** — it uses the **bubble**.
6051
+ `ListDropdown::MOVE_KEYS` is `UP/DOWN/PAGE_UP/PAGE_DOWN/^U/^D`, deliberately
6052
+ excluding Home/End *because the combo's field needs them for the caret*; the
6053
+ field claims just one of the six (no `on_key_up`/`on_key_down` set; `^U` clears
6054
+ the query as of `D_kill_keys`) and exposes no `on_enter`, so the other five
6055
+ plus ENTER decline and reach `ComboBox#handle_key` untouched, while printables
6056
+ and editing keys are consumed below and never arrive. The whole `field_key` if/elsif tree became a seven-line `handle_key`.
6057
+ ESC is the one exception — the field consumes it — so the combo takes it
6058
+ through the purpose-fit `on_escape`. All 39 combo specs passed unchanged,
6059
+ driving real dispatch, which is what makes the equivalence a measurement
6060
+ rather than an argument.
6061
+ - **The sampler's slash menu became `SlashCommandTextArea`.** Note what does
6062
+ *not* work here: routing it "through the value". `on_change` already does the
6063
+ refill that way, but Up/Down/PgUp/PgDn over a dropdown have no value
6064
+ semantics at all — navigation is irreducibly about keys. It just doesn't need
6065
+ a *callback*; it needs an override.
6066
+ - **pikuri-tui's `ConfirmerPopup`** claims ENTER on a `TextArea`, which is the
6067
+ exact case book ch5 already teaches as `PromptTextArea < TextArea`. The gem
6068
+ documented the subclass route *and* shipped the callback, and the downstream
6069
+ app reached for the callback — the clearest sign the two-ways-to-do-it was
6070
+ costing something.
6071
+
6072
+ **What is deliberately kept.** The *named* key callbacks stay:
6073
+ `TextField#on_enter` / `#on_key_up` / `#on_key_down` and
6074
+ `AbstractStringField#on_escape`. They are not vetoes — each claims one key whose
6075
+ meaning the widget itself has no use for, they compose (four can coexist), and
6076
+ ESC in particular *must* be a callback rather than a bubble, since the field
6077
+ consumes it before any ancestor could see it.
6078
+
6079
+ **Re-grow rule.** A general key callback comes back only if a caller appears
6080
+ that genuinely *cannot* subclass — which today means the framework would first
6081
+ have to grow a way to inject an inner field into a composed widget. Even then it
6082
+ would be a constructor-injected component, not a proc slot.
6083
+
6084
+ ## D_bad_input — `HasBadInput`: the field reports input its value cannot represent (2026-09-03)
6085
+
6086
+ **Status:** Accepted; **v1 (the pull) implemented** in
6087
+ `Component::HasBadInput`, included by `IntegerField`, `FloatField` and
6088
+ `BigDecimalField`. The push notice (`on_bad_input_change`) is **deferred
6089
+ indefinitely, not rejected** — see "No push notice yet" below; the `on_blur`
6090
+ hook it was waiting on has since shipped for its own reasons (`D_on_blur`).
6091
+
6092
+ **Context — `on_value_change` is structurally incapable of carrying this.** It
6093
+ is a diff over **values**, and the map from input to value is not injective:
6094
+ every unrepresentable input collapses onto the same `nil`. The information was
6095
+ destroyed by the parse before the diff ran. With a *derived* parse
6096
+ (`D_integer_field`) that leaves four cases, and only one of them fires anything:
6097
+
6098
+ | input before | input after | `value` before | after | `on_value_change` |
6099
+ |---|---|---|---|---|
6100
+ | `"2020-01-01"` | `"xyz"` | a date | `nil` | **fires** (`nil`) — but says *empty*, not *bad* |
6101
+ | `""` | `"xyz"` | `nil` | `nil` | **silent** |
6102
+ | `"xyz"` | `"xyzw"` | `nil` | `nil` | **silent** |
6103
+ | `"xyz"` | `""` | `nil` | `nil` | **silent** — and the field is now *genuinely* empty |
6104
+
6105
+ The silent rows are the plumbing problem; the first row is the semantic one —
6106
+ even when an event fires it reports the wrong fact, and a form reading "empty"
6107
+ as "the user cleared it" saves `nil` over a value they believe they typed.
6108
+ Vaadin hit this and named it (v25.2 `components-binder-validation.md`): *"Since
6109
+ the field is optional, the binder doesn't complain… This behavior can create the
6110
+ illusion for the user that they were able to save an invalid value."*
6111
+
6112
+ **Decision — a mixin with one override point, returning a message or `nil`.**
6113
+ `bad_input_message` is the whole seam; `bad_input?` is its presence. A message
6114
+ rather than a boolean because the *reason* differs per field kind and the field
6115
+ is where that constant belongs. `nil`-means-fine is the convention
6116
+ `Component#extent` already uses. Being a mixin is what makes
6117
+ `is_a?(HasBadInput)` a locator seam for a future forms layer and for tests — the
6118
+ same argument that keeps `HasCaption` a mixin (`D_has_value`). Both members are
6119
+ **public**: the reader is the *app*, not the framework, so `D_hook_visibility`'s
6120
+ protected-hook rule doesn't apply. The default raises `NotImplementedError`
6121
+ (`Layout::Box`'s precedent) rather than returning `nil`, so including the mixin
6122
+ and forgetting the override is loud instead of a silent "never bad".
6123
+
6124
+ **Decision — empty input is not bad input.** An empty buffer parses to nothing
6125
+ too, so the naive predicate is `value.nil?` and it is wrong: an optional field
6126
+ left blank would block every save, which is the exact failure this channel
6127
+ exists to prevent, inverted. Each override therefore reads
6128
+ `value.nil? && !content.text.empty?`. Emptiness is `HasValue#empty?`'s fact;
6129
+ this one is about input the value *could not use*. The three-line rule is
6130
+ duplicated per field rather than derived in the mixin from an abstract `input`
6131
+ reader: that base would need two hooks over one expression (`D_float_field`'s
6132
+ duplicate-rather-than-DRY rule), and a `DateField` will not share the shape
6133
+ anyway — a mask distinguishes *incomplete* (`"__/05/2026"`) from *invalid*, which
6134
+ Vaadin gives its own message (`setIncompleteInputErrorMessage`).
6135
+
6136
+ **Decision — the field reports; it never stores a verdict.** Two error
6137
+ categories exist and exactly one belongs to the component:
6138
+
6139
+ | | **bad input** — this entry | a rule's verdict — elsewhere |
6140
+ |---|---|---|
6141
+ | example | `"xyz"` is not a date; a lone `"-"` | must be in the past; age ≥ 18 |
6142
+ | authority | **the field, and only the field** (it owns the format) | the app / a binder (it owns the domain) |
6143
+ | when known | on every input mutation | when the rules run |
6144
+ | the field's role | **it is the fact** | a mailbox it cannot fill, defend, or recompute |
6145
+
6146
+ The tempting economy is one `invalid?` flag both write. Vaadin's own custom-field
6147
+ guide warns against it (*"Do not rely on the same `invalid` and `errorMessage`
6148
+ properties for internal validation. Otherwise… external validation is likely to
6149
+ override or ignore the internal state."*) and then needs two mechanisms to stop
6150
+ the shared flag lying — a pull via `getDefaultValidator` and a push via
6151
+ `ValidationStatusChangeEvent`. Tuile inherits neither, because it puts the two
6152
+ facts in two *places* rather than sharing one cell — which is exactly what
6153
+ `D_has_validation` then built: `error_message` is a *stored* member only an
6154
+ outside validator writes, beside this *derived* one only the field answers, and
6155
+ there is still no `invalid?` anywhere.
6156
+
6157
+ **Decision — fixed English, one frozen constant per field kind, no
6158
+ interpolation.** `"not a whole number"`, never `"'xyz' is not a whole number"`:
6159
+ the method is called per read and a future error ink would call it per paint, so
6160
+ interpolating allocates a fresh `String` every call (the rule `default_bg_color`
6161
+ already follows), and it sidesteps quoting a 500-character paste into a message.
6162
+ There is no wording knob even though every *other* user-facing string in the gem
6163
+ is an overridable default (`ConfirmWindow.alert(..., button: "OK")`), because
6164
+ **the message is advisory and `bad_input?` is the escape hatch**: a consumer
6165
+ wanting its own prose, in any language, reads the boolean and composes its own.
6166
+ That is what makes fixing the language cheap *and* reversible. **Re-grow rule:**
6167
+ when i18n arrives it arrives as the *wording* fork — a settable message, or a
6168
+ catalogue lookup inside `bad_input_message` — never as a redesign of the channel.
6169
+
6170
+ **Decision — the fact is continuous; the consumers settle. No push notice yet.**
6171
+ Every prefix of a valid date is bad input, so typing `2026-05-01` walks nine bad
6172
+ states before one good one. The signal is correct at every instant and unusable
6173
+ if consumed naively — an enabled-state Save button would flicker while the user
6174
+ types *correctly*. Rather than settle centrally, the fact stays continuous and
6175
+ each consumer settles for itself; and v1 has no continuous consumer at all, so
6176
+ none is needed. A save gate asked at the click (`ideas/binder.md`) sees one
6177
+ settled state, and the red well reads the pull per paint. The push therefore
6178
+ lands with the first consumer that must react *between* keystrokes without
6179
+ being asked — which is also whoever owes the settling rule. Nothing is waiting
6180
+ on machinery any more: the commit point it would settle against is
6181
+ {Component#on_blur} (`D_on_blur`), which shipped for its own reasons, and the
6182
+ notice itself is one `attr_accessor` plus a sole-writer `sync_bad_input` in
6183
+ `ProgressBar#sync_ticker`'s discipline — called from every input mutation,
6184
+ never toggled by whichever event noticed. It is deferred for want of a
6185
+ *consumer*, and re-derivable in a sitting when one appears.
6186
+
6187
+ **Population — include it iff your parse is partial.** That is
6188
+ `D_integer_field`'s compose-vs-subclass taxonomy read from the other side:
6189
+
6190
+ - **Yes:** the three numeric fields, and a future date or masked field. Note the
6191
+ first three reach this list *after* prevention (`D_input_filters`): their
6192
+ grammar is prefix-closed, so their residue is the half-typed prefixes a filter
6193
+ must admit — `"-"` for `IntegerField`, and for `FloatField` an infinite family
6194
+ (`"e"`, `"1e"`, `"1.0e-"`, …). They need the channel least and are the only
6195
+ place to exercise it before a date field exists.
6196
+ - **No, the parse is identity:** `TextField`, `TextArea`, `PasswordField`. A
6197
+ string field's value *is* its input. `PasswordField` also pins a vocabulary
6198
+ boundary — **input is what the user put in, never what is painted**; the
6199
+ asterisks are `display_text`, one level below.
6200
+ - **No, input and value are one act:** `Checkbox`, `Select`, `RadioGroup`,
6201
+ `CheckboxGroup`. Nothing sits between the keystroke and the value.
6202
+ - **No, and it is the interesting exclusion:** `ComboBox`. It has an input layer,
6203
+ but the input is a **filter**, not a formatting of the value, so a no-match is
6204
+ not a failed conversion — it resolves the desync by *reverting* the query. A
6205
+ third strategy beside nil-out and report, and the reason the mixin is not
6206
+ called `HasInput`.
6207
+
6208
+ **Roads not taken.**
6209
+
6210
+ - *A `bad_input? = false` default on `HasValue`*, to spare consumers the
6211
+ `respond_to?`. It would put a field-kind concept on every `Checkbox` and
6212
+ destroy the locator seam — the same argument that kept `tab_stop?` out of
6213
+ `HasValue` (`D_has_value`). The capability is a class fact a consumer may cache
6214
+ at bind time; the status may never be.
6215
+ - *Cache the status in an ivar and diff it.* Caching a derived fact has bitten
6216
+ three times (theme accents, `bg_color`, `TextArea#@wrap`). Deriving it also
6217
+ makes notice *order* immaterial: when `"2020-01-01"` becomes `"xyz"`, both
6218
+ `on_value_change(nil)` and (once it exists) the bad-input notice fire, and
6219
+ whichever a consumer receives first, asking `bad_input_message` yields the
6220
+ current answer. The residual trap is a consumer that reacts to
6221
+ `on_value_change` *without* re-asking — it concludes "the user cleared the
6222
+ field", which is the illusion in the table above.
6223
+ - *Vaadin-faithful: one shared flag plus a pull seam and a push event.* Rejected
6224
+ on the strength of Vaadin's own warning above — the repair mechanisms exist
6225
+ *because* the flag is shared.
6226
+ - *Do nothing; bad input reads as empty.* The prior behavior. Defensible for the
6227
+ fields Tuile has now that prevention is in place — their residue is visibly
6228
+ half-typed — and not defensible at all under a text-input date field, where no
6229
+ filter can shrink the residue in the first place. Shipping the seam now is what
6230
+ keeps that field from inventing an ad-hoc `parse_error` accessor.
6231
+
6232
+ **Consequences elsewhere.**
6233
+
6234
+ - **`HasValue#empty?` gained an rdoc caveat** — it is empty of *value*, and a
6235
+ required-field rule must ask `bad_input?` first or it reports "required" for a
6236
+ field that is full.
6237
+ - **`clear` clears the *input*, not the value.** `HasValue#clear` is
6238
+ `self.value = empty_value` and the mixin's default `value=` returns early when
6239
+ the value is unchanged — so on a field holding bad input, whose `value` already
6240
+ reads `nil`, an inherited `clear` would be a silent no-op leaving the garbage on
6241
+ screen. Today's three are safe because each overrides `value=` without that
6242
+ guard; the rule is now written on `HasValue#clear` and specced per field.
6243
+ - **A field holds bad input *or* a value, never both**, which is why nothing
6244
+ here needs revert-on-commit machinery: with a derived parse, `value=`
6245
+ overwrites the input by formatting it, so setting a value *is* clearing the
6246
+ bad input and there is nothing left to revert. `ComboBox` is outside the
6247
+ population precisely because it breaks that — it holds both a query and a
6248
+ selected item, and resolves the divergence by reverting the query. Don't
6249
+ generalize either half.
6250
+ - **Items-plus-value components stay out of it.** `Select`, `ComboBox`,
6251
+ `RadioGroup` and `CheckboxGroup` deliberately allow a `value` their `items` do
6252
+ not contain, with no reconcile and no clamp (`D_combobox`, `D_checkbox_group`,
6253
+ `D_radio_group`). That is a domain rule, not bad input, and wiring it up here
6254
+ is the obvious wrong move now that a channel exists.
6255
+ - **`EmailField` is not blocked on this, and is probably not a component.** Its
6256
+ value *is* its input, so it has no bad-input state at all and contributes only
6257
+ a packaged regex — re-tiered toward reject in `ideas/new-components.md`.
6258
+
6259
+ ## D_caption_ownership — A field carries no caption; the layout around it does (2026-09-03)
6260
+
6261
+ **Status:** Accepted; the code side is a **non-change** — no field has ever
6262
+ included `HasCaption`, and this entry is what keeps it that way. The container
6263
+ half (`FormLayout`) is unbuilt. Graduated from
6264
+ `ideas/caption-and-error-ownership.md`, retired 2026-09-03; what it kept — the
6265
+ `FormLayout` geometry — is now `ideas/form-layout.md`.
6266
+
6267
+ **Context.** Vaadin shipped both answers, which is what made this a real fork
6268
+ rather than a preference. Vaadin 8: `field.setCaption("Name")`, the component
6269
+ renders its own label. Vaadin 25: `formLayout.addFormItem(field, "Name")`, the
6270
+ form item owns the geometry. Tuile had to pick before a `FormLayout` could
6271
+ exist, and the answer decides whether `HasCaption` reaches `HasValue`.
6272
+
6273
+ **Decision — the caption is the container's.** Three reasons, any one
6274
+ sufficient:
6275
+
6276
+ - **A field cannot paint one.** Layout is top-down: a field is handed one row
6277
+ and cannot grow a second, and advertising a wanted height is the bottom-up
6278
+ channel v0.9.0 deleted. A caption inside the row displaces the value. So
6279
+ `field.caption = "Name"` would be a stored string with *no reader on the
6280
+ component's own face* — the mailbox shape `D_bad_input` refused for a rule's
6281
+ verdict, in the other channel.
6282
+ - **The rendering is not the field's to fix.** Caption left, caption above,
6283
+ caption column aligned across a form — all three are legitimate, all three
6284
+ are the container's arithmetic, and storing the string on the field implies a
6285
+ rendering it never performs.
6286
+ - **Nothing above needs it there.** A binder binds values and writes verdicts;
6287
+ a caption is presentation. `D_has_value` already parks model-mapping above the
6288
+ field.
6289
+
6290
+ **The axis is "paints it", not "is a field",** and the existing membership
6291
+ already discriminates correctly: `Checkbox` is `HasValue` **and** `HasCaption`
6292
+ because it draws `[x] Enable logging` inside its own rect, as `Button` and
6293
+ `Window` draw theirs. So this entry bans a caption on `TextField`, not on every
6294
+ input.
6295
+
6296
+ **Consequence — caption-based lookup relocates, it does not die.** AGENTS.md's
6297
+ mixin-for-lookup rule (a tree walk finding "the Button captioned Submit" via
6298
+ `is_a?(HasCaption)` plus a compare) still holds for the three painters, which
6299
+ still pass the reachable-plus-more-than-one-class test (`D_tabs` is where that
6300
+ test stops). For a field, the caption lives in the `FormLayout`'s per-child map
6301
+ — held by the thing that actually knows the caption↔field association — so a
6302
+ locator asks the authority (`form.field_for(caption: "Name")`), which is what
6303
+ Karibu-Testing does against Vaadin 25 form items. A direct handle
6304
+ (`Component#id`, reached through `Tuile::Testing.get`) is a separate decision,
6305
+ since made: `D_component_lookup`.
6306
+
6307
+ **Consequence — a field outside a form has no caption**, and an app puts a
6308
+ `Label` beside it, exactly as every pane in the sampler already does. This
6309
+ declines to add a capability; it removes none.
6310
+
6311
+ **Roads not taken.**
6312
+
6313
+ - *`HasCaption` on `HasValue`, painted by the container anyway.* The worst of
6314
+ both: the field stores a string it never reads, and two places can now claim
6315
+ authorship of the same text.
6316
+ - *A caption that grows the field a row.* The deleted bottom-up channel, and
6317
+ `D_status_bar` refuses the framework-placed row from the other side.
6318
+ - *Vaadin 8 wholesale (caption **and** error ink on the field).* Half of it
6319
+ survived on the merits — see `D_has_validation`, which keeps the *verdict* on
6320
+ the field for a reason that does not apply to the caption: a field can paint
6321
+ invalidity inside its rect without displacing the value, because ink is a
6322
+ restyle of cells it already paints.
6323
+
6324
+ ## D_has_validation — `HasValidation`: the field holds the verdict, the container paints the message (2026-09-03)
6325
+
6326
+ **Status:** Accepted and implemented in `Component::HasValidation`
6327
+ (`error_message`, `on_error_message_change`, the protected `error_ink?`),
6328
+ included by `HasValue`; plus `Theme#error_color` / `#error_bg_color` /
6329
+ `#error_active_bg_color` and `Component#error_bg_color`. The container that
6330
+ renders the *message* is unbuilt — `FormLayout` — so the sampler's Validation
6331
+ pane is the only consumer today. Graduated from
6332
+ `ideas/caption-and-error-ownership.md` (retired 2026-09-03; its container half
6333
+ lives on as `ideas/form-layout.md`), then amended the same day from
6334
+ `ideas/error-background-tint.md`, which reversed the ink ruling below.
6335
+
6336
+ **Context — `D_bad_input` shipped a channel and then could not say where a
6337
+ rule's verdict lives**, because the answer depended on who paints. That entry's
6338
+ authority table is still correct; what it left open is this.
6339
+
6340
+ **Decision — split "invalid" by geometry, and the fork dissolves.** It was
6341
+ being treated as one thing and it is two:
6342
+
6343
+ | | the **verdict** | the **message** |
6344
+ |---|---|---|
6345
+ | what | this field is invalid | "Username is required" |
6346
+ | cells | fits the one row the field has — ink is a restyle of cells it already paints | needs cells the field does not own |
6347
+ | so it lives | on the field (`error_message`) | wherever the cells are: a `FormLayout`, or an app's own `Label` |
6348
+
6349
+ So the field stores the fact and paints itself; whoever has the cells reads the
6350
+ text off it. The re-grow rule that governs both halves —
6351
+ *a component gets a member only when something on its own face reads it*, and
6352
+ never as a mailbox for a value it cannot compute or paint —
6353
+ passes here and fails for the caption (`D_caption_ownership`), which is why the
6354
+ same rule gives the two halves opposite answers.
6355
+
6356
+ **Decision — one member, no `invalid?`.** A second predicate beside
6357
+ `bad_input?` gives a caller no way to know which to ask, and the two differ in
6358
+ authority, population and lifetime (`D_bad_input`'s table). Invalid *is* a
6359
+ non-nil message; `""` is the flag with nothing to say.
6360
+
6361
+ **Decision — the field never writes it, which is the whole answer to Vaadin's
6362
+ warning.** `D_bad_input` quotes the custom-field guide: *"Do not rely on the
6363
+ same `invalid` and `errorMessage` properties for internal validation.
6364
+ Otherwise… external validation is likely to override or ignore the internal
6365
+ state."* Tuile's two facts stay in two members: the field's own report is
6366
+ `bad_input?` (derived on read, never stored), and `error_message` is written
6367
+ only from outside — the field computes no verdicts, so it has nothing to write.
6368
+ That leaves exactly one writer, and the discipline that writer owes is one
6369
+ sentence: **set *or clear* it on every validate pass.** Vaadin needs
6370
+ `getDefaultValidator` and `ValidationStatusChangeEvent` to repair a shared
6371
+ cell; Tuile inherits neither, for the same reason it inherited neither in
6372
+ `D_bad_input`.
6373
+
6374
+ **Decision — it carries a change notice, where `bad_input?` deliberately does
6375
+ not.** Not an inconsistency: `bad_input?` is *continuous* (every prefix of a
6376
+ date is bad input), so a display consumer owes a settling rule first — the ink
6377
+ has since paid that (`bad_input_settled?`, `D_date_field`) — while
6378
+ `error_message` is *discrete* — asserted at a click or a binder pass. So the
6379
+ notice is plain listener inversion, and it is load-bearing rather than
6380
+ speculative: the message is painted in cells the field does not own and does not
6381
+ invalidate, so without it a `FormLayout` cannot know to repaint.
6382
+
6383
+ **Decision (amended) — the verdict is a red *well*, not red text.** The first
6384
+ cut made it a foreground, reasoning that `invalid` and `focused` co-occur so an
6385
+ error background would put two meanings in the channel `bg_color` owns and would
6386
+ owe a 2×2 precedence ruling. That shipped with a gap the entry admitted — *an
6387
+ empty invalid field has no glyphs to tint*, which is the required-field case,
6388
+ i.e. the commonest validation failure there is — and a second one it missed:
6389
+ `under_fg` was fill-unset, so a span already carrying a color never reddened
6390
+ (a `RadioGroup` row with a styled label, a `List` with per-item colors). The fg
6391
+ channel needed glyphs **and** needed them unstyled.
6392
+
6393
+ The reframe that unblocked it: *why does a field have a well at all?* To show
6394
+ its boundary. A red well shows the boundary **and** the verdict, so nothing is
6395
+ lost — and the 2×2 dissolves because the pair is *declared*, not derived:
6396
+ `Theme#error_bg_color` / `#error_active_bg_color` are `input_bg_color` /
6397
+ `active_bg_color`'s red counterparts. Two tokens rather than one flat error
6398
+ color, because otherwise a focused invalid `Select` shows no focus at all —
6399
+ `D_bg_surface` already found that exact bug ("`select.bg_color = X` silently
6400
+ removes the only focus indicator a `Select` has — it paints no caret").
6401
+ `BG_STATES` stays closed either way: error is a *level in the chain*, not a
6402
+ state key. This still re-weighs `D_color_slots` (a chrome token over a
6403
+ per-component slot), which validity spanning every `HasValue` field is the case
6404
+ that argument was waiting for.
6405
+
6406
+ **The hook sits above `bg_color`, not under it.** `Component#error_bg_color`
6407
+ (protected, nil by default) resolves first:
6408
+ `error_bg_color || @bg_color || default_bg_color || parent`. Under it — the
6409
+ obvious placement, and where the `default_bg_color` precedent points — an app
6410
+ tinting a panel would silently switch the validation signal off on the fields
6411
+ inside, which is the issue-#11 bug class again. It also keeps the change to one
6412
+ site: `default_bg_color` is overridden by six widgets that would each have
6413
+ needed a `return super if invalid` line, and the seventh would forget it. So
6414
+ **no widget needed a line of paint code**, and the ordinary background chain
6415
+ does the composing — a composed field's inner face is already `BG_INHERIT` and a
6416
+ group's `List` declares no background, so both walk up and land on the
6417
+ composer's answer with nothing forwarded. `ambient_bg_color` skips this level as
6418
+ it skips `default_bg_color`: outside its extent the widget is not there.
6419
+
6420
+ **The well ORs `bad_input?`,** through the protected `error_ink?` hook that
6421
+ `HasBadInput` widens — so `HasBadInput` `include`s `HasValidation` to pin the
6422
+ ancestor order its `super` needs. This knowingly inherits `D_bad_input`'s
6423
+ continuity problem on the *face* only: a `FloatField` reddens at the half-typed
6424
+ `"1."`, an `IntegerField` at a lone `-`. Accepted because a save gate that lets
6425
+ you press Save on a field it will reject is the worse failure.
6426
+
6427
+ **Amended 2026-09-04 — the flicker bit, and the settling rule landed exactly
6428
+ where this entry parked it.** `DateField` took the measurement nobody had taken:
6429
+ where *every* prefix of the grammar is bad input, the OR holds the well red for
6430
+ the whole time the user types a correct date, which reads as "you are wrong"
6431
+ rather than "you are not finished". So `HasBadInput` grew the protected
6432
+ `bad_input_settled?` and `error_ink?` became
6433
+ `(bad_input? && bad_input_settled?) || super`. As predicted, it gates **the ink
6434
+ only** — `bad_input?` stays derived-on-read, so a save gate asking at a click is
6435
+ untouched. Its default is `true`, so the numeric fields still redden
6436
+ immediately, and that is a ruling rather than inertia: their residue is one or
6437
+ two transient buffers (`-`, `"1."`, and the `-`/`-0.` pair while `-0.5` is
6438
+ typed), so the flicker is brief and the early warning beats the quiet.
6439
+ `DateField` overrides it with a latch on its commit gestures (`D_date_field`).
6440
+
6441
+ **Rejected — blending the well toward `error_color`.** Attractive because it
6442
+ composes with an app's own panel tint, and because modelling error as a
6443
+ *transform over* the resolved background is what dissolves the 2×2 (that
6444
+ reframe survives; the transform did not). It dies on quantization: a lerp is a
6445
+ contraction, `|tint(active) − tint(normal)| = (1−w)·|active − normal|`, so the
6446
+ tint squeezes out the focus shade it composes with. Under `palette256` only
6447
+ `w = 0.40` keeps all three conditions in both schemes, and 0.40 is `#8f4f4f` —
6448
+ not "slight". The elegant repair (add chroma, preserve luminance, so the delta
6449
+ survives by construction) is *worse*: it fails on `DARK` at every weight,
6450
+ because the dark grey ramp is dense enough that a chroma-only shift off a dark
6451
+ grey snaps back onto it. Two further reasons not to revisit: `Theme` validates
6452
+ every member `is_a?(Color)`, so a per-scheme *weight* token would mean loosening
6453
+ that check; and the blend's own best outputs were palette 95/131 and 174/181 —
6454
+ opaque cells an opaque token can simply name, which is what made picking one
6455
+ per state the cheaper answer (and what then let the pair be *retuned* to the
6456
+ cells below without touching a line of resolution code).
6457
+
6458
+ **Rejected — real alpha in `Color`.** It buys multi-layer composition and
6459
+ nothing needs more than one layer. Also settled while deciding, and worth not
6460
+ re-deriving: alpha could never reach the `Buffer` — terminal cells are opaque
6461
+ (`D_bg_inherit`), a cell holds one final color, and there is nothing underneath
6462
+ to composite against except the previous frame — so it would have to be
6463
+ flattened during resolution, and putting it in `Color` makes that value type
6464
+ partial (a translucent color has nothing to hand `sgr_codes`). If a dim factor
6465
+ is ever needed, `ideas/modal-backdrop.md` owns both `Color#mix` and the type
6466
+ question, and has the harder version of it: a fan-out over unknown,
6467
+ app-authored bases rather than one known one.
6468
+
6469
+ **Token choice.** `DARK` uses palette 88 (`#870000`) / `LIGHT_PINK4` (95,
6470
+ `#875f5f`), `LIGHT` uses `MISTY_ROSE1` (224, `#ffd7d7`) / `LIGHT_PINK1` (217,
6471
+ `#ffafaf`). Three conditions were measured on every candidate, at each depth:
6472
+ **A** resting well ≠ `input_bg_color`, **B** focused well ≠ `active_bg_color`,
6473
+ **C** focused ≠ resting. C is the one that kills candidates — losing it means a
6474
+ focused invalid `Select` shows no focus at all, since it paints no caret. Both
6475
+ pairs hold all three at `truecolor` and `palette256`, and each keeps **A and C**
6476
+ at `ansi16`, where the shipped 95/131 and 181/174 had failed all three.
6477
+
6478
+ Each pair also **splits in the same direction as its own valid pair**, so an
6479
+ invalid field feels like the same widget: `DARK` darker → lighter on focus
6480
+ (`GREY27` → `GREY37`, 88 → 95), `LIGHT` lighter → darker (`GREY85` → `GREY82`,
6481
+ 224 → 217).
6482
+
6483
+ Two things not to re-derive:
6484
+
6485
+ - **The `DARK` focused well must avoid the bright mid-reds around `#af5f5f`.**
6486
+ That is where terminals put the *cursor*, so `INDIAN_RED` (131) made a caret
6487
+ sitting in an invalid field blur into the well — reported from real use, not
6488
+ from the arithmetic. Tuile cannot know the cursor color (it is the terminal's;
6489
+ OSC 12 would report it, `D_background_rgb` is the precedent) and deliberately
6490
+ chooses rather than queries, exactly as `D_background_rgb` argues a theme
6491
+ picks colors to sit *against* the terminal.
6492
+ - **224 is the floor on `LIGHT`, not a preference.** Anything paler quantizes
6493
+ onto the grey ramp — `#ffeaea` → 255 — so a subtler well is *colorless* on a
6494
+ 256-color terminal; `#fbdede` and `#f7d0d0` both land back on 224. The cost is
6495
+ that 224 sits a shade above `GREY85` in luminance, so an invalid field reads
6496
+ level with a valid one rather than more recessed. Accepted: near-white was the
6497
+ ask, and the tint carries the signal by hue.
6498
+
6499
+ The remaining live trade is `ansi16`, where B is unreachable for both pairs — but
6500
+ focus is *already* invisible there (`GREY27` and `GREY37` both quantize to
6501
+ `:bright_black`), so nothing is lost that was not already gone, and
6502
+ `D_color_depth` rules out solving it with a depth-conditional strategy. A/B/C
6503
+ are asserted at both depths in `theme_spec`'s "the error wells" context, so a
6504
+ future retune is measured rather than eyeballed.
6505
+
6506
+ **Decision — a separate mixin, included by `HasValue`, not members on
6507
+ `HasValue`.** Four reasons:
6508
+
6509
+ - **Different authorities.** `HasValue` is the field's own state;
6510
+ `error_message` is written from outside. `D_has_value` keeps that seam
6511
+ deliberately thin and self-owned, and a foreign-written member sits better
6512
+ behind its own name.
6513
+ - **Lookup.** A binder or a test locator iterating "everything that can carry a
6514
+ verdict" walks `is_a?(HasValidation)` — reachable, more than one implementing
6515
+ class, the test `D_bad_input` and `D_tabs` both apply.
6516
+ - **A non-field can be invalid** — a composite custom field, a form section
6517
+ wrapping several — and includes it alone.
6518
+ - **It is Vaadin's split**, so the binder port reads familiar.
6519
+
6520
+ Cost is ~12 lines, the same trade `HasCaption` made.
6521
+
6522
+ **Population — every `HasValue`, and nothing else.** Unlike `HasBadInput`
6523
+ (include it iff your parse is partial), any field can be the subject of a rule,
6524
+ including a `Checkbox` ("you must accept the terms"). `ProgressBar` stays out
6525
+ for the reason it stays out of `HasValue`: a display widget is not a field
6526
+ (`D_progress_bar`).
6527
+
6528
+ **Roads not taken.**
6529
+
6530
+ - *The container stores the message, in its per-child map.* The shape
6531
+ `D_box_layouts` already uses for constraints, and the first instinct — a field
6532
+ should not have to know what its parent is. It dies on the binder: `binder` is
6533
+ handed *fields* and has no reference to the layout they happen to sit in, so
6534
+ the only thing that ever computes a verdict could not report one. A click
6535
+ handler is the same — it holds `username` and `password`, not `form`. And it
6536
+ would leave a field outside a `FormLayout` with no way to show anything at
6537
+ all.
6538
+ - *Vaadin 25 read as "the container owns errors too".* A misreading worth
6539
+ recording, because it nearly settled this the other way: Vaadin 25 moved the
6540
+ *caption* to the form item and kept `invalid` / `errorMessage` on the field
6541
+ (`HasValidation`), which renders both. Even the container-owns precedent does
6542
+ not put the error on the container.
6543
+ - *An `invalid?` boolean plus a separate message.* Two members for one fact, and
6544
+ the predicate collides with `bad_input?` as above.
6545
+ - *A red foreground on the glyphs.* The first cut, argued from the
6546
+ co-occurrence with focus and **reversed by the amendment above** — that worry
6547
+ dissolved once the pair was *declared* rather than derived, and the fg channel
6548
+ turned out to need glyphs it does not have on the required-field case.
6549
+ - *Leaving `bad_input?` out of the well.* Also the first cut, on
6550
+ `D_bad_input`'s continuity grounds; the amendment ORs it in through
6551
+ `error_ink?` and accepts the flicker, for the reason given above — a Save
6552
+ gate that rejects a field the face called fine is the worse failure. A
6553
+ settling rule has since been written and softens the *well* only, as this line
6554
+ predicted (the amendment above; `D_date_field`).
6555
+ - *Forwarding the message down to a composed field's inner widget.* What a
6556
+ push-it-down design would have needed on four composed fields and two groups;
6557
+ resolving the well through `effective_bg_color` deletes the whole category,
6558
+ since that chain already inherits.
6559
+
6560
+ ## D_component_lookup — `Component#id` plus `Tuile::Testing`: scope is an argument, not a receiver (2026-09-03)
6561
+
6562
+ **Status:** Accepted; **v1 implemented** — `Component#id`, `Component#inspect`
6563
+ plus the protected `inspect_details` hook, and `Tuile::Testing.find` / `.get` /
6564
+ `.dump`. Graduated from `ideas/component-lookup-for-tests.md`. The checked
6565
+ interactions, a `value:` match and a `test_id`/`name` split are deferred, not
6566
+ rejected (listed at the end).
6567
+
6568
+ **Context — the specs had already written this locator, twelve times.**
6569
+ `sampler_spec` alone carried ten walks shaped
6570
+ `on_tree { |c| combo ||= c if c.is_a?(ComboBox) }`, plus one each in
6571
+ `confirm_window_spec` and `has_caption_spec`. They were the reason to build it,
6572
+ and one of them named the trap in a comment: *"demo_window, not the sampler:
6573
+ the jump box is a ComboBox too, and it comes first in tree order."* The `||=`
6574
+ resolves ambiguity by silently taking whichever component the tree walk reached
6575
+ first — so a pane that grows a second `ComboBox` re-points the spec at a
6576
+ different widget and nothing goes red. Reporting that as an error, rather than
6577
+ picking a winner, is most of what `get` buys.
6578
+
6579
+ **Decision.** A `Symbol` `id` on `Component`, and a `Tuile::Testing` module
6580
+ with three public methods: `find` (every match, optional `count:`), `get`
6581
+ (exactly one) and `dump` (the tree, for a failure message).
6582
+
6583
+ **Scope is the `in:` keyword, not a `Component#get`.** The open question in the
6584
+ note was whether the module should also install a receiver-style subtree
6585
+ lookup. It should not, on four counts:
6586
+
6587
+ - Scope is a parameter *of the search*, not a property of a component. Both
6588
+ spellings end in the same `on_tree` walk, so the receiver form adds surface
6589
+ without adding power.
6590
+ - `get` is a generic name on a class apps subclass freely — the sampler alone
6591
+ has `Panel`, `ShortcutBox`, `TickingBox`. `id` is already one squat on every
6592
+ subclass; take one, not two.
6593
+ - Test-only API stays off production classes. The precedent is
6594
+ `Screen#invalidated?`, which lives on `FakeScreen`.
6595
+ - **Re-grow rule:** if receiver syntax is ever wanted, it comes back as a
6596
+ *refinement* inside `Testing`, so `component.get(Button)` exists only in
6597
+ files that `using` it. Never as a method on `Component`.
6598
+
6599
+ For the same collision reason the *documented* call form is qualified —
6600
+ `Testing.get(...)` — and `config.include Tuile::Testing` is deliberately not
6601
+ recommended: `find` and `get` are the two most collision-prone names in a spec
6602
+ suite (an app driving Capybara already has a `find`). Karibu-Testing solved
6603
+ this with the `_get` / `_find` prefix, which Ruby idiom rules out. Tuile's own
6604
+ specs sit inside `module Tuile`, so they get `Testing.get` with nothing to
6605
+ include.
6606
+
6607
+ **`find` returns an Array and takes `count:`; that is Karibu's `_expect`.**
6608
+ `count:` accepts an Integer (exactly) or a Range (a bound), raises
6609
+ `Testing::LookupError` on a mismatch, and defaults to any number. `get` is then
6610
+ defined as `find(count: 1).first` rather than as a second search, which is why
6611
+ it reports an ambiguous spec instead of resolving it. `count: 0` is legal
6612
+ because it falls out of the same check, but it is **not** the idiom for
6613
+ "nothing is open" — a spec asserting that keeps `assert_empty
6614
+ Screen.instance.popups`, a direct assertion on the list beating a lookup that
6615
+ finds nothing.
6616
+
6617
+ **`caption:` and `count:` both match with `===`.** A String caption is exact
6618
+ and a Regexp partial; an Integer count is exact and a Range a bound. The
6619
+ polymorphism is the feature, and it is why the one `spec_match?` helper carries
6620
+ a `Style/CaseEquality` disable rather than being rewritten into two branches.
6621
+ Karibu needed separate exact and regex knobs for the same job.
6622
+
6623
+ **The class positional accepts a Module, so a mixin is a first-class spec.**
6624
+ `find(Component::HasValue)` finds every field, `find(Component::HasBadInput)`
6625
+ every field whose parse can fail. This is the mixin-as-locator-seam rule
6626
+ (AGENTS.md, *Input values*) finally having a consumer: `has_caption_spec`'s
6627
+ seam example now asserts through `Testing.get` rather than hand-rolling
6628
+ `is_a?(HasCaption)` plus a compare. The limit stated in `D_tabs` is unchanged —
6629
+ a `Tabs::Tab` is not a `Component`, appears in no `on_tree`, and so is
6630
+ unreachable by any of this.
6631
+
6632
+ **Uniqueness is enforced at lookup, never at assignment, and production never
6633
+ checks it.** A detached tree cannot know the screen, so an assignment-time
6634
+ check would have nothing to check against; and two `TabSheet` panes may
6635
+ legitimately carry the same `id`, since only one is attached at a time.
6636
+ `get` raising on two matches is the whole mechanism, and it costs nothing.
6637
+ The setter's one guard is a type check: `id = "save"` is refused rather than
6638
+ coerced, because a String would never match a `get(id: :save)` — silently.
6639
+
6640
+ **An `id` is not the mailbox that `caption` and `error_message` are.** The
6641
+ re-grow rule those two live under is "a component gets a member only when
6642
+ something on its own face *reads* it". An identifier inverts it: identification
6643
+ *is* the purpose, nothing is expected to paint it, and inertness is therefore
6644
+ not a smell. Worth stating because the shape looks identical and isn't.
6645
+
6646
+ **`Component#inspect` is part of v1, not a nicety.** The tree dump in a failed
6647
+ lookup is most of a locator's value — Karibu's real lesson — and there was no
6648
+ `Component#inspect`, so `Object#inspect` would have walked `parent`, `children`
6649
+ and the `Screen`, dumping the whole UI for one component. The base line is
6650
+ class, `id` and rect; mixin details arrive through a **protected
6651
+ `inspect_details` hook** that each mixin extends with `super + [...]`, so the
6652
+ base stays ignorant of which mixins a component includes — the same rule that
6653
+ rejected a leaf checking `parent.is_a?(HasValue)` (`D_bg_surface`). They appear
6654
+ in reverse include order, the last-included module calling `super` first.
6655
+ `HasValue` truncates a String value at 40 characters *before* calling
6656
+ `inspect`, since a `TextArea`'s value is its whole buffer. The dump strips the
6657
+ `Tuile::` namespaces so a fifty-row tree stays readable, and flags the matches
6658
+ with a leading arrow; an app's own classes keep their full name.
6659
+
6660
+ **It ships in `lib/`, not as a separate gem.** Zeitwerk loads
6661
+ `lib/tuile/testing.rb` on the first reference, so an app that never names
6662
+ `Tuile::Testing` pays nothing. Karibu is separate from Vaadin because Vaadin
6663
+ was someone else's project; here one author owns both sides, and a spec suite
6664
+ that has to add a gem to locate a component will keep hand-rolling `on_tree`
6665
+ instead. The `Testing` name signals intent rather than a hard boundary: if an
6666
+ app ever needs the id walk in production (a `FormLayout#field_for`), that is a
6667
+ re-grow onto `Component`, not a reason to rename the module.
6668
+
6669
+ **This is additive to the assertion channel, not a replacement.** A spec
6670
+ asserting what a component *shows* still asserts `Screen#buffer`
6671
+ (`D_list_items`). What the locator replaces is the *driving* half — and, as a
6672
+ side effect, about a dozen `instance_variable_get(:@overlay)` reach-ins, since
6673
+ an open overlay is a popup under the pane and so reachable by class.
6674
+
6675
+ **Deferred, not rejected.**
6676
+
6677
+ - **Checked interactions** (`_click` / `_setValue`): refuse when the component
6678
+ could not have received the interaction for real — not attached, not
6679
+ focusable, not on the focus chain. Needs a modal-scope predicate, which
6680
+ already exists as `bubble_key`'s `modal_popup || content` rule, and a ruling
6681
+ on whether a key is simulated through the ladder or handed to `handle_key`.
6682
+ - **`value:` in the match spec**, and an `error_message:` one now that
6683
+ `HasValidation` has merged (`D_has_validation`) — the mixin itself is already
6684
+ matchable as a class positional, like every other seam.
6685
+ - **A `test_id` / `name` split** — a stable test handle distinct from an
6686
+ app-meaningful identifier. One member until a second meaning actually turns
6687
+ up.
6688
+ - **An `id:` constructor kwarg.** No component constructor takes kwargs today,
6689
+ so it is a sweep over ~30 classes to save one line per call site.
6690
+
6691
+ ## D_on_blur — `on_blur`, the commit point Tuile lacked (2026-09-04)
6692
+
6693
+ **Status:** Accepted; implemented — the protected `Component#on_blur`, fired
6694
+ from `Screen#focused=`. Graduated from `ideas/bad-input.md`, now retired; the
6695
+ push notice that note also carried is **postponed indefinitely** (last section).
6696
+
6697
+ **Context — the gap was on record three times, from three directions.**
6698
+ `D_integer_field` declined to canonicalize `"007"` and `D_bigdecimal_field`
6699
+ declined a `scale=` knob, both citing *"a blur/commit point a TUI lacks"*;
6700
+ `D_bad_input` needed the same thing for a settled bad-input notice. And
6701
+ `on_enter` is not that point: Tab is unconditional (rung 1, `D_key_dispatch`),
6702
+ so tabbing out of a half-typed field is the *likely* exit, not the exotic one.
6703
+
6704
+ **Decision — one hook, at the site that already existed.** `Screen#focused=`
6705
+ already held `previous` and already diffed it for `on_focus_changed`, so the
6706
+ whole implementation is the private `fire_focus_hooks`: blur, then focus, then
6707
+ the app notice. The rulings on its shape:
6708
+
6709
+ - **Protected, reached with `__send__`** (`D_hook_visibility`). The same call
6710
+ now reaches `on_focus` too, and that is a fix this entry owes rather than a
6711
+ drive-by: shipping a *protected* sibling makes the natural grouping
6712
+ (`protected` / `def on_blur` / `def on_focus`) likely, and it would have
6713
+ broken the explicit-receiver `@focused.on_focus` — precisely the landmine
6714
+ `D_hook_visibility` accepted while `on_focus` stood alone. `__send__` at the
6715
+ site retires it without protecting `on_focus`, which three mixins present as
6716
+ a composition seam.
6717
+ - **Blur before focus** — the DOM order, and the order `ideas/hover.md` had
6718
+ already settled for its own exit/enter pair, so the framework has one answer
6719
+ to the question rather than one per notice.
6720
+ - **Edge-triggered and fired on one component**, exactly like `on_focus`: not on
6721
+ the ancestors that drop off the active chain. They already have a better seam
6722
+ for it — `Component#active=`, which `ComboBox` overrides to close its dropdown
6723
+ and revert a half-typed query when focus leaves the *widget* (focus sits on
6724
+ its inner field, so a chain-wide `on_blur` would still be the wrong shape:
6725
+ it would fire on the field, which is not who owns the dropdown).
6726
+ Focus that merely *passes through* still blurs, so a container
6727
+ forwarding focus from `on_focus` blurs itself one hop later. Accepted: the
6728
+ pointer really did move, and suppressing it would mean remembering which
6729
+ assignments were forwards.
6730
+ - **A notification, not a veto.** No return value and no refuse-to-leave, which
6731
+ would have to fight the one key nothing can suppress. A handler *may* reassign
6732
+ focus: the nested assignment wins and the outer one stops, so `on_focus` never
6733
+ fires for a component that no longer holds focus (`screen_spec` pins it).
6734
+ - **No listener writer.** `on_focus` has none either, and `on_blur=` is additive
6735
+ whenever a stock-assembly consumer turns up — in the `on_theme_changed=`
6736
+ shape, public writer over protected hook.
6737
+ - **It fires wherever focus is *dropped*,** not only where a user moved it: the
6738
+ popup-close repair blurs an **already-detached** component (an `invalidate`
6739
+ there is a silent no-op, as in `on_detached`), and `Screen#close` blurs on the
6740
+ way out, while the tree is still mounted. Written down here because both are
6741
+ invisible from the call site and specced for the same reason.
6742
+
6743
+ **Alternatives rejected.**
6744
+
6745
+ - *`Screen#on_focus_changed` alone.* It exists, and it is the app-level channel
6746
+ (`D_status_bar`) — but a *field* cannot commit itself from it, so every app
6747
+ would rewrite the same dispatch-by-identity. `ideas/hover.md` asks the mirror
6748
+ question for hover (does `on_hover_changed` make `on_mouse_exit` unnecessary?);
6749
+ this is the focus half of the answer, and it is no.
6750
+ - *A public hook, for symmetry with `on_focus`.* The symmetry is real but
6751
+ cosmetic; `D_hook_visibility`'s shape wins, and `__send__`-ing both hooks buys
6752
+ the symmetry back where it matters — an override may declare any visibility.
6753
+ - *Reuse `on_detached` as the commit point.* Wrong axis: focus leaves a field
6754
+ that stays mounted for the rest of the session, and by the time one detaches
6755
+ the container it should report to may be gone.
6756
+ - *Name it `on_focus_lost`.* Longer, and `blur` is the word every neighbouring
6757
+ toolkit uses (DOM, Swing's `focusLost`/`FocusEvent`, Textual's `Blur`); the
6758
+ hover note independently reached for `enter`/`exit` from the same instinct.
6759
+
6760
+ **Consequences.**
6761
+
6762
+ - **Two entries' "a TUI lacks a blur/commit point" is now false**, and both were
6763
+ edited: `D_integer_field`'s no-normalization and `D_bigdecimal_field`'s no-
6764
+ `scale=` now rest on the half that survives — rewriting the buffer under the
6765
+ caret while typing. They are re-openable on the merits, no longer blocked.
6766
+ - **The bad-input push notice stays deferred, and the design sketch is
6767
+ deliberately not preserved.** `on_bad_input_change` was blocked on this hook
6768
+ *and* on a consumer; the hook has landed and the consumer still has not asked.
6769
+ The shipped red well reads the pull per paint (`D_has_validation`) and a Save
6770
+ gate asks at the click (`ideas/binder.md`), so nothing is waiting. If one ever
6771
+ is, the shape is an hour's work re-derived from scratch — one `attr_accessor`
6772
+ plus a sole-writer `sync_bad_input` in the `ProgressBar#sync_ticker`
6773
+ discipline, called from every input mutation — and it arrives together with
6774
+ the settling rule its first *continuous display* consumer owes. Half of that
6775
+ debt is now paid: the **ink** settles via `HasBadInput#bad_input_settled?`,
6776
+ latched by `DateField` on its commit gestures (`D_date_field`). That is the
6777
+ template a push notice copies, not an argument for building one — the pull
6778
+ plus a latch covered the only consumer that could not be asked at a click.
6779
+
6780
+ ## D_placeholder — `HasPlaceholder`: a hint in the field's own cells, in ink tuned to be missed (2026-09-04)
6781
+
6782
+ **Status:** Accepted; implemented — `Component::HasPlaceholder`, painted by
6783
+ `TextField` and forwarded by the four composed fields, in the new
6784
+ `Theme#placeholder_color`. Graduated from `ideas/text-field-placeholder.md`, now
6785
+ retired.
6786
+
6787
+ **Context.** `DateField` wanted it first (`D_date_field`): a date field must
6788
+ tell the user *which* of its formats it writes back, information available
6789
+ nowhere else.
6790
+ But that is a general text-input affordance, so it ships as one rather than as a
6791
+ private `DateField` trick.
6792
+
6793
+ **Decision — a paint-time branch, not a `display_text` substitution.** The seam
6794
+ that *looks* right is `TextField#display_text`, and it is the wrong one: its
6795
+ contract is one display character per `text` character, in order, because
6796
+ `column_at`, `index_at`, `visible_text` and `adjust_left_column` all measure it
6797
+ as the rendering of the buffer. An empty buffer showing ten glyphs of hint
6798
+ breaks that in the most visible way there is — `cursor_position` would park the
6799
+ caret past the hint instead of at column 0. So the hint is a branch in
6800
+ `repaint`, beside `visible_text`, and the rest of the class is untouched: with an
6801
+ empty buffer `caret` and `left_column` are both 0, so the caret lands correctly
6802
+ and the scrolling machinery has nothing to do.
6803
+
6804
+ The rulings on its shape:
6805
+
6806
+ - **Paint-only, in every direction.** Not in `text`, `value`, `empty?`,
6807
+ `on_value_change`, a paste, or `max_text_length`'s budget. That asymmetry *is*
6808
+ the feature — a placeholder living in the buffer would be a default value, and
6809
+ a form saving it would write `"dd.mm.yyyy"` to the database.
6810
+ - **The condition is `text.empty?` alone — no focus term.** Browsers used to
6811
+ hide the hint on focus and HTML5 stopped; here the argument is stronger than
6812
+ convention, because the format hint is wanted *precisely* while the user is
6813
+ typing into the field. One condition also means no `on_focus` bookkeeping.
6814
+ - **A plain `String`, and `placeholder=` raises on a `StyledString`** rather
6815
+ than flattening it. Two reasons, and the second is the durable one: an
6816
+ app-supplied `StyledString` bakes its colors at construction and would need an
6817
+ `on_theme_changed` rebuild to survive a flip (the trap `D_theme_ref` exists to
6818
+ keep off chrome); and the ink is deliberately calibrated to be *barely*
6819
+ visible, so a per-app color is not a missing knob but a knob for defeating the
6820
+ design.
6821
+ - **It ellipsizes rather than clips.** A middle-cut `dd.mm.yyy` reads as a
6822
+ *complete* format that happens to be wrong, where `dd.mm.y…` reads as
6823
+ truncated — and for the motivating case that difference is the whole point.
6824
+ Free, too: `StyledString#ellipsize` already defaults to the one-column `…`,
6825
+ which is East-Asian Ambiguous and already inside `D_ambiguous_width`'s
6826
+ inventory (`Checkbox`, `ComboBox`), so this adds no glyph and reopens no bet.
6827
+ - **An invalid field still shows it.** An empty *required* field is the
6828
+ commonest invalid state and exactly when a hint about what belongs there is
6829
+ worth most: the red well says *something is wrong*, the hint says *what goes
6830
+ here*, and they are complementary rather than competing.
6831
+ - **`Select` does not include it** — not a contradiction of this entry but the
6832
+ case `D_select` already ruled: a blank face plus `▾` is self-evidently
6833
+ "nothing picked", so an absent enum *value* needs no hint the way an
6834
+ unguessable input *format* does.
6835
+
6836
+ **The ink — `hint_color` was the obvious choice and is wrong.** The idea note
6837
+ filed it as "the subdued-secondary-text token". It is not: it is
6838
+ `LIGHT_SKY_BLUE3` (109) on dark and `TURQUOISE4` on light, a saturated accent
6839
+ whose two consumers both use it to *pull* the eye (the shortcut caption in
6840
+ `"q quit"`, `PickerWindow`'s option captions). A placeholder painted in it makes
6841
+ an empty field *louder* than a filled one, which is the affordance backwards.
6842
+ `hint_color`'s own rdoc was widened to say "subdued **accent** text" in the same
6843
+ change, since that is what it has always been.
6844
+
6845
+ So a new token, `placeholder_color` — and the shade is a *rule*, not a taste
6846
+ call, because the hard part is that the background varies: one ink must survive
6847
+ `input_bg_color`, `active_bg_color`, both error wells, and terminal-default
6848
+ under `BG_INHERIT`. Quantizing the candidates settles it:
6849
+
6850
+ | shade | → `ansi16` |
6851
+ |---|---|
6852
+ | `GREY27`, `GREY37` — the *dark* wells | `:bright_black` |
6853
+ | `GREY42` … `GREY62` (247) | `:bright_black` |
6854
+ | `GREY66` (248) … `GREY85` — incl. the *light* wells | `:white` |
6855
+
6856
+ On a 16-color terminal there is **no middle ground in either theme**: every grey
6857
+ subtle enough to want collapses onto its own theme's wells and the hint is not
6858
+ subtle but *gone*, while the first shade that separates is already at full text
6859
+ brightness. So each token is the boundary value on its side — `DARK` takes
6860
+ `GREY66` (248), the dimmest that still reads `:white`; `LIGHT` takes `GREY62`
6861
+ (247), the palest that still reads `:bright_black`. **A hint the user is allowed
6862
+ to miss must fail loud, never absent**, which is the tie-break, and
6863
+ `theme_spec`'s "the placeholder ink" pins the whole rule at all three depths
6864
+ (unlike the error wells beside it, `ansi16` *is* asserted here — that is the
6865
+ depth the shade was chosen for).
6866
+
6867
+ **The token is required, not defaulted**, following `D_scrollbar_ink`'s
6868
+ precedent exactly: a default would keep hand-rolled themes working while baking
6869
+ a dark-tuned grey into light ones. One `**Breaking:**` line, and
6870
+ `Theme::DARK.with(...)` — the documented path — is unaffected.
6871
+
6872
+ **The seam is a mixin, and it is the odd one in the `Has*` family.** Every other
6873
+ `Has*` shares real behavior (`HasCaption` *stores* the caption for all its
6874
+ includers, which own only the rendering). This one cannot: the leaf `TextField`
6875
+ stores and paints, while each composed field **delegates** to its inner field,
6876
+ because a copy in the composer beside the copy in the field is two sources of
6877
+ truth for one fact — the desync `D_tree_api` forbids for slots, in miniature. So
6878
+ every composer overrides both accessors, and what the mixin buys is the contract
6879
+ in one place, a shared `inspect_details`, storage for the single leaf, and
6880
+ `is_a?(HasPlaceholder)` as a lookup seam. Written down because a reader who
6881
+ assumes it works like its siblings will "fix" the composers onto the mixin's
6882
+ storage and reintroduce the desync.
6883
+
6884
+ **Why the composers forward at all**, when `content` is public on `HasContent`
6885
+ and `content.placeholder =` already works: an app should not have to know that
6886
+ an `IntegerField` is a `TextField` in a trenchcoat. The counter-argument — that
6887
+ `content` is already the seam for every other inner-field knob
6888
+ (`max_text_length`, `mask_char`), so promoting this one implies the others are
6889
+ unreachable — was weighed and lost. A placeholder is part of a field's *public
6890
+ face* in a way a scroll or masking detail is not.
6891
+
6892
+ **Alternatives rejected.**
6893
+
6894
+ - *A `dim` (SGR 2) attribute on `StyledString::Style`, instead of a token.*
6895
+ Conceptually the nicest: dim is *relative* to whatever foreground is in play,
6896
+ so it inherits the terminal's own fg the way the no-global-fg rule wants,
6897
+ needs no token, and does not quantize at all — it is the only design that
6898
+ keeps subtlety on an `ansi16` terminal. Rejected for v1 because it changes the
6899
+ most-specced frozen value type (parse, `to_ansi`, the diff, the sig) and
6900
+ deserves its own argument rather than riding in on a placeholder. **Its
6901
+ trigger condition is precise:** reach for it if and only if the two greys
6902
+ cannot be tuned, or `ansi16` subtlety turns out to matter.
6903
+ - *Painting it on `TextArea` too.* Deferred, not refused. The state is generic
6904
+ but the paint is not — `TextField` writes one windowed row, `TextArea` wraps
6905
+ into a viewport — and putting the accessor on `AbstractStringField` while only
6906
+ one subclass paints it ships a public setter that is silently inert on the
6907
+ other. A multi-line free-text box rarely has an unguessable *format*, so there
6908
+ is no near-term second caller; if one appears the accessor moves up **with
6909
+ both paints written**.
6910
+ - *Treating it as a caption.* `D_caption_ownership` says a field paints no
6911
+ caption, its container does, and the boundary is exactly the cells: a caption
6912
+ sits *outside* the field's rect, in cells the field neither owns nor
6913
+ invalidates. A placeholder is inside the field's own rect, on cells it already
6914
+ paints and already invalidates. Sharper still: **a caption is unconditional
6915
+ and describes the *field*; a placeholder is conditional on emptiness and
6916
+ stands in for the *value***. Which yields the corollary an app needs — never
6917
+ use a placeholder *as* a caption to save a row in a tight form, because the
6918
+ hint disappears the instant the user types.
6919
+
6920
+ **Consequences.**
6921
+
6922
+ - **`TextField#repaint` does not call `super`, so the padded row *is* the
6923
+ well** — `visible_text` has always padded itself to `rect.width`, and nothing
6924
+ else clears the rect. The placeholder branch therefore ellipsizes to
6925
+ `rect.width` **and pads back out to it**; a row only as wide as the hint would
6926
+ leave the rest of the field holding whatever was painted there before, with
6927
+ the background stopping mid-way. The idea note's first sketch got this wrong.
6928
+ - **`PasswordField` inherits it and should.** "password" under an empty masked
6929
+ field is the standard look, and the mask only ever applies to buffer content —
6930
+ a field showing its hint has none. Pinned in `password_field_spec`.
6931
+ - **`DateField` derives its hint after all — amended 2026-09-04.** This entry
6932
+ ruled the other way first: an explicit default string, because the formats are
6933
+ strftime, so a `"%d.%m.%Y"` → `"dd.mm.yyyy"` mapping table is a second grammar
6934
+ that will drift out of step with the format list. `D_date_field` lifted that by
6935
+ making the table **best-effort** — it serves the placeholder and nothing else,
6936
+ so it is allowed to *abstain*: a primary format holding any directive the table
6937
+ does not cover derives `nil` rather than a half-translation. A table that
6938
+ abstains cannot drift *against* the format, because it makes no claim about
6939
+ what it does not cover. The mixin needed no change for it, and the question
6940
+ this entry flagged (a *settable* accessor on a field whose hint is *computed*)
6941
+ answered itself: `DateField` overrides the accessor pair, holds the app's
6942
+ override in an ivar of its own, and the storage that matters still lives on the
6943
+ leaf `TextField` — `nil` restores the derived hint, `""` suppresses it.
6944
+
6945
+ ---
6946
+
6947
+ ## D_wrapping_field — `AbstractWrappingField`, and what `HasContent` actually means (2026-09-04)
6948
+
6949
+ **Status:** Accepted; implemented 2026-09-04 (`Component::AbstractWrappingField`;
6950
+ `IntegerField` / `FloatField` / `BigDecimalField` migrated). Graduated from
6951
+ `ideas/composed-field.md`, which retains only the unbuilt `CompositeField`
6952
+ sketch.
6953
+
6954
+ **Context — a fourth copy, and a rule that was backwards.** `D_float_field` and
6955
+ `D_select` both ruled *duplicate rather than DRY a shallow shell*, and set the
6956
+ bar at a **fourth** copy. `DateField` (`D_date_field`) is that copy, and
6957
+ by then the shell was not shallow: six obligations sat in all four composed
6958
+ fields — the mixin set, `bg_color = BG_INHERIT` on the inner field, a
6959
+ character-identical `default_bg_color`, `cursor_position`, the `placeholder`
6960
+ pair, and the `@last_value` change guard. One of them already carried a warning
6961
+ in AGENTS.md ("a **new** composed field owes both or its face paints untinted"),
6962
+ and a rule that needs a warning in the contributor doc wants to be code.
6963
+
6964
+ Underneath sat a worse problem. `HasContent`'s own rdoc said to include it *"when
6965
+ the child is permanent and integral — a typed field's inner `TextField`"*, which
6966
+ is exactly backwards: the mixin ships a **public `content=`**, so
6967
+ `integer_field.content = Button.new` succeeded and left the widget permanently
6968
+ broken (`value` then raised `NoMethodError`). Six components had followed that
6969
+ rule correctly into a hole.
6970
+
6971
+ **Decision — `HasContent` is a statement about the public surface.** *I have a
6972
+ primary child named `content`, this is my content which you populate; my other
6973
+ children are chrome, mine to manage.* Not arity (a `Window` has two app-settable
6974
+ children and the mixin names which is *the* content) and not
6975
+ permanent-vs-swappable (an `Overlay`'s body is permanent **and** public; that
6976
+ correlated for `Slot` alone). Legitimate includers: `Slot`, `Window`, `Overlay`.
6977
+
6978
+ **Decision — a class, not a mixin, and one for the editor-faced fields only.**
6979
+ A class because it has a constructor obligation and two ivars: a mixin would need
6980
+ an `init_wrapper(editor)` an includer must remember to call, which is the very
6981
+ footgun this deletes (`AbstractStringField` is the precedent for an `Abstract`
6982
+ component base, and composition-over-inheritance permits a *cohesive* one). The
6983
+ `Abstract` prefix follows a rule rather than habit — Tuile's precedent is split,
6984
+ `AbstractStringField` carries it and `Layout::Box` does not — **prefix when the
6985
+ unprefixed name would read as an instantiable widget**.
6986
+
6987
+ **Decision — the commit point is `Component#active=`, not `on_blur`.**
6988
+ `D_on_blur` had already ruled this and named the seam; `ComboBox` already
6989
+ implemented it. It is right at both tiers for free: "the widget left the focus
6990
+ chain" is what a commit means, and moving focus *between* two editors of a future
6991
+ composite keeps the composite active, where an `on_blur` design would fire on
6992
+ every internal hop.
6993
+
6994
+ **Amended 2026-09-04 — ENTER is the second commit gesture, and the base owns
6995
+ it.** A form whose default button is reached by ENTER never moves focus, so
6996
+ leaving the focus chain is not enough: `DateField` would canonicalize *after*
6997
+ the save. So `handle_key` commits on ENTER, and `on_enter=` is **wrapped rather
6998
+ than forwarded** — the editor's slot runs `commit` and then the app's callback,
6999
+ so an ENTER handler never reads an uncommitted buffer. Two consequences a
7000
+ subclass must not undo:
7001
+
7002
+ - **ENTER is committed and then left to keep bubbling.** `handle_key` returns
7003
+ `super` (false), because `TextField` consumes ENTER only when *its* `on_enter`
7004
+ is set — so a field with no callback declines the key, it arrives here by
7005
+ bubbling, and a scope's default button still sees it. Consuming it instead
7006
+ would silently break every form whose Save is bound to ENTER, and the first
7007
+ cut of `DateField` did exactly that by claiming the editor's slot
7008
+ unconditionally. Exactly one commit runs on either path, since the two are
7009
+ mutually exclusive.
7010
+ - **A third claimed slot needs a hook, not a claim.** The base already owns the
7011
+ editor's `on_change` (the change guard) and now its `on_enter`; a subclass
7012
+ reacting to *edits* gets the protected `on_editor_change` no-op instead, which
7013
+ is what `DateField`'s settling latch hangs on (`D_date_field`). One callback
7014
+ slot cannot be shared (`D_no_key_interceptor`), so every one the base claims
7015
+ owes the subclasses a hook in its place.
7016
+
7017
+ **The admission test, which is what keeps this from becoming a junk drawer.** A
7018
+ member belongs here **iff it is true of every wrapping field *because* it wraps**
7019
+ — if you can state it without mentioning the inner editor, it belongs on
7020
+ `Component`, a `Has*` mixin, or the subclass. That admits the delegations and
7021
+ rejects `min`/`max`, `required`, rounding, a `converter=` and a caption, each of
7022
+ which is separately refused elsewhere. Its sharpest consequence is the
7023
+ **forwarding test**: forward a knob only if it means something in the face's own
7024
+ domain. `max_text_length` and `mask_char` fail it — a character count is an
7025
+ editor idea, meaningless on an `IntegerField` (which would want a value
7026
+ `min`/`max`, a different feature) — so they are **not** forwarded and a subclass
7027
+ sets them on its editor internally. Both of the first two candidates coming out
7028
+ *no* is the evidence the surface stays short.
7029
+
7030
+ **Alternatives rejected.**
7031
+ - *Keep `HasContent` and make `content=` protected.* The mixin's whole point for
7032
+ `Slot` / `Window` / `Overlay` is that the caller sets the child; the split is
7033
+ by *audience*, not by visibility of one method.
7034
+ - *Migrate `ComboBox` too.* It fails both premises this base rests on — its
7035
+ buffer is a transient **query** rather than a rendering of its value, and only
7036
+ a commit moves the value. Forcing it in would need three overrides that each
7037
+ *undo* a base behaviour (`clear`, the `on_change` wiring, the `on_enter`
7038
+ forwarder, which would let the inner field eat the ENTER that opens the
7039
+ dropdown). A base whose members a subclass must disable is not a fit. Same line
7040
+ `HasBadInput` already draws for the same component.
7041
+ - *Cover the two group widgets as well.* `CheckboxGroup` / `RadioGroup` wrap a
7042
+ `List` and want four of the fourteen members; ten inapplicable is not a shared
7043
+ base. They were fixed the other way, in the same release: they drop
7044
+ `HasContent` and own their `List` privately, but expose it **read-only** as
7045
+ `list`. That is the second legal shape, and the one the *populate* half of the
7046
+ rule picks out — an app tunes that `List` (`scrollbar_visibility`,
7047
+ `show_cursor_when_inactive`, the cursor) but never supplies it. Forwarding
7048
+ those knobs instead would fail the forwarding test above: they are `List`
7049
+ concepts, not group concepts. Addressable is not the same as yours.
7050
+ - *Names.* `AbstractWrappedField` — the passive names the *inner* thing, and both
7051
+ objects are fields. `AbstractDelegatingField` — the real contender, lost to
7052
+ stdlib `Delegator`'s `method_missing`-based *total* delegation, which promises
7053
+ exactly the forwarding the test above refuses. `AbstractTypedField` —
7054
+ mis-scopes: `Select`'s value is typed and it wraps nothing.
7055
+ `AbstractComposedField` — one letter from the eventual `CompositeField`.
7056
+
7057
+ **Consequences.**
7058
+ - **`content` / `content=` are gone from the three typed fields** — a breaking
7059
+ change to documented API, 6 call sites in-tree. There is no app-facing
7060
+ replacement *by design*: a need the delegation surface does not cover is
7061
+ either a forwarder this class should grow or an editor-shaped knob that fails
7062
+ the forwarding test. Specs are the exception and use `Testing.get`.
7063
+ - **`clear` now empties the *input*.** The trap `HasBadInput`'s rdoc names — a
7064
+ field whose value already reads `empty_value` while glyphs remain — only failed
7065
+ to bite because all three `value=` wrote the buffer unconditionally.
7066
+ - **`value` / `value=` raise `NotImplementedError` in the base**, since
7067
+ `HasValue`'s defaults store into `@value` and never touch the editor.
7068
+ - **`empty_value` is called during construction** to seed the change guard, so it
7069
+ must not depend on subclass state. In practice it is a constant per class.
7070
+ - **`D_placeholder` needs amending, not superseding.** Its argument for
7071
+ forwarding `placeholder` was that "`content` is already the seam for every
7072
+ other inner-field knob"; that premise is void, and the conclusion is now
7073
+ stronger — `placeholder` earns a forwarder precisely because it is the only one
7074
+ of the three that is a domain concept.
7075
+ - **No `extent` declaration.** Checked rather than assumed: a 6-row
7076
+ `IntegerField` paints its well on row 0 only, so there is nothing to fix.
7077
+ - **`Testing.find(HasValue)` matches twice per wrapping field** — the face and
7078
+ its inner editor — and **that is correct and must stay**. Note the asymmetry:
7079
+ an *app* never reaches the editor (the surface above is the whole story), but
7080
+ a *test* legitimately does — `Testing.get(Component::TextField, in:
7081
+ field)` is how a spec puts a field into a state no public setter reaches (a
7082
+ lone `"-"`, a half-typed date) or sends it characters, and it is the sanctioned
7083
+ replacement for the `content` this entry removed. So the locator reports the
7084
+ tree **verbatim** and filters nothing; teaching it to hide a component because
7085
+ of who owns it would both break that technique and make the tree it dumps
7086
+ disagree with the tree that exists, which is the whole debugging value
7087
+ (`D_component_lookup`).
7088
+
7089
+ ## D_date_field — `DateField`: several formats in, one format out (2026-09-04)
7090
+
7091
+ **Status:** Accepted; implemented 2026-09-04 (`Component::DateField`).
7092
+ Graduated from `ideas/date-field.md`, now retired. v1 is manual entry only — the calendar
7093
+ grid stays Tier 2 in `ideas/new-components.md`, blocked on the Popover
7094
+ extraction, and the field unblocks itself by dropping it. Its twin is
7095
+ `D_time_field` (2026-09-05), which inherits every ruling here it does not
7096
+ question and records one as *shared* — Up/Down from an unparseable buffer
7097
+ stepping to today/now — changeable only in both fields at once.
7098
+
7099
+ **Context.** `DateField` is the component that forced `HasBadInput` into
7100
+ existence: a date is the first Tuile value whose input cannot be constrained
7101
+ keystroke-by-keystroke, so it is the first field that must *accept* input it
7102
+ cannot represent. It is also the first field whose *display* is a choice rather
7103
+ than a rendering — `42` has one spelling, 4 September 2026 has a dozen.
7104
+
7105
+ **Decision — the value is stdlib `Date`, so the component is `DateField`.**
7106
+ `D_float_field`'s naming rule (named after the Ruby class of its value) applies
7107
+ unchanged. The `LocalDate` instinct is right about the semantics and does not
7108
+ transfer: `LocalDate` exists in Java only because `java.util.Date` was a
7109
+ misnamed instant, while Ruby's `Date` *is* the civil date and `DateTime` /
7110
+ `Time` are the ones carrying time and offset. A Tuile-owned value type would be
7111
+ one no app's models, ORM columns or serializers speak — Tuile's job is to edit
7112
+ the app's values, not to introduce its own. Not `DatePicker`: Vaadin's name
7113
+ names the widget *category*, and a later calendar popup is a feature of this
7114
+ field, not a rename.
7115
+
7116
+ **Decision — a list of strftime formats: parse in order, first whole match
7117
+ wins, `formats.first` writes back.** Stolen from Vaadin's
7118
+ `i18n.setDateFormats` (v25.2), which is the good idea because it makes a field
7119
+ lenient about what it accepts and strict about what it shows, with no mode flag
7120
+ and no ambiguity about which format is *the* format. Two corollaries:
7121
+
7122
+ - **The list belongs to the app, not to the component.** The same mechanism
7123
+ that makes a field lenient makes leniency configurable without a second
7124
+ concept — one format is strict ISO, three accept what a European or an
7125
+ American types, and nothing changes but the array. This is *not* the
7126
+ `converter=` strategy `D_integer_field` refused: a format list configures the
7127
+ field's own parse/format pair, it does not replace it with an injected one.
7128
+ - **strftime, not Java patterns.** `strptime` and `strftime` share one
7129
+ vocabulary, so lenient-parse/strict-write costs one array; translating
7130
+ `dd.MM.yyyy` would be a second grammar to own and keep correct.
7131
+
7132
+ **Decision — the default is one ISO format, and *not* a lenient list.** A
7133
+ lenient default cannot be shipped: `%m/%d/%Y` and `%d/%m/%Y` both match
7134
+ `04/09/2026` and disagree about what it means, and **no validator can detect
7135
+ that** — only the app knows which reading was intended. Shipping the ambiguous
7136
+ pair would silently produce April 9 for a European who typed 4 September: a
7137
+ *wrong value that saves cleanly*, strictly worse than bad input, which is at
7138
+ least visible. So the order of the list is the disambiguation and it is the
7139
+ app's call; Tuile ships the culture-neutral, sortable, screenshot-stable one and
7140
+ lets an app shoot itself in the foot deliberately. (Vaadin's three-format
7141
+ example is *app* code, not its default.)
7142
+
7143
+ **Decision — `default_format` / `default_calendar_start` are app-global
7144
+ *seeds*.** Read at construction into the per-instance `formats` /
7145
+ `calendar_start`, exactly as `ThemeDef.default` seeds new screens rather than
7146
+ being read live: an app sets them before building its UI, and a later change
7147
+ does not reach fields already built. Both rdocs say **"may change in the
7148
+ future"**, because both are stopgaps for the locale seam of `ideas/locale.md`
7149
+ (which also eventually owns `FloatField`'s decimal comma and a calendar grid's
7150
+ month names). The global holds *one* format rather than a list to keep the later
7151
+ deletion small; the accepted cost is that an app-wide lenient list is
7152
+ per-instance only. The house warning that comes with a reassignable app-global
7153
+ applies in full: a spec that reassigns one must restore it.
7154
+
7155
+ **Superseded 2026-09-04 (`D_locale`), as its rdoc promised.** Both globals are
7156
+ **deleted**; `formats` and `calendar_start` became nil-means-inherit readers
7157
+ over `Screen#locale`. The two predictions in the paragraph above both held —
7158
+ the deletion was small, and the "one format, not a list" hedge was what made it
7159
+ so, since a detected `Locale#date_formats` *is* a list and the seed had nothing
7160
+ to lose. What it got wrong was the *seed* shape: a `Locale.default` was designed
7161
+ and then dropped, because a locale is a detected fact whose app override is one
7162
+ line after `Screen.new`, where a `ThemeDef` is an app-authored artifact a whole
7163
+ spec suite needs. So this entry's `ThemeDef.default` parallel is the one part
7164
+ not to copy for the next such global — and the spec-restore warning went away
7165
+ with it. The `formats` rulings this entry makes (the ordering-is-the-app's-call
7166
+ disambiguation, the round-trip validator, the derived placeholder, `%y`'s
7167
+ rejection) all stand, with one loosening `D_locale` records: only the *primary*
7168
+ must round-trip.
7169
+
7170
+ **Decision — parse with `Date._strptime` for the leftover, then build and
7171
+ rescue.** Three findings, verified rather than predicted:
7172
+ `Date.parse("4 sep")` cheerfully guesses (which would make the format list
7173
+ decorative), `Date.strptime("2026-09-04junk", "%Y-%m-%d")` *succeeds* while
7174
+ silently ignoring the tail, and `Date._strptime("2026-02-30", "%Y-%m-%d")` hands
7175
+ back `mday: 30` because it does not check the calendar. So a match is two gates:
7176
+ a non-empty `:leftover` is no match, and only constructing the `Date` catches
7177
+ February 30th. Free leniency falls out — `"2026-9-4"` parses without zero
7178
+ padding, which is itself an argument for canonicalizing on commit.
7179
+
7180
+ **Decision — `formats=` validates by round-tripping each pattern against one
7181
+ pre-1969 reference date.** `Date.strptime(REF.strftime(f), f) == REF` with
7182
+ `REF = Date.new(1962, 9, 4)`, and it does five jobs at assignment instead of at
7183
+ the first keystroke: it rejects `%D` (a whole `mm/dd/yy`, the typo for `%d`), an
7184
+ incomplete `"%Y-%m"` (which silently fills `mday: 1`), a write-only
7185
+ `"%B %-d, %Y"` (strptime takes no `-` flag), `%G` (the ISO *week*-based year
7186
+ masquerading as `%Y`, which round-trips to the reference year whatever you feed
7187
+ it) and every `%y`. Every property of the reference date is load-bearing:
7188
+ *pre-1969* so `%y` fails, *post-1582-10-15* so the Gregorian reform fails no
7189
+ innocent format, and *month ≠ day* so a `%m`/`%d` swap is not masked. It is a
7190
+ canary rather than a proof — but a century-lossy directive is lossy in both
7191
+ directions, so one pre-window date catches the class that ships, and the formats
7192
+ an app plausibly writes (`"%d.%m.%Y"`, `"%m/%d/%Y"`, `"%Y%m%d"`,
7193
+ `"%B %d, %Y"`, `"%d-%b-%Y"`, `"%A, %d %B %Y"`, `"%Y-%j"`) all pass. What it
7194
+ deliberately does *not* catch is an order **ambiguity** — `"%m/%d/%Y"`
7195
+ round-trips itself perfectly — which is correct, since which reading was meant
7196
+ is the app's call. The one thing it forbids is a deliberately incomplete
7197
+ parse-only format like `"%d/%m"` meaning "this year", which is
7198
+ `Date.parse`-flavoured guessing; the rejection is a feature. `%x` / `%X` / `%c`
7199
+ are rejected *separately*, by name, with a message saying they are not
7200
+ locale-aware: Ruby's `%x` is a fixed `"09/04/26"` under every locale and
7201
+ round-trips fine, so it would pass validation while silently meaning "American".
7202
+
7203
+ **Decision — `%y` is out of a format list entirely; the app writes `%Y`.**
7204
+ Ruby's window is fixed *and closed at both ends*: `%y` is exact on
7205
+ 1969-01-01…2068-12-31 and silently wrong outside it in both directions (1962
7206
+ writes `62` and reads back 2062; 2069 and 2100 write `69` and `00` and read back
7207
+ 1969 and 2000). Since the primary is the write-back format and
7208
+ canonicalize-on-commit makes the rendered text *be* the value, a `%y` primary
7209
+ turns `field.value = Date.new(2100, 9, 4)` into a field holding 2000 — the same
7210
+ wrong-value-that-saves-cleanly the ISO-default ruling refused. And there is **no
7211
+ compact replacement, by arithmetic rather than by stdlib wart**: two characters
7212
+ cannot carry a century. `"%C%y-%m-%d"` round-trips 1962/2026/2100 exactly but is
7213
+ four digits wide, i.e. `%Y` with extra keystrokes. So Vaadin's `referenceDate`
7214
+ (a 100-year window centred on today) is not declined but *moot* — with no
7215
+ two-digit years there is nothing to centre. The cost, accepted and reversible:
7216
+ an app cannot make `04.09.26` typeable.
7217
+
7218
+ **Decision — the placeholder is derived from the primary format, exactly or not
7219
+ at all.** `HasBadInput` mandates one frozen constant with no interpolation, so
7220
+ the message is `"not a valid date"` and can *never* name the accepted formats:
7221
+ the placeholder is the only channel that tells the user what to type, which
7222
+ makes it load-bearing rather than decorative. `D_placeholder` had refused to
7223
+ derive it, on the grounds that a `"%d.%m.%Y"` → `"dd.mm.yyyy"` table is a second
7224
+ grammar that will drift. Lifted, by making the table **best-effort**: it serves
7225
+ the placeholder only, so it may abstain. Every directive of the primary in the
7226
+ table ⇒ a derived hint; any one missing ⇒ `nil`, and the field shows no hint
7227
+ unless the app set one. That kills the drift objection *structurally* — the
7228
+ table cannot disagree with a format it makes no claim about, and a
7229
+ half-translated hint with a raw `%j` in it is unreachable. So the table is
7230
+ deliberately **not** the validator's enumeration: `"%Y-%j"`, `"%F"` and even
7231
+ `"%s"` validate cleanly and simply cost their instance a derived hint. Three
7232
+ directives plus `%%`; no `%b`/`%B` (a month *name* would need an invented
7233
+ `mmm`), no `%e` (a hint of `" d"` is not worth an entry), and `%-d` / `%-m`
7234
+ cannot appear in a format list at all. `nil` restores the derived hint and `""`
7235
+ suppresses it, which is free since `""` is truthy in Ruby and an empty hint
7236
+ paints nothing — and `field.placeholder` on an untouched field therefore reads
7237
+ `"yyyy-mm-dd"` rather than `nil`, an asymmetry with `IntegerField` and the more
7238
+ useful reading ("what does this field show?").
7239
+
7240
+ **Decision — no input filter at all.** `DateField` overrides no `insert_text`:
7241
+ every character is admitted, typed or pasted, and the residue is reported
7242
+ through `bad_input?`. The grammar is not prefix-closed (`"2020-13-45"` is
7243
+ well-formed at every character), which is the condition `D_input_filters` names
7244
+ for taking the accept-and-report road; the tempting middle — rejecting
7245
+ characters no configured format can contain — is refused by the same entry, a
7246
+ partial filter *reads as a guarantee and isn't*. `max_text_length` likewise caps
7247
+ at a flat 64 rather than at the longest configured format: it is a pasted-novel
7248
+ guard, not a grammar.
7249
+
7250
+ **Decision — leaving the field canonicalizes it, and ENTER commits too.** On
7251
+ blur a buffer that parses is rewritten in the primary format: type `4.9.2026`
7252
+ into an ISO field, Tab away, see `2026-09-04`. The UX argument is decisive —
7253
+ *the user sees that the field understood what they typed* — and it is what makes
7254
+ a multi-format list legible rather than mysterious. This is a deliberate
7255
+ divergence from `IntegerField`, which leaves `"007"` alone: a format list is a
7256
+ statement that input and display are *separate vocabularies*, which
7257
+ `IntegerField` never claimed. The seam is `AbstractWrappingField#commit`
7258
+ (`D_wrapping_field`). **ENTER commits too**, because a form whose default button
7259
+ is reached by ENTER never moves focus, so blur alone would let it save an
7260
+ uncanonicalized buffer — and that generalized into the base, which commits on
7261
+ ENTER and then lets the key keep bubbling to whatever binds it. Two gestures,
7262
+ not a general "commit" notion: there is no third candidate.
7263
+
7264
+ **Decision — the red well is latched to those two gestures.** This is the first
7265
+ consumer of the settling rule `D_bad_input` left owed, and it had to be: every
7266
+ prefix of a date is bad input, so the OR in `error_ink?` held the field red from
7267
+ the first keystroke to the last — `2`, `20`, `202` all reddening on the way to a
7268
+ correct `2026-09-04`, which reads as "you are wrong" where the truth is "you are
7269
+ not finished". The rule gates the **ink** and nothing else, exactly as
7270
+ `D_has_validation` predicted it would: `HasBadInput#bad_input_settled?`
7271
+ (default `true`, so the numeric fields are unchanged — their residue is two
7272
+ transient buffers, where the early warning beats the quiet) is overridden here by
7273
+ a latch that `commit` sets and the base's `on_editor_change` clears. So the well
7274
+ reddens when the user leaves the field or presses ENTER, goes quiet on the next
7275
+ edit, and `bad_input?` — the pull a save gate uses — never waits for any of it.
7276
+ Two details that are easy to get backwards: `commit` settles **after** rewriting
7277
+ the buffer, since the rewrite is itself an edit that clears the latch; and
7278
+ settling must `invalidate`, because an ENTER on an untouched buffer paints no
7279
+ cells of its own and would otherwise change the ink with nothing repainting it.
7280
+
7281
+ **Decision — Up/Down step a day; an empty or unparseable field steps to
7282
+ today.** `IntegerField` treats an unparseable buffer as `0`, so the analogue is
7283
+ `Date.today` — and unlike the integer case the step *lands* on today rather than
7284
+ today ± 1, since today is what a picker would have opened on. They claim the
7285
+ editor's `on_key_up` / `on_key_down` slots, as the numeric fields do, leaving
7286
+ the general key seam free.
7287
+
7288
+ **Decision — `Date::GREGORIAN` by default, not Ruby's `Date::ITALY`.**
7289
+ Investigated rather than assumed, and the evidence runs one way. Ruby core calls
7290
+ the split a mistake — in [bug #18946][d_date_field_bug] Matz wrote *"`to_date`
7291
+ has been use GREGORIAN calendar since 2011-05-31 and `to_datetime` preserved the
7292
+ old `DEFAULT_SG` (ITALY). I assume this is a mistake and both should use
7293
+ GREGORIAN"* — `Time` is proleptic Gregorian and always has been (so
7294
+ `Date.new(1500,1,1).to_time` and `Time.new(1500,1,1).to_date` are nine days
7295
+ apart), ISO 8601 mandates proleptic Gregorian and ISO is this field's default
7296
+ primary format, and under `GREGORIAN` the ten days the reform skipped stop being
7297
+ a `Date::Error` the user cannot type their way out of. The cost, stated rather
7298
+ than hidden: the round-trip is exact only while the field's calendar matches
7299
+ that of the `Date`s the app hands it, and `Date.new(1500, 1, 1)` in *app* code
7300
+ is `ITALY` — so an app that builds pre-1582 dates naively gets them back nine
7301
+ days off after a canonicalization. That is what the per-instance setting is for.
7302
+
7303
+ [d_date_field_bug]: https://bugs.ruby-lang.org/issues/18946
7304
+
7305
+ **Rejected alternatives.**
7306
+ - **`value=` remembering the incoming `Date`'s own `start` and parsing back with
7307
+ it.** Makes the round-trip exact for free, and is wrong anyway: `value` would
7308
+ stop being a pure function of the buffer — the shape every other typed field
7309
+ has (`D_integer_field`: the buffer is the single source of truth) — and a
7310
+ field typed into from empty would have no `start` to remember.
7311
+ - **A runtime guard on `value=`.** `DateTime < Date` is true and `Time#strftime`
7312
+ exists, so `field.value = Time.now` "works", formats as the civil date and
7313
+ reads back a `Date`. Keep the thinness (`IntegerField#value=` just calls
7314
+ `to_s`) and rule the truncation in rdoc: it is the same lenient-in/strict-out
7315
+ shape as the format list.
7316
+ - **Two reference dates in the validator** — a pre-1969 one for the primary and
7317
+ an in-window one for the rest, so `%y` stays typeable as a fallback. Bought a
7318
+ two-digit shortcut at the price of a per-position rule; dropped with `%y`
7319
+ itself. Re-allowing it later is purely additive.
7320
+ - **A mask (`dd/mm/yyyy` with per-field ranges).** It *is* a format declaration
7321
+ by another route, and it manufactures a third state: `"__/05/2026"` is neither
7322
+ garbage nor a value but **incomplete**, which Vaadin models separately
7323
+ (`setIncompleteInputErrorMessage`). If this field ever grows one, it owes a
7324
+ ruling on whether incomplete is bad input or its own thing — and it would ride
7325
+ the same `error_ink?` hook either way.
7326
+ - **Designs that make bad input impossible** rather than reportable: a
7327
+ calendar-grid-only picker (no text input, so no parse at all — but ~30
7328
+ keystrokes for a birth date) and text entry behind a modal commit (a
7329
+ `ConfirmWindow`-shaped dialog that won't close on garbage — heavy in a form
7330
+ with six dates). A multi-format parse weakens the case for both.
7331
+
7332
+ **Consequences.**
7333
+ - **`require "date"` is hoisted into `lib/tuile.rb`.** `Date` is not preloaded,
7334
+ but unlike `bigdecimal` it is a default gem — always present, never optional —
7335
+ so it gets none of `D_bigdecimal_field`'s lazy-load treatment, and citing that
7336
+ precedent for it would be a misreading. `rake sig:validate` gains `-r date`
7337
+ for the same reason.
7338
+ - **`formats=` and `calendar_start=` can change `value` with no edit**, since
7339
+ the value is a derived parse — so both fire `on_value_change` when the reparse
7340
+ differs. Neither touches the buffer: it is text, it reparses on the next read,
7341
+ and if it has gone bad the red well says so.
7342
+ - **The humanizer must recognize a directive it does not know.** A `gsub` of the
7343
+ three known ones leaves `"%Y-%j"` as the literal hint `"yyyy-%j"` — exactly
7344
+ the lying hint the derive-exactly rule forbids, produced by the honest-looking
7345
+ code. It scans the *general* strftime directive shape (flags, width, the
7346
+ `E`/`O` modifiers, `%%`, `%::z`) and returns `nil` on any match outside the
7347
+ table.
7348
+ - **No `extent` declaration and no paint code**, like the other wrapping
7349
+ fields: the editor paints, and the red well arrives through
7350
+ `HasBadInput#error_ink?` (`D_has_validation`).
7351
+ - **PageUp/PageDown stepping a month is deferred**, recorded so it is a decision
7352
+ rather than an omission.
7353
+
7354
+ ## D_kill_keys — Ctrl+U and Ctrl+W in the string fields; Shift+Backspace is not a key (2026-09-04)
7355
+
7356
+ **Status:** Accepted; implemented 2026-09-04.
7357
+
7358
+ **Context.** Emptying a `ComboBox` query meant holding Backspace down. The
7359
+ obvious binding — Shift+Backspace — does not exist on the wire: Backspace is a
7360
+ single byte (`\x7f`) with nowhere to carry a modifier, so a terminal delivers
7361
+ Shift+Backspace as plain Backspace. Only opt-in protocols express it (xterm's
7362
+ `modifyOtherKeys=2` sends `\e[27;2;127~`, kitty's keyboard protocol
7363
+ `\e[127;2u`), Tuile enables neither, and VTE and Konsole send `\x7f` regardless.
7364
+ Nothing binds it, so nothing needed to.
7365
+
7366
+ What every terminal input *does* bind is readline's kill trio: **Ctrl+U** to the
7367
+ line start, **Ctrl+W** the previous word, Ctrl+K to the line end. bash, zsh, fzf
7368
+ (`clear-query`), Textual's `Input` (`delete_left_all` / `delete_left_word`),
7369
+ prompt_toolkit, the ratatui ecosystem's `tui-input` and vim's insert mode all
7370
+ agree, and have since the 1980s. The GUI toolkits have no keyboard equivalent at
7371
+ all — Vaadin's combo ships a clear `×` and browsers rely on the mouse; macOS's
7372
+ Cmd+Delete is the closest cousin.
7373
+
7374
+ **Decision — Ctrl+W on `AbstractStringField`, Ctrl+U on each subclass, both
7375
+ targeting a deletion the *key* names.** They share one protected primitive,
7376
+ `delete_back_to(index)`, so the caret and cluster rules are written once:
7377
+
7378
+ - **Ctrl+W** is in the base, because "delete back to `word_left`" means the
7379
+ same thing in one line and in many — it deletes exactly what Ctrl+Left would
7380
+ have skipped, newline crossing included.
7381
+ - **Ctrl+U** is per subclass, because the target is not shared: index 0 in a
7382
+ `TextField`, the caret's **row** start in a `TextArea` — the wrapped row, so
7383
+ it kills back to wherever Home goes. Pinning it to the *line* would have made
7384
+ Ctrl+U and Home disagree in a wrapped paragraph, which is the more visible
7385
+ surprise.
7386
+
7387
+ Ctrl+K is deliberately not bound: killing *forward* is the rarer half of the
7388
+ trio and the one nobody reached for here. It stays free, and the shape above
7389
+ (one `when`, one `delete_forward_to`) is what to copy if it is ever wanted.
7390
+
7391
+ **The cost, paid knowingly: a `ComboBox` loses Ctrl+U half-page scrolling.**
7392
+ `ListDropdown::MOVE_KEYS` includes Ctrl+U/D, and a combo only ever sees the keys
7393
+ its field declines — so five of the six still bubble, and Ctrl+U now clears the
7394
+ query instead of moving the highlight five rows. `Select`, which wraps no
7395
+ editor, keeps all six. Worth it in one direction only: half-page-up over a
7396
+ ten-row dropdown duplicates what two arrow presses do, while clearing a query
7397
+ had no key at all. (`D_no_key_interceptor`'s "the field claims none of the six"
7398
+ is amended by exactly this.)
7399
+
7400
+ **Alternatives rejected.**
7401
+ - **Bind it on `ComboBox` alone, gated on the dropdown being closed.** The first
7402
+ proposal, and it fails twice. The gate is a mode flag in dispatch — a key
7403
+ meaning two things depending on invisible state — and the moment `TextField`
7404
+ grows the same key for its own sake (which it should, being a text field),
7405
+ Ctrl+U means one thing in a bare field and another inside a combo. A key
7406
+ earns its meaning from the widget that has focus; here that is always the
7407
+ field.
7408
+ - **Ctrl+W only, leaving Ctrl+U to the dropdown.** No collision, and repeated
7409
+ Ctrl+W clears a one-word query in one press. Declined because it makes Tuile
7410
+ the only terminal input where Ctrl+U does not clear the line, to protect a
7411
+ scroll gesture the arrows already cover.
7412
+ - **Drop Ctrl+U/D from `MOVE_KEYS` so the constant tells the truth.** It reads
7413
+ tidier and is strictly worse: it would take the half-page jump away from
7414
+ `Select` too, which has no editor and no conflict. The constant lists what the
7415
+ dropdown *accepts*; what reaches it is dispatch's business, and the rdoc says
7416
+ so.
7417
+ - **A `clear` gesture on the widget instead of a key** (a `×` affordance in the
7418
+ face, or ESC clearing rather than reverting). The face is one row with one
7419
+ spare column, already spent on the `▾`; and ESC's revert-to-the-committed-
7420
+ label is the behavior that makes the query transient (`D_has_value`), so
7421
+ spending it on clearing would cost more than it buys.
7422
+
7423
+ **Consequences.**
7424
+ - **`delete_before_caret` is now `delete_back_to(cluster_boundary_before(caret))`**
7425
+ — one deletion path, so the "write `@caret` before `text=`" rule (a caret left
7426
+ past the shortened text lands at its end) is stated once.
7427
+ - **Every `TextField` subclass and composed field inherits both keys** —
7428
+ `PasswordField`, the three numeric fields, `DateField`, `ComboBox`. Deletion
7429
+ passes through no `insert_text`, so an input filter has nothing to say about
7430
+ it (`D_input_filters`).
7431
+ - **A container under a text field can no longer bubble-bind Ctrl+U or Ctrl+W.**
7432
+ The same rule that already covers printables and the editing keys, now two
7433
+ keys wider.
7434
+
7435
+ ---
7436
+
7437
+ ## D_locale — `Locale`: the formatting conventions, and never the prose (2026-09-04)
7438
+
7439
+ **Decision.** `Tuile::Locale` is a frozen `Data` value type of formatting
7440
+ conventions — `date_formats`, `calendar_start`, `first_weekday`, the four
7441
+ month/day name tables, `decimal_separator`. `Screen#locale` holds one, seeded in
7442
+ `Screen#initialize` from `Locale.system` (which shells out to `locale -k`), and
7443
+ `Screen#locale=` replaces it, firing `Component#on_locale_changed` across the
7444
+ tree and invalidating all of it. `Locale::ISO` is the only shipped constant and
7445
+ the universal fallback. `DateField#formats` / `#calendar_start` became
7446
+ nil-means-inherit readers over it, and `DateField.default_format` /
7447
+ `.default_calendar_start` are deleted (`D_date_field`, superseded).
7448
+ Graduated from `ideas/locale.md`, which is retired.
7449
+
7450
+ **Why it became necessary.** Three components needed locale-shaped *data* and
7451
+ were about to grow three separate class-globals for it: `FloatField`'s decimal
7452
+ comma (`D_float_field` deferred it verbatim), a phase-2 calendar grid (which
7453
+ *cannot* be built without month names, since `Date::MONTHNAMES` is frozen
7454
+ English), and `DateField#formats` — whose two stopgap globals shipped the same
7455
+ day this was filed, marked "may change in the future" precisely because of this.
7456
+
7457
+ **The boundary rule, which is the whole design.**
7458
+
7459
+ > `Locale` holds formatting conventions — how a value is rendered and parsed.
7460
+ > It never holds prose.
7461
+
7462
+ That sentence is what keeps this eight members instead of a subsystem, and it is
7463
+ the gate a ninth has to pass. A message catalogue — per-string lookup,
7464
+ interpolation, pluralization — is a different beast, and `D_bad_input` already
7465
+ ruled that wording arrives as *the wording fork* (a settable message, or a
7466
+ catalogue lookup inside `bad_input_message`), never as a redesign of the
7467
+ channel. The rule is not Tuile's invention: **POSIX draws the same line**,
7468
+ putting prose in `LC_MESSAGES` and formatting in `LC_TIME` / `LC_NUMERIC`.
7469
+ Reading only the formatting categories is what lets a session coherently want an
7470
+ English UI, ISO dates and a decimal comma at once — which is not a contrived
7471
+ example but the author's own machine, and the strongest single argument in the
7472
+ whole design.
7473
+
7474
+ **Why a subprocess, which is a first for Tuile.** Ruby exposes no locale data at
7475
+ all, verified rather than remembered (3.3.8 / glibc): no `nl_langinfo` binding,
7476
+ no `D_FMT`, nothing on `Date` or in `Etc`; `Encoding.locale_charmap` is a
7477
+ charset and `RbConfig` has a path, not data. `Date::MONTHNAMES` is frozen
7478
+ English under any `LC_ALL`. And `strftime("%x")` is a **trap**, not a channel:
7479
+ it returned a fixed `"09/04/26"` under every locale tried *and*
7480
+ `Date.strptime("09/04/26", "%x")` parses, so `%x` in a format list is a silently
7481
+ American `%m/%d/%y` that would sail through a round-trip validator — hence the
7482
+ explicit rejection of `%x` / `%X` / `%c` with a message saying so, since that
7483
+ misconception is why this whole entry exists.
7484
+
7485
+ `locale(1)` is POSIX and hands back **strftime patterns — Tuile's exact
7486
+ vocabulary**, so there is no second grammar to translate or keep correct, and
7487
+ one ~1 ms call spans both categories (libc resolves each keyword in its own).
7488
+ The honest cost, and the thing this decision actually has to argue: **a
7489
+ subprocess at `Screen` construction is new.** `ColorDepth.detect` is env-only;
7490
+ `TerminalBackground` is an OSC query on a stream Tuile already owns. The
7491
+ mitigations are that it is gated (below), that nothing in it can fail loudly,
7492
+ and that it is one call rather than one per keyword.
7493
+
7494
+ **`-k`, not the bare keyword form.** Verified: `locale d_fmt bogus_key
7495
+ decimal_point` prints **two** lines for three keys, so positional parsing
7496
+ silently misaligns every later value — and that is exactly the shape of a
7497
+ keyword a non-glibc `locale` lacks (`first_weekday` is a glibc extension POSIX
7498
+ never defined). `-k` prints `key=value`, so a missing key is simply absent.
7499
+
7500
+ **The exit status is useless in both directions**, also verified: a bad locale
7501
+ name prints to stderr, **exits 0** and silently returns the C locale
7502
+ (`LC_ALL=xx_YY.UTF-8 locale d_fmt` → `%m/%d/%y`), while an unknown *keyword*
7503
+ **exits 1** yet still prints every good key. So the status is ignored, stderr
7504
+ discarded, and each value validated on its own — falling back to its `ISO`
7505
+ member individually rather than all-or-nothing. That per-member fallback reuses
7506
+ the real constructor (`locale.with(member => value)` in a `rescue`) instead of
7507
+ restating the shape rules, so there is exactly one validator.
7508
+
7509
+ **Detect only when the user said something — and gate it *per category*. This is
7510
+ the one judgement call the design rests on.** The C/POSIX default is American,
7511
+ so "said nothing" and "wants `%m/%d/%y`" are indistinguishable from the answer,
7512
+ and a container with no `LANG` would confidently show American dates to
7513
+ everyone. A *single* gate on "any locale variable is set" is wrong on a real
7514
+ machine: a session exporting only `LC_NUMERIC=de_DE.UTF-8` opens it, and then
7515
+ `d_fmt` resolves through an unset `LC_TIME` and an unset `LANG` to C's American
7516
+ pattern. So the date half is kept only if `LC_ALL` / `LC_TIME` / `LANG` speaks
7517
+ and the numeric half only if `LC_ALL` / `LC_NUMERIC` / `LANG` does, with unset,
7518
+ `C` and `POSIX` all counting as silence. One subprocess, two gates.
7519
+
7520
+ **`en_GB`'s `d_fmt` is `%d/%m/%y`, and it gets fixed rather than fudged.** It is
7521
+ the case that survives doing detection *correctly*, and the fix is principled: a
7522
+ two-digit year cannot round-trip, because `Date.new(1962, 9, 4)` renders
7523
+ `"04/09/62"` and reparses as **2062** under Ruby's fixed POSIX window
7524
+ (`69`→1969, `26`→2026). So the detected primary is widened `%y`→`%Y` while the
7525
+ raw pattern stays in the *parse* list, and a Brit typing `04/09/26` is still
7526
+ understood — lenient in, strict out, `DateField`'s own design doing the work.
7527
+ **Widen at the probe, raise at assignment**, and that asymmetry is deliberate:
7528
+ the probe has no author to tell, an assignment does. It is not an inconsistency
7529
+ to iron out.
7530
+
7531
+ That forced one **loosening of `D_date_field`'s validator**: only
7532
+ `formats.first` must survive a round-trip, since it is the only one ever
7533
+ *written*; a later entry only ever parses, so it needs merely to be a usable
7534
+ strptime pattern. Without it the detected list above is unrepresentable. It is
7535
+ backwards compatible (strictly more input accepted), and it improves the error
7536
+ message, which now says a `%y` pattern may still appear later in the list.
7537
+
7538
+ **Why `Screen` is the home, objection and all.** The objection is real: a locale
7539
+ is a property of the *human*, and `Screen` is "the service" (`D_tree_first`).
7540
+ Three things answer it. (1) `Screen` is already the **environment** boundary,
7541
+ not just the terminal: it owns both existing environment probes and the
7542
+ dark/light *scheme* derived from one of them — and a colour scheme is every bit
7543
+ as much a property of the human as a date format is. The locale arrives from the
7544
+ same place: the process environment the session was launched in. One session,
7545
+ one locale, one `Screen`. (2) It is machinery by `D_tree_first`'s own split;
7546
+ nothing about the *tree* changes. (3) The alternatives are worse — a
7547
+ `Tuile.locale` module global reintroduces the spec-leak hazard that AGENTS.md
7548
+ warns about for `ThemeDef.default`, and a `bg_color`-style resolve-up-the-tree
7549
+ chain has no consumer asking for two locales in one process.
7550
+
7551
+ **No `Locale.default`, and that is the interesting divergence from `ThemeDef`.**
7552
+ One was designed and dropped. `ThemeDef.default` exists because a theme *pair*
7553
+ is an app-authored artifact a whole spec suite needs every screen to carry; a
7554
+ locale is a **detected fact** whose app override is one line after `Screen.new`.
7555
+ So this follows the `ColorDepth` precedent instead: detect in `initialize`, pin
7556
+ in the fake, nothing global to leak or restore. The payoff is concrete — the
7557
+ spec-restore obligation `ThemeDef.default` and `VerticalScrollBar.handle_char`
7558
+ both carry simply does not exist here, because the next `Screen.fake` resets the
7559
+ only place the value lives. `Locale.system` is also **not memoized**: once per
7560
+ `Screen.new`, 1 ms, and a memo is one more thing a spec would have to reset.
7561
+
7562
+ **Name tables are keyed by the `Date` accessor that reads them.**
7563
+ `month_names[d.month]`, `day_names[d.wday]` — so months are a `Hash` keyed
7564
+ `1..12` and days stay Ruby's 0-based `Array` verbatim. The differing shapes are a
7565
+ *consequence* of the rule, not an inconsistency, and the rule makes the
7566
+ off-by-one **unrepresentable rather than documented against**: a 0-based month
7567
+ array answers `month_names[9] # => "October"`, a plausible wrong answer,
7568
+ silently — the same failure class as `%y` reparsing as 2062 and an `%m`/`%d`
7569
+ swap no validator can see. Out-of-range answers `nil`. The cost is one `.values`
7570
+ where a month picker wants all twelve.
7571
+
7572
+ **`calendar_start` belongs here, and the first answer that said otherwise was
7573
+ wrong for an instructive reason:** it conflated *probeable* with *belonging*.
7574
+ `locale(1)` has no keyword for the calendar reform date, but `Locale` was never
7575
+ defined as "whatever `locale -k` returns" — it is defined as *how a value is
7576
+ rendered and parsed*, and `calendar_start` is literally an argument to
7577
+ `Date.strptime`. It is also the most **regional** fact in the object (Italy 1582,
7578
+ England 1752, Russia 1918), one libc declines to expose only because nobody has
7579
+ needed it since. A member `Locale.system` never sets is fine; the *override*
7580
+ channel is the point, and an app in the pre-reform world now writes
7581
+ `ISO.with(calendar_start: Date::ITALY)` once instead of per field.
7582
+
7583
+ **All eight members ship ahead of their consumers — an explicit, bounded
7584
+ exception.** By this entry's own no-consumer rule (which keeps `t_fmt` /
7585
+ `d_t_fmt` / `am_pm` out), v1 would have been
7586
+ `Data.define(:date_formats, :calendar_start)`: the grid does not exist and
7587
+ `decimal_separator`'s field-side work is deferred above. The author overruled
7588
+ that, and the reasoning is worth recording because it is an exception. The probe
7589
+ is one subprocess either way, so five more keys buy a rounding error; the
7590
+ expensive part is the *rulings* (the keying, the `first_weekday` conversion, the
7591
+ per-category gate), which were settled while fresh and are cheaper to encode in
7592
+ code than to re-derive later; and the rule's real target is *inventing*
7593
+ conventions nobody asked for, which these are not — two consumers are already
7594
+ filed. The boundary that keeps it from becoming licence: the time formats stay
7595
+ out, because their consumer is a `TimeField` nobody has filed. That is the
7596
+ difference between deferred and unimagined. (Filed since — `D_time_field`,
7597
+ 2026-09-05, adds `time_formats` as the ninth member, and rules that the *seconds
7598
+ strip* is the field's policy rather than the probe's normalization, so the
7599
+ member carries `t_fmt`'s full precision; the expansion table stays at the
7600
+ boundary as this entry's rule requires.)
7601
+
7602
+ **`locale=` invalidates the whole tree, plus one hook.** A locale change is a
7603
+ once-a-session event, so invalidate-all costs nothing worth optimizing, and a
7604
+ field losing a half-typed buffer to the new grammar is **accepted**: the text
7605
+ stays, the value goes nil, the field reads as bad input, and the user can see
7606
+ and fix it. Invalidation alone is not sufficient, though, and the reason
7607
+ generalizes: anything *pulled* at paint or parse time comes out right on the
7608
+ next frame, but anything **pushed** does not. `DateField`'s typing hint lives in
7609
+ its editor's `placeholder`, written when the formats were last set, so a repaint
7610
+ would faithfully repaint the stale `dd.mm.yyyy`. Hence `on_locale_changed`,
7611
+ shaped exactly like `on_theme_changed` (protected, `__send__` fan-out per
7612
+ `D_hook_visibility`, `super` from an override, plus an assignable listener for
7613
+ apps that compose rather than subclass). It stays a one-consumer mechanism by
7614
+ design: the calendar grid will read names at paint time and need nothing.
7615
+
7616
+ **Roads not taken:**
7617
+
7618
+ - *The `i18n` gem, or CLDR (`ruby-cldr`, `twitter_cldr`).* They have the data and
7619
+ they are correct; they are also dependencies, and Tuile's *one* optional
7620
+ dependency has a whole entry justifying it (`D_bigdecimal_field`, which says
7621
+ explicitly that a second needs its own argument rather than that precedent).
7622
+ Asking the system ships **zero** locale data, which keeps the no-catalogue rule
7623
+ better than any bundled table could.
7624
+ - *`ENV["LC_ALL"] || ENV["LC_TIME"] || ENV["LANG"]` plus a locale-name → format
7625
+ table.* This is the cheap version, and it is the catalogue **plus** a
7626
+ reimplementation of libc's precedence chain. It gets the author's machine wrong
7627
+ twice over (reading `LANG=en_US` for both categories, where the compiled data
7628
+ says otherwise for each), and it gets `en_DK` wrong unless the table happens to
7629
+ carry it. The subprocess's value is not that it reads env vars — it is that it
7630
+ reads the **compiled locale data**, per category.
7631
+ - *A `TUILE_LOCALE` env override*, for symmetry with `TUILE_COLOR_DEPTH`. A whole
7632
+ locale does not fit in an env var without inventing a serialization, and the pin
7633
+ a PTY spec needs already exists: pass `{"LC_ALL" => "C"}` in the env hash it has
7634
+ to pass anyway for the colour-depth reason, or a real locale name to exercise
7635
+ the real path. Unit specs need nothing — `FakeScreen` pins `ISO`.
7636
+ - *Preset constants (`Locale::EN_US`, `::DE`, …).* Two presets are a catalogue, a
7637
+ catalogue implies completeness, and `en_GB`'s own `%d/%m/%y` shows how
7638
+ opinionated even a "correct" entry is. Apps build theirs with `ISO.with(...)`;
7639
+ Tuile hands over the gun and ships no ammunition. The name helps — `ISO` names
7640
+ a *standard*, not a country, and three of its members cite it.
7641
+ - *Mirroring `Date::MONTHNAMES`' 13-entry array with `nil` at index 0.*
7642
+ `[d.month]` would work, at the price of a `nil` hole in a frozen value type —
7643
+ worse than the `Hash`, and it makes "12 names" unexpressible as a shape check.
7644
+ - *Making the day tables `Hash`es too.* Deferred, with a trigger rather than a
7645
+ shrug: the only real argument is a future "widest name in this table" helper for
7646
+ the grid's column arithmetic, which reads `.values` on a `Hash` and the table
7647
+ itself on an `Array`. Until the grid exists, re-keying `Date::DAYNAMES` is
7648
+ transformation noise in a constant whose virtue is being Ruby's data verbatim.
7649
+ - *Validating the probe's answer as a whole.* Rejected in favour of per-member
7650
+ fallback: one odd keyword on one distro would otherwise discard a `d_fmt` that
7651
+ was perfectly good.
7652
+ - *Trusting `locale`'s exit status*, or reading `t_fmt` / `am_pm` "while we are
7653
+ in there". The first is measured to be meaningless; the second is the
7654
+ no-consumer rule, which the eight-member overrule above explicitly does not
7655
+ extend to.
7656
+
7657
+ **Known-unverified, and deliberately so.** macOS / BSD `locale -k` keyword
7658
+ support is believed fine but unchecked (CI is `ubuntu-latest` only), and
7659
+ `first_weekday` is a glibc extension likely absent there. This is safe to leave
7660
+ open precisely because of the `-k` shape and the per-member fallback: an absent
7661
+ or odd key degrades to its `ISO` value with nothing raised. A missing binary
7662
+ (Windows, some musl containers) yields `ISO` whole, and Windows' gate never opens
7663
+ anyway.
7664
+
7665
+ ## D_empty_ancestor — An empty rect propagates down, and the drain filter enforces it (2026-09-04)
7666
+
7667
+ **Status:** Accepted; implemented 2026-09-04 in `Layout::Box#relayout`,
7668
+ `Screen#repaint`'s drain filter and `Screen#initialize`, with `Box#add`'s `at:`
7669
+ and `Box#constrain` alongside. Reported downstream by pikuri-tui as issue #17.
7670
+ Leans on `D_repaint_cascade` (the same bug shape one layer up — a notice that
7671
+ must keep travelling down), `D_tabs` (hiding is detachment; the `visible?`
7672
+ re-grow rule), `D_box_layouts` (the sugar this amends) and `D_extent` (why an
7673
+ empty rect is a *paint* convention and gates nothing else).
7674
+
7675
+ **Context — the symptom.** pikuri-tui's coding shell hides its sidebar column by
7676
+ handing it a zero-width rect. The two panes inside it are `Layout::Vertical`s,
7677
+ which kept the rects they had while visible. Nothing painted them — until the
7678
+ user closed the `Ctrl+K` leader menu, a popup, and `Screen#remove_popup`'s
7679
+ `needs_full_repaint` invalidated **every** component directly. The sidebar came
7680
+ straight back, and, being later in tree order, over the conversation pane's
7681
+ scrollbar column. Every subsequent popup open/close flickered it away and back.
7682
+
7683
+ **The mechanism.** `Box#relayout` opened with `return if rect.empty?`, so a box
7684
+ whose own rect went empty never assigned its children. The branch immediately
7685
+ below it does exactly the right thing — `children.each { _1.rect = … 0, 0 }` for
7686
+ an empty `inner_rect` — and the guard made it unreachable. Three paths refuse to
7687
+ paint the resulting stale subtree (`Component#repaint`'s own empty-rect return,
7688
+ the default container `repaint` cascading only from a non-empty parent, and
7689
+ `Screen#repaint`'s detached filter), which is why it stayed hidden;
7690
+ `needs_full_repaint` bypasses all three.
7691
+
7692
+ A sweep says the fault is `Box`'s alone: `Window#layout_footer`, `Slot#layout`
7693
+ and `TabSheet#rect=` all let an empty rect fall through to the arithmetic and
7694
+ zero their children correctly. `Box` was also inconsistent with *itself* — an
7695
+ over-subscribed child is already starved to an empty rect and placed anyway.
7696
+
7697
+ **Decision — a container propagates its own empty rect to every child.** The
7698
+ guard conflated *"no rect yet"* with *"rect deliberately emptied"*, and only the
7699
+ first ever needed protecting; it survives the deletion, because during
7700
+ construction the children are already empty (so the assignments are no-ops) and
7701
+ the trailing `invalidate` returns early while detached. This is the load-bearing
7702
+ half: it is what makes `cursor_position` answer `nil` for a collapsed field, and
7703
+ so what stops the hardware cursor parking inside the *visible* pane.
7704
+
7705
+ **Decision — `Screen#repaint`'s drain filter is ancestor-aware.** The filter
7706
+ already dropped detached components; it now also drops a component with an empty
7707
+ rect anywhere on its ancestor chain. This is not new policy — `Component#repaint`
7708
+ gates each component on its *own* empty rect, and this is that same gate made to
7709
+ see one hop further — so it removes an inconsistency rather than adding a rule.
7710
+ The point is that it holds for a container that has **not** been fixed, including
7711
+ an app's own `Absolute` subclass: a forgetful container now leaves an inert
7712
+ subtree instead of a subtree that paints at stale coordinates. It is also a
7713
+ strictly narrower repaint set, so marginally cheaper.
7714
+
7715
+ **Consequence — the pane is sized in `Screen#initialize`.** `@pane.rect` used to
7716
+ be assigned only by `#layout`, which runs from the event loop; under the new
7717
+ filter an unsized pane is an empty *ancestor* rect for the entire tree, and every
7718
+ `FakeScreen`-driven repaint painted nothing. Seeding it at construction, exactly
7719
+ as `#size` is already seeded from `TTYSizeEvent.create`, is the honest fix: it
7720
+ makes the fake match production, where `#layout` runs before anything paints.
7721
+ `FakeScreen` re-seeds it after resizing itself to 160×50.
7722
+
7723
+ **Decision — a collapse is not hiding, and `Box` lets you re-place a child.**
7724
+ The issue proposed a `visible` property with Android's `INVISIBLE` / `GONE`
7725
+ split. What this entry settled is the measurement that any such flag has to
7726
+ honour: geometry cannot express hiding, because `tab_stop?` does not consult
7727
+ geometry and must not. A zero-rect sidebar keeps its tab stops and still takes
7728
+ keys **after** this fix — the fix only stops it painting and moves its cursor
7729
+ off-screen. So `Fixed[0]` is *collapse*, not hide, and its rdoc says so. The
7730
+ flag itself was deferred here under `D_tabs`' re-grow rule and has since been
7731
+ accepted as `D_visibility`, as the full focus-and-paint gate this measurement
7732
+ demands; that entry owns hiding now.
7733
+
7734
+ What pushed pikuri-tui into the zero-rect idiom is that detachment — the answer
7735
+ at the time — was not expressible in a `Box`: `Box#add` had no `at:`, so a
7736
+ removed child came back at the end of a multi-child box, and a child's
7737
+ constraints could not be changed after `add` at all. Both are closed —
7738
+ `add(child, main, at: i)` and `constrain(child, main = nil, cross: nil, align:
7739
+ nil)`, the latter `nil`-means-keep so one axis moves alone — and both stay
7740
+ useful beside the flag: `remove` / `add(…, at:)` is the move when the lifecycle
7741
+ hooks *should* fire.
7742
+
7743
+ **Roads not taken.**
7744
+
7745
+ - **A `Component#paintable?` predicate** for the filter to call. Rejected: it
7746
+ reads as a component-level concept ("am I paintable?") when it is one screen's
7747
+ drain-time question, and it would be a new public predicate every component
7748
+ answers. The AND is spelled at the one call site instead.
7749
+ - **Fixing `needs_full_repaint` alone**, as the issue proposed. It is the path
7750
+ that surfaced the bug, but not the only one that can reach a stale subtree, and
7751
+ the invariant belongs at the single choke point every invalidation drains
7752
+ through.
7753
+ - **Blanking the cells a collapsed subtree vacated.** Deliberately not attempted.
7754
+ `clear_outside_extent` reaches an L, never an interior hole, and the general
7755
+ version is the rect-subtraction geometry `D_repaint_cascade` already declined
7756
+ in the hottest path. In a tiled layout a sibling grows into the space and
7757
+ repaints, which is what happens in pikuri-tui and in `#constrain`'s
7758
+ `Fixed[0]`-gives-its-space-to-siblings spec. The minimal repro — a root box
7759
+ emptied by nobody, so no sibling exists — keeps its stale glyphs, and that is
7760
+ the artificial case.
7761
+ - **Making a collapsed child cost no `Box#spacing`.** A `Fixed[0]` child still
7762
+ leaves the gap around it. Changing that would also change over-subscription
7763
+ starvation, which shifts existing layouts, and `D_box_layouts` holds that a gap
7764
+ belongs to the *sequence*. Documented on `Fixed` instead. `D_visibility`
7765
+ draws the line: a collapsed child is still a member of the sequence and keeps
7766
+ its gap; a *hidden* one is not, and costs nothing.
7767
+
7768
+ ## D_component_contract — A contract suite over a catalog of every component (2026-09-04)
7769
+
7770
+ **Status:** Accepted; `spec/tuile/component_contract_spec.rb` added 2026-09-04
7771
+ with three invariants and a completeness guard. Prompted by `D_empty_ancestor`,
7772
+ whose bug the suite would have caught. Leans on `D_repaint_cascade` and
7773
+ `D_progress_bar` (two of the three invariants are their rules), and on
7774
+ `D_component_lookup` (the other additive-to-the-assertion-channel testing tool).
7775
+
7776
+ **Context.** Tuile's per-widget specs are thorough and all of them share a blind
7777
+ spot: they assert what *their* widget paints, in *its* rect, after *one*
7778
+ repaint. Three framework-wide obligations are invisible from there. A widget
7779
+ that overruns its rect paints on a *neighbour*. A widget that blanks a cell it
7780
+ is about to repaint marks it dirty, so `flush` re-emits it — identical on screen.
7781
+ A container that fails to zero its children's rects strands them, and nothing
7782
+ paints until an unrelated full repaint does. All three fail with no exception
7783
+ and no red example; `D_empty_ancestor` shipped for exactly that reason, and
7784
+ `D_repaint_cascade`'s bug was found by a hand-rolled sweep, which is the same
7785
+ observation made once and then thrown away.
7786
+
7787
+ **Decision — one suite, run over a catalog, with a guard that makes enrolment
7788
+ mandatory.** The catalog maps each concrete `Component` subclass to a factory
7789
+ returning one populated enough to paint; the guard eager-loads `lib/` and fails
7790
+ on any subclass in neither the catalog nor an `excluded` map that carries a
7791
+ reason per entry. The eager load is the load-bearing half: Zeitwerk resolves on
7792
+ first reference, so without it the guard would see only the classes the catalog
7793
+ itself named — it could never spot the new component it exists to spot. The
7794
+ value is entirely in the components that *do not exist yet*; a suite you have to
7795
+ remember to extend is a suite that documents the day it was written.
7796
+
7797
+ **Decision — a violator is `pending`, never `skip`.** RSpec fails a `pending`
7798
+ example that starts passing, so a fix prompts deleting its entry; a `skip` would
7799
+ let the deviation outlive its reason. The distinction matters because the first
7800
+ run found one (below), and the temptation was to widen the check's precondition
7801
+ until it went quiet — which is how a suite becomes decoration.
7802
+
7803
+ **What the first run found.** `Component::Window` and its four subclasses
7804
+ re-emit their whole border on every unchanged repaint: `Window#repaint` calls
7805
+ `super`, whose default clears the rect because the content slot does not tile it
7806
+ (it is inset by the border), and then `repaint_border` redraws the border over
7807
+ the cells just blanked. Measured at **925 bytes per unchanged 80×24 repaint**,
7808
+ paid on every focus change, since that invalidates the whole focus chain and
7809
+ every enclosing `Window` is on it. Note AGENTS.md asserted the opposite —
7810
+ "components that paint their entire rect themselves (currently `Window` … and
7811
+ `List`) opt out" — which is true of `List` and was never true of `Window`. Left
7812
+ **fixed the same day**, and the fix was smaller than the guess: not an override
7813
+ of `children_tile_rect?` but `Window#repaint` dropping `super` for
7814
+ `invalidate_children` plus `clear_background(content_rect)` on the one path
7815
+ nothing else covers — a window with no content in it. Nothing had to change
7816
+ about a shrinking content rect, because the content rect is derived from the
7817
+ window's own (`Window#content_rect`, extracted for this) and so cannot shrink
7818
+ independently of it. The check found a second instance while measuring the
7819
+ first: with `scrollbar = true` the window drops its right border so the
7820
+ content's bar can own that column, but `repaint_border` painted `│` down it
7821
+ anyway before the bar covered it, dirtying the column into every frame —
7822
+ 270 bytes an unchanged frame, now 0. Both are pinned in `window_spec`; the
7823
+ `pending` entry is gone, which is the mechanism working as designed.
7824
+
7825
+ **Alternatives rejected.**
7826
+
7827
+ - **Runtime enforcement in the framework**, the way `Tuile::Final` guards the
7828
+ tree methods. Right for a rule with a cheap check at a single call site;
7829
+ wrong for these, which need a *painted buffer* to compare against. Verifying
7830
+ "painted nothing outside its rect" at runtime means bounds-checking every
7831
+ `Buffer` write against the painting component's rect — a check in the hottest
7832
+ path, for a bug that a test catches once per CI run.
7833
+ - **Auto-discovering instances instead of a catalog** (walk `lib/`, call
7834
+ `klass.new`). It fails on every component with a required argument and, worse,
7835
+ yields *empty* components — an unpopulated `List` paints nothing, so every
7836
+ invariant passes vacuously. The factories are the point: the catalog is a set
7837
+ of canonical, painted specimens, and writing one is the work.
7838
+ - **Folding these into `component_spec.rb`.** That file already has the pattern
7839
+ — "keeps children, `@children` and the parent pointers in agreement" walks a
7840
+ tree of every container kind — but it is the spec *for* `Component`, mirroring
7841
+ `lib/tuile/component.rb` per the one-spec-per-source-file rule. A suite whose
7842
+ subject is "every component" mirrors no file, so it is a sibling of
7843
+ `nomenclature_spec.rb`: a guard, named for what it guards.
7844
+ - **Starting with more invariants.** Screen-free construction, `extent` honesty
7845
+ ("a component paints every cell it declares"), and `bg_color` inheritance were
7846
+ all drafted and cut. Three that hold and are understood beat eight where two
7847
+ are half-true — a suite with a hedged invariant teaches contributors to widen
7848
+ preconditions rather than fix code.
7849
+
7850
+ ## D_visibility — `Component#visible=`: a *gone* flag, kept in the tree (2026-09-05)
7851
+
7852
+ **Status:** Accepted and implemented 2026-09-05 — `Component#visible=` /
7853
+ `#visible?` / `#on_shown_tree` / `#on_child_visibility_changed`, the gates in
7854
+ `Screen`, `ScreenPane`, `Layout`, `HasContent`, `Box`, `Overlay` and `Testing`,
7855
+ a `component_contract_spec` invariant over the whole catalog, book ch5 + ch7 and
7856
+ a sampler pane (Shell ▸ Visibility). Brainstormed in `ideas/visibility.md`, with
7857
+ a 24-toolkit precedent survey beside it; both retired. Satisfies the re-grow
7858
+ rule `D_tabs` recorded and supersedes `D_empty_ancestor`'s "hiding is still
7859
+ detachment".
7860
+ Leans on `D_slots` (an empty slot keeps its space — the *other* state),
7861
+ `D_box_layouts` (a gap belongs to the sequence), `D_overlay` (an overlay is
7862
+ dismissed, not hidden), `D_attach_hooks` (what a hidden component keeps
7863
+ running), `D_component_lookup` (the locator's contract) and
7864
+ `D_component_contract` (where the new invariant is enforced).
7865
+
7866
+ **Context.** A form hides fields on choices above them — "Company name" only
7867
+ when "Business customer" is ticked. After `D_empty_ancestor` the sanctioned
7868
+ way to hide was `box.remove(field)` and `box.add(field, Fixed[1], at: i)`, and
7869
+ three things make that the wrong tool for exactly this consumer: the app has to
7870
+ know `i`, which shifts under it (an insert above, or two fields re-shown in the
7871
+ other order, and they land in each other's places — a shadow copy of the child
7872
+ order, the very thing `D_tree_api` forbids the framework from keeping, pushed
7873
+ onto every app); `remove` drops the child's placement, so the show path re-states
7874
+ `Fixed[1], cross: Fixed[30]` from somewhere; and detachment fires the lifecycle
7875
+ hooks, which is wrong for a pane that must stay live while hidden (a running
7876
+ job, a subscription). The two geometric idioms fail too, measured in
7877
+ `D_empty_ancestor`: `Fixed[0]` keeps its `spacing` gap and its tab stop, and a
7878
+ `Slot` with no content keeps its row. `D_tabs`' re-grow rule asked for a second
7879
+ consumer and a ruling on layout arithmetic; this is both. Note that `D_tabs`
7880
+ argued against *zero-rect* hiding and for detachment as `TabSheet`'s
7881
+ implementation — it never argued against a flag.
7882
+
7883
+ **Decision — one boolean, meaning *gone*.** `Component#visible?` /
7884
+ `#visible=`, default `true`. `false` means: **as if detached — but it stays in
7885
+ the tree.** Paints nothing, takes no space in a `Box`, and is invisible to
7886
+ focus, keys, the cursor and the mouse, exactly as a detached component is; and
7887
+ unlike one it keeps its parent, its rect, its placement, its state and its
7888
+ running resources, so no lifecycle hook fires. Say it that way and not "hiding
7889
+ is detaching" — the analogy is about what the *user* can reach, and the one
7890
+ place it breaks is the one an implementor would otherwise assume: `on_detached`
7891
+ is never called. This is Android's `GONE`, and only `GONE`. The keep-the-space state
7892
+ (`INVISIBLE`) is not a second value: a `Slot` whose content is `nil` already
7893
+ *is* that (`D_slots`), so Tuile has both Android states with no enum. The
7894
+ survey says this is the industry shape, not a Tuile shortcut: where a single
7895
+ boolean exists (Qt, GTK4, Vaadin, Lanterna, WinForms, AppKit stack views,
7896
+ Flutter's `Visibility`, Textual's `display`, FTXUI's `Maybe`, Tk's `grid
7897
+ remove`) it means gone, and keep-the-space is everywhere the opt-in
7898
+ (`retainSizeWhenHidden`, `maintainSize`, `setHonorsVisibility(false)`,
7899
+ `detachesHiddenViews = false`). Named `visible`, positive: a caller wanting a
7900
+ thing shown writes `visible = true`, never `hidden = !x`; it is also the name
7901
+ the re-grow rule used and the sibling of `scrollbar_visibility`.
7902
+
7903
+ **Decision — the gates live on the component tree, not in the containers.**
7904
+ The flag is ancestor-inclusive: a component is *shown* iff it and every
7905
+ ancestor are `visible?`, and every walk prunes the hidden subtree at its root
7906
+ rather than testing leaves, so a `TextField` three levels under a hidden panel
7907
+ is skipped without knowing it. The gates, and where each sits:
7908
+
7909
+ - **Paint:** `Screen#repaint`'s drain filter — the one choke point every
7910
+ invalidation passes — drops a hidden component or one under a hidden
7911
+ ancestor. Same AND as `D_empty_ancestor`'s empty-rect term, one more term.
7912
+ `invalidate` keeps recording; showing again re-invalidates the subtree
7913
+ (`on_tree`), as `bg_color=` does.
7914
+ - **The parent's gap clear:** `children_tile_rect?` excludes hidden children, so
7915
+ a hidden child's cells count as a gap and the parent blanks them. This is what
7916
+ actually erases the field in a container that never re-assigns its rect.
7917
+ - **Focus walks:** `Screen#cycle_focus`, `ScreenPane#first_tab_stop_or_root`,
7918
+ `Layout#on_focus` and `HasContent#on_focus` prune hidden subtrees, through one
7919
+ shared walk helper rather than four copies of the rule.
7920
+ - **Focus assignment:** `Screen#focused=` raises on a hidden target, as it does
7921
+ on a detached one. Focusing what the user cannot see is the cursor-in-the-
7922
+ visible-pane bug of `D_tabs`, and it should fail at the call site.
7923
+ - **Mouse:** `Component#handle_mouse` skips hidden children — belt to the
7924
+ empty-rect braces, since an `Absolute` child keeps its rect.
7925
+ - **Cursor and keys** follow: the focused component is always shown, and a
7926
+ hidden one is never on the focus chain.
7927
+ - **Lifecycle: nothing fires.** `on_attached` / `on_detached` stay bound to
7928
+ `parent=`. That is the feature — the hidden-but-live pane.
7929
+
7930
+ Why component-side: a hidden child that keeps a stale rect is then *harmless*
7931
+ in every container — it paints nothing, its cells are blanked, it takes no
7932
+ focus and no clicks — so a container that never heard of the flag degrades to
7933
+ a hole rather than to a leak. That inverts `D_empty_ancestor`'s failure mode,
7934
+ where every container between the app and the leaves was a separate chance to
7935
+ forget.
7936
+
7937
+ **Decision — the layout arithmetic.** `Box` skips a hidden child entirely: out
7938
+ of the `count` that prices `spacing * (count - 1)`, no share of `available`, and
7939
+ assigned the empty rect at the box's origin (the rect its `inner.empty?` branch
7940
+ assigns). Its placement entry is **kept**, so `visible = true` puts it back with
7941
+ its constraints intact. This is the one place gone reclaims space, and it makes
7942
+ a `spacing: 1` form close up cleanly. It settles the point `D_empty_ancestor`
7943
+ left open: a collapsed (`Fixed[0]`) child is still a member of the sequence and
7944
+ keeps costing its gap; a hidden child is not, and costs nothing. Every box
7945
+ layout in the survey prices spacing over shown children only (Qt's
7946
+ `previousNonEmptyIndex`, GTK's `(n_visible_children - 1) * spacing`, Lanterna,
7947
+ AWT `FlowLayout`, Android `LinearLayout` and its dividers, AppKit); the one
7948
+ double-gap trap is Swing `BoxLayout`, and only because its gaps are strut
7949
+ *components* — Tuile's `spacing` is a number, so it cannot fall into it. `Box`
7950
+ learns of a flip through one protected upward hook,
7951
+ `on_child_visibility_changed(child)`, default no-op, the shape of
7952
+ `on_child_removed`; a future `FormLayout` overrides it too. `Absolute` does
7953
+ nothing — its `rect=` is app arithmetic, and an app that wants the space
7954
+ reclaimed reads `child.visible?` in its own pass. `Window` / `Popup` / `Slot` /
7955
+ `HasContent` have one child and nothing to reclaim; hidden content leaves the
7956
+ inner area blank, as an empty `Slot` does, and a `Window` keeps its border.
7957
+
7958
+ **Decision — hiding the focused subtree repairs focus through the parent, never
7959
+ to `nil`, and never restores on re-show.** When the flag lands on the focused
7960
+ component or an ancestor of it, focus goes to the hidden component's parent and
7961
+ the parent's ordinary `on_focus` cascade takes it from there (a `Layout` to the
7962
+ first *shown* tab stop in its subtree, a `Window` into its content, the
7963
+ `ScreenPane` through its own repair). This is the focus-repair half of
7964
+ `Component#on_child_removed`, reused as-is, so the two ways a subtree can
7965
+ leave the user's reach land focus in the same place and are specced once —
7966
+ only the repair is shared, not the detach; the hidden component keeps its
7967
+ parent and hears no hook. `nil`
7968
+ was the other candidate and it is not neutral in Tuile: `ScreenPane#focus_chain`
7969
+ returns nil, `bubble_key` delivers to nobody — not even the scope root, so a
7970
+ form's Enter-to-submit goes dead — and the next unhandled `q` or ESC falls
7971
+ through to the loop and **quits the app**. The DOM's focus-to-viewport works
7972
+ because a browser has no quit key. Nothing is restored when the component
7973
+ reappears — no toolkit surveyed does, and a stored "focus to restore" is one
7974
+ more pointer into a tree that may have changed. Prior art, verified against
7975
+ source: DOM moves focus to the viewport, and Chromium resumes Tab from the
7976
+ removed node's *parent* (the shape chosen here); Swing (`hide` →
7977
+ `transferFocus(true)`) and Qt (`hide_helper` → `focusNextPrevChild(true)`, also
7978
+ when an ancestor is hidden) go to the **next** component; Android `GONE` clears
7979
+ and lands on the **first** focusable from the top; Textual's `display = False`
7980
+ does *not* reset focus, so its `focused` goes stale — the leak `D_tabs` listed,
7981
+ shipped.
7982
+
7983
+ **Decision — `Testing.find` never finds a hidden component.** Karibu-Testing's
7984
+ standing policy, held for years: a test is a user, and a user cannot click what
7985
+ is not on the screen; a locator that reached a hidden `Button` would pass a form
7986
+ the user cannot operate. So `find` / `get` prune hidden subtrees with the same
7987
+ walk the focus gates use. The failure message then **counts the hidden matches
7988
+ it excluded** — `found 0 (1 hidden match excluded)` — because "I *know* it's
7989
+ there" is the commonest confusion and one clause diagnoses it; and `dump` shows
7990
+ the whole tree, `inspect_details` adding `hidden` and the excluded matches
7991
+ getting their own marker beside the `→` of found ones. **No `visible:` filter.**
7992
+ A test asserting a field *is* hidden holds the reference and asserts
7993
+ `refute field.visible?`; one asserting the user cannot reach it asserts
7994
+ `count: 0`, which is what the user experiences. A filter reaching hidden
7995
+ components is the same hole as `_setValue` on a disabled field, and comes back
7996
+ only argued from a test that cannot be written otherwise.
7997
+
7998
+ **Decision — overlays refuse the flag; `TabSheet` stays on detachment.**
7999
+ `visible=` raises on an `Overlay`: it is dismissed, not hidden (`D_overlay`),
8000
+ the "live while hidden" motivation does not apply, and the pane consults
8001
+ `@popups` for hit-testing and `modal_popup` for key scope — a hidden popup that
8002
+ was still modal and still caught clicks is the `focusable?`-and-`modal?` trap
8003
+ again. `TabSheet` keeps detaching: the lifecycle hooks on switch are a feature
8004
+ the book teaches, and switchers split evenly in the survey (Textual, Swing,
8005
+ Android, Qt, GTK on the flag; urwid, Flutter, SwiftUI, AppKit on detachment). A
8006
+ keep-alive pane, if wanted, would use GTK's split — a container-only
8007
+ `child-visible` beside the public `visible` — and is deliberately not this
8008
+ entry.
8009
+
8010
+ **Consequences.** The contract suite gains a fourth invariant — *a hidden
8011
+ component paints nothing, is not in the Tab cycle, and reappears where it was
8012
+ with its constraints* — over the catalog. AGENTS.md's "Hiding a component means
8013
+ detaching it" becomes "hiding is `visible = false`; `TabSheet` detaches for the
8014
+ lifecycle hooks", and book ch7's section of that name is rewritten; `Fixed`'s
8015
+ collapse note gains "a hidden child costs no gap". `visible?` is a second reason,
8016
+ beside the empty rect, for a component not to paint, and the two are different
8017
+ axes: geometry says *where* and *how much*, the flag says *whether*.
8018
+
8019
+ **Roads not taken.**
8020
+
8021
+ - **A `Hidden` / `Gone` `Box` constraint** (`constrain(field, Gone)`). Parent-side
8022
+ only, so it reclaims space and leaves every focus leak `D_tabs` listed — it is
8023
+ `Fixed[0]` with better branding, the exact thing `D_empty_ancestor` refused.
8024
+ - **An enum** (`:visible` / `:invisible` / `:gone`). The middle state is a `Slot`
8025
+ with no content, a hidden-but-space-keeping field has no form use, and an enum
8026
+ invites `:disabled` next.
8027
+ - **`visible=` as sugar over `remove` / `add(at:)`.** Fires lifecycle, loses the
8028
+ live-while-hidden case, and only `Box` could implement it — an `Absolute` has
8029
+ no `at:`.
8030
+ - **A public `shown?` reader** for the effective state. The walks prune at the
8031
+ hidden root and need none; a reader that walks ancestors is one more thing to
8032
+ keep un-cached. Add only when an app needs it.
8033
+ - **"Next tab stop" focus repair**, the Swing/Qt answer. Implementable (collect
8034
+ stops, pick the successor, before hiding) but then hiding and removing a
8035
+ focused subtree would land focus in different places; if it is ever wanted,
8036
+ change `on_child_removed`'s repair and this in one move so they stay one rule.
8037
+ - **A `hidden` / `hidden=` name** (HTML's attribute, AppKit's `isHidden`).
8038
+ Negative names make callers negate what they want.
8039
+
8040
+ ## D_time_field — `TimeField`: a `Time` on a fixed epoch; `step` owns the precision (2026-09-05)
8041
+
8042
+ **Status:** Accepted and implemented 2026-09-05 — `Component::TimeField`,
8043
+ `Locale#time_formats`, `Locale::TimeFormats`, the `Locale::Formats` lexer hoist,
8044
+ a spec mirror, a `component_contract_spec` catalog entry, book ch7 + ch10 and a
8045
+ sampler pane (Input ▸ Typed ▸ TimeField). Graduated from `ideas/time-field.md`,
8046
+ now retired. Both verifications the design was pending held: Rails casts
8047
+ `"13:45"` to `2000-01-01 13:45:00 UTC`, so the epoch is *alignment* rather than
8048
+ invention; and Ruby's `strptime` accepts `1:45pm`, `1:45 PM`, `1:45PM` and
8049
+ `1:45 pm` alike under `%I:%M %p`, so the feared spaceless-secondary workaround
8050
+ was never needed.
8051
+ `D_date_field`'s twin — every ruling not questioned here is inherited from that
8052
+ entry verbatim: the format list consumed in order with `formats.first` writing
8053
+ back, no input filter, the red well latched to the commit gestures,
8054
+ canonicalize on blur and ENTER, the placeholder derived exactly or not at all,
8055
+ `MAX_TEXT_LENGTH = 64`, a frozen `bad_input_message`. Gives `D_locale` its ninth
8056
+ member. Two findings in this entry are marked **surveyed** — read off another
8057
+ toolkit's docs or source — as distinct from **verified**, which means run in this
8058
+ repo's Ruby.
8059
+
8060
+ **Context.** A one-row field whose value is a *local time of day* — a wall-clock
8061
+ reading with no date and no zone; `09:30` means half past nine wherever you are.
8062
+ It is the twin `D_date_field` predicted and `D_locale` explicitly left out
8063
+ ("the time formats stay out, because their consumer is a `TimeField` nobody has
8064
+ filed"). Two things make it more than a copy: Ruby has no civil-time class, so
8065
+ the value type has to be *decided* where `DateField` could point at `Date`; and
8066
+ a time has a **precision** — seconds or not — which a date does not, and which
8067
+ turns out to be the one genuinely new ruling.
8068
+
8069
+ **Decision — the value is a `Time` pinned to `2000-01-01` UTC, so the component
8070
+ is `TimeField`.** Four candidates, priced by `D_float_field`'s naming rule (a
8071
+ typed field is named after the Ruby class of its value):
8072
+
8073
+ | Value | Component | Cost |
8074
+ |---|---|---|
8075
+ | `Time` on a fixed epoch date, in UTC | `TimeField` | the value is a real *instant*, so it can be passed where an instant is wanted |
8076
+ | `Tuile::LocalTime` (`Data.define`) | `LocalTimeField` | Tuile ships a domain value type; every app converts at its own boundary |
8077
+ | `Integer` seconds since midnight | — (`IntegerField` by the rule) | indistinguishable from a duration; the name is not derivable |
8078
+ | `String` | — | that is a `TextField`; no typed field at all |
8079
+
8080
+ `Time` wins on a sharpening of `D_date_field`'s "Tuile's job is to edit the
8081
+ app's values, not to introduce its own", which needed sharpening precisely
8082
+ because there is no stdlib type to defer to: **Tuile owns UI value types —
8083
+ `Point`, `Size`, `Rect`, `Fraction`, `Color`, `StyledString`, `Theme`,
8084
+ `Locale` — and no domain value types.** A time of day is domain data: it lands
8085
+ in a model, a column, a serializer. And the ecosystem's near-consensus is
8086
+ exactly this shape — a `Time` on a dummy date is what Rails' `time` column hands
8087
+ you. The epoch is `2000-01-01` **pending one verification** (Rails' date, from
8088
+ recollection; `gem install activerecord` and cast `"13:45"`); if it holds, an
8089
+ ActiveRecord round-trip is exact and the choice is *alignment with an existing
8090
+ convention*, the strongest version of the argument. `Sequel::SQLTime` is
8091
+ recalled to default to *today*, class-configurable, so no fixed epoch could match
8092
+ it and Rails is the one convention available. **UTC, not local:** a local epoch
8093
+ puts every value on a date whose offset the zone can change, so a transition on
8094
+ the epoch date makes some wall times unrepresentable or silently shifted — the
8095
+ whole DST class, removed. Cells still read correctly (**verified**:
8096
+ `Time.utc(2000,1,1,13,45,0).strftime("%H:%M") == "13:45"`).
8097
+
8098
+ The accepted cost, stated loudly in rdoc: the value is an instant, so it is
8099
+ *wrong* somewhere else while being a perfectly good `Time` —
8100
+ `field.value = Time.now; field.value == Time.now` is `false` (**verified**;
8101
+ different date, different zone). An app that forgets to combine it with a date
8102
+ gets the year 2000 in its output, which is *visible*, and by this project's
8103
+ standard beats silently wrong. `value=` is thin and lenient the way
8104
+ `DateField#value=` is: anything responding to `hour` / `min` / `sec` is taken by
8105
+ those readers and rebuilt on the epoch (`Time`, `DateTime`, `Sequel::SQLTime`);
8106
+ `nil` clears; a `Date` raises `TypeError` (no hour to take — accepting it means
8107
+ inventing midnight) and so does a `String` (that is what the buffer is for; a
8108
+ `value=` that parsed would be a second parse path with its own leniency);
8109
+ sub-seconds truncate silently. Three ergonomics, so no caller ever assembles a
8110
+ `Time` on the epoch by hand: `#set_to(hour, minute, second = 0)` and
8111
+ `#set_to_now` to *write* one, and `.time_of_day(hour, minute, second = 0)` to
8112
+ build a value to *compare* against, plus the epoch public as `MIDNIGHT`.
8113
+
8114
+ **The class method is deliberately not named for the class.** It shipped as
8115
+ `TimeField.at` and was renamed on review: every other class method on a
8116
+ component here (`ConfirmWindow.alert`, `Notification.show`) returns an
8117
+ *instance*, so `TimeField.at(13, 45)` reads as a constructor while returning a
8118
+ `Time` — the one thing a reader should not have to check. `time_of_day` says
8119
+ what comes back, and the wordiness is free because the rename also moved the
8120
+ common case off it: setting a field is now `field.set_to(13, 45)`, and the
8121
+ factory is left with the rare read-side job of building something to compare a
8122
+ value against. Deleting it outright was the other candidate and is worse: it
8123
+ pushes `Time.utc(2000, 1, 1, …)` — the hand-written epoch this exists to
8124
+ prevent — into every spec and every app that checks a value it got back.
8125
+ `#set_to_now` is sugar rather than a fix (an app writing `value = Time.now` is
8126
+ already corrected by the buffer round-trip), but it names a concept the field
8127
+ already had: it is where Up/Down land an empty field.
8128
+
8129
+ **Decision — the parse has a third gate, because `Time` normalizes where `Date`
8130
+ raised.** `D_date_field`'s parse is two gates — a non-empty `:leftover` is no
8131
+ match, and constructing the `Date` catches February 30th. The second does not
8132
+ transfer (**verified**): `Date._strptime("24:00", "%H:%M")` yields
8133
+ `{hour: 24, min: 0}` and `Time.utc(2000,1,1,24,0,0)` is *the next day*;
8134
+ `"13:45:60"` parses and `Time.utc(…,13,45,60)` is `13:46:00`. Both are
8135
+ wrong-values-that-save-cleanly, and the rollover is worse than it looks — a value
8136
+ past midnight lands on a *different date* from every other value the field
8137
+ produces, so comparison and sorting quietly break. So `TimeField` range-checks
8138
+ `hour` 0..23, `min` 0..59, `sec` 0..59 itself, `Time.utc` becomes construction
8139
+ rather than validation, and **`.time_of_day` and `#set_to` share the one gate** —
8140
+ building normalizes just as silently as parsing, and a spec comparing against
8141
+ `time_of_day(24, 0)` would pass against the wrong date. `Date._strptime` already
8142
+ range-checks the *field width* (`"25:00"` and `"13:99"` yield `nil`,
8143
+ **verified**) and gives padding leniency for free (`"1:45"` under `%H:%M`). `24:00`
8144
+ being rejected costs one documented sentence: it is legal ISO 8601 end-of-day
8145
+ and `Time` cannot hold it. No new `require`: the parse is `Date._strptime`
8146
+ (`date` is hoisted per `D_date_field`), `Time` is core, and `Time.strptime` — the
8147
+ one thing needing `require "time"` — is not used.
8148
+
8149
+ **Decision — minute precision by default; `step` is the precision knob; there is
8150
+ no per-field `formats=`.** The ruling that is new to this field:
8151
+
8152
+ > **Precision is not a spelling.** A format list is ordered leniency, and for
8153
+ > time the order also decides *how much of the value survives a commit*. Only the
8154
+ > widening direction is lossless: with `%H:%M:%S` primary, typing `13:45`
8155
+ > canonicalizes to `13:45:00` and adds nothing false. With `%H:%M` primary,
8156
+ > typing `13:45:30` canonicalizes to `13:45` and **loses the seconds** — the
8157
+ > buffer is the single source of truth, so truncating the buffer truncates the
8158
+ > value.
8159
+
8160
+ So the default shows minutes and `13:45:30` is bad input — visible, reportable,
8161
+ fixable — and an app that wants seconds sets `step` below a minute. The field
8162
+ has **one** precision-bearing knob and it is the stride: **`step < 60` shows
8163
+ seconds, `step >= 60` does not**, Vaadin's rule and HTML's, and the whole of it.
8164
+ `formats` is a **read-only report**, derived on every read from `Screen#locale`
8165
+ and `step`: it exists so the placeholder, the rdoc, the specs and a curious app
8166
+ can name the list in force, and for the reason `Component#size` exists — to
8167
+ squat the name, so a writer can only return through the re-grow rule below.
8168
+ The escape hatch is `screen.locale=`, exactly as Vaadin's is `setLocale()`: an
8169
+ app wanting a spelling the probe did not find assigns a `Locale` and every
8170
+ `TimeField` follows, which is what a *convention* is. No `precision` reader
8171
+ either — `formats` squats the name that matters, a `precision` reader invites
8172
+ "where is the writer?", and the API is one rdoc sentence.
8173
+
8174
+ The survey that settled the default (**surveyed** 2026-09-05):
8175
+
8176
+ | Toolkit | Default precision | Seconds | Knob |
8177
+ |---|---|---|---|
8178
+ | Vaadin `TimePicker` | `hh:mm` | yes | `step` (`Duration`), default 1 hour |
8179
+ | HTML `<input type="time">` | `hh:mm` | yes | `step`, default `60` |
8180
+ | MUI X `TimePicker` | hours + minutes (+ meridiem) | yes | `views:` array |
8181
+ | Qt `QTimeEdit` | locale **ShortFormat** (en_US `h:mm AP`) | yes | `displayFormat` string |
8182
+ | Flutter `showTimePicker` | hour + minute | **none** — `TimeOfDay` has no field for them | — |
8183
+ | Taiga UI `InputTime` | `mode` enum, `HH:MM` … `HH:MM:SS.MSS` | yes | `mode` |
8184
+ | WinForms `DateTimePicker` | OS **long time**, `h:mm:ss tt` | shipped | `CustomFormat`, to *remove* them |
8185
+ | Ant Design `TimePicker` | `HH:mm:ss` | shipped | `format` string |
8186
+
8187
+ Six of eight default to minutes. The useful half is *which two dissent, and
8188
+ why*: WinForms inherits the OS *long time* pattern and Ant Design simply picked
8189
+ a format — both a clock-display format used as a form-field format, putting
8190
+ `:00` in front of every user who never asked for it, which is the `t_fmt`
8191
+ failure below observed in the wild. And the split between the two knob shapes
8192
+ has a **cause**, not a style: precision is either a property of the format
8193
+ string (Qt, Ant Design, WinForms) or a selector orthogonal to spelling
8194
+ (Vaadin/HTML `step`, MUI `views`, Taiga `mode`), and **every toolkit that fuses
8195
+ precision into `step` is one where the app cannot write a format at all** —
8196
+ Vaadin's Java API has `setLocale()` and `setStep()` and no format setter
8197
+ (**surveyed**), HTML has no `format` attribute. Every toolkit exposing a format
8198
+ string leaves `step` alone; zero exceptions either way. So the fusion is not a
8199
+ school anyone joined; it falls out of *not having* a format writer — which is
8200
+ exactly why Tuile adopts it cleanly, by removing the writer so the precondition
8201
+ is *made true* rather than importing a workaround into an API that contradicts
8202
+ it.
8203
+
8204
+ **The costs, named so this is chosen and not inherited.** (1) **Seconds precision
8205
+ with a minute stride is unsayable** — a field holding `13:45:30` whose Up key
8206
+ walks to `13:46:30`; asking for the third segment hands you a one-second stride.
8207
+ Narrow, Vaadin lives without it, and additive to fix (a `precision:` whose nil
8208
+ means derived-from-step). (2) **No per-field spelling override**, so the twin is
8209
+ asymmetric: `DateField#formats=` is a writer. Defensible on the merits — a date
8210
+ has a genuine *per-field* axis (one form mixing `dd.mm.` and ISO for a technical
8211
+ value), a time has none; 12- versus 24-hour is a session convention, which is
8212
+ what `Locale` is for. (3) **Deliberate lossy leniency** (`["%H:%M", "%H:%M:%S"]`
8213
+ as an app's own list) is gone; it was a sanctioned edge, never a need. Two
8214
+ things are Tuile's and not Vaadin's: the **default is 60, not 3600** — Vaadin's
8215
+ hour is a dropdown-density number that leaks into the arrow keys, so Up from
8216
+ `13:45` lands on `14:45`, and there is no overlay at all (the dropdown decision
8217
+ below) — and there is **no divisor rule** (Vaadin requires a step to divide an hour or day); Tuile adds and
8218
+ wraps, and a constraint that exists for a picker's row grid is the picker's to
8219
+ add.
8220
+
8221
+ **Decision — the two derived lists are asymmetric, by the ruling above.** At
8222
+ `step < 60`, `formats` is the *full-precision forms, then the stripped forms*, so
8223
+ typing `13:45` parses through the lenient secondary and canonicalizes to
8224
+ `13:45:00` — the lossless direction, free. At `step >= 60` it is the stripped
8225
+ forms *only*: admitting `%H:%M:%S` there is the lossy direction, so `13:45:30`
8226
+ stays bad input. One rule, read from the primary. (The first draft's table
8227
+ showed only what each mode *writes*, which is how it hid that the seconds mode
8228
+ would have demanded `:00` from the user — the reverse of the annoyance the
8229
+ default exists to avoid.)
8230
+
8231
+ **Decision — the seconds strip is the field's, not the locale's.** glibc's
8232
+ `t_fmt` is a *clock display* format and carries seconds nearly everywhere, so
8233
+ honoring it puts `:00` in every form field in the world. The field therefore
8234
+ **keeps the locale's spelling and drops its precision** — separator, digit
8235
+ order and the 12/24-hour choice are conventions; the seconds are not. Qt arrives
8236
+ at the same place independently (`QDateTimeEdit` seeds itself with
8237
+ `loc.timeFormat(QLocale::ShortFormat)`, en_US `h:mm AP` where the long form is
8238
+ `h:mm:ss AP t`), and gets it *free* because CLDR ships short/medium/long time
8239
+ patterns as separate data; glibc ships one `t_fmt` with no short variant to ask
8240
+ for. So the strip is Tuile computing by hand the datum CLDR would have handed
8241
+ it. But it runs in **`TimeField`, not at the `Locale` boundary**, for two
8242
+ reasons — and the second stands on its own: the strip is *policy*, not
8243
+ normalization (`D_locale`'s boundary rule covers the `%T` → `%H:%M:%S` expansion
8244
+ and `first_weekday`'s renumbering, both representation changes; "default to
8245
+ minutes" is a form field's ruling, and `Locale` holds conventions, not UI
8246
+ defaults — its own stated gate); and it is **lossy** where those are not — a
8247
+ status-bar clock, a log timestamp, any consumer rendering a time *display*
8248
+ legitimately wants `t_fmt` with its seconds, and a boundary strip makes that
8249
+ unrecoverable, so they would hardcode a separator and take the same regression
8250
+ one layer down with no knob to fix it. So `Locale#time_formats` carries the
8251
+ locale's spelling at **full detected precision** (fi_FI
8252
+ `["%H.%M.%S", "%H:%M:%S"]`), `Locale::ISO` is `["%H:%M:%S"]` (ISO 8601 permits
8253
+ both; the member holds the fuller one and the field's default reduces it), the
8254
+ expansion table (`%T` → `%H:%M:%S`, `%R` → `%H:%M`, `%r` → `%I:%M:%S %p`) stays
8255
+ at the boundary, and the field strips. `t_fmt_ampm` is **not** read: en_GB's is
8256
+ `%l:%M:%S %P %Z`, a zone name and a blank-padded hour, two directives this
8257
+ field rejects. The strip rule — *drop `%S` and the literal run immediately
8258
+ preceding it*; a format with no `%S` passes through as already-minute; `step <
8259
+ 60` over a locale with no seconds falls back to ISO `%H:%M:%S` rather than
8260
+ splicing a separator it would have to invent — is deliberately the smallest
8261
+ grammar surgery that works, and `Locale` places **no `%S` requirement** on an
8262
+ entry, since the fallback already covers the case a raise would cover.
8263
+
8264
+ **Decision — stepping adds and wraps; `step=` at runtime is the locale-change
8265
+ path.** Up/Down add `step` seconds modulo 24 h (`23:59` + 1 → `00:00`; a clock
8266
+ has no day to carry into, so wrapping is arithmetic, not policy). An empty or
8267
+ unparseable field steps to **now**, truncated to the field's precision — the
8268
+ `Date.today` analogue, landing *on* now rather than now ± 1; that this clobbers a
8269
+ half-typed `13:4` is `D_date_field`'s ruling inherited knowingly (an arrow
8270
+ mid-typo is asking for help; a no-op is a dead key) and recorded here as a
8271
+ **shared** ruling — change it in both fields in one commit or the divergence
8272
+ reads as a bug. **Add, never snap:** `step = 900` from `13:07` goes to `13:22`,
8273
+ not the `13:15` grid line — snapping silently moves a value the user did not
8274
+ ask to change; Vaadin adds too ("accepts values that don't align with the
8275
+ specified step"), and HTML's snap is to a `min` base this field lacks. `step=`
8276
+ takes a positive `Integer` in `1...86400` and raises `ArgumentError` otherwise:
8277
+ no `nil` (nil-means-inherit is for locale-derived knobs; the default is set in
8278
+ `initialize`), no `Float`/`Rational` (a sub-second stride implies `%L`, rejected
8279
+ by name), nothing that wraps onto itself. And **`step=` with a value present is
8280
+ "re-derive, then run the locale-change path"**, not a second code path — the
8281
+ rule `on_locale_changed` already has, *re-canonicalize a buffer that still
8282
+ parses, leave one that does not*: widening `60 → 1` rewrites `13:45` to
8283
+ `13:45:00`; narrowing `1 → 60` leaves `13:45:30`, which becomes bad input
8284
+ (`value` reads `nil`, `on_value_change` fires, `bad_input?` says why). Nothing is
8285
+ silent on any channel, and the field never truncates a value it did not type —
8286
+ the *app* narrowed it and hears about it through the seam it already listens on.
8287
+ PageUp/PageDown step an hour whatever the stride — the dropdown decision below
8288
+ says why that is a key and not an overlay; `D_date_field` still defers the month.
8289
+
8290
+ **Refined during implementation: a narrowing step *carries* a value the new
8291
+ primary can still write exactly.** `13:45:00` narrowed to minutes shows `13:45`
8292
+ rather than reddening, because dropping a *zero* second discards nothing — the
8293
+ no-truncation rule is about not losing what the user meant, and there is nothing
8294
+ there to lose. The test is the round-trip the validator already uses
8295
+ (`parse(value.strftime(new_primary), new_primary) == value`), so it needs no new
8296
+ concept; `13:45:30` fails it and is left as typed, exactly as ruled above. Only
8297
+ `step=` can do this, since it can read the outgoing value before switching;
8298
+ `on_locale_changed` fires *after* the conventions have changed and has no such
8299
+ handle, which is the same asymmetry `D_date_field` lives with. Without it, an
8300
+ app toggling precision for display reasons would redden a field whose value was
8301
+ never in doubt — an error state with nothing wrong, which is the failure mode
8302
+ this project treats as worse than a visible one.
8303
+
8304
+ **Decision — the round-trip reference is `Time.utc(2000, 1, 1, 13, 45, 0)`, it
8305
+ validates on `Locale` only, and zone and sub-second directives are rejected by
8306
+ name.** Every property of the canary is load-bearing (all **verified**): *hour ≥
8307
+ 13*, so a 12-hour directive with no `%p` fails — `%I:%M` writes `"01:45"` and
8308
+ reads back 1 o'clock, the exact analogue of `%y` not carrying a century, while
8309
+ `%I:%M %p`, `%I.%M %p` and `%I:%M%P` pass; *minute ≠ hour*, so an `%H`/`%M`
8310
+ swap is not masked; *second = 0*, so a minute-precision primary is legal, which
8311
+ the default is. What it deliberately does not catch is a **precision
8312
+ truncation** — `%H:%M` round-trips itself perfectly, and precision is `step`'s
8313
+ business. Pass: `%H:%M`, `%H:%M:%S`, `%T`, `%R`, `%r`, `%H.%M`, `%Hh%M`, `%H%M`,
8314
+ `%k:%M`, `%I:%M %p`. Rejected: `%I:%M`, `%I:%M:%S`, `%l:%M`, `%p`, `%H`, `%M:%S`,
8315
+ `%s`, any `%-H`. Two new by-name rejections, because both **round-trip cleanly
8316
+ and lose information anyway** (**verified**): **zone directives** `%z` `%Z` `%:z`
8317
+ `%::z` `%s` — `"%H:%M:%S%z"` writes `+0000` and reads it back, and
8318
+ `Date._strptime("13:45:00+0200", …)` hands back `zone: "+0200", offset: 7200`
8319
+ which this field would drop on the floor, so a user typing an offset would see it
8320
+ silently reinterpreted as a wall time; and **sub-second directives** `%L` `%N` —
8321
+ `sec_fraction: (1/2)` parsed and dropped. `%x` / `%X` / `%c` stay rejected as
8322
+ locale lookalikes. With no per-field writer the validator has exactly two call
8323
+ sites, both on `Locale`: the `time_formats:` assignment (an author to tell — it
8324
+ raises) and the `t_fmt` probe (nobody to tell — a failing format is dropped and
8325
+ the list falls back to ISO), the same asymmetry as `date_formats`. One known
8326
+ limitation written down rather than fixed: **Ruby's `%p` is fixed English**;
8327
+ under a 12-hour locale whose `am_pm` is not `AM;PM` the field writes English,
8328
+ because implementing `%p` from `Locale#am_pm` would mean owning a second
8329
+ formatting grammar, which `D_date_field`'s "strftime, not Java patterns" refuses.
8330
+ Whether strptime accepts `1:45pm` / `1:45PM` under `%I:%M %p` (the literal space
8331
+ and the case of `%p`) is **to verify before building**, and ruled either way: if
8332
+ strict, accept the cost rather than derive a spaceless secondary — that is
8333
+ grammar surgery on the locale's pattern, and the ISO `%H:%M` secondary means
8334
+ `13:45` always works.
8335
+
8336
+ **Decision — `TimeFormats::HINTS` is its own table.** `%H`/`%I`/`%k`/`%l` →
8337
+ `hh`, `%M` → `mm`, `%S` → `ss`, `%p` → `AM`, `%P` → `am`, `%%` → `%`. Kept apart
8338
+ from `DateFormats::HINTS` because `%m` → `mm` (month) and `%M` → `mm` (minute) are
8339
+ both right in their own table, and a combined one is a question only a
8340
+ `DateTimeField` has to ask. `%p` is admissible where `%b` was not: `mmm` would be
8341
+ an invented token, while `AM` is literally what the field prints and a
8342
+ placeholder is a typing *sample* — so en_US's derived hint is `hh:mm AM` and
8343
+ fi_FI's `hh.mm`. `%k` and `%l` are in because both pass the round-trip and a
8344
+ directive that passes but has no hint makes the placeholder "not at all" for no
8345
+ reason.
8346
+
8347
+ **Decision — no dropdown; PageUp/PageDown is the hour jump; segment stepping is
8348
+ phase 2.** Vaadin's `TimePicker` drops open a list of times spaced by `step`, and
8349
+ `ideas/new-components.md` carried that as this field's cheap phase 2. It is
8350
+ rejected, on `D_mouse`'s rule and two facts of its own. **A list of times computes
8351
+ nothing:** the calendar grid `DateField` will grow answers questions the user
8352
+ cannot (which weekday is the 17th, does this month have a 31st), while every row
8353
+ of a time list is derivable from its neighbour — it is a list of the thing the
8354
+ user was about to type, and typing `1345` beats scrolling to it. And **Vaadin's
8355
+ dropdown is an implementation inheritance, not a UX finding** —
8356
+ `vaadin-time-picker` wraps `vaadin-combo-box-light`, and it hides the list below
8357
+ a 15-minute step (**surveyed**, from recollection of `__generateDropdownList`;
8358
+ re-check before citing it further), so at this field's default stride a faithful
8359
+ port shows nothing. Building it would also re-import the coupling the default of
8360
+ 60 was chosen to escape: a useful list needs an hour-ish `step`, which coarsens
8361
+ the arrows, and decoupling them is a second density knob beside the one knob this
8362
+ entry is built on. Last, there is no open gesture: the editor eats printables
8363
+ (Space is legal in `1:45 PM`), the arrows already mean step, and open-on-focus
8364
+ makes a Tab pass through a form flash overlays.
8365
+
8366
+ What the keyboard actually lacked was the *hour jump* — 60 Ups from `13:45` to
8367
+ `14:45` — and that is a key, not an overlay: **PageUp/PageDown step an hour,
8368
+ whatever `step` is**, wrapping like the arrows and landing on now from an empty
8369
+ field like the arrows. The editor declines both, so the field claims them in its
8370
+ own `handle_key` (rung 3, one hop up the bubble) — no new `TextField` slot, and
8371
+ no printable, so a form's letter bindings keep working. The keyboard lineage of
8372
+ every toolkit built for hands-on-keys is **segmented stepping** — Qt's
8373
+ `QTimeEdit`, WinForms' `DateTimePicker`, HTML's `<input type=time>`, and the one
8374
+ TUI ancestor with a time widget, `dialog --timebox`: Up in the hour segment steps
8375
+ an hour, in the minute segment a minute, on `%p` toggles meridiem. That is the
8376
+ honest phase 2 — buildable, since the `Locale::Formats` lexer already yields a
8377
+ caret → directive map, and shared with `DateField` — deferred until PageUp/
8378
+ PageDown proves insufficient, because it changes what `step` means where the
8379
+ caret sits. **Re-grow rule for the dropdown:** only as a mouse affordance, only
8380
+ if a mouse-driven use actually appears, in `Select`'s shape with Vaadin's density
8381
+ gate (`SECONDS_PER_DAY / step <= 96`, so it exists only where the app has already
8382
+ coarsened `step` for its own reasons) — never with a density knob of its own.
8383
+
8384
+ **Rejected alternatives.**
8385
+
8386
+ - **A Vaadin-style dropdown of times by `step`** — computes nothing, empty at
8387
+ the default stride by Vaadin's own gate, and re-couples list density to the
8388
+ arrow stride; the decision above.
8389
+
8390
+ - **`Tuile::LocalTime` as the value** (a `LocalTimeField`). It makes the wrong
8391
+ state unrepresentable — a `NoMethodError` beats the year 2000 — and loses on
8392
+ the UI-not-domain rule plus a cost the table understates: owning a value type
8393
+ means owning `Comparable`, arithmetic, `to_s`, `inspect`, RBS and a spec file,
8394
+ in a toolkit whose job is editing. It buys nothing for the composite either
8395
+ (`DateTimeField` combining a `Date` with either representation is one line).
8396
+ **Re-argue if** an app is observed passing a `TimeField#value` somewhere it
8397
+ means an instant — that is the failure this trades away. As a later
8398
+ *supplement* it stays open and cheap, because the naming rule lets the two
8399
+ coexist with no mode flag: `TimeField#value` is a `Time`,
8400
+ `LocalTimeField#value` a `Tuile::LocalTime`, and an app picks by its model's
8401
+ type. It would be the third copy of this shell — `D_float_field`'s re-argue
8402
+ point — and the honest answer will probably still be "duplicate". It must
8403
+ **not** arrive as a `value_type:` knob on `TimeField`: that is the injected
8404
+ converter `D_integer_field` refused, and it makes `value`'s class un-derivable
8405
+ from the component's name.
8406
+ - **`Integer` seconds since midnight.** Indistinguishable from a duration, and
8407
+ `IntegerField` by the naming rule.
8408
+ - **A per-field `formats=` writer beside `step`** — the two-knob shape this
8409
+ entry held for one day, and MUI's shipped design (`views` plus `format`, with
8410
+ `format` documented as *"Defaults to localized format based on the used
8411
+ `views`"*, **surveyed**). It is the right shape **if** a writer has to exist,
8412
+ and the interaction rule is settled should one return (**re-grow**):
8413
+ `precision` becomes a stored selector (`:minutes` / `:seconds`, nil meaning
8414
+ derived from `step`) and `formats=` the override; precision is a *property of*
8415
+ `formats.first`, so there is one authority and which one depends on whether
8416
+ the derivation ran — `formats` nil → `precision` selects the detected form,
8417
+ `formats` set → `precision` reads back *derived* from `formats.first`;
8418
+ disagreeing explicit values **raise at assignment**, either order, naming both
8419
+ (letting `precision` win silently rewrites a pattern the author wrote, the
8420
+ zone directives' class; letting `formats` win makes `precision` a silent
8421
+ no-op; there is an author to tell — `D_locale`'s asymmetry, and `bg_color=`'s
8422
+ eager `KeyError` is the shape); the check keys on `formats.first` alone, so a
8423
+ lossy-leniency list stays legal beside `precision = :minutes`; and
8424
+ `precision` is stored, never eagerly resolved into a list, or it snapshots the
8425
+ session locale. Not v1 because v1 has no writer to reconcile against and
8426
+ reaches MUI's outcome — the spelling survives the precision switch — with one
8427
+ knob. Qt is the other reading of the same table and the one declined: a
8428
+ format writer *and* a locale default, with the spelling gap that implies, and
8429
+ no fix.
8430
+ - **A fused `step` *with* `formats=`** — importing Vaadin's workaround into an
8431
+ API that has the thing it works around. Two writers for one fact; needed the
8432
+ raise above to hold together.
8433
+ - **Stripping seconds at the `Locale` boundary** — lossy for every non-field
8434
+ consumer; see the strip decision.
8435
+ - **Honoring `t_fmt`'s precision** — `:00` in every form field in the world;
8436
+ WinForms and Ant Design are what that looks like shipped.
8437
+ - **Reading `t_fmt_ampm`** — carries `%Z` and `%l`, two rejected directives.
8438
+ - **Snapping a step to the grid** — silently moves the user's value.
8439
+ - **A `precision` reader** — squats a second name for one fact and invites the
8440
+ writer question; the API is one sentence.
8441
+ - **Splicing a separator to add `%S` to a seconds-less locale format** — grammar
8442
+ surgery the strip rule is kept deliberately minimal about; ISO is the fallback.
8443
+
8444
+ **Consequences.**
8445
+
8446
+ - **`Locale` grows `time_formats`**, its ninth member, passing its own gate
8447
+ (how a value is rendered and parsed; no prose). `KEYWORDS` gains `t_fmt`;
8448
+ `Locale.system`'s `LC_TIME` gate already covers it. Detected list is
8449
+ `[t_fmt expanded, "%H:%M:%S"].uniq` — the `[widened, raw, ISO]` shape of
8450
+ `date_formats_from` with no widening step. Specs drive it through
8451
+ `Locale.from_keywords`; `FakeScreen` keeps pinning `Locale::ISO`.
8452
+ - **The strftime lexer is hoisted** out of `Locale::DateFormats` into a
8453
+ `Locale::Formats` module — `DIRECTIVE`, `each_directive`, `LOCALE_LOOKALIKES` —
8454
+ with `DateFormats` and `TimeFormats` as two validators over it. Two copies of
8455
+ `DIRECTIVE` drifting apart is a silent bug in both, and "duplicate rather than
8456
+ DRY a shallow shell" (`D_float_field`) is about component shells, not a lexer.
8457
+ Moving three constants is one **Breaking** CHANGELOG line in a pre-1.0 gem;
8458
+ taking it now is cheaper than a third format kind later, and it is its own
8459
+ commit — a refactor bundled into the feature is the one split the git rules
8460
+ ask for.
8461
+ - **The field is the second copy of the date-field shell** exactly as
8462
+ `FloatField` is the second of the numeric one; duplicate, re-argue at the
8463
+ fourth.
8464
+ - **The picker phase 2 is segment-aware Up/Down, not a dropdown** (decision
8465
+ above); it is `DateField`'s phase 2 as well, and the calendar grid stays that
8466
+ field's own, because a calendar carries information a list of times does not.
8467
+ - **`DateTimeField` over a `DateField` plus a `TimeField`** is what
8468
+ `ideas/composite-field.md` was filed for, still blocked on which component
8469
+ wears a *combination* error.
8470
+ - **The sampler pane supplies its own locale** — a canned fi_FI-shaped `Locale`
8471
+ via `Locale.from_keywords`, `step: 60` and `step: 1` side by side — because the
8472
+ point is that a non-colon spelling survives the switch, and on an en_US box
8473
+ the host locale demonstrates nothing.
8474
+ - `D_date_field` owes a pointer here; `D_locale`'s "the time formats stay out"
8475
+ sentence resolves to this entry.
8476
+
8477
+ ## D_mouse — Keyboard first: the mouse is additive, ranked by activity (2026-09-05)
8478
+
8479
+ **Status:** Accepted 2026-09-05. Decided while ruling out the `TimeField`
8480
+ dropdown (`D_time_field`), which is its worked rejection; nothing implemented,
8481
+ because the entry mostly names what already holds. `virtui` — visualization-heavy,
8482
+ the one downstream app — is what keeps the mouse in scope at all.
8483
+
8484
+ **Context.** Tuile parses the mouse (`MouseEvent`: buttons and the wheel), routes
8485
+ a click down the tree (`Component#handle_mouse`), hit-tests it (`extent_rect`),
8486
+ focuses on click and dismisses overlays on an outside click. Every one of those
8487
+ arrived because a keyboard-motivated component needed it, and none was argued for
8488
+ on its own. The question kept coming back per feature — should a `TimeField` have
8489
+ a dropdown so a mouse user can pick? should hover paint an accent? — with no
8490
+ ruling to point at, so each was re-argued from scratch. This entry is the ruling.
8491
+
8492
+ **Decision — the ranking, by activity.** Mouse is **better** than keyboard for
8493
+ window-moving and dragging; **at least equal** for scrolling; **equal** for focus;
8494
+ a **distant second** for value entry. Rungs, not a blanket: "the mouse is a
8495
+ distant second" is true of *value entry* and false of the wheel, and a rule
8496
+ stated per activity is what stops either reading from being generalized. Focus is
8497
+ "equal" as complementary rather than interchangeable — Tab is sequential (next
8498
+ stop, hands stay put), a click is random access (any widget, one gesture, cost
8499
+ growing with distance rather than stop count); neither dominates, so neither
8500
+ drives design.
8501
+
8502
+ **Decision — the operative rule: every capability is reachable from the keyboard;
8503
+ the mouse is additive.** No capability exists *only* through the mouse, and no
8504
+ component, overlay or knob is built on a mouse argument alone. Applied per rung:
8505
+
8506
+ - **Where the mouse is better or at least equal (dragging, scrolling)** it may get
8507
+ features of its own — the wheel on the scrollers, someday window-moving —
8508
+ provided the keyboard already does the job (PageDown; a move-window key). The
8509
+ mouse gets to be *nicer*, never *necessary*.
8510
+ - **Where it is equal (focus)** both exist and neither shapes design;
8511
+ click-to-focus ungated by geometry is already that.
8512
+ - **Where it is a distant second (value entry)** it gets exactly what
8513
+ `Component#handle_mouse`'s routing hands over for free: a `super`-then-act
8514
+ hit-test on a widget that exists for keyboard reasons. A `Checkbox` toggles on
8515
+ click, a `List` row selects, a `Select` row chooses, a `Button` fires — all
8516
+ shipped, all one line, all staying. What is refused is a feature whose only
8517
+ argument is "so a mouse user can…": the `TimeField` dropdown is the worked case.
8518
+
8519
+ **Rejected alternatives.**
8520
+
8521
+ - **The mouse as an equal peer** — every field gets a picker, every enumeration a
8522
+ clickable face. That is a GUI toolkit's rule, and it produces Vaadin's
8523
+ `TimePicker`: a dropdown that exists because the widget is a `ComboBox` skin,
8524
+ hidden below a 15-minute step because it stops being useful. A TUI user's hands
8525
+ are on the keys; typing `1345` beats scrolling to it.
8526
+ - **A blanket "no mouse value entry"** — the first phrasing, and literally false
8527
+ of shipped code (the four click handlers above). Deleting them would be
8528
+ gratuitous; the rule is about what *motivates* a feature, not what the mouse may
8529
+ touch.
8530
+ - **A `mouse:` knob per component, or a `Screen#mouse = false`** — a setting for
8531
+ something nobody has asked to turn off; the ranking is a design priority, not a
8532
+ runtime mode.
8533
+ - **Mouse-only features "for virtui"** — the app is the reason the mouse is in
8534
+ scope, not a licence to build widgets for it. The framework owes virtui the
8535
+ plumbing (events, routing, extents, the wheel); a mouse-heavy visualization is
8536
+ the app's own component, built on that plumbing.
8537
+
8538
+ **Consequences.**
8539
+
8540
+ - **`D_select` is untouched, and says so where the question will be asked.**
8541
+ Select's dropdown is *enumeration* — options the user cannot type without
8542
+ seeing, the same information test the calendar grid passes and a list of times
8543
+ fails — and its no-printable claim is a keyboard argument. Nothing here demotes
8544
+ Select relative to `ComboBox`; the enum-vs-data criterion stands.
8545
+ - **`ideas/hover.md` is compatible by construction**, not by exemption: its own
8546
+ framing is "opt-in and never load-bearing" — hover adds ink, not a capability,
8547
+ so a keyboard user loses nothing. It stays paused on its own merits.
8548
+ - **The wheel on the scrollers is sanctioned and unscheduled.** `MouseEvent`
8549
+ already parses `:scroll_up` / `:scroll_down`; no `List`, `TextView`, `TextArea`
8550
+ or `ListDropdown` consumes them. It is the one gap the ranking exposes, and it
8551
+ arrives as a small feature whenever someone wants it — PageUp/PageDown already
8552
+ do the job.
8553
+ - **Window-moving needs its own `D_` when it comes:** motion and release events
8554
+ (modes 1002/1006 — Tuile runs X10 mode 1000, press only; the Split Layout
8555
+ blocker in `ideas/new-components.md`, and what `ideas/hover.md` step 1
8556
+ designs), plus the keyboard equivalent the rule above requires *first*.
8557
+ - **Segment-aware Up/Down** (`D_time_field`'s phase 2) is the keyboard's picker,
8558
+ and the shape any future "picker" for a typed field takes before an overlay is
8559
+ considered.
8560
+ - AGENTS.md carries the one-line invariant — no capability reachable only
8561
+ through the mouse — beside the `handle_mouse` routing rule, since a new
8562
+ component can break it from its own file.