@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35

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 (57) hide show
  1. package/CHANGELOG.md +13313 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -3
  4. package/docs/adr/0001-accessibility-as-data.md +414 -0
  5. package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
  6. package/docs/adr/0003-modal-surfaces.md +248 -0
  7. package/docs/adr/0004-multi-window.md +626 -0
  8. package/docs/adr/0005-the-paint-vocabulary.md +601 -0
  9. package/docs/adr/0006-c-abi-versioning.md +257 -0
  10. package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
  11. package/docs/adr/0008-live-regions-and-announcements.md +379 -0
  12. package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
  13. package/docs/adr/0010-a-segment-primitive.md +410 -0
  14. package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
  15. package/docs/adr/0012-the-exit-budget.md +411 -0
  16. package/docs/adr/0013-effects-as-data.md +197 -0
  17. package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
  18. package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
  19. package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
  20. package/docs/adr/0017-selection-as-a-scope.md +696 -0
  21. package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
  22. package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
  23. package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
  24. package/docs/adr/0021-one-subject-per-example.md +729 -0
  25. package/docs/adr/0022-focus-regions.md +205 -0
  26. package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
  27. package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
  28. package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
  29. package/docs/adr/0026-hit-testing-by-shape.md +197 -0
  30. package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
  31. package/docs/adr/0028-derived-tokens.md +572 -0
  32. package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
  33. package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
  34. package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
  35. package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
  36. package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
  37. package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
  38. package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
  39. package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
  40. package/docs/adr/0037-a-family-is-named.md +134 -0
  41. package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
  42. package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
  43. package/encoder.js +1282 -0
  44. package/howto.md +1774 -0
  45. package/index.d.ts +5162 -0
  46. package/index.js +1414 -0
  47. package/jsx-dev-runtime.js +1 -0
  48. package/jsx-runtime.d.ts +905 -0
  49. package/jsx-runtime.js +45 -0
  50. package/native.cjs +118 -0
  51. package/package.json +51 -6
  52. package/prebuilds/darwin-arm64/kui_node.node +0 -0
  53. package/prebuilds/darwin-x64/kui_node.node +0 -0
  54. package/prebuilds/linux-arm64/kui_node.node +0 -0
  55. package/prebuilds/linux-x64/kui_node.node +0 -0
  56. package/prebuilds/win32-x64/kui_node.node +0 -0
  57. package/props.md +636 -0
