@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,601 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-09-04
4
+ amended: 2026-09-07
5
+ ---
6
+
7
+ # The paint vocabulary: group opacity and shadows in, gradients out, exit animations designed
8
+
9
+ > **Amended 2026-09-04, twice.** Exit animations are built, as
10
+ > `crates/kui-core/src/depart.rs` and one `exit` schema row. The design
11
+ > below held; two things changed, both recorded in
12
+ > [Amendment: exit animations, built](#amendment-exit-animations-built) —
13
+ > where the departing subtree is copied *from*, and the answer to the open
14
+ > question about `animating()`. And **rounded clipping is built too**: the
15
+ > consequence below prices it as "a bigger bill than either of these" on an
16
+ > estimate, and
17
+ > [Amendment: rounded clipping, built](#amendment-rounded-clipping-built)
18
+ > replaces the estimate with the measurement, which is about 1% of a frame
19
+ > that never clips. Both amendments are at the end of this document.
20
+ >
21
+ > **Amended again 2026-09-04:** the exit amendment's "No corpus scene, and
22
+ > why" is superseded — there is one, and the two corpus-protocol additions
23
+ > it declined (a clock step, a per-frame builder in Node) were built. See
24
+ > [the superseding note](#no-corpus-scene-and-why--superseded-there-is-one-backlog-b3).
25
+ >
26
+ > **Revisited 2026-09-07** by
27
+ > [ADR 0012](0012-the-exit-budget.md), which is what the placeholder policy
28
+ > below was waiting for. Two of its three answers hold and are now measured
29
+ > rather than assumed: `exit` stays opt-in, and there is still no duration
30
+ > cap. The third — what happens at the budget's edge — does not: the split
31
+ > is positional and its size depends on nodes-per-row, so a frame's
32
+ > departures will animate whole or not at all. `animating()` itself was the
33
+ > wrong thing to have worried about, and 0012 says so with the numbers.
34
+
35
+ `crates/kui-core/src/display.rs` was the whole renderer contract — fill,
36
+ border, four radii, glyph, image — and three things a real UI wants were
37
+ missing from it: **group opacity**, **shadows** and **gradients**. A fourth,
38
+ **exit animations**, was blocked on the first: `Enter` can fade a node's own
39
+ `bg` but not a subtree, so a panel could fade in and never out, and the
40
+ README said so ("a removed node vanishes at once").
41
+
42
+ We shipped **group opacity** as a `VisualStyle::opacity` multiplied into
43
+ every quad a subtree emits, and **drop shadows** as a new
44
+ `QuadKind::Shadow` carrying a blur radius, rendered by one more branch in
45
+ the existing SDF. We are **not** shipping gradients in v0. We are **not**
46
+ building exit animations yet, and the design below is what building them
47
+ would mean, written down so the next person starts from the shape of the
48
+ problem rather than from scratch.
49
+
50
+ ## Context
51
+
52
+ - **The quad struct is hot.** `benches/frame.rs` runs 10k-node frames;
53
+ anything added to `Quad` is paid 20,000 times a frame. That argues for
54
+ fields that many quads use, and against fields one kind uses — and it is
55
+ why `opacity` is not on the quad at all.
56
+ - **Every addition costs four bindings.** A plain `PROPS` row is nearly
57
+ free: Lua looks it up by snake\_name, Node parses it by kind, the TS types
58
+ and `docs/props.md` regenerate, and only `KuiSpec` needs a field, which
59
+ `every_schema_prop_has_a_c_counterpart` then forces. A *composite* is
60
+ hand-written four times. Six plain rows beat one composite here.
61
+ - **The frame model is immediate.** `Tree` is rebuilt every frame from what
62
+ the view declares; the only thing that survives a frame is keyed retained
63
+ state (`AnimStore`, `ScrollStore`, `EditStore`), and none of it holds a
64
+ *node*. A node the view stops declaring is gone before `finish_frame`
65
+ runs. That is the whole difficulty of exit animations.
66
+ - **`Enter` already proves the pattern.** A first-sighted node's slots start
67
+ somewhere other than where the view declares them, stated as data. Its
68
+ mirror image — where a *last*-sighted node's slots end — needs the node to
69
+ still exist, which is a different kind of change.
70
+
71
+ ## Decision
72
+
73
+ ### Group opacity: a spec slot, multiplied at emission
74
+
75
+ `VisualStyle::opacity` (0..=1, default 1) is inherited multiplicatively down
76
+ the tree in `finish_frame`'s existing pass 1, beside the clip propagation
77
+ and gated the same way (`any_opacity`, so a frame that fades nothing pays
78
+ one branch). `emit_node` records where its quads begin and multiplies the
79
+ inherited product into `color.a` and `border_color.a` of everything it
80
+ pushed. Scrollbars and the focus ring fade with the node they belong to.
81
+
82
+ Consequences we accept:
83
+
84
+ - **It is not a composite.** Overlapping pieces of one subtree double-blend
85
+ where a real offscreen composite would not — the classic seam through a
86
+ faded stack of translucent boxes. Compositing means a render target per
87
+ faded subtree, a second pass, and a `DisplayList` that stops being one
88
+ flat list; that is a renderer redesign, not a paint prop. The doc comment
89
+ and `docs/props.md` say this outright rather than letting someone discover
90
+ it.
91
+ - **Nothing but paint changes.** A faded subtree still lays out, still takes
92
+ clicks, and is still read out by assistive technology — CSS's rule for
93
+ `opacity: 0`, and the one that makes a fade-*out* usable at all, since the
94
+ thing being faded out is normally still interactive on the way.
95
+ - **It animates.** `Slot::Opacity` joins the tween slots, `Keyframe::opacity`
96
+ and `Enter::opacity` join their parse tables, so `enter: { opacity: 0 }`
97
+ with a `transition` fades a whole panel in. `KuiEnter` / `KuiKeyframe`
98
+ gain a `set` bit each.
99
+ - **C needs `opacity_set`.** A zeroed `KuiSpec` is the schema default, and
100
+ the default is 1, so 0 cannot double as "unset" the way every color field
101
+ does. The bit is the price of keeping a fully transparent subtree
102
+ expressible from C.
103
+
104
+ ### Shadows: a `QuadKind`, not a nine-slice
105
+
106
+ `QuadKind::Shadow` plus one `blur: f32` on `Quad`. The core emits the
107
+ shadow as the node's rect moved by `dx`/`dy`, grown by `spread`, inflated by
108
+ `blur` on every side; the shader insets by `blur` again to recover the
109
+ shape, and ramps the edge over it with the `sd_rounded_box` it already has
110
+ for rounded rects. Five plain schema rows (`shadowColor`, `shadowBlur`,
111
+ `shadowX`, `shadowY`, `shadowSpread`) and two tween slots (the geometry as
112
+ one four-vector, the color as another).
113
+
114
+ Against the nine-slice: it needs a blurred texture per (radius, blur) pair
115
+ in the glyph atlas, an atlas eviction policy for something that is not a
116
+ glyph, nine quads where this needs one, and seams to get right at
117
+ fractional scales — all to avoid about fifteen lines of WGSL. The ramp is a
118
+ smoothstep rather than a true Gaussian, which at UI blur radii does not
119
+ read; if it ever does, the fix is a better curve in one shader function,
120
+ not a different architecture.
121
+
122
+ Scope kept deliberately small: **outer shadows only** (no inset), **one per
123
+ node**, and the shape is **not knocked out of the middle** — CSS clips the
124
+ box out of its own shadow, and a translucent background here shows the
125
+ shadow through itself. Each of those is a note in the doc comment, not a
126
+ silent surprise.
127
+
128
+ ### Gradients: out of scope for v0
129
+
130
+ Two colors and a direction sound like one more `PROPS` row and are not. A
131
+ gradient needs a stop list (so: a `Keyframes`-shaped parse, in five
132
+ bindings), a type (linear, radial, conic), a geometry (angle or two points,
133
+ in the node's own space), and a decision about interpolation space that
134
+ looks arbitrary in every choice. On the quad it is at minimum a second
135
+ color, a direction, and a discriminant; via the atlas it is a texture per
136
+ distinct gradient. Neither is a paint prop's worth of work, and the
137
+ workaround — an image, or a stack of solids — exists. The README says v0
138
+ has no gradients rather than leaving a reader to infer it.
139
+
140
+ ### Exit animations: the design, not the build
141
+
142
+ Not built. What building it means:
143
+
144
+ 1. **A departing node is retained, not re-declared.** `finish_frame` diffs
145
+ this frame's keys against the last frame's. A key that was present, is
146
+ absent now, and whose last spec declared both a `transition` and an
147
+ `exit` becomes a *departing subtree*: its specs, contents and laid-out
148
+ rects are copied out of the old `Tree` into a `DepartStore` keyed by
149
+ `Key`, alongside the frame time it left.
150
+ 2. **It is replayed, not re-laid-out.** On every later frame the departing
151
+ subtree is appended after the live tree with its rects frozen at the
152
+ moment it left, its `exit` slots eased from where they were toward what
153
+ `exit` declares (`Enter` in reverse — `dx`/`dy`, size, `bg`, `radius`,
154
+ `opacity`), and emitted like a float: on top, escaping ancestor clips,
155
+ because its ancestors may be gone. Freezing is the key simplification —
156
+ a dying node must not fight the live layout for space, and CSS's own
157
+ exit transitions work the same way (the element is out of flow the
158
+ moment it is removed).
159
+ 3. **It is inert.** No hit region, no place in the Tab ring, no access row —
160
+ it is a picture of a node, not a node. A departing subtree that held
161
+ focus has already given it up, by the same path a removed modal does.
162
+ 4. **It is dropped when its transition ends**, or when the same key comes
163
+ back (the live node wins immediately and the ghost is discarded, so a
164
+ toast dismissed and re-shown does not double), or after a frame budget —
165
+ `AnimStore`'s existing `EVICT_AFTER_FRAMES` sweep is the model, and the
166
+ store must be bounded, since a view that removes a thousand rows at once
167
+ must not retain a thousand subtrees indefinitely.
168
+ 5. **What it costs.** `Tree` grows a way to be sliced and copied, which it
169
+ does not have today; `finish_frame` grows a key diff over the previous
170
+ frame (a `FxHashSet` of the last frame's keys, or a generation stamp per
171
+ key in the existing map); and every emission pass has to tolerate nodes
172
+ whose parents do not exist. The spec grows one `exit: Option<Enter>`
173
+ field, which needs no new parse code — an exit *is* an `Enter`, read the
174
+ other way — so the four bindings cost one row.
175
+
176
+ The open question, and the reason this is written down rather than
177
+ built: **what a departing subtree does to `animating()`**. The driver
178
+ schedules frames while anything is mid-flight; a ghost keeps that true for
179
+ its whole duration, which is correct but means removing a node now costs
180
+ frames. That is fine for a dialog and wrong for a list that drops a
181
+ thousand rows, and the policy — cap the ghosts, cap the duration, or make
182
+ `exit` opt-in per node as designed here — should be decided with a real
183
+ view in front of us. **Answered in the amendment below: all three of the
184
+ first and none of the second.**
185
+
186
+ ## Considered options
187
+
188
+ - **Opacity as a `Quad` field** rather than pre-multiplied at emission.
189
+ Rejected: four bytes on the hot struct and a multiply in every backend, to
190
+ express something the alpha channel already expresses. Pre-multiplying
191
+ costs one pass over the quads a *faded* node emitted, and nothing at all
192
+ otherwise.
193
+ - **Opacity as a true composite** (render target per faded subtree).
194
+ Rejected for v0: it is the correct rendering and the wrong amount of
195
+ work, and it would make `DisplayList` a tree instead of a list, which is
196
+ the one thing the renderer boundary promises it is not.
197
+ - **Shadows as a nine-slice** out of the glyph atlas. Rejected above.
198
+ - **Shadows as a composite prop** (`shadow = { color, blur, x, y, spread }`).
199
+ Rejected: five plain rows are free in three of four bindings; one
200
+ composite is hand-written in all four and needs a conformance scene to
201
+ keep them honest. The Rust builder still offers `shadow(Shadow { .. })`
202
+ for the whole thing at once.
203
+ - **Exit animations by keeping the whole previous `Tree`** and diffing
204
+ frame to frame. Rejected as the starting point: it doubles the retained
205
+ frame state to serve the rare case, where a keyed `DepartStore` pays only
206
+ for nodes that actually declared an `exit`.
207
+
208
+ ## Consequences
209
+
210
+ - `Quad` grows one `f32` (104 → 108 bytes) and `QuadKind` one variant. Both
211
+ are ABI: `KuiQuad` mirrors the layout field for field, so the C example's
212
+ digest mirror and the JS decoder shifted with it. Measured on the 10k-node
213
+ benches, the growth is not visible above run-to-run noise, and a 10k-node
214
+ frame where *every* node casts a shadow under a faded root
215
+ (`frame_10k_rects_with_shadows_and_opacity`, 20k quads) costs about what
216
+ the plain 10k-quad frame costs.
217
+ - The `kinds` line of the conformance report grew a sixth column, and
218
+ `Expect` a `shadows` count; the `layout` scene's card now carries both new
219
+ props, so all four bindings reproduce them or fail.
220
+ - `docs/props.md` gains six rows and `KuiSpec` seven fields (appended, so
221
+ the C ABI stays additive).
222
+ - Rounded clipping is not addressed here: `Quad::clip` is a rect, so a
223
+ rounded scroll container does not round its children. Four more floats on
224
+ the hot struct plus a second SDF per fragment looked like a bigger bill
225
+ than either of these, and it was left in the backlog with that price on
226
+ it. **Superseded by
227
+ [Amendment: rounded clipping, built](#amendment-rounded-clipping-built)**,
228
+ which measured the bill instead of estimating it: the four floats are
229
+ invisible, the plumbing around them is about 1% of a frame that never
230
+ clips, and it is built.
231
+
232
+ ## Amendment: exit animations, built
233
+
234
+ Built as designed, with two corrections and one answer. The view they were
235
+ decided against is `examples/rust/toasts.rs`, which now has an `exit`
236
+ on every toast and on the panel, and a "clear" button that drops the whole
237
+ stack in one frame.
238
+
239
+ ### The answer: `exit` is opt-in, the store is bounded, and the duration is the view's
240
+
241
+ > **Partly superseded by [ADR 0012](0012-the-exit-budget.md)
242
+ > (2026-09-07)**, which measured this policy against a view that reaches
243
+ > the boundary — something no view in this repo had ever done. The first
244
+ > and third bullets stand. The second does not: "what is refused vanishes
245
+ > at once, which is *exactly* what a node with no `exit` does" is true of a
246
+ > subtree and false of a view, because a 1000-row list gets its first 512
247
+ > rows animated and the rest dropped, which is a third behaviour neither
248
+ > policy produces. The claim in the paragraph below it — that the 1000-row
249
+ > list "animates its first 512 nodes' worth of rows out and drops the rest
250
+ > at once, having said so" — is exactly right, and is the defect.
251
+
252
+ The open question was what a ghost does to `animating()`, with three
253
+ candidate policies. The answer is that two of them are needed and the third
254
+ is not:
255
+
256
+ - **`exit` is opt-in per node, and needs a `transition` as well.** This is
257
+ the primary control and it costs nothing: a node that declares neither is
258
+ never a candidate, is never copied, and never keeps a frame owed. It also
259
+ puts the decision where the knowledge is — a toast wants to leave
260
+ visibly, a table row does not, and only the view knows which it is
261
+ looking at.
262
+ - **The store is bounded, over nodes.** `depart::MAX_NODES` is 512;
263
+ a departure that would take the store past it is refused, and what is
264
+ refused vanishes at once — which is *exactly* what a node with no `exit`
265
+ does, so the failure mode is the old behaviour rather than a wrong one.
266
+ The budget is over nodes rather than subtrees because a node is what a
267
+ replayed frame pays for: one dialog of forty nodes and forty toasts of
268
+ one are the same bill.
269
+ Silently, though, "only some of my rows animated" reads as a bug, so the
270
+ first refusal of a frame raises an `exit-budget` warning through the
271
+ ordinary diagnostics path, with the sentence that fixes it (put `exit` on
272
+ the list, not on every row).
273
+ - **The duration is not capped.** A ghost runs for its own node's
274
+ `transition`, and a view that declares a ten-second transition gets ten
275
+ seconds of frames — which is already true of every live transition, so a
276
+ cap here would be a second, inconsistent rule for the same declaration.
277
+ What *is* bounded is the store's patience with a driver that stops
278
+ advancing its clock: an unreplayed ghost is swept after
279
+ `EVICT_AFTER_FRAMES`, the same backstop `AnimStore` keeps. And a driver
280
+ with no clock at all gets no ghosts: every transition snaps without one,
281
+ and an exit that snaps is the plain disappearance it always was.
282
+
283
+ So `animating()` stays honestly true while a ghost is in flight, and the
284
+ frames that costs are bounded by 512 nodes times whatever the view asked
285
+ for. The 1000-row list of the original worry animates its first 512 nodes'
286
+ worth of rows out and drops the rest at once, having said so.
287
+
288
+ ### Correction: the previous frame is kept, not re-copied
289
+
290
+ Step 1 above says `finish_frame` copies the departing subtree "out of the
291
+ old `Tree`". There is no old `Tree` at that point: `begin_frame` clears it
292
+ before the view builds, so by the time a frame can notice a node missing,
293
+ the node is gone. The "Considered options" list rejected *keeping the whole
294
+ previous `Tree`* as the starting point, on the grounds that it doubles the
295
+ retained frame state to serve the rare case.
296
+
297
+ It does not have to be doubled unconditionally. `Core` holds two tree
298
+ buffers and `begin_frame` **swaps** them instead of clearing one — but only
299
+ when the frame that just ended declared an `exit` somewhere (`any_exit`,
300
+ alongside `any_clip`, `any_float` and the rest). A frame with no exits
301
+ clears the spare, so a stale tree can never be diffed against, and the cost
302
+ of the feature to an app that does not use it is one bool. To an app that
303
+ does, it is an allocation that already existed, reused a frame later
304
+ instead of immediately.
305
+
306
+ The text list goes with it. A text node holds a `TextId` into the frame's
307
+ text list, which is rebuilt every frame, so a subtree copied out of the
308
+ previous tree carries ids that only the previous list can resolve —
309
+ resolving them against the current one draws whatever text happens to sit at
310
+ that index. `TextSystem` keeps its previous list on the same condition and
311
+ in the same way, and the ghost stores the shaped buffer's cache key rather
312
+ than the id, so what it replays is its own text for as long as the text
313
+ cache still has it (and nothing, rather than someone else's, once it does
314
+ not).
315
+
316
+ The diff itself is then two flat `Vec<Key>` compared. Almost every frame
317
+ declares the same nodes in the same order, so that comparison is the whole
318
+ diff; only a frame that actually changed shape pays for the set of watched
319
+ keys (the previous frame's exit-declaring roots, plus the roots already
320
+ departing) and the per-node walk, which is one AND against a 64-bit
321
+ membership mask and a hash lookup only for the few keys that collide with
322
+ it.
323
+
324
+ ### Correction: a ghost eases itself
325
+
326
+ Step 2 says the exit slots are "eased from where they were", which reads
327
+ like an `AnimStore` tween. It is one lerp instead. Nothing can retarget a
328
+ ghost — the view has stopped talking about it, so its target cannot change
329
+ — and a retained tween exists to survive retargets. The ghost keeps the
330
+ clock reading it left at and the duration and easing its last spec
331
+ declared, and interpolates. Spring easings sample as ease-out, the
332
+ substitution `AnimStore::sample` already makes for a keyframed slot, for
333
+ the same reason: there is no leg to carry momentum across.
334
+
335
+ One limit falls out of freezing: `exit`'s `width` / `height` resize the
336
+ departing *root's* own box, and nothing inside it moves, because re-laying
337
+ out a frozen subtree is the one thing the design rules out. It is a note in
338
+ the doc comment and in `docs/props.md` rather than a surprise.
339
+
340
+ ### Correction: a ghost keeps its place (2026-09-05)
341
+
342
+ Step 2 says a ghost is "emitted like a float: on top", and the build did
343
+ that — every ghost after every live quad. It looked wrong the first time a
344
+ float left from under another float: the toasts example's side panel is
345
+ declared before its latency HUD, so the HUD is painted over the panel, and
346
+ for the 420ms of its exit the panel's ghost jumped *over* the HUD. There is
347
+ no z-index in kui — floats stack in tree order — and a picture of a node
348
+ that changes layer for its last few frames is not a picture of that node.
349
+
350
+ A ghost now keeps the place its node painted in. The departure records a
351
+ `Place`: which pass the root was painted in (in flow, or among the floats)
352
+ and the key of the first node painted after it in that pass that the frame
353
+ noticing it gone still declares. The passes paint each ghost just under
354
+ that node when they reach it, and a ghost whose node is gone ends its pass
355
+ — after the in-flow content it was among, still under every float. When
356
+ the node a ghost sits under departs too, the ghost takes the place that
357
+ node's ghost takes, behind it, so neighbours leaving one after the other
358
+ keep their order. Unclipped stays: the ancestors may still be gone.
359
+
360
+ What it needs is a fragment of the previous frame's paint order, built
361
+ once on a frame that has a departure: the in-float bit per node and, per
362
+ pass, the next still-declared node at or after each index. That is two
363
+ linear walks over the previous tree and one hash set of this frame's keys,
364
+ on frames that already changed shape and paid for a layout. The diff moved
365
+ from after the passes to before them for it, which nothing else noticed.
366
+ What it does not do is pin the *other* direction: a node declared later
367
+ than the ghost's neighbour in the new frame paints over the ghost, as it
368
+ would have painted over the node.
369
+
370
+ Benched the same way as the table below, on the same day's machine:
371
+ `drop_1k_rows_declaring_exit` 260 → 253 µs fastest and
372
+ `replay_a_full_depart_store` 12.9 → 12.8 µs, both within noise — but only
373
+ once the hand-on walk (a departure passing its place to the ghosts under
374
+ it) was gated by a membership mask over the ghosts' `before` keys. Ungated,
375
+ a mass removal walked the whole store per row and the same bench read
376
+ 305 µs: the one quadratic corner the design has, and it is 64 bits of mask.
377
+
378
+ ### What it cost
379
+
380
+ Measured on `crates/kui-core/benches/frame.rs`, fastest of 100 samples on one
381
+ machine (the medians drift 15% with thermal state; the fastest does not),
382
+ against the same benches at the commit before.
383
+
384
+ **What everybody pays**, whether or not they use it:
385
+
386
+ | frame | before | after |
387
+ |---|---|---|
388
+ | `frame_10k_rects` (no `exit` anywhere) | 1.20 ms | 1.26 ms |
389
+ | `frame_1k_typical` | 157 µs | 164 µs |
390
+
391
+ That ~5% is not the diff, which never runs there — it is `NodeSpec` growing
392
+ an `Option<Enter>`, 60 bytes on a struct the tree holds one of per node
393
+ (668 → 728 bytes, so 600 KB more tree at 10k nodes). It is the one cost here
394
+ that lands on frames that declare nothing, and it is worth naming what would
395
+ buy it back if `NodeSpec` grows another of these: boxing `enter` and `exit`
396
+ together behind one pointer, which recovers about half of it and puts an
397
+ allocation per declaring node per frame on the views that use them. Measured,
398
+ not guessed — it was tried. Not worth it for one slot; the next slot is where
399
+ it becomes worth it.
400
+
401
+ **What the feature itself costs**, from the new benches:
402
+
403
+ | frame | fastest |
404
+ |---|---|
405
+ | `frame_10k_rects_one_exit` — one dialog with an `exit` in a 10k-node frame | 1.28 ms (vs 1.26 ms with none) |
406
+ | `frame_10k_rects_all_transitioning` — 10k nodes with a `transition` | 2.65 ms |
407
+ | `frame_10k_rects_all_declaring_exit` — the same 10k, each also with an `exit` | 3.2 ms |
408
+ | `drop_1k_rows_plain` — 1000 rows built, then dropped | 96 µs |
409
+ | `drop_1k_rows_declaring_exit` — the same, every row with an `exit` | 274 µs |
410
+ | `replay_a_full_depart_store` — a full 512-node store replayed over an empty frame | 12 µs |
411
+
412
+ The realistic shape — a dialog with an `exit` inside a big app — costs under
413
+ 2% of the frame it is in, even though keeping the previous frame is per frame
414
+ rather than per exit. The pathological one — `exit` on all 10k cells of a grid
415
+ — costs about 21% over the same grid's transitions, and is the declaration
416
+ the `exit-budget` warning exists to argue with. A mass removal costs about
417
+ 180 µs over the plain one for the budget's worth of subtree copies, once, on
418
+ the frame it happens; replaying them afterwards is 12 µs a frame, which is
419
+ what makes "keep drawing until the exit finishes" affordable at all.
420
+
421
+ ### No corpus scene, and why — **superseded: there is one (backlog B3)**
422
+
423
+ The original argument, kept because half of it still holds:
424
+
425
+ > `exit` is a plain `PROPS` row of the kind three of the four bindings lower
426
+ > by table lookup: Lua reads it by snake\_name, Node's encoder takes its id
427
+ > straight off the protocol, and C — the one that maps by hand — is forced by
428
+ > `every_schema_prop_has_a_c_counterpart`, which fails the build for a row
429
+ > with no `KuiSpec` field. Node's own sweep ("every generic schema prop
430
+ > reaches the stream and lowers") covers the wire. What is left is the ghost,
431
+ > and that is entirely `kui-core`: there is no per-binding behaviour for a
432
+ > scene to catch four of.
433
+ >
434
+ > Pinning it in the corpus anyway would need two changes to the corpus
435
+ > protocol that serve nothing else. A **clock step**, because without one
436
+ > every transition snaps and there is no ghost to see, in all four adapters.
437
+ > And a **per-frame builder** in the Node adapter, which builds its scene tree
438
+ > once and reuses it for every frame — a departure is a tree that changed, so
439
+ > a scene that never changes its tree cannot express one. Both are
440
+ > worthwhile if a second cross-binding behaviour ever wants a clock; neither
441
+ > is worth doing for a row that is already forced twice.
442
+
443
+ What the argument got wrong is the sentence "there is no per-binding
444
+ behaviour for a scene to catch". The corpus does not only compare
445
+ *lowerings*; it compares quads, access rows and events over driven steps,
446
+ and `exit` is the first feature where those three disagree with each other
447
+ on purpose — the ghost draws, and is at the same time absent from the hit
448
+ regions, the Tab ring and the access tree. Nothing but a scene checks that
449
+ the three stay out of step in the same way in four languages, and "this
450
+ row lowers, therefore this row behaves" is exactly the inference this
451
+ corpus exists because the project stopped making.
452
+
453
+ The two protocol changes were built rather than avoided, and both are
454
+ smaller than they read: `Step::Phase(n)` leaves a number the builder is a
455
+ function of (every other scene ignores it), `Step::Time(ms)` sets the frame
456
+ clock, and neither is an input, so each adapter grew one arm that does not
457
+ call `handle_input`. The Node adapter now builds its tree per frame, with
458
+ the corpus fixtures registered once on the first build that asks for them,
459
+ so its handles are still the ones `conformance::fixtures` hands out.
460
+
461
+ The `exit` scene ends its steps 80 ms into a 400 ms exit, because a report
462
+ keeps one frame and that is the only frame where all four claims differ at
463
+ once — the same lesson the `modal` scene wrote down about the Tab ring.
464
+ `fade` is mid-flight (it draws, with the text from the *previous* frame's
465
+ text list, which is the other half of the buffer swap); `blink` ran 50 ms
466
+ and is over; `flash` left and came back while its own exit was running, so
467
+ the frame holds one picture of it and not two; and `bulk` is one node past
468
+ the budget, so it is refused whole with an `exit-budget` warning. The
469
+ inertness is three separate observations in that frame: a press on ground
470
+ covered by both where the node was and where its ghost now is emits nothing,
471
+ where the earlier press on the live node emitted `hit`; two Tabs walk from
472
+ `A` to `B`; and the access tree lists neither.
473
+
474
+ Honest outcome: **it caught nothing**. All four adapters agreed on the first
475
+ run that compiled. That is the expected result for a mechanically lowered
476
+ row, and it is not the reason to keep the scene — the reason is that the
477
+ next change to `depart.rs` now has four readers instead of one.
478
+ `crates/kui-core/tests/exit.rs` still holds the finer-grained behaviour,
479
+ including the toast-stack shape the policy was decided against.
480
+
481
+ ## Amendment: rounded clipping, built
482
+
483
+ The consequence above left rounded clipping in the backlog with a price on
484
+ it: "four more floats on the hot struct plus a second SDF per fragment is a
485
+ bigger bill than either of these". The price was an estimate. It has now
486
+ been measured, and it is smaller than the estimate implied — smaller, in
487
+ particular, than the 5% every frame already pays for `NodeSpec::exit` — so
488
+ it is built.
489
+
490
+ `Quad` grows `clip_radius: [f32; 4]` (108 → 124 bytes) and `display::Clip`
491
+ is the rect-plus-radii pair that the clip propagation, `Paint`, and the text
492
+ and editor emitters now carry instead of a bare `Rect`. A node that clips
493
+ (`clip`, `scroll_x`, `scroll_y`) and has a `radius` rounds the clip its
494
+ descendants inherit — no new prop, in any of the four bindings, because the
495
+ radius is the clipping node's own and the rule is CSS's.
496
+
497
+ ### What it cost
498
+
499
+ Measured on `crates/kui-core/benches/frame.rs`, fastest of 100 samples over
500
+ 14 interleaved rounds on one machine, against the same benches built from
501
+ the commit before — the *same bench file* on both sides, including the two
502
+ benches added for this, so the ~5% a bench binary moves by gaining functions
503
+ is not being read as a result. Two copies of the baseline binary ran in the
504
+ same rounds; they differ by −0.6% to +0.4%, which is the method's noise floor
505
+ and the number every row below has to beat to mean anything.
506
+
507
+ | frame | before | after | |
508
+ |---|---|---|---|
509
+ | `frame_10k_rects` (nothing clips) | 1.269 ms | 1.281 ms | +0.9% |
510
+ | `frame_10k_rects_with_text_and_hits` | 1.663 ms | 1.682 ms | +1.1% |
511
+ | `frame_10k_rects_with_shadows_and_opacity` | 1.384 ms | 1.398 ms | +1.0% |
512
+ | `frame_1k_typical` | 163 µs | 166 µs | +1.5% |
513
+ | `list_10k_rows_naive` (a real scroll container) | 4.93 ms | 4.98 ms | +1.0% |
514
+ | `frame_10k_rects_square_clip` (100 clipping rows) | 1.289 ms | 1.334 ms | +3.5% |
515
+ | `frame_10k_rects_rounded_clip` (the same, rounded) | 1.284 ms | 1.346 ms | +4.8% |
516
+
517
+ Read it as three numbers. **About 1% is what everybody pays**, whether or
518
+ not anything clips: sixteen more bytes on `Quad` and one more field written
519
+ per quad, 20,000 times a frame. **About 3.5% is what a frame that clips
520
+ pays**, because a `Clip` is 32 bytes where a `Rect` was 16 and it travels
521
+ through the propagation pass, `Paint`, and every emitter. And **the rounding
522
+ itself is the last 1.3 points** — inside the new build, the rounded frame is
523
+ 0.9% slower than the square one (1.346 vs 1.334 ms), which is the per-corner
524
+ [`Clip::intersect`] running on all 10k nodes in the shape a real view never
525
+ declares: it rounds the card, not each of its rows.
526
+
527
+ The four floats, on their own, were measured first and separately, against a
528
+ one-float `clip_radius` that could not express a per-corner radius: at
529
+ 108 → 112 vs 108 → 124 bytes the two were indistinguishable from each other
530
+ and from the baseline. The generality is free; it was the plumbing around it
531
+ that cost the 1%.
532
+
533
+ The shader side is one more `sd_rounded_box` per fragment, taken **instead
534
+ of** the rect test rather than after it, and only on the branch where
535
+ `clip_radius` is not all zero — which it is on every quad of every frame
536
+ that has no rounded clipper. The value comes off the instance, so the branch
537
+ is uniform across a quad's fragments. That is not measured on a GPU here;
538
+ what is checked is that both preprocessed variants of the shader still
539
+ validate (`shader_variants_validate`) and that the shape the shader cuts is
540
+ the shape the core described, by mirroring `shade`'s clip block on the CPU
541
+ and evaluating it over quads a real `Core` emitted
542
+ (`crates/kui-wgpu/tests/rounded_clip_coverage.rs`, beside the shadow work's
543
+ `shadow_coverage.rs` — both now share one transcription of the WGSL in
544
+ `tests/wgsl`).
545
+
546
+ ### One rounded rect cannot name the intersection of two
547
+
548
+ The clip a node inherits is one rect and four radii, and clippers nest, so
549
+ the intersection of two rounded rects has to collapse into that shape.
550
+ `Clip::intersect` decides **per corner**: a corner takes whichever of the
551
+ two shapes rounds it more — the intersection of two rounded corners is the
552
+ tighter one, so that is exact — and keeps a radius at all only while that
553
+ corner of the result is still the same point as that corner of the shape it
554
+ came from. A corner an ancestor's straight edge has already cut away is
555
+ square, which is what the ancestor made it, so that is exact too.
556
+
557
+ The one case it approximates: an ancestor edge that cuts *partway* into a
558
+ rounded corner moves that corner, so the radius drops to zero there and a
559
+ sliver at the very corner goes unclipped. It takes two rounded clippers
560
+ offset from each other to build. The alternative is carrying the rounding
561
+ shape's own rect alongside the intersection — eight more floats rather than
562
+ four, to serve a shape nobody has drawn yet.
563
+
564
+ ### What is deliberately not rounded
565
+
566
+ - **Hit testing.** `HitRegion::clip` stays a rect: a click in the corner of
567
+ a rounded scroll container still reaches the row under it. Browsers do the
568
+ same for descendants of a rounded `overflow: hidden`, and the alternative
569
+ is a per-corner test in the hot input path for a few pixels.
570
+ - **Culling.** The "entirely clipped away" test in `finish_frame` is still
571
+ the rect intersection, so a node that survives only inside a corner's arc
572
+ is emitted and clipped rather than dropped. Cheaper, and never wrong in
573
+ the visible direction.
574
+ - **A clipper's own quads.** `clips[i]` is ancestors only, as it always was,
575
+ so a rounded card is not clipped by itself — it draws its own rounded
576
+ shape through `radius`, which is the same shape.
577
+ - **Scrollbars**, for the same reason: a container does not round away its
578
+ own bar.
579
+
580
+ ### The option not taken, and what would buy it back
581
+
582
+ The cheapest version of this feature makes `Quad` *smaller*: replace
583
+ `clip: Rect` with a `clip_id: u32` into a `DisplayList::clips` side table
584
+ (there is one entry per clipping node, not per quad), which is 96 bytes with
585
+ full rounded clipping instead of 124 without. It was not taken because it
586
+ breaks what the renderer boundary promises: a quad stops being
587
+ self-contained, every backend grows a second array to bind, and a C host
588
+ reading `KuiDrawData.quads` has to resolve an index. If `Quad` ever has to
589
+ grow again, that is the trade to reopen — and it would pay for the growth
590
+ twice over.
591
+
592
+ ### ABI
593
+
594
+ `KuiQuad` gained the field and `KUI_ABI_VERSION` went 1 → 2, which is
595
+ exactly the rule ADR 0006 wrote down: `KuiQuad` is a **[lib]** struct, the
596
+ host strides the array with its own `sizeof`, and there is no in-band
597
+ handshake to catch a mismatch. `conformance::quad_digest` hashes the new
598
+ words (0..=18 and 23..=30 of a now 31-word struct), so a binding whose
599
+ mirror of `KuiQuad` missed the field fails every scene rather than none —
600
+ and the corpus's `overflow` scene now rounds its scrolling list, so those
601
+ words are not all zero.