@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,134 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-27
4
+ ---
5
+
6
+ # A rounded background is joined by meeting
7
+
8
+ > **Accepted and built 2026-09-27**, the day it was proposed (backlog
9
+ > F101). Raised by kawoosh's rounded selection. The editor draws a
10
+ > selection as a translucent `bg` on the spans of each row it covers,
11
+ > and wanted it drawn as one rounded outline, the way an editor's
12
+ > selection usually is. Its first cut was a `fragment` under each row's
13
+ > text, told its own extent and its neighbours' by the app (the reason
14
+ > for a6092cc's square fragment and snapped fragment quad). That cut
15
+ > could not reach a markdown paragraph. A paragraph is one text whose
16
+ > lines wrap where layout breaks them, and a row of the view is not a
17
+ > line of the paragraph, so the app did not know the lines to draw
18
+ > under. And it was a frame behind, since the extents came from the last
19
+ > frame's layout.
20
+
21
+ ## Context
22
+
23
+ - **What a span's background is.** One square quad per line a run of
24
+ spans of one background covers (`build_decorations`, one rect per run
25
+ since dfc635b), each edge on a whole pixel from where layout put the
26
+ text (`on_pixels`). Two rows' backgrounds meet on one pixel line with
27
+ nothing drawn twice. Only the corners were left to do.
28
+ - **What a rounded outline needs at a corner.** Where a line reaches
29
+ past the line above, its corner is convex. Where it falls short, the
30
+ corner is concave: a fillet past its end, filled in the line's own
31
+ colour. So a line's corners depend on the lines above and below it.
32
+ Those can be in the same text (a wrap), in another text (an editor's
33
+ next row), or in another node beside it (the cell an editor draws for
34
+ a line's newline).
35
+ - **When the lines are known.** A text's quads are emitted in its
36
+ node's turn of `emit_frame`, in paint order, and at that point the
37
+ node after it has not been laid out into quads. Every text's lines are
38
+ known only once the frame's quads are all emitted, and that is still
39
+ before the frame leaves the core. So a pass there sees every line,
40
+ from the layout of the frame it draws.
41
+ - **What already draws a shape no quad kind has.** The polygon (ADR
42
+ 0025, decision 6) is a `fragment` quad painted by a WGSL source the
43
+ core registers itself. Its params are the shape, and every backend
44
+ that draws fragments draws it with nothing new.
45
+
46
+ ## Decisions
47
+
48
+ 1. **A span's background takes a radius, and nothing else is new on
49
+ the surface.** `Span::bg_radius(r)` in Rust, `bgRadius` on a
50
+ `<span>` in JSX, `bg_radius` in a Lua span table and on `KuiSpan` in
51
+ C, in logical px. At 0, the default, the background is the square
52
+ one it always was, byte for byte.
53
+ 2. **Pieces are joined by meeting, not by a name.** Two rounded
54
+ backgrounds belong to one shape when they have the same colour and
55
+ radius and one's edge touches the other's exactly: on the line above
56
+ or below, overlapping it sideways, or end to end on the same line. It
57
+ does not matter which node drew either. Nothing groups them, so
58
+ there is no id to declare, keep stable or clear, and two that touch
59
+ are one. Meeting is exact because the pieces are on whole pixels
60
+ already. Pieces that meet end to end on a line are made one extent
61
+ first. That extent is what the lines above and below are told, and
62
+ no corner is drawn where the pieces meet.
63
+ 3. **The join is a pass after emission.** A rounded background is
64
+ emitted square, like every background, and noted (`text::JoinBg`:
65
+ its quad, radius, the clip its text was given, and its text's own
66
+ box when that box clips, as a no-wrap line's does). At the end of
67
+ `emit_frame`, `join::shape` finds each piece's neighbour above and
68
+ below, the one overlapping it most, and rewrites its quad in place.
69
+ Paint order and the quad count are unchanged, and no backend,
70
+ adapter or wire learns a new kind. The shape uses the layout
71
+ of the frame it is drawn in, so it is never a frame behind.
72
+ 4. **Each piece is drawn by a stock fragment, `fragment::JOIN`.** The
73
+ core registers it as it registers the polygon's. A piece's quad is as
74
+ tall as its line and as wide as its extent plus the fillets its
75
+ neighbours ask for. Its params are its own extent, the neighbours'
76
+ extents, the radius and which neighbours there are, in physical px
77
+ from the quad's left. A corner is convex where the piece reaches past
78
+ its neighbour, a fillet where the neighbour reaches past it, square
79
+ where the two end together, and round where nothing meets it. At a
80
+ join the radius is at most half the step, and both pieces work it out
81
+ from the same two ends, so the convex half above and the fillet below
82
+ meet. The fill is the quad's colour. Near the ends and arcs it is
83
+ sampled sixteen times a pixel, and elsewhere it is solid.
84
+ 5. **A piece is clipped as its text's parent is.** A fillet past the
85
+ end of a short no-wrap line lies outside that text's own box, beside
86
+ it, so the piece takes the clip the text was given, not its own box.
87
+ A piece is still no wider than its text's own box shows. An
88
+ ancestor's clip, such as a scrolled pane, cuts the shape square where
89
+ it cuts, as it cuts everything.
90
+
91
+ ## Considered options
92
+
93
+ - **The app draws it (kawoosh's first cut).** A `fragment` a row, the
94
+ extents from the last frame's layout. A frame behind on every edit
95
+ and scroll, and blind to a wrapped paragraph's lines, which only
96
+ layout knows. The square fragment and its snap (a6092cc) stay useful
97
+ for other stacks. For a selection over text, this ADR replaces that
98
+ cut.
99
+ - **A named group** (`bgGroup="sel"`). It would let two selections of
100
+ one colour that touch stay apart. It also needs an id per selection,
101
+ stable across frames, in four bindings. The pass would still have to
102
+ find which pieces of a group are neighbours, and only geometry
103
+ answers that. An editor's selections that touch are one selection.
104
+ Kept in reserve, as an optional key on the same pass, if an app ever
105
+ needs two touching shapes of one colour kept apart.
106
+ - **A new quad kind for rounded runs.** Every backend, the C header's
107
+ `KUI_QUAD_*` and the Node decoder would learn it. The fragment path
108
+ already carries sixteen params and a registered source, and the
109
+ polygon showed that path is enough.
110
+ - **Joining in layout.** Layout sizes boxes and does not know a text's
111
+ line rects in physical px, which is what the join compares. That
112
+ knowledge would move into layout for a paint effect.
113
+
114
+ ## Consequences
115
+
116
+ - The first paint that reads across nodes. The pass costs a scan of
117
+ the frame's rounded pieces against each other. That is the pieces on
118
+ screen, a few dozen for a selection, and nothing when no span has a
119
+ radius.
120
+ - A rounded background is a `KUI_QUAD_FRAGMENT` on the wire, and its
121
+ `color` carries the fill with its alpha, not only the group opacity
122
+ as a node's fragment quad does. A host that draws fragments itself
123
+ gets the source from `kui_fragment_source`, as for the polygon. A host
124
+ that draws no fragments draws no rounded background, where it drew a
125
+ square one.
126
+ - Two touching selections in one colour and radius are one shape, by
127
+ design (decision 2).
128
+ - The C `KuiSpan` gains `bg_radius` under the unreleased ABI 20, and the
129
+ Node frame goes to v17, one slot a span.
130
+ - Pinned by `tests/joined_backgrounds.rs` in kui-core (the pieces and
131
+ their params across texts, a wrap, colours and radii that do not
132
+ join, pieces meeting end to end on a line) and in kui-wgpu (the shape
133
+ composited through `JOIN`'s mirror, `tests/wgsl::join_alpha`), and by
134
+ the corpus's `joined-backgrounds` scene in four adapters.
@@ -0,0 +1,142 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-27
4
+ ---
5
+
6
+ # An event handler gets the window its event came from
7
+
8
+ > **Accepted and built 2026-09-27**, the day it was proposed (backlog
9
+ > DX9, from the DX sweep of kawoosh's views). The user chose the name
10
+ > `on_event_with`. What the building added: a window that closed after
11
+ > its event was made lends the main window's core, not none, so no
12
+ > event is dropped. The clipboard example moved its copy, paste and
13
+ > selection answer into the handler and lost three model fields. Two
14
+ > things were not built as planned. The runner has no two-window test,
15
+ > since a pane needs a real window; `testing::Drive` pins the handler's
16
+ > core, and `window_core` is the one lookup. The debug assertion against
17
+ > `frame()` in a handler was not added, and the trait's doc says it
18
+ > instead.
19
+
20
+ A Rust app hears its events in `App::on_event(&mut self, ev: UiEvent)`
21
+ and has nothing else in hand. Everything it wants to *do* about an event
22
+ beyond changing its model — write the clipboard, ask for a paste, move
23
+ focus, reveal a row, scroll, ask for a frame, open a menu — is a call on
24
+ the window's `Core`, and the only place a Rust app reaches a `Core` is
25
+ `view`, through `Ui`. So kawoosh parks each of those in its model for
26
+ the next `view` to carry out:
27
+
28
+ - `clip_out`, the text to copy, because "`on_event` has no `Ui`"
29
+ (`kawoosh/src/app.rs:191`);
30
+ - `reclaim_focus`, set by ten handlers (`app.rs:224`; DX10's `keepFocus`
31
+ removed most of the need);
32
+ - the devtools tab and alignment requests (`app.rs:137`, `:253`).
33
+
34
+ Each is a field that is not state, a branch in `view` that is not
35
+ drawing, and a frame of delay between the event and its effect. ADR 0008
36
+ (decision 6) and the closed C18 and C33 each met this and wrote around
37
+ it. None of them filed it.
38
+
39
+ ## Context
40
+
41
+ - **Node already has it.** `update(model, msg, event, surface)` gets the
42
+ `Ctx` or `KuiWindow` the loop runs on as its fourth argument since D4.
43
+ `surface.setClipboard(…)` from `update` is the Node spelling of what
44
+ kawoosh parks. ADR 0013 kept it that way on purpose: kui's effects
45
+ are calls on the surface, and only the app's own effects are returned
46
+ as data.
47
+ - **C already has it.** A C host drives its core and polls its events
48
+ itself, so its handler holds the `KuiCtx`.
49
+ - **Lua is a guest (ADR 0014).** A script's `on_event(ev)` returns
50
+ replies for its host to act on, and its `env` is a reading of one
51
+ `view`. That is the design, not a gap: what a script wants done
52
+ outside its own tree, it asks its host for (kawoosh's `kawoosh.copy`).
53
+ - **The runner can hand one over.** `Shell::route_events` calls
54
+ `app.on_event(ev)` with `panes` (each window's `Core`) and `app` as
55
+ separate fields of `Shell`, so it can lend the handler the pane's core
56
+ while it runs. `ev.window` names the pane. A pumped runner's
57
+ `route_events(events, to_app)` and `testing::Drive` route the same way.
58
+ - **The core's verbs already work between frames.** `set_clipboard` and
59
+ `request_paste` queue menu actions the runner drains after routing.
60
+ `reveal`, `focus_region` and the DX15 labels resolve when the next
61
+ frame finishes. `set_scroll`, `set_key_focus` and `request_frame` ask
62
+ for a frame. The verb table (`schema::DOORS`) already lists the
63
+ between-frames doors a host has, and it is the same list Node's
64
+ surface is checked against.
65
+
66
+ ## Decisions
67
+
68
+ 1. **`App` gains a handler that takes the window's core, and the old
69
+ one stays.**
70
+
71
+ ```rust
72
+ fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
73
+ self.on_event(ev)
74
+ }
75
+ ```
76
+
77
+ The runner calls `on_event_with`. An app that overrides only
78
+ `on_event` behaves as it does today, so nothing breaks. The name is
79
+ open: `on_event_with` says what changed, and `update` would match
80
+ Node's word, but `update` in Rust usually means the model's step.
81
+
82
+ 2. **The surface is the `Core`, not a new context type.** It is the core
83
+ of the window `ev.window` names: the pane's, or the main window's for
84
+ an event a host made itself. It is `&mut Core` because the core's
85
+ between-frames doors already are the curated list. `DOORS` pins them
86
+ against Node's surface and C's header. A `EventCx` with a chosen
87
+ subset would be a fourth list to keep in step, and it would drift.
88
+
89
+ 3. **What the handler does lands where the verb says.** A clipboard write
90
+ goes out with the batch the runner drains after routing. A focus move
91
+ or a reveal is seen by the next frame, which the verb asks for. The
92
+ handler adds no ordering of its own.
93
+
94
+ 4. **Every driver passes it.** The winit runner, the pumped runner's
95
+ `route_events` (its `to_app` becomes `FnMut(&mut A, UiEvent, &mut
96
+ Core)`, a break for the few hosts that call it), and
97
+ `testing::Drive`. An event raised while a frame is being built, such
98
+ as a slot's reply, is routed after the frame like every other, so the
99
+ core is never lent while `Ui` holds it.
100
+
101
+ 5. **Lua and C are unchanged.** C already holds its context. A Lua
102
+ script stays a guest and asks through replies (ADR 0014). This is
103
+ Rust's door catching up with Node's, so no verb is added to the table.
104
+
105
+ ## Considered options
106
+
107
+ - **Change `on_event`'s signature.** One method, the obvious spelling,
108
+ and a break for every Rust app on kui (kawoosh has one, the examples
109
+ thirty-odd). The default method in decision 1 gets the same result
110
+ without the break. Kept as the thing to do once it has shipped a
111
+ release, if the old method is to go.
112
+ - **An `EventCx` of chosen verbs** (the backlog's first shape). Easier
113
+ to document and to keep an app from calling `frame()` from a handler.
114
+ But it is a second list of the core's verbs, beside the one `DOORS`
115
+ pins, in Rust only. A verb added to the core would need adding here
116
+ too or be missing only in Rust handlers. What it would buy — a handler
117
+ that cannot call `frame()` and build a frame the runner did not ask
118
+ for — is a rule the core's docs already state for any host holding a
119
+ `Core` between frames, and a debug assertion can hold it here.
120
+ - **Effects as data** (ADR 0013's shape for Rust). Return
121
+ `Vec<Effect>` for the runner to apply. kui's effects are core verbs,
122
+ and ADR 0013 kept those as calls. Only the app's own effects are data.
123
+ A Rust app's own effects are its own code.
124
+ - **Leave it**, since kawoosh's workaround works. The cost is not
125
+ kawoosh's three fields but that every Rust app meets the same wall at
126
+ its first clipboard shortcut, and the Node app next to it does not.
127
+
128
+ ## Consequences
129
+
130
+ - A Rust handler copies, pastes, focuses, reveals and scrolls in answer
131
+ to the event, as a Node `update` does. The model fields and `view`
132
+ branches that parked those go. kawoosh's `clip_out`, the devtools
133
+ requests and what `reclaim_focus` has left are the first to go.
134
+ - `route_events`' closure gains the core: a break for pumped hosts,
135
+ listed under What breaks. `testing::Drive` routes the same way, so a
136
+ test sees what the window does.
137
+ - Nothing in the core, the IR, the schema, the C ABI, Lua or Node
138
+ changes.
139
+ - Built with a runner test that a handler's `set_clipboard` reaches the
140
+ drained actions of the same turn and a `reveal` the next frame, and a
141
+ `Drive` test that the handler's core is the window its event came
142
+ from when two windows are open.
@@ -0,0 +1,134 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-27
4
+ ---
5
+
6
+ # A family is named where the text declares it
7
+
8
+ > **Accepted and built 2026-09-27**, the day it was proposed (backlog
9
+ > DX17, from the DX sweep of kawoosh's views). The user chose frame v18
10
+ > for decision 4. What the building settled: the parser resolves the
11
+ > name. `NameRefs::family`, over a token lookup that now carries the
12
+ > session, registers it through `Session::register_family`, the body
13
+ > `add_system_font` always had, moved where a parse holding the core's
14
+ > lookup can reach it. So Lua and Node resolve it in the one step they
15
+ > already resolve a `$token` in. A miss is remembered beside the token
16
+ > misses and raised by the same code. The schema gained `Kind::Family`
17
+ > and `Parsed::Family`. C keeps the index and the handle door (decision
18
+ > 5), and its parity sample is the stock family at index 1. `font` and
19
+ > `family` write the same field, so the last one declared wins, and the
20
+ > row's doc says so rather than "the handle wins".
21
+
22
+ `family` takes `sans`, `serif` or `mono`. Any other face is drawn with a
23
+ handle: the host registers the family by name (`Core::add_system_font`,
24
+ `addSystemFont`, `kui_font_add_system`) and hands the `FontId` down, and
25
+ the text names it as `font`. A Lua view cannot register anything (ADR
26
+ 0014: registering is the host's), so a script that shows an installed
27
+ face needs its host to register it and pass it in. kawoosh built that
28
+ door, `kawoosh.fonts.face(NAME)`. Its first cut registered a family
29
+ when a card first asked for it, at the next frame, so every card the
30
+ fonts pane scrolled to drew one frame in kui's mono and then in its own
31
+ face. The fix it shipped registers all 613 installed families at once,
32
+ 10 ms once in a debug build (`kawoosh/src/fonts.rs:7-12`,
33
+ `docs/design/fonts.md:48-60`).
34
+
35
+ ## Context
36
+
37
+ - **Registering a family by name loads nothing.** The font database
38
+ scans every installed face's metadata when it starts, which is how
39
+ `system_fonts` (F97) lists them without opening a file. So
40
+ `add_system_font` is a query against that scan and an idempotent
41
+ registry entry. The face's bytes are memory-mapped and shaped only
42
+ when a text first uses it, as for any face. Resolving a name in the
43
+ frame that declares it costs a lookup. The frame of mono in kawoosh
44
+ came from its host deferring the registration a frame, not from kui.
45
+ (Since DX24 the session's first registration of any font also maps
46
+ the installed files once, ~30 ms on a Mac, so the first frame to name
47
+ a family pays that; noted by the alpha.22 regression pass.)
48
+ - **`TextStyle` is `Copy`,** and views rely on that everywhere (a
49
+ closure capturing a style, `style` passed by value). A name cannot
50
+ live in it. A handle can, which is why `FontFamily::Custom(FontId)`
51
+ exists.
52
+ - **Every binding lowers a style where the core is at hand.** Lua's
53
+ parser runs inside `view` with the `Ui`. Node's frame is lowered by
54
+ the addon (`binary.rs`), with the window's `Core`. C builds a
55
+ `KuiTextStyle` itself and has `kui_font_add_system`. Rust has the
56
+ `Ui`.
57
+ - **The weights follow a family, not a handle.** RG59 reweighs text
58
+ already shaped when a face of its family is loaded, and F100
59
+ synthesizes bold for a family with none. Both key on the family. A
60
+ named family resolves to the handle `add_system_font` returns, so
61
+ both keep working.
62
+
63
+ ## Decisions
64
+
65
+ 1. **`family` takes an installed family's name.** `family = "Berkeley
66
+ Mono"` in Lua, `family="Berkeley Mono"` in JSX. The stock names
67
+ `sans`, `serif` and `mono`, lowercase, stay the stock families. Any
68
+ other string is a family name, matched as `add_system_font` matches
69
+ it: installed, or loaded with `load_fonts_dir` / `load_font_file`.
70
+ 2. **The name is resolved where the style is lowered, to the handle.**
71
+ The binding's lowering calls `Core::add_system_font(name)` and sets
72
+ `FontFamily::Custom(id)`, so the text shapes in its face in the frame
73
+ that declares it. `TextStyle` stays `Copy`, and nothing after
74
+ lowering knows a name was involved. The registry's answer is
75
+ idempotent. A name lowered every frame costs a lookup, and a
76
+ name→handle map in the session makes it constant if a profile asks.
77
+ 3. **A name nothing matches is a warning, and sans.** `unknown-family`,
78
+ keyed by the name, so a typo in a view drawn every frame is one line.
79
+ The text shapes as sans, as a stale handle does today.
80
+ 4. **Node carries the name on the wire.** The frame's `family` slot is
81
+ an index into `schema::FAMILIES`. A name needs the string table the
82
+ frame already has for text. So `family` becomes an index for the
83
+ stock three and a string for anything else, and the frame is v18.
84
+ The encoder and addon ship together, so this breaks only a stale
85
+ prebuilt addon, which refuses the stream by its version. The other
86
+ route is below, with why it is second.
87
+ 5. **Rust and C gain nothing new but a pass-through.** Both already hold
88
+ the door. `Ui::system_font(name) -> Option<FontId>` is added so a
89
+ Rust view needs no `core()` to reach it. C keeps
90
+ `kui_font_add_system` and `KuiTextStyle.font`.
91
+ 6. **The prop's type says it.** The schema row's kind becomes a family
92
+ kind, the three stock names or a string. Lua's meta types it
93
+ `"sans"|"serif"|"mono"|string`, and TS `'sans' | 'serif' | 'mono' |
94
+ (string & {})`, so an editor still offers the three.
95
+
96
+ ## Considered options
97
+
98
+ - **Leave it to the host** (today). It works for Rust, C and Node, which
99
+ can call the door. For Lua it means every host that shows a face
100
+ builds kawoosh's `fonts.face` and learns about its flicker.
101
+ - **A Lua `env.add_system_font` door.** It would let a script register.
102
+ But registering is the host's (ADR 0014), and a handle is a thing to
103
+ keep and pass around. A family by name has nothing to own or free,
104
+ like `family = "mono"`, so it can be a declaration, not a verb.
105
+ - **Node resolves in JS**, with `ctx.addSystemFont(name)` from the
106
+ encoder, cached per name, emitting the `font` handle the wire already
107
+ has. No frame version, but the encoder would need a context. Today
108
+ there is one encoder per module, built from `protocol()` alone
109
+ (`index.js:56`) and shared by every window and every test. A string on
110
+ the wire keeps it that way.
111
+ - **Load asynchronously and swap** (CSS's `font-display: swap`): draw a
112
+ fallback, register off the frame, reshape when ready. It answers a
113
+ cost that is not there (decision 2's context). The swap is the flicker
114
+ kawoosh removed.
115
+ - **A root `fonts = { … }` preload.** A second place to name a family,
116
+ for the same lookup the prop makes.
117
+
118
+ ## Consequences
119
+
120
+ - A Lua view draws an installed face by naming it. kawoosh's
121
+ `kawoosh.fonts.face` and its 613-family registration go, and the fonts
122
+ pane's cards say `family = name`.
123
+ - The Node frame is v18: one slot's encoding, under What breaks.
124
+ `schema::FAMILIES` and C's `KUI_FONT_*` are unchanged.
125
+ - One new warning, `unknown-family`, in `diag::WARNINGS`, the TS union
126
+ and the Lua docs.
127
+ - A family's handles and weights come from one registry. A family named
128
+ in a view and one registered by the host get the same handle,
129
+ pinned by a test.
130
+ - Built with a core test that a named family shapes in its face in the
131
+ frame that names it (no frame of sans), the warning for a name
132
+ nothing matches, a Lua and a Node scene naming a family the corpus
133
+ loads from its fixture font, and the reweigh (RG59) still following a
134
+ named family's new face.
@@ -0,0 +1,188 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-28
4
+ ---
5
+
6
+ # A scroll gesture latches its target
7
+
8
+ > **Accepted and built 2026-09-28**, the day it was proposed (backlog
9
+ > F107). Raised by kawoosh's todo of that day, three items about where
10
+ > the wheel goes. A swipe that moved the pane strip carried a terminal
11
+ > under the pointer, and the terminal took the rest of the swipe, so
12
+ > the strip stopped hard at every terminal it met. A new swipe over a
13
+ > list already at its end moved nothing, where the scroller around it
14
+ > could have moved. And a swipe over a scroller did not always go the
15
+ > scroller's way.
16
+ >
17
+ > **Amended by the alpha.22 regression pass (2026-09-28):** the pick
18
+ > walks the scroller's ancestors in the tree, not the regions painted
19
+ > under the pointer (decision 3), and reads what it needs off the region
20
+ > the last finished frame drew rather than off the tree, so a wheel
21
+ > event that arrives during a build routes by that frame.
22
+ >
23
+ > **Amended 2026-10-02 (backlog F117, from kawoosh's sticky-swipe
24
+ > report):** a finger put down begins a gesture, pause or none
25
+ > (decision 2). A swipe started while the last one still glided was the
26
+ > glide's gesture, its target and its axis, until a pause came.
27
+ >
28
+ > **Amended 2026-10-02 (backlog F118, from the same report):** a wheel
29
+ > handler that is also a scroll container on an axis is answered there
30
+ > by its room (decision 3), so a code view or a table at its sideways
31
+ > edge passes a swipe that way to the strip around it.
32
+
33
+ ## Context
34
+
35
+ - **Where a delta went.** `InputEvent::Scroll` walked the scroll
36
+ regions under the pointer, innermost by paint order first. Each
37
+ scroll container took the axes it scrolls, and the rest went on
38
+ (DX13, so a `scrollY` list hands `x` to the `scrollX` strip around
39
+ it). An `on_scroll` node took whatever was left and stopped the walk.
40
+ Every delta walked afresh, from wherever the pointer was at that
41
+ moment.
42
+ - **What moves under a still pointer.** A two-finger swipe does not
43
+ move the pointer. The strip scrolls under it, so a swipe that began
44
+ over a list ends over a terminal. kawoosh's terminal is an
45
+ `on_scroll` node that takes every delta for its history, so the
46
+ moment it arrived under the pointer it took the swipe.
47
+ - **What an `on_scroll` node cannot say.** The core keeps no offset for
48
+ it and cannot ask whether it has anywhere to go. A container it can
49
+ ask, from the last layout's geometry.
50
+ - **Where gestures begin.** The core is clock-free, as it is for
51
+ clicks. The native runner already has a clock and already tells one
52
+ swipe from the next: F104's axis lock ends a swipe after a 200 ms
53
+ pause. winit's `TouchPhase` is not usable for this alone. It names a
54
+ momentum run's start as it names a finger's (F117 tells them apart by
55
+ order, decision 2).
56
+ - **What browsers do.** A wheel or trackpad gesture latches to the
57
+ scroller it started on and keeps it until the gesture ends
58
+ (*latching*). The scroller is picked at the start as the innermost
59
+ one under the pointer that can scroll the way the gesture goes, so a
60
+ scroller at its limit passes the gesture to its ancestor
61
+ (*chaining*). A gesture that reaches a limit partway stops there.
62
+ CSS's `overscroll-behavior: contain` stops the chaining.
63
+
64
+ ## Decisions
65
+
66
+ 1. **A gesture is delimited by the driver.** `InputEvent::ScrollGesture
67
+ { delta, begins }` carries a delta and whether it is its gesture's
68
+ first. A bare `InputEvent::Scroll` is a gesture of its own, so a
69
+ driver that knows nothing of gestures (C's `kui_input_scroll`, Node's
70
+ `ctx.scroll`, the corpus) gets per-event targeting, now with
71
+ chaining. C has `kui_input_scroll_gesture(ctx, dx, dy, begins)` for a
72
+ host with its own loop.
73
+ 2. **The native runner begins a gesture** on its first event, after a
74
+ pause of 200 ms (F104's `GAP`), on a switch between line and pixel
75
+ deltas, and, for a wheel only, when the pointer moves.
76
+ - A wheel spun without a pause is one gesture, as in a browser, so a
77
+ list reaching its end under a spinning wheel does not hand the
78
+ rest to the page.
79
+ - Moving the mouse is aiming somewhere new, so it ends a wheel's
80
+ gesture.
81
+ - A swipe's momentum outlives the hand, and moving the pointer
82
+ during the glide must not move the glide somewhere else, so it
83
+ does not end a swipe's.
84
+ - An event the axis lock keeps to zero is not dispatched. The
85
+ beginning it carried is owed to the next one that is.
86
+ - *Amended 2026-10-02 (F117):* a finger put down begins a gesture
87
+ too, however soon after the last event, and ends the axis lock's
88
+ swipe. A `Started` within 100 ms of a finger's `Ended` is the
89
+ glide's (macOS starts it on the next event) and keeps the gesture;
90
+ every other `Started` is a finger's — on nothing lifted, or on a
91
+ glide, which macOS ends as the finger lands. Before this, a swipe
92
+ begun mid-glide was the glide's gesture until a 200 ms pause, which
93
+ a glide's events a frame apart never leave.
94
+ 3. **The target is picked per axis, when the gesture first moves on
95
+ that axis, and latched.**
96
+ - Picking starts at the topmost region under the pointer and walks
97
+ out through the regions around it in the tree (amended by the
98
+ alpha.22 regression pass: it walked every region under the pointer
99
+ by paint order, so a list at its end in a popover chained to a
100
+ page it merely floated over). The first to take the axis is the
101
+ target:
102
+ - an `on_scroll` node takes it if its `scroll_axes` names it,
103
+ whether or not it has anywhere to go — *amended 2026-10-02
104
+ (F118):* unless it scrolls that axis as a container too, its
105
+ offset the app's to set from what it hears; there the core can
106
+ ask, and it is answered as a container is, below;
107
+ - a container takes it if it scrolls on that axis and can still
108
+ move the way the delta goes (more than half a pixel of room,
109
+ measured from where the delta would be added);
110
+ - a container that says `overscroll: contain` takes it in any
111
+ case.
112
+ - Later events of the gesture go to the latched target, found by key
113
+ among the frame's scroll regions wherever the pointer or the
114
+ content has gone.
115
+ - A target that is gone from the frame, or is behind a modal now, is
116
+ dropped, and the axis is picked again under the pointer.
117
+ - An axis that found nothing stays unlatched, so a gesture that turns
118
+ back can still find a scroller.
119
+ - Per axis is what DX13's hand-off becomes: a list's `y` and the
120
+ strip's `x` are two targets. With F104's lock a swipe has one in
121
+ practice, and a lock that turns picks the new axis's target at the
122
+ turn.
123
+ 4. **No chaining partway through.** A latched container at its limit
124
+ keeps the gesture and moves nothing, as a browser's does, and turning
125
+ back within the gesture moves it again.
126
+ 5. **`overscroll: "auto" | "contain"`** (`Overscroll`,
127
+ `KUI_OVERSCROLL_*`) opts a container out of being chained past. It
128
+ holds only on the axes the container scrolls. So a contained
129
+ `scrollY` list still passes a sideways swipe to the strip, where CSS
130
+ would stop that too. kui's one-axis scrollers are
131
+ not scroll containers on their other axis.
132
+ 6. **`scrollAxes: "both" | "x" | "y"`** (`ScrollAxes`,
133
+ `KUI_SCROLL_AXES_*`) lets an `on_scroll` node decline an axis. A
134
+ gesture on an axis it does not take passes it by. This is how a node
135
+ the core cannot ask about says it does not need a gesture: a
136
+ terminal that scrolls only its history says `y`. It is declared
137
+ rather than answered per event, because the pick happens inside
138
+ `handle_input`, before any app code could answer.
139
+ 7. **Programmatic scrolls, scrollbar drags, keyboard scrolling, the
140
+ primary drag's edge scrolling and `reveal` are untouched.** Only the
141
+ wheel routes through the latch.
142
+
143
+ ## Considered options
144
+
145
+ - **Re-target on every event, and let `on_scroll` nodes decline by
146
+ axis only.** Declining alone fixes a y-only terminal met by a
147
+ sideways swipe, but a terminal that takes both axes, such as an
148
+ editor's text column, would still grab a strip swipe partway. The
149
+ report's item 3 is about the swipe being cut off, and only latching
150
+ fixes that.
151
+ - **An `on_scroll` node answers whether it can move.** An event whose
152
+ reply says "not mine" would need the app in the middle of dispatch,
153
+ and a frame's latency for a question the next delta has already
154
+ asked. `scrollAxes` covers the case that exists: a node that never
155
+ wants one axis.
156
+ - **Chain partway, when the target hits its limit.** This is what
157
+ browsers moved away from. A list flicked to its end would start
158
+ moving the page behind it mid-flick, which is the surprise item 2
159
+ asks to avoid by deciding at the start.
160
+ - **Keep the gesture clock in the core** (`Core::set_time`). The
161
+ frame clock ticks per frame, not per event, and a clock-free core
162
+ is the rule clicks already follow. The runner has the clock and
163
+ already draws F104's boundaries.
164
+ - **winit's `TouchPhase`.** It marks a momentum run as a new start, so
165
+ the momentum would re-target. The gap is what tells a glide from a
166
+ new swipe. *Amended (F117):* the gap alone kept a swipe begun
167
+ mid-glide in the glide's gesture; the phases' order now tells the
168
+ glide's `Started` from a finger's, and both are used.
169
+
170
+ ## Consequences
171
+
172
+ - A notch over a list at its end now moves the scroller around it (a
173
+ `Scroll` is its own gesture). That changes behaviour only where there
174
+ is one to move. kawoosh's lists sit in an `x`-only strip, so nothing
175
+ moves there.
176
+ - An `on_scroll` node in a `both`-axis layout hears a delta with one
177
+ component zero when the other axis is latched elsewhere, where it
178
+ heard the whole delta. F104's lock had already made that the
179
+ trackpad's case.
180
+ - `KuiSpec` gains `overscroll` and `scroll_axes` under the unreleased
181
+ ABI 20 (648 bytes on a 64-bit target). `InputEvent` gains a variant,
182
+ a Rust break for a host that matches it exhaustively.
183
+ - The thresholds are F104's, a first reading to be tuned on a trackpad.
184
+ - Pinned by `tests/scroll_gestures.rs` in kui-core, by
185
+ `scroll_gesture.rs`'s tests in kui-native, by `surface.c`'s check of
186
+ the C door, and by the corpus's `scroll-gestures` scene in four
187
+ adapters. The scene holds a contained list at its limit and a y-only
188
+ handler met by a sideways notch.