@@ -0,0 +1,452 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-03
4
+ ---
5
+
6
+ # Keyboard focus as data: one focus, every control reachable
7
+
8
+ Keyboard focus in kui reached editors and key sinks only: Tab cycled the
9
+ editors, a click or a per-frame declaration put focus on a sink, and a
10
+ button could not be reached or pressed from the keyboard at all. We
11
+ decided that the core keeps **one** focus, that every control the access
12
+ tree knows about is focusable, that Tab walks them in tree order and
13
+ Enter / Space activate them, that keyboard-driven focus draws a ring the
14
+ view did not have to ask for, and that all of it is data: a `focusable`
15
+ row, a `disabled` row and a `focusBg` row in the schema, `focused` and
16
+ `disabled` on the access node, and a focus request from assistive
17
+ technology landing on the same focus a Tab press moves.
18
+
19
+ ## Context
20
+
21
+ - ADR 0001 made semantics data and deferred keyboard reach as a focus-model
22
+ decision. The gap it left is concrete: `Core::focus_adjacent_edit` walked
23
+ `NodeContent::Edit` nodes only, buttons advertised `Click` but not
24
+ `Focus`, and Enter and Space never activated anything, because Space
25
+ became `InputEvent::Text` and Enter an `EditKey` that only an editor
26
+ consumed.
27
+ - The same gap is why a screen reader seemed to see editors only. A
28
+ VoiceOver cursor is pulled to whatever the application reports as its
29
+ focused element; kui reported an editor or a key sink or nothing, and
30
+ the remaining controls sat inside the AccessKit view as an unnamed
31
+ group the user had to interact with by hand. AccessKit also treats a
32
+ node as focusable only when it supports the `Focus` action, so a
33
+ reader's own "move keyboard focus here" was a no-op on every control.
34
+ - Focus lived in two places: `EditStore::focused` for editors and
35
+ `Core::key_focus` for sinks, with every path (click, Tab, access
36
+ requests, autofocus) keeping the pair consistent by hand.
37
+ `Core::is_focused` answered for editors only, so a binding could not
38
+ draw a focused button even if it wanted to.
39
+ - `set_key_focus` was a per-frame declaration that always won ("the next
40
+ declaration wins it back"), and the examples that own their keyboard
41
+ call it every frame. Any focus the core moved by itself would be
42
+ clobbered on the next frame.
43
+ - There was no `disabled`: nothing to skip in a Tab ring, nothing to tell
44
+ a screen reader, and a disabled-looking button still clicked.
45
+
46
+ ## Decision
47
+
48
+ 1. **One focus, in the core.** `Core` keeps `focus: Option<Key>`; the
49
+ edit store's focus mirrors it for editor keys (the caret, IME and
50
+ clipboard paths read that mirror as before). `Core::set_focus` is the
51
+ only writer. `is_focused(key)` answers for any node, `focus()` names
52
+ it, and a `focus_visible` flag says whether the keyboard put it there.
53
+ 2. **What is focusable is what the access tree calls a control.** In tree
54
+ order of the last frame: built-in editors, custom editors, key sinks
55
+ (`on_key`), every declarable control role (`button`, `checkbox`,
56
+ `radio`, `switch`, `slider`, `tab`, `link`), a derived button (a box
57
+ with `on_click`), and any node declaring the new `focusable` flag (a
58
+ list row, a card). Never: `disabled` nodes, `role="none"` subtrees,
59
+ window chrome (the platform's own controls are not Tab stops), plain
60
+ structure.
61
+ 3. **Tab walks the ring; Enter and Space activate.** Tab and Shift-Tab
62
+ move to the next / previous focusable node and wrap; with nothing
63
+ focused they enter from either end. A multiline editor (built-in or a
64
+ custom editor role) keeps Tab as indentation, and a key sink keeps
65
+ every key, Tab included, because a sink is an app that owns its
66
+ keyboard; it hands focus on with `Ui::focus_next` when it wants to.
67
+ (What the sink hears *after* it has handed focus on is the amendment at
68
+ the end of this file, and
69
+ `docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`.) On
70
+ a focused control that is neither an editor nor a sink, Enter and Space
71
+ emit its click payload through the path `AccessAction::Click` already
72
+ uses, and on a focused slider the arrow keys emit the `increment` /
73
+ `decrement` access events the app already handles. Keyboard-driven
74
+ focus scrolls the node into view through the shared helper.
75
+ 4. **Focus visible, as data and as pixels.** Focus moved by Tab or by an
76
+ assistive-technology request sets `focus_visible`; a mouse press that
77
+ moves focus clears it, like the web's `:focus-visible`. When it is set,
78
+ the core draws a ring around the focused node on top of the frame, in
79
+ the frame's own display list, so every binding gets it without drawing
80
+ anything. A node that declares `focusBg` swaps its background instead
81
+ (the same resolution as `hoverBg`; pressed wins over focus wins over
82
+ hover) and gets no ring. Editors and sinks get no ring either: an
83
+ editor shows its caret and a sink is an app surface that styles
84
+ itself, through `is_focused` and `focus_visible` like any other state.
85
+ 5. **Declaration is edge-triggered; there is an imperative call.**
86
+ `take_key_focus(key)` (`keyFocus` in JSX, `key_focus` in Lua,
87
+ `kui_set_key_focus` in C) now means "focus this node when it starts
88
+ being declared": a node declared every frame takes focus once, on the
89
+ first frame, and a Tab press afterwards is not clobbered. To move focus
90
+ at any time a view calls `Ui::focus(key)` (`ctx.focus(key)`,
91
+ `kui_focus`), `blur()`, `focus_next()` / `focus_prev()`. An
92
+ `autofocus` editor takes focus only while nothing else holds it, as
93
+ before, but through the same single writer.
94
+ 6. **`disabled` is a row.** A disabled node keeps its hit region (a
95
+ tooltip may explain why) and loses everything else: no click, no drag,
96
+ no key sink, no hover or pressed background, no place in the Tab ring,
97
+ and the access tree says `disabled` and drops the `Click` action.
98
+ 7. **The access tree carries the same focus.** Every focusable node
99
+ advertises `Focus` and `Blur`; `focused` and the tree's `focus` follow
100
+ `Core::focus` for any node, so AccessKit's focus events point a
101
+ screen reader's cursor at the button Tab landed on, and a reader's
102
+ `Focus` request lands on the core's focus the way Tab does. Windows,
103
+ macOS and AT-SPI adapters read `disabled` from the node.
104
+ 8. **Three schema rows, every binding.** `focusable` (id 61, flag),
105
+ `disabled` (id 62, flag) and `focusBg` (id 63, colour) join `PROPS`;
106
+ JSX, Lua and C get them the way every simple row lands (one `npm run
107
+ gen`, a `KuiSpec` field each, pinned by the parity test). Nothing else
108
+ in the IR changes.
109
+
110
+ ## Considered options
111
+
112
+ - **Tab through every `on_click` node and leave the rest.** Rejected: a
113
+ checkbox or slider drawn by the app has no click payload, and a row
114
+ that opens on Enter needs to be reachable without pretending to be a
115
+ button. Tying focusability to the role vocabulary reuses the decision
116
+ ADR 0001 already made about what a control is.
117
+ - **A `tabIndex` row.** Rejected: an ordering integer is how the web
118
+ patched a document model that had no notion of tree order. kui's tree
119
+ order is reading order, and a view that wants a different order draws
120
+ in a different order.
121
+ - **Keep two focus stores and add a third for controls.** Rejected: the
122
+ two existing ones already cost every path a pair of updates; a third
123
+ would triple it, and the access tree needs one answer.
124
+ - **Let a key sink give up Tab to the ring.** Rejected: a modal editor,
125
+ a terminal and a game bind Tab, and a sink is by definition an app
126
+ that owns its keyboard. It gets `focus_next` instead.
127
+ - **Arrow keys within radio groups, tab lists and lists.** Deferred: the
128
+ ARIA patterns (arrows move within a composite, Tab leaves it) need
129
+ the composite roles' children enumerated, which the access tree can
130
+ do, but the activation rules differ per pattern and no example needs
131
+ them yet. A slider's arrows are in because the app already handles
132
+ the events.
133
+ - **A focus ring the view draws.** Rejected as the only option: every
134
+ binding would draw its own ring, or forget to. The core's ring is a
135
+ default; `focusBg` and `is_focused` remain for views that want their
136
+ own look.
137
+
138
+ ## Consequences
139
+
140
+ - A keyboard alone reaches and operates every control in every example
141
+ and in every binding, and a screen reader's cursor follows. The
142
+ accessibility example needs no change beyond its comments: its
143
+ per-frame `take_key_focus` is now one-shot by definition.
144
+ - `take_key_focus` changes meaning for apps that relied on it winning
145
+ back focus every frame after a click elsewhere. None of the examples
146
+ did; an app that wants that calls `focus(key)` when it decides to.
147
+ - `Core::key_focus()` keeps working and answers the unified focus.
148
+ `Core::is_focused` widens from editors to every node, which is what
149
+ `widgets::text_input` and the bindings' `isFocused` already assumed.
150
+ - `EditStore::declare` no longer touches focus; autofocus is the core's
151
+ decision, so an autofocus editor cannot steal focus from a focused
152
+ button.
153
+ - Headless tests pin the ring order, activation, slider arrows, the ring
154
+ quad, `focusBg`, `disabled`, the access tree's focus and actions, and
155
+ the edge-triggered declaration. What only a screen reader session can
156
+ show is announcement and cursor following; the macOS audit script
157
+ gains a Tab check.
158
+ - Arrow-key composites (radio groups, tab lists, lists) and a
159
+ configurable ring colour are the next steps, both small.
160
+
161
+ ## Amendment: a press inside a sink, built (2026-09-05)
162
+
163
+ The consequence above — "`take_key_focus` changes meaning for apps that
164
+ relied on it winning back focus every frame after a click elsewhere. None
165
+ of the examples did" — was true when it was written and stopped being true
166
+ the moment `examples/rust/splitmux.rs` landed. Splitmux owns its whole
167
+ keyboard through one `on_key` sink and draws its panes *inside* that sink,
168
+ each with an `on_click` so a click can focus one. Decision 2 makes a box
169
+ with `on_click` a derived button, and so focusable; decision 5 makes the
170
+ sink's per-frame declaration one-shot. Together: the first click on a pane
171
+ moved focus to the pane, the sink's next declaration was not an edge, and
172
+ every ⌥ chord in the app was dead for the life of the process — with no
173
+ way for the app to notice, because focus moving is not an event it sees.
174
+
175
+ That is not a splitmux problem. It is the shape of every app that owns its
176
+ keyboard and draws surfaces to click: a multiplexer, a canvas with
177
+ handles, a game with a HUD, a diagram editor. Decision 3 already says why:
178
+ *a key sink is an app that owns its keyboard*, which is why it keeps Tab.
179
+ The pointer had simply not been held to the same rule.
180
+
181
+ So, refining decisions 2 and 3 rather than reversing either:
182
+
183
+ 9. **A primary press inside a key sink leaves the keyboard on the sink.**
184
+ The press resolves to the nearest enclosing sink, not to the node it
185
+ landed on — a pane, a derived button, a `focusable` row, or dead space,
186
+ which used to blur. Those nodes stay focusable and stay in the Tab
187
+ ring; being focusable makes a node a Tab stop, not a claim on keys the
188
+ app drawing it already owns. A node that owns a keyboard in its own
189
+ right still takes focus from inside a sink: an editor (with its caret
190
+ placed where the click was, as ever) and a nested sink. A `disabled`
191
+ node is not a sink at all (decision 6), so it neither answers nor hides
192
+ a live sink above it.
193
+ 10. **A press on window chrome leaves focus exactly where it is.** A
194
+ `window_drag` strip or a window button is the platform's control, and
195
+ decision 2 already keeps it out of the Tab ring for that reason;
196
+ dragging a window to another monitor is not the app being asked to
197
+ give up its keyboard. Previously it blurred, because chrome is not
198
+ focusable and a press on nothing focusable blurred.
199
+
200
+ Nothing changes outside a sink: a click on a button in ordinary content
201
+ focuses that button, and a click on the background still blurs.
202
+
203
+ An app whose keyboard leaves for something genuinely outside its sink —
204
+ splitmux's tab bar, which sits above the sink and whose tabs are real
205
+ controls that should focus — still asks for it back with `focus(key)`, the
206
+ imperative call decision 5 exists to provide. That is the case the original
207
+ consequence describes, and it is the right one to make an app spell out:
208
+ it is the app choosing to overrule a focus the user moved.
209
+
210
+ `cargo test -p kui-native --example splitmux` drives the example headlessly
211
+ through the `App` trait and presses ⌥v after a pane click, a tab click and
212
+ a titlebar grab; `crates/kui-core/tests/keys.rs` pins the three rules on
213
+ the shapes themselves, including the editor that must still win.
214
+
215
+ ## Amendment: a key has two codes, built (2026-09-05)
216
+
217
+ Chasing the sink bug above turned up a worse one underneath it. `code` was
218
+ the character the active *layout* produced, and every keymap in the repo is
219
+ written in Latin — `match code { "v" => split, "t" => new_tab }`. On a
220
+ Cyrillic, Greek, Hebrew or Arabic layout the key US-QWERTY prints V on
221
+ produces `м`, `ω`, `ה`, `ر`. Not a different arm: *no* arm. Splitmux was
222
+ silently keyboard-dead for a large fraction of the world, and nothing in
223
+ the app could detect it — the events arrived, they just matched nothing.
224
+
225
+ Pure scan codes are the obvious fix and the wrong one. Bind position and a
226
+ Dvorak user pressing the key **printed V** gets whatever chord QWERTY keeps
227
+ at that slot; the docs say ⌥v, the keycap says V, and the app disagrees
228
+ with both. Neither view is right on its own: position is right about the
229
+ Cyrillic case and wrong about the Dvorak one, and the layout is exactly the
230
+ reverse.
231
+
232
+ So both, with a stated default:
233
+
234
+ 11. **`code` prefers the label, and falls back to the position.** A press
235
+ carries what the layout produced and which key produced it, and
236
+ `KeyPress::from_layout` resolves the two: while the layout's key is
237
+ ASCII it wins, so a chord lands on the key the user can see (Dvorak's
238
+ ⌥v on the key printed V, AZERTY's ⌘a on the one printed A, QWERTZ's ⌘z
239
+ on the one printed Z). When it is not — or names nothing this
240
+ vocabulary knows, as a dead key does — the US-QWERTY key at that
241
+ position stands in, so a Latin keymap keeps matching; as Shift prints
242
+ it, since the position is reported unshifted (`J`, `:` and `~` on a
243
+ Russian layout, not `j`, `;` and `` ` ``; F76, 2026-09-21) — except under
244
+ Alt, where the unshifted position stands in, since ⌥ composes a
245
+ character (⌥⇧J is `Ô` on a US Mac) and the chord a keymap names is
246
+ ⌥⇧j, as the winit runner always reported it (RG27, 2026-09-25). This is the rule
247
+ browsers use to keep ⌘C copying on a Russian layout, and it is the
248
+ reason an app can stay ignorant that layouts exist. `text` is
249
+ untouched: the typing view is always the layout's own character.
250
+ Judged key by key, though, the label rule took a non-Latin layout's
251
+ ASCII for a label: macOS's Russian puts `]` on the key printed
252
+ `` ` `` and `:` on ⇧5, Windows' `.` on `/`, and a keymap's `` ` ``,
253
+ `%` and `/` went to `]`, `:` and `.` (F115, 2026-10-01). Whether a
254
+ layout's ASCII is its label is the layout's to say, not the key's —
255
+ AZERTY's `&` on the key printed 1 is, Russian's `]` is not — so the
256
+ driver says which alphabet the layout writes (`LayoutScript`,
257
+ through `KeyPress::from_layout_in`), and on one that writes no Latin
258
+ every key falls back to the position, its ASCII too; a key that is
259
+ its position's own character, or sits where US-QWERTY has no key,
260
+ keeps the layout's. It is macOS's rule for a ⌘ shortcut, resolved
261
+ through the ASCII-capable layout when the current one is not; the
262
+ winit runner asks macOS that question and Windows what its letter
263
+ keys type, and elsewhere goes by what the letter keys last typed.
264
+ A driver that cannot ask says `Latin`, and gets the key-by-key rule.
265
+ 12. **`physical` is a payload field of its own.** The US-QWERTY key at that
266
+ position, in the *same* vocabulary as `code` — `"v"`, `"1"`, `"left"`,
267
+ `"f5"` — so an app switching a keymap from one to the other keeps its
268
+ match arms byte-identical. A keymap that wants the finger rather than
269
+ the label reads it: WASD stays a square on AZERTY, where `code` would
270
+ make it ZQSD. It rides on every key event in every binding.
271
+
272
+ The derivation lives in `kui_core::KeyPress::from_layout`, not in the winit
273
+ runner, so a host with its own windowing gets it by handing over both codes
274
+ — C through the new `physical` argument on `kui_input_key_down` / `_up`,
275
+ Node through the trailing `physical` argument on `keyDown` / `keyUp`. NULL
276
+ or omitted means "the key I named", which is what a host that does not
277
+ track positions says, and what every existing call already meant.
278
+
279
+ `crates/kui-core/tests/keys.rs` pins the fold for Russian, Greek, Hebrew and
280
+ Arabic and the pass-through for Dvorak, AZERTY and QWERTZ; splitmux presses
281
+ ⌥v on a Russian layout and on a Dvorak one and checks it splits both times,
282
+ for opposite reasons.
283
+
284
+ ### Rejected
285
+
286
+ - **Replace `code` with the position outright.** The clean model, and it
287
+ makes every Latin non-QWERTY user's chords disagree with their own
288
+ keycaps. Position is the fallback because it is right less often than
289
+ the label, not more.
290
+ - **Leave `code` alone and add `physical` beside it.** Purely additive and
291
+ fixes nothing: the naive binding stays the broken one, and every app has
292
+ to be rewritten one at a time to get what it already assumed it had.
293
+ - **W3C `code` names for `physical`** (`"KeyV"`, `"Digit1"`, `"ArrowLeft"`).
294
+ Unambiguous about being a position, at the price of a second vocabulary
295
+ in the docs, a second name table in C and Lua, and match arms that no
296
+ longer line up with `code`'s. Reusing the one vocabulary makes switching
297
+ a keymap between the two a one-word edit.
298
+
299
+ ## Amendment: what a sink hears once it is not the focus, built (2026-09-06)
300
+
301
+ Decision 3 answers the Tab half of an app that owns some of its keyboard:
302
+ a sink keeps Tab and hands the ring on with `focus_next`. It has no answer
303
+ to the half that comes next. Once focus is on a control, `route_key`
304
+ resolves nothing — a sink hears a press only while it holds focus — so an
305
+ app shell that hands the ring on goes deaf, and the pomodoro in backlog F7
306
+ loses the Space that starts its timer the moment its slider becomes
307
+ reachable. Both field reports on alpha.6 found it, from opposite ends: one
308
+ had a ring and no shortcuts, the other shortcuts and no ring.
309
+
310
+ The rejected option above, "let a key sink give up Tab to the ring", is
311
+ still rejected and still for the same reason. What was missing is not a
312
+ way to take keys *from* a sink but a way to give it the ones nothing else
313
+ wanted:
314
+
315
+ 13. **A press the focused node does not claim goes to the nearest
316
+ enclosing sink.** A focused control keeps the keys the core presses it
317
+ with — Enter and Space where there is a click payload, a slider's
318
+ arrows, a composite's arrows, Home, End and type-ahead — and Tab stays
319
+ the ring's wherever focus is. Every other press, chords included,
320
+ walks up to the first non-disabled `on_key` ancestor, which hears it
321
+ as it hears everything today. A sink that holds focus keeps every key,
322
+ exactly as decision 3 says.
323
+
324
+ The rules, the alternatives (a `keys` allow-list on the sink, claiming by
325
+ role, bubbling dynamically), the phase question and the modal boundary are
326
+ in `docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`. Decisions 2, 3, 5
327
+ and 9 are unchanged; decision 3's `focus_next` is now half of a pattern
328
+ rather than a whole answer.
329
+
330
+ ## Amendment: a key that acts on the focus shows it, built (2026-09-09)
331
+
332
+ Decision 4 names two ways focus starts showing — a Tab step and an
333
+ assistive-technology request — and one way it stops: a mouse press. That
334
+ is the whole of `:focus-visible` as a *starting* rule and only half of it
335
+ as a live one. A browser also turns the indicator on when the keyboard is
336
+ used on focus a click placed, and kui did not:
337
+
338
+ Click the `+1` button in `examples/rust/counter`, then press Space. The
339
+ count goes up — Space presses the focused control, decision 3 — and
340
+ nothing on screen says which of the three buttons answered. Press Tab
341
+ first and the ring appears, and from then on Space looks like it works.
342
+ So the honest reading of the report this came from is not "Space does not
343
+ press the button" but "I cannot tell that it did".
344
+
345
+ 4a. **A key the core acts on the focused control with shows that focus.**
346
+ Space and Enter pressing it, a slider's arrows, a composite's arrows,
347
+ Home, End and type-ahead — the same set decision 13 says a control
348
+ keeps. Escape is the exception, because it acts by letting go and a
349
+ ring around nothing is not a ring; and a key that bubbles to an
350
+ enclosing sink (ADR 0011) never reaches this arm, so a shortcut
351
+ handled above the control does not pop a ring around it.
352
+
353
+ Composite motion already did this (`docs/adr/0007`, decision 5, through
354
+ `move_within_composite`), which is the same rule reached one pattern at a
355
+ time. The clearing rule is untouched: the next mouse press hides it again.
356
+
357
+ ### Rejected
358
+
359
+ - **Show on any key, as the browsers do.** They set the flag on *every*
360
+ keydown that is not a modifier, wherever it goes. In kui that would
361
+ light a ring on a control while a shortcut layer above it handled the
362
+ key — the app's own surface answering, and the ring naming a node that
363
+ did nothing — because ADR 0011 sends unclaimed keys past the control.
364
+ "The core acted on this node" is the fact the ring is drawn from.
365
+ - **Show it on the click.** A pointer user gets a ring around whatever
366
+ they last touched, which is the noise `:focus-visible` exists to
367
+ remove, and the app that reads `focus_visible` for its own styling
368
+ loses the distinction entirely.
369
+
370
+ ## Amendment: rings, not a ring — and the two rules beside them (2026-09-10)
371
+
372
+ Decision 2 says every control is in *the* Tab ring, and decision 5 says an
373
+ `autofocus` editor "takes focus only while nothing else holds it". The
374
+ first is now "in the ring of the region it is in", and the second is an
375
+ edge rather than a standing claim; a third rule, that a key sink on the
376
+ root hears keys when nothing is focused, closes the gap that made shells
377
+ take focus on the root. All three are
378
+ `docs/adr/0022-focus-regions.md`; decisions 2, 3, 5 and 8 here are
379
+ otherwise unchanged.
380
+
381
+
382
+ ## Amendment: where a key is, the modifier keys, the locks, built (2026-09-28)
383
+
384
+ A terminal speaking kitty's keyboard protocol asked for the keyboard
385
+ whole (backlog F108, from kawoosh): the protocol reports the keypad's
386
+ digits apart from the main block's, the left Shift apart from the
387
+ right, the modifier keys pressed and released on their own, Caps Lock
388
+ and Num Lock, F13 to F35, and the media keys. A press carried none of
389
+ it — the keypad's `1` was the main block's `1` in both codes, the
390
+ modifiers were only ever held, nothing said what was locked, and the
391
+ runner dropped every key it had no name for.
392
+
393
+ 14. **A key says which of its twins it is.** `location` —
394
+ `"standard"`, `"left"`, `"right"`, `"numpad"` (`KeyLocation`) — rides
395
+ on every key payload, as winit and the DOM report it. `code` and
396
+ `physical` stay what the key is: the keypad's `1` is still `"1"` and
397
+ its Enter `"enter"`, so every keymap written against decision 11 is
398
+ unchanged, and one that cares reads the place. A press and its
399
+ release are matched by place as well as position, so the two Shifts
400
+ and the two `1`s are two keys held.
401
+ 15. **The modifier keys are keys only to a sink that asks.** `shift`,
402
+ `ctrl`, `alt`, `super`, `capslock`, `numlock`, `scrolllock` are codes
403
+ of their own, delivered to a sink that says `modifierKeys`
404
+ (`NodeSpec::modifier_keys`, `KuiSpec.modifier_keys`) with the side in
405
+ `location`, and to no other. A keymap is the common case, and a
406
+ keymap reading `<leader>F` would see Space, Shift, F — the Shift a
407
+ key between two others, the sequence broken — so the default is the
408
+ old reading: a modifier is held, in the next key's `shift` / `ctrl` /
409
+ `alt` / `super` and the `modifiers` event, and never pressed. A
410
+ modifier key's own event carries the state *after* it — Shift's
411
+ press has `shift`, its release none unless the other Shift is still
412
+ down — as a terminal speaking kitty's protocol reports it; winit
413
+ tells the modifiers after the key, so the runner sets that bit
414
+ itself.
415
+ 16. **The lock state is the press's, not a modifier.** `caps_lock` and
416
+ `num_lock` (`KeyLocks`) ride on every key payload beside `location`.
417
+ `KeyMods` stays what is held down: an accelerator and the devtools
418
+ chord compare it exactly, and a Caps Lock left on must not make
419
+ ⇧⌘I miss. The runner asks the OS where it answers cheaply — macOS's
420
+ `NSEvent.modifierFlags` (a Mac has no Num Lock, so it reads off, as a
421
+ Mac terminal reports it),
422
+ Windows' `GetKeyState` and the X server's XKB state (amended by
423
+ backlog RG104) — and elsewhere (Wayland) tracks the lock keys' own
424
+ presses, which knows nothing of a lock set before the app's first
425
+ window opened or turned while another app had the keyboard. One
426
+ record for the app, since there is one keyboard,
427
+ and a lock key's own press reports the state it made on all three —
428
+ what the two OS answers are read after the toggle (amended by
429
+ backlog RG96, where Linux said what it found and each window kept
430
+ its own).
431
+
432
+ The keys past the editing block — F13–F35, `printscreen`, `pause`,
433
+ `menu`, `clear`, and the media keys (`mediaplaypause`, `volumeup`, …) —
434
+ are named and delivered like any other; their names join the one table
435
+ `KeyCode::name` and `KeyCode::from_name` share. The doors take the place
436
+ and the locks with the key: C in bits of the `kmods` word that were
437
+ zero (`KUI_KLOC_*`, `KUI_KLOCK_*`), so a host passing only `KUI_KMOD_*`
438
+ sends what it sent; Node in the mods object; Rust as `with_location` and
439
+ `with_locks`.
440
+
441
+ ### Rejected
442
+
443
+ - **Keypad codes of their own** (`"kp1"`, `"kpenter"`). Every keymap that
444
+ binds `1` or Enter would miss the keypad's until it learned a second
445
+ name, which is the breakage decision 11 exists to avoid. The place is
446
+ the new fact; the key is the same key.
447
+ - **The modifier keys to every sink.** The browser model, where every
448
+ app filters Shift out of its keydown handler. Here it breaks every
449
+ sequence keymap on upgrade, silently, for a fact few sinks want.
450
+ - **Lock bits in `KeyMods`.** One word for the C door, and exact
451
+ comparisons of held modifiers — accelerators, chords — start failing
452
+ whenever Caps Lock is on.