@qxuken/kui 0.1.0-alpha.44 → 0.1.0-alpha.46

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.
package/CHANGELOG.md CHANGED
@@ -21,6 +21,281 @@ listed under both (backlog F61, from the alpha.12 field reports: the list
21
21
  is what the release knows it broke, and a fix it did not think of as one
22
22
  was the first bare bump to break an app in five releases).
23
23
 
24
+ ## 0.1.0-alpha.46 (2026-10-09)
25
+
26
+ **What breaks.**
27
+
28
+ - C ABI 27 (under Added, F131): `KuiClip` grows from eight words to
29
+ twenty — `transform`, `inner` and `inner_radius` — so a host that
30
+ strides `KuiDrawData.clips` reads the new stride and the conformance
31
+ digest hashes the whole entry; `KuiSpec` appends `rotate`, `scale`,
32
+ `pivot_set`, `pivot_x`, `pivot_y` and `iterations` (F133; 64-bit size
33
+ 744), `KuiKeyframe` and `KuiEnter` append `rotate` and `scale`, and
34
+ `KuiKeyframe` `dx` and `dy` with `KUI_KF_OFFSET` (strides 52 and 48;
35
+ F132). `KuiSpec` holds `KuiEnter` twice by value, as `enter` and
36
+ `exit`, so its fields after `enter` move by 8 bytes and those after
37
+ `exit` by 16: recompile, and re-derive the offsets of a hand-written
38
+ mirror (ctypes, Zig, an Odin of its own) rather than append to it. A
39
+ zeroed field is what every spec, stop and entrance had before. Node's
40
+ `clips()` buffer has the same stride, and `decodeClips` reads
41
+ `transform`, `inner` and `innerRadii`. A renderer of its own that
42
+ ignores the new words draws a turned subtree upright.
43
+ - Rust struct literals: none of these is `#[non_exhaustive]`, so a
44
+ literal that names every field gains the new ones or a `..` base —
45
+ `Clip` (`transform`, `inner`, `inner_radius`; it has no `Default`,
46
+ so `..Clip::NONE`), `Keyframe` (`dx`, `dy`), `Slots` and `Enter`
47
+ (`rotate`, `scale`), `AnimSpec::iterations`,
48
+ `InteractSpec::transform`, `NodeInfo` (`rotate`, `scale`),
49
+ `HitRegion::turn`, `ScrollRegion::turn` and `EnvFacts::now`.
50
+ - Node's `WarningCode` union gains `'ambiguous-name'` (F137): a
51
+ `switch` over it that TypeScript checks for exhaustiveness names it.
52
+
53
+ ### Added
54
+
55
+ - **A node turns about its pivot** (backlog F131, from berainder's
56
+ review; [ADR 0043](docs/adr/0043-a-node-turns-about-its-pivot.md)).
57
+ `rotate` (turns, clockwise), `scale` (a uniform factor) and
58
+ `pivotX` / `pivotY` (fractions of the box, the centre by default) on
59
+ any node — `NodeSpec::rotate` / `scale` / `pivot` in Rust, the rows in
60
+ JSX and Lua, `KuiSpec.rotate` / `scale` / `pivot_set` with
61
+ `KUI_PIVOT_X` / `KUI_PIVOT_Y` in C (ABI 27), the generated `Spec`
62
+ fields in Odin. Paint-only: the node takes the room its upright self
63
+ takes and `onLayout` reports the layout rect; everything the subtree
64
+ draws turns with it — backgrounds, borders, shadows, text, images,
65
+ strokes, fragments — and so does what it clips, so a photo stays
66
+ inside a tilted card's rounded corners. Hit where drawn: a tilted
67
+ card is grabbed on its tilted edge, a press in its box past its edge
68
+ falls through, a scroller inside a turn takes the wheel where it is
69
+ drawn; the access rect is the bounding box. Turns nest by
70
+ composition; a float anchored to its parent turns with it, a viewport
71
+ float does not. A turn and a scale are one slot that tweens with
72
+ `transition` — a card follows the pointer while a drag holds the
73
+ transition off and springs back when it is on — and an entrance, an
74
+ exit and a keyframe stop name `rotate` and `scale` (`enter: { scale:
75
+ 0.8 }`, `keyframes: [{ rotate: 0 }, { rotate: 1 }]` spins a box); a
76
+ departing subtree keeps its turn. On the wire the clip entry carries
77
+ the space (`Clip::transform`, `inner`, `inner_radius`); the quad does
78
+ not change. A `path`'s own `rotate` keeps ADR 0041's meaning and
79
+ composes under the node's. The `transform` example.
80
+ - `Transform`, the similarity a clip entry carries, with `about`,
81
+ `apply`, `unapply`, `then`, `bounds`; `Clip::turned`, `turned_by`,
82
+ `visible`, `shown`; `HitTurn` on a hit region and a scroll region;
83
+ `NodeInfo::rotate` / `scale` in the devtools' facts (F131).
84
+ - **A keyframe stop names a position** (backlog F132, from berainder's
85
+ review). `dx` / `dy` on a stop — `Keyframe::dx` / `dy` / `offset` in
86
+ Rust, the stop's fields in JSX and Lua, `KuiKeyframe.dx` / `dy` with
87
+ `KUI_KF_OFFSET` in C (ABI 27), `Keyframe.dx` / `dy` with `.Offset` in
88
+ Odin — are logical px from where layout put the node, as an entrance's
89
+ are: the node and its subtree are drawn, hit and read by assistive
90
+ technology that far away at the stop, a lane a stop leaves out is 0,
91
+ and the offset adds to a `slide`'s. `[{ dy: 0 }, { dy: -6 }]` with
92
+ `repeat: 'alternate'` bobs a box; three stops drift a sparkle up as it
93
+ fades. Paint, hit and access only: the room the node takes is its
94
+ place's, and an `onLayout` node reports its layout rect, not a cycle
95
+ that would post an event every frame it ran. The `transition` example
96
+ has a bob and a sparkle.
97
+ - **A keyframe cycle plays so many times** (backlog F133, from
98
+ berainder's review). `iterations` — CSS's `animation-iteration-count`:
99
+ `NodeSpec::iterations` in Rust, the row in JSX and Lua, `KuiSpec.iterations`
100
+ in C (0 is for ever; ABI 27), the generated `Spec.iterations` in Odin.
101
+ Left out, a cycle runs for ever and reads the clock as it always did,
102
+ so siblings stay in phase. A finite one plays from the first frame its
103
+ node is declared with it, holds its first stop through its `delay`,
104
+ and rests where its last iteration ended (CSS's fill `both`); then it
105
+ owes no frame, so `animating()`, `owed()` and the frame trace go quiet
106
+ as a settled transition's do. `1` plays a burst or a shake once, `2`
107
+ alternating goes out and back, `0.5` stops halfway; `delay` plus
108
+ `iterations: 1` staggers one-shots. A node that leaves and comes back
109
+ plays again, and so does one whose stops the view drops for a frame
110
+ and declares again: that is how a view replays it.
111
+ - **The view reads the frame clock** (backlog F134). `Ui::now` /
112
+ `Core::now` in Rust, `env.now` in Lua, `kui_now` in C, `now` in Odin,
113
+ `ctx.now()` in Node: the driver's seconds that `transition` and
114
+ `keyframes` read this frame, 0 before a driver sets one. A deadline
115
+ read off it — a toast's expiry, a sequence's beats — agrees with the
116
+ core's easing, and a test's `advance` moves both.
117
+ - **A frame asked for at a time** (backlog F135). `Ui::request_frame_at`
118
+ / `Core::request_frame_at` and `Core::next_frame_at` in Rust,
119
+ `env.request_frame_at` in Lua, `kui_request_frame_at` /
120
+ `kui_next_frame_at` in C (`INFINITY` for none), the same doors in Odin,
121
+ `ctx.requestFrameAt` / `nextFrameAt` in Node; `Waker::wake_at(Instant)`
122
+ for a thread. On the frame clock: the runner sleeps to the earliest
123
+ time asked for and runs the view, with nothing owed until then, so
124
+ `animating()` stays false. A time already past is a frame now; the
125
+ time is kept until a frame reaches it.
126
+ - **The exit named at the removal** (backlog F136). `Ui::exit_with` /
127
+ `Core::set_exit` in Rust, `env.exit_with` in Lua, `kui_exit_with` in
128
+ C, `exit_with` in Odin, `ctx.exitWith` in Node: the exit a node leaves
129
+ by if it leaves in the frame that finishes next, over the one its
130
+ last frame declared, so the handler that removes a card says which way
131
+ it goes. It lapses when that frame finishes; it aims a node that
132
+ declares an `exit`, with that node's `transition`.
133
+ - **A lookup by accessible name** (backlog F137). `Core::key_named` /
134
+ `Drive::key_named` in Rust, `kui_key_named` in C, `key_named` in Odin,
135
+ `ctx.keyNamed` in Node: the first node in the last frame whose
136
+ accessible name — its `label`, else a control's text — is the one
137
+ asked for, so a test presses "the button named Like" as a reader would.
138
+ Two with one name raise the new `ambiguous-name` warning. The docs now
139
+ say "key label" for what `key_of` and `texts_under` read, the name the
140
+ view opened a node under, and "accessible name" for the `label` row.
141
+ - **The runner decodes images, GIFs included** (backlog F138).
142
+ `kui_native::decode_image(bytes)` turns PNG, JPEG, WebP or GIF bytes
143
+ into straight RGBA and a size, the shape `add_image` takes;
144
+ `decode_animation` keeps every frame of an animated GIF, APNG or WebP,
145
+ composited to the whole canvas, with its delay and the loop count, and
146
+ `Animation::at(elapsed)` says which frame shows and when the next is
147
+ due, so a GIF plays on the frame clock through `update_image_with` and
148
+ `request_frame_at` with nothing owed between steps.
149
+ `Launcher::icon_bytes` takes an icon from a PNG
150
+ (`Launcher::try_icon_bytes` for bytes from outside the program). C: `kui_decode_image`,
151
+ `kui_decode_animation`, `kui_animation_at` and `kui_pixels_free`, with
152
+ `runner`; Odin: `decode_image`, `decode_animation`, `animation_at`,
153
+ copied into the context allocator; Node: `decodeImage`,
154
+ `decodeAnimation` and `animationAt` on the package. The runner's
155
+ `image` gains its `gif` feature. The `decode` example plays a shipped
156
+ spinner.
157
+
158
+ **What you can delete.**
159
+
160
+ - A width and a height tweened against each other to fake a card's
161
+ tilt, and a `path` drawn in a box's place so that it could turn
162
+ (F131).
163
+ - A float declared where a drifting thing ends, entering from where it
164
+ starts over a transition as long as its life, to move it along a path
165
+ (F132): its stops say the path.
166
+ - A clock of the app's own and `request_frame` on every frame to time a
167
+ sequence that plays once — a burst, a pop, a row of stars — and the
168
+ frame owed for good by a cycle the app meant to stop (F133).
169
+ - A clock of the app's own, beside the core's, that a test had to push
170
+ forward by hand (F134): read `now()`.
171
+ - A thread per deadline that sleeps and then calls `Waker::wake` (F135):
172
+ `request_frame_at`, or `Waker::wake_at` from off the view.
173
+ - A frame drawn with the exit aimed before the frame that removes the
174
+ card, and the model field and `request_frame` that schedule it
175
+ (F136): `exit_with` in the handler.
176
+ - The `image` dependency an app added to decode its photos, its version
177
+ pin and the `[profile.dev.package."*"]` line that made it fast, and a
178
+ GIF crate beside it (F138): `decode_image` and `decode_animation`.
179
+
180
+ ### Native verification
181
+
182
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-09, over
183
+ F131–F138 from the berainder review, with alpha.46's pre-tag pass over
184
+ it on the Mac: the mechanical round as CI runs it, the windowed round,
185
+ the accessibility audit and the bench guard, then three read-only
186
+ reviewers of the diff since alpha.45 — the core and the renderer, the
187
+ bindings, the docs — each claim probed. What they found was corrected
188
+ before the tag (the backlog's alpha.46 pre-tag section lists it), and
189
+ one entry, RG155 — the caret inside a turned node — is open.
190
+
191
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
192
+ scripts/test.nu`: **1997 tests over 149 suites**, 0 failed. The C round
193
+ passes (5 checks), and so do the **58 scenes** through Rust, Lua, C and
194
+ Node; Node's tests under `KUI_CONFORMANCE_REQUIRED=1`, **224 of 224**;
195
+ `npm run gen` with no diff, the examples' typecheck, the headless round,
196
+ the book, and `cargo audit` over the lockfile the decoder grew. The
197
+ windowed round with Node's: **55 examples on both bases**, clean on a
198
+ first run, twice. The accessibility audit: **106 of 106**. The bench
199
+ guard against the alpha.45 tag: **green**, the eight guarded rows −0.9%
200
+ to +0.9%; the unguarded exit rows read +2% to +4%, the 16 bytes an
201
+ entrance and an exit grew by. The Odin steps did not run: this
202
+ machine's Odin links an LLVM that is gone, so the hand-written Odin
203
+ layer this round needed is checked by CI's `odin.nu` steps on the tag,
204
+ and its layout was compared by script against the C side's asserts. No
205
+ Windows or Linux machine ran this round.
206
+
207
+ ## 0.1.0-alpha.45 (2026-10-08)
208
+
209
+ **What breaks.**
210
+
211
+ - A menu row whose accelerator would leave its label under 48 px — a
212
+ window narrower than the accelerator with its gaps — draws the
213
+ accelerator cut short with "…" and the label's first glyphs, where it
214
+ drew the whole accelerator past the panel's edge and no label (under
215
+ Fixed, RG154).
216
+ - A `menu_panel` floating in the viewport whose caller declared its
217
+ ceiling as a size expression (`max_width(Bound::Calc(..))`, `"50%"`)
218
+ bounds its labels by that ceiling read against the window, where it
219
+ bounded them by the window alone (under Fixed, RG154). A panel
220
+ anchored elsewhere keeps the window as its labels' bound.
221
+ - A menu bar the app leaves out of a frame while a submenu switch waits
222
+ its 0.3 s starts the wait again when the bar is back, where the switch
223
+ went through at once on the next build (under Fixed, RG154).
224
+ - Windows and Plasma: a `Blur` or `Tinted` window on a desktop with no
225
+ wallpaper kui can read is `Opaque` from its first frame again, as it
226
+ was in alpha.43; alpha.44's `Tinted`-then-`Opaque` is GNOME's alone
227
+ now (under Fixed, RG154).
228
+
229
+ ### Fixed
230
+
231
+ - **The accelerator is bounded too** (backlog RG154, from the alpha.44
232
+ pre-tag pass). A row's label was what the panel's width bounded, and
233
+ its accelerator was drawn whole: in a window narrower than the
234
+ accelerator the label shrank to nothing and the accelerator ran past
235
+ the panel, which is what RG150 set out to stop. The label keeps a
236
+ floor of 48 px (`widgets::MENU_LABEL_MIN`) and the accelerator takes
237
+ what that leaves, ending in "…". And a ceiling declared as a size
238
+ expression on a panel floating in the viewport now caps the labels as
239
+ a px one does, read against the window; a panel anchored to a node
240
+ keeps the window's ceiling for its labels, since the room layout will
241
+ read the expression against is not placed when the rows are built.
242
+ - **A menu bar the app stops drawing owes no frames** (backlog RG154).
243
+ A submenu switch waiting its 0.3 s was cleared by the next build of the
244
+ bar's menu; an app that stopped drawing the bar inside the wait (or
245
+ handed it to the platform) kept the switch, and the frames it owed,
246
+ for good. A frame that builds no menu on a surface drops the switch
247
+ waiting there.
248
+ - **Windows and Plasma: no wallpaper, opaque from the first frame**
249
+ (backlog RG154). RG150 moved finding the wallpaper's path to the
250
+ loader's thread for GNOME's sake, where it is up to three `gsettings`
251
+ processes; on Windows it is one call and on Plasma one file read, so
252
+ the loop asks there and a window with none to draw never reads
253
+ `Tinted` first. GNOME keeps the thread. Windows run on screen;
254
+ Plasma compiled and read.
255
+
256
+ **What you can delete.**
257
+
258
+ - A shorter accelerator chosen for a menu that has to fit a narrow
259
+ window (RG154).
260
+ - A frame an app requested itself after stopping its menu bar, to let
261
+ the core settle (RG154).
262
+
263
+ ### Native verification
264
+
265
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-08, over
266
+ RG154 — what alpha.44's pre-tag pass left — with alpha.45's pre-tag pass
267
+ over it on the Windows machine and under WSLg: the mechanical round on
268
+ both, then a read-only review of the diff since alpha.44, each claim
269
+ probed. It filed nothing; its one finding, a `Calc` ceiling read against
270
+ the window for a panel anchored elsewhere, was corrected inside RG154
271
+ before the tag.
272
+
273
+ **Windows**, the pre-tag pass. fmt and clippy are clean; `nu
274
+ scripts/test.nu --node`: **2148 tests over 143 suites**, 0 failed. The
275
+ C round passes (6 checks), and so do the **57 scenes** through Rust,
276
+ Lua, C, Node and Odin; the Odin binding's four steps with CI's pinned
277
+ `dev-2026-09`; Node's tests under `KUI_CONFORMANCE_REQUIRED=1` (**218 of
278
+ 219**, the one skip Windows'), `npm run gen` with no diff, the
279
+ examples' typecheck and the headless round. The windowed round with
280
+ Node's: **53 examples on both bases**, clean on a first run; `counter`
281
+ and `host` opened by hand after `cbuild`, and the backdrop examples
282
+ opened with and without `KUI_BACKDROP_EMULATE`. The bench guard against
283
+ the alpha.44 tag, on a quiet machine: **green**, the eight guarded rows
284
+ −0.7% to +2.1% (`frame_10k_rects_with_access_tree` the high one), the
285
+ worst run-to-run spread 3.5%. A first run beside the WSL round read two
286
+ rows unreadable at 13 and 21% spread, which is why it was run again.
287
+
288
+ **Linux**, under WSLg (llvmpipe), the same commit: fmt and clippy
289
+ clean; `cargo test --workspace`: **1926 tests over 142 suites**, 0
290
+ failed; the C round (5 checks) and the 57 scenes through every adapter,
291
+ Node **219 of 219** with the corpus required, gen clean, the typecheck,
292
+ the headless round. The windowed round under X11 with Node's: **53
293
+ examples on both bases**, clean on a first run, two at a time. The
294
+ backdrop examples under X11 read no wallpaper (WSLg has none to name)
295
+ and went `Opaque`, as they should; Plasma's on-the-loop read is
296
+ compiled and read, not run. No Mac ran this round: the AX audit is CI's
297
+ and the next Mac round's.
298
+
24
299
  ## 0.1.0-alpha.44 (2026-10-08)
25
300
 
26
301
  **What breaks.**
@@ -143,7 +143,9 @@ date: 2026-10-05
143
143
  framebuffer space, a scroller inside a turned box scrolls along a
144
144
  tilted axis, hit-testing inverts a matrix per ancestor, and the access
145
145
  tree's rects become bounding boxes. Not built here, and this does not
146
- rule it out: a path's own `rotate` would compose under it.
146
+ rule it out: a path's own `rotate` would compose under it. *Built as
147
+ [ADR 0043](0043-a-node-turns-about-its-pivot.md) on 2026-10-08, with
148
+ the path's turn composing under the node's as said.*
147
149
  - **A stock arc fragment** (an SDF sector, angles in its params, as
148
150
  `polygon` is a stock fragment). A turn is then two floats a frame and
149
151
  no raster, exact at any angle. ADR 0040 declined a `sector` element
@@ -0,0 +1,370 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-10-08
4
+ ---
5
+
6
+ # A node turns about its pivot: `rotate` and `scale` on any node, paint-only, carried by the clip entry
7
+
8
+ > **Accepted and built 2026-10-08**, the day it was proposed; the
9
+ > *Amendment* at the end records what the building changed. Asked by
10
+ > berainder (backlog F131): a card that tilts a few degrees towards the
11
+ > side it is being dragged to is the swipe's whole look, and the app
12
+ > tweened `width` and `height` instead and called it depth. [ADR
13
+ > 0041](0041-a-mask-turns-about-its-centre.md) gave `rotate` to a
14
+ > `path` alone and listed "a transform on any node" under *Considered
15
+ > options* as "the general thing, and a different document" — every
16
+ > quad kind turns, glyphs leave the pixel grid, the clip stops being a
17
+ > rect in framebuffer space, a scroller inside a turned box scrolls
18
+ > along a tilted axis, hit-testing inverts a matrix per ancestor, the
19
+ > access tree's rects become bounding boxes. This is that document. It
20
+ > takes each of those costs, decides which to pay and which to decline,
21
+ > and lands on a turn and a uniform scale about a pivot, **paint-only**:
22
+ > layout is what it was, and everything after layout — the quads, the
23
+ > clip, the hits, the access rect — is drawn, cut, hit and read through
24
+ > the transform. [ADR 0005](0005-the-paint-vocabulary.md)'s paint
25
+ > vocabulary gains two slots and no quad kind; ADR 0041's path turn
26
+ > composes under this one and is otherwise untouched.
27
+
28
+ ## Context
29
+
30
+ - **What a turning box costs today: everything.** No node has a
31
+ transform. `slide`, `enter` and `exit` move and fade; ADR 0025 gave
32
+ layout a scale factor (the DPI), not a rotation; backlog V7 declined
33
+ a layout `zoom` — a subtree laid out in its own logical space — until
34
+ a second app asks for it, and berainder did not: it asked for the
35
+ *look* of a tilt, with the card taking the room its upright self
36
+ takes. A view that wants one today fakes it (width and height eased
37
+ against each other), or draws the card as a `path` (which cannot
38
+ hold a photo or text), or goes to a framework with CSS transforms.
39
+ - **The vertex stage already turns.** ADR 0041 put a path's turn in the
40
+ quad: the `blur` slot carries radians for kinds 1 and 3, and
41
+ `vs_main` turns the four corners about the quad's centre
42
+ (`shader.wgsl`). `local` and `uv` stay the quad's own, so the fragment
43
+ stage's SDF, border, radii and image sampling come out right on a
44
+ turned quad without knowing it turned. The one thing that does not
45
+ turn is the clip, tested against the framebuffer position.
46
+ - **The clip is already a table.** Since ABI 11 a quad names a
47
+ [`Clip`] entry in `DisplayList::clips` rather than carrying its rect
48
+ (`display.rs`): one entry per *distinct* clip a frame reaches, a
49
+ handful, interned by run length as emission walks paint order. Every
50
+ quad under one card names the same entry. An entry is the space a
51
+ quad is painted in; today that space is "framebuffer, cut to this
52
+ rounded rect".
53
+ - **Hits are by shape.** ADR 0026 made a region's hit test the rect and
54
+ then a shape inside it — rounded corners, a stroke's pieces, a
55
+ polygon, a path's outline — in the region's own coordinates
56
+ (`input.rs`, `contains`). A region's test is local already; what it
57
+ lacks is the step from the pointer to local.
58
+ - **The slots are a list.** `anim::Slot` names nine things a transition
59
+ tweens; an entrance and a keyframe stop name five of them through
60
+ `Slots` (`slots.rs`), parsed in one place for every binding. A tenth
61
+ slot is a row in that enum, a lane in that struct and a branch in
62
+ `ease_transitioning`.
63
+ - **What a turn is for, in the apps this repo has seen.** A card that
64
+ tilts with a drag and springs back; a pulse (`scale` 1 → 1.05 → 1 on
65
+ a cycle); a wobble (`rotate` ±0.01 on a cycle); a spinner that is a
66
+ box and not an arc; a chip that enters at `scale: 0.8` and settles;
67
+ an icon that flips. Every one is a turn or a uniform scale about a
68
+ point in the box, and every one wants to **tween**. None is a skew,
69
+ a matrix or a 3D flip.
70
+
71
+ ## Decisions
72
+
73
+ 1. **`rotate`, `scale`, `pivotX`, `pivotY` on any node.** `rotate` in
74
+ turns, clockwise with y down, 0 for none — the unit a `path`'s
75
+ `rotate` and a gradient's `angle` already take. `scale` a uniform
76
+ factor, 1 for none. `pivotX`/`pivotY` fractions of the node's box,
77
+ `0.5, 0.5` — the centre — by default, the point the turn and the
78
+ scale are about. In Rust `NodeSpec::rotate(turns)`, `scale(f)`,
79
+ `pivot(fx, fy)`; JSX `rotate`, `scale`, `pivotX`, `pivotY`; Lua
80
+ their snake case; C `rotate`, `scale` (0 is 1), `pivot_set`,
81
+ `pivot_x`, `pivot_y` on `KuiSpec`; Odin the generated `Spec` fields.
82
+ Container rows — a box, an image, an edit, a `line`, a `polygon`;
83
+ on a `text` they are dropped with the `unknown-prop` warning every
84
+ container row on a text raises (put them on the box around it). On
85
+ a `path`, `rotate` and `pivot` keep ADR 0041's meaning — the path's
86
+ own turn about a point in `d`'s coordinates, which does not tween —
87
+ and `scale` is the node's; a path that wants a tweened turn is put
88
+ in a box. The two should become one; see *Open questions*.
89
+ 2. **Paint-only.** Layout does not see the transform: the node takes
90
+ the room its upright self takes, its siblings do not move, a scroller
91
+ around it scrolls the untransformed content size, `onLayout` reports
92
+ the layout rect, `rect_of` and `layout_of` answer it. A transform is
93
+ `opacity`'s kind of row — something applied after layout to
94
+ everything the subtree draws — and not `width`'s. V7's layout zoom
95
+ stays declined on its own terms; nothing here is it.
96
+ 3. **A node's whole subtree turns with it, about the node's pivot.**
97
+ Every quad the subtree emits — backgrounds, borders, shadows,
98
+ glyphs, images, fragments, segments, masks — is drawn through the
99
+ same similarity: `pixel = R(angle) · scale · p + t`, with `t` chosen
100
+ so the pivot stays put. Transforms **nest** by composition: a node
101
+ turned inside a turned node turns about its own pivot in its
102
+ parent's turned space. A float anchored to its parent turns with the
103
+ parent as its content does; a viewport float does not; a float
104
+ anchored to a node by key (the devtools' tabs) does not.
105
+ 4. **The clip entry carries the space.** [`Clip`] gains three fields:
106
+ `transform` — angle in radians, scale, `tx`, `ty`, the similarity
107
+ every quad naming the entry is drawn through — and `inner` with
108
+ `inner_radius`, a second clip in the quad's own space, before the
109
+ transform. The entry's `rect` and `radius` stay what they were: the
110
+ clip in framebuffer space. A clipping node *outside* any turn
111
+ narrows `rect`; one *inside* a turn narrows `inner`, since its box is
112
+ in the turned space. Between two nested turns a clip is approximated
113
+ by its bounding box in the inner space (its corners drop to square);
114
+ a tilted card inside a tilted card inside a scroller is the one
115
+ shape that pays, by a sliver at a corner. A frame with no transform
116
+ interns exactly the entries it interned before, with the identity
117
+ transform and no inner clip, so the run-length intern and every
118
+ backend that ignores the new fields are unchanged.
119
+ 5. **The shader turns the corners and tests two clips.** `vs_main`
120
+ applies the entry's transform after a path's own turn (decision 1 of
121
+ ADR 0041 composes under it, as that document said it would), and
122
+ passes the pre-transform position down as a varying. The fragment
123
+ stage tests the framebuffer clip against the framebuffer position,
124
+ as before, and the inner clip against the pre-transform position —
125
+ an affine function of position, so the interpolated varying is
126
+ exact, and no inverse is computed per pixel. A segment's capsule SDF
127
+ reads the pre-transform position too, so a turned stroke is a
128
+ stroke. The instance grows by three `vec4`s (48 bytes); the quad on
129
+ the wire does not change. A fragment pipeline's epilogue
130
+ (`fragment::EPILOGUE`) tests the inner clip the same way, so a
131
+ `fragment` inside a turned card is cut by the card.
132
+ 6. **Hit where drawn.** A region under a transform carries it and the
133
+ inner clip; `contains` unprojects the pointer into the node's space
134
+ and tests the rect, the shape and the inner clip there, and the
135
+ framebuffer clip with the pointer as it is. A tilted card is grabbed
136
+ on its tilted edge; its rounded corners still miss; a press in its
137
+ box off its outline falls through. Drag payloads stay in viewport
138
+ px — what a drag means to the app that moves the card is where the
139
+ pointer is on screen. A scroll region inside a turned node is hit
140
+ the same way, so the wheel over a tilted list scrolls it; what moves
141
+ is the content along the node's own axes, since the offset is
142
+ applied in layout and the turn after.
143
+ 7. **The access rect is the bounding box.** Assistive technology gets
144
+ an axis-aligned rect, so a turned node's is the bounding box of its
145
+ turned layout rect, cut to the framebuffer clip as every rect is.
146
+ Reading order and actions are untouched.
147
+ 8. **A turn and a scale are one slot, and it tweens.** `Slot::Transform`
148
+ carries `[rotate, scale, 0, 0]`: with a `transition` a card follows
149
+ the pointer while a drag holds the transition off and springs back
150
+ when it is on, as `slide` does for position. An entrance names
151
+ `rotate` and `scale` (`enter: { scale: 0.8 }` settles a chip in), an
152
+ exit names them (`exit: { rotate: 0.1, scale: 0 }` spins a card
153
+ away), a keyframe stop names them (`keyframes: [{ scale: 1.05, at:
154
+ 0.5 }]` pulses; `[{ rotate: 0 }, { rotate: 1 }]` spins a box) —
155
+ through `Slots`, so every binding gets them from the one parser. A
156
+ lane a stop or an entrance leaves out is the node's own value,
157
+ which for `scale` is 1 and not 0: a stop that names only `rotate`
158
+ does not shrink the box.
159
+ 9. **A ghost keeps its turn.** A departing subtree is replayed with the
160
+ transform its nodes declared, the root's eased toward the exit's;
161
+ the turns of ancestors outside the picture — which may be gone — are
162
+ not replayed, as their clips are not.
163
+ 10. **What is declined.** A matrix, skew, 3D, a per-axis scale, a
164
+ transform origin outside the box (`pivot` past 0..1 is allowed and
165
+ means what it says; it is not clamped). CSS's `transform` is the
166
+ general thing; a turn and a uniform scale are what every app has
167
+ asked for, and each is one float that tweens. A `backdropBlur`
168
+ under a turn blurs its upright box (the blur reads the framebuffer
169
+ region); `pixelSnap` snaps the layout rect before the turn, so a
170
+ turned snapped box is a turned box. Both are said in `props.md`.
171
+
172
+ ## Considered options
173
+
174
+ - **The transform on the quad.** Four floats on every quad of every
175
+ frame, for a feature almost no quad uses; and the inner clip has no
176
+ room there at all. The clip table exists for exactly this reason
177
+ (ABI 11), and a quad under a turned card already names the entry its
178
+ siblings do. Not taken.
179
+ - **Rasterize the subtree offscreen and draw the texture turned.** One
180
+ quad, exact clipping, and the way a browser composites a transformed
181
+ layer. It costs a render target per turned subtree per frame, a copy
182
+ of every pixel under it, and text rendered to a texture and
183
+ resampled — blurry at any angle, which is the one thing a glyph
184
+ turned by its own quad is not. The backdrop pass (F129) does this
185
+ for a blur because a blur needs the pixels; a turn does not.
186
+ - **A transform on the clip only, hits and access left upright.** Half
187
+ the build and a lie: a tilted card hit on its upright rect is grabbed
188
+ in the air beside its corner. ADR 0026's shapes make the honest
189
+ version cheap.
190
+ - **Unify the path's `rotate` with this one now.** The path's turn
191
+ would become a node transform, its `pivot` a point in the box, the
192
+ square box ADR 0041 sweeps unnecessary, and its turn would tween for
193
+ free. It is the right end state and the wrong day: `path` is
194
+ released, its `pivot` is in `d`'s coordinates by a decision this
195
+ repo argued, and `path_coverage.rs` pins the sweep. Left open below.
196
+ - **A per-axis scale (`scaleX`/`scaleY`).** A flip (`scaleX: -1`) is
197
+ the one use, and a flip of a box with text in it mirrors the text.
198
+ The slot's lanes have room for it if an app asks.
199
+
200
+ ## Consequences
201
+
202
+ - **A tilt is one row and it tweens.** berainder's card is
203
+ `rotate(lean * 0.03)` with its transition held off while dragging,
204
+ and the spring back is the transition it already has.
205
+ - **The instance grows.** Three `vec4`s per quad in `kui-wgpu`: half a
206
+ megabyte more upload on a 10,000-quad frame, a few microseconds.
207
+ Nothing on the core's side of the wire grows per quad; the clip
208
+ table's entries grow from 32 to 80 bytes, and a frame has a handful.
209
+ - **The C ABI bumps to 27.** `KuiClip` grows (a `[lib]` struct a host
210
+ strides with its own `sizeof`), `KuiSpec`, `KuiEnter` and
211
+ `KuiKeyframe` gain fields, and the conformance digest hashes the
212
+ whole clip entry. The Node `clips()` buffer's stride grows with it;
213
+ `decodeClips` reads the new fields.
214
+ - **Every binding gets the four rows and the two slot lanes** from the
215
+ schema and the one slot parser; the Odin layer regenerates its
216
+ `Spec`, `Enter`, `Keyframe` and `Clip`.
217
+ - **Glyphs leave the pixel grid under a turn**, as a turned path's
218
+ mask does: a turned text is resampled at its angle and reads
219
+ softer. A text that must stay crisp is not turned. Under a scale, the
220
+ SDF ramps scale with the box, so a box scaled up has an edge
221
+ `scale` times as soft; a UI scale stays near 1.
222
+ - **The devtools' facts show `rotate` and `scale`** beside `opacity`.
223
+
224
+ ## Open questions
225
+
226
+ - **The path's own turn.** When a release can break `path`, its
227
+ `rotate` should be this one — tweened, composed, about a pivot in
228
+ the box — and `pivot` should take the box's fractions as every other
229
+ node's does; ADR 0041's square sweep goes with it. Condition: the
230
+ first app that wants a path's turn to tween.
231
+ - **Text sharpness under a turn.** A glyph mask is bilinear-sampled at
232
+ its angle. If a turned paragraph is ever asked for — not a card with
233
+ a word on it — the answer is a supersampled mask or a rasterization
234
+ at the angle, both of which the atlas's turn-bin rejection in ADR
235
+ 0041 already priced.
236
+
237
+ ## Measurements to take
238
+
239
+ - `frame_10k_rects` and the guarded `frame` rows: the transform walk is
240
+ behind `Tree::any_transform`, so a frame with none should read as it
241
+ did (the guard allows the noise floor).
242
+ - A frame of 1,000 turned boxes, each its own clip entry, against the
243
+ same frame upright: the cost of the per-node compose and the
244
+ bounding-box cull. Not yet taken (RG155).
245
+
246
+ ## Amendment: what the building changed (2026-10-08)
247
+
248
+ - **`KuiSpec` is 744 bytes, not 728.** The five appended fields land
249
+ after the 8-byte-aligned tail, and the parity test said so.
250
+ - **`pivot_set` is two bits.** A C host setting one axis — the parity
251
+ test's one-row-at-a-time check was the first — needs to say which, so
252
+ `KUI_PIVOT_X` and `KUI_PIVOT_Y` are bits as `KUI_VALUE_*` are, and the
253
+ Odin lowering ORs them in. `scale` stays "0 is 1".
254
+ - **The slot bits are `KUI_KF_ROTATE` 64, `KUI_KF_SCALE` 128,
255
+ `KUI_ENTER_ROTATE` 64, `KUI_ENTER_SCALE` 128.**
256
+ - **A glyph cull reads the clip in the quad's space.** The text, cell
257
+ and editor painters compared glyph positions against the entry's
258
+ framebuffer rect; under a turn that is the wrong space. They read
259
+ `Clip::visible` — the inner clip narrowed by the outer one pulled
260
+ back through the transform — which is the rect itself when nothing
261
+ turns.
262
+ - **The pre-transform position is a varying, not an inverse.** `pre` at
263
+ location 10 of `VsOut`; the fragment epilogue takes it at the same
264
+ location, with `inner` and `inner_radii` at 11 and 12. A fragment
265
+ entry point lists a subset of the vertex outputs, so an app's
266
+ fragment compiled against the old prelude still links.
267
+ - **A scrollbar's thumb under a turn is hit upright.** The bar is drawn
268
+ turned (its quads name the scroller's entry) but its track is tested
269
+ as an upright rect. A turned scroller is a demonstration shape, and
270
+ the wheel over it is right; the thumb waits for an app that drags one.
271
+ - **A caret placed inside a turn reads the pointer upright.** A press
272
+ finds an editor in a tilted card, but where its caret lands, the drag
273
+ that extends it, a static text's selection drag and `text_hit` take
274
+ the pointer less the content origin without pulling it back through
275
+ the turn, so under a turn or a scale the caret lands on the wrong
276
+ character. Found by alpha.46's pre-tag pass; open as RG155, with the
277
+ inner clip's unsmoothed edge and the second measurement below.
278
+ - **The Odin layer was regenerated by hand.** No Odin ran on the
279
+ building machine (its binary wants an LLVM the machine does not have),
280
+ so `kui_c.odin`, `layout.odin`, `generated.odin`, `types.odin` and the
281
+ generator's policy were written as the generator writes them, and
282
+ CI's `odin.nu gen --check` is what says whether they match.
283
+ It did not, at first: `KuiSpec` embeds `KuiEnter` twice (`enter`,
284
+ `exit`), whose eight new bytes moved every field after them by 16,
285
+ and the hand-written `layout.odin` moved only the tail. Found while
286
+ building F133 (2026-10-09) and rewritten from the C mirror's own
287
+ asserts (`target/kui-abi-assert.c`), all 77 offsets, with every field
288
+ of every mirrored struct checked to have one.
289
+ - **Lua's `path` strips the row.** Lua reads every key of a table as a
290
+ schema row before an element reads its own, so `path { rotate = }`
291
+ turned the node as well as the mask and the `path` scene's digest
292
+ moved; the arm now drops the node transform the generic walk read.
293
+ Node's encoder had the same fault and the same fix (its `path` case
294
+ writes the rows without `rotate`): the full run that cleared this ADR
295
+ loaded a stale addon and passed, and the next one, building F133,
296
+ caught it. C and Odin take a path's turn as an argument.
297
+ - **The conformance digest hashes twenty words a clip** in all five
298
+ adapters, and the `transform` scene is the first whose digest moves
299
+ if a turn, a scale or an inner clip does.
300
+ - **Measured, and given back (2026-10-09).** The first build passed
301
+ the bench guard against `v0.1.0-alpha.44` with every frame that turns
302
+ nothing 4% to 9% slower. Investigated before merging, against the
303
+ build's own parent (`acbbbb4d`, RG154 — which reads as alpha.44 does,
304
+ so all of it was this ADR's), by alternating the two bench binaries
305
+ and profiling with `sample`. Six costs, each found by an experiment
306
+ before it was fixed:
307
+
308
+ 1. **The paint path carried the whole clip.** `Paint` held the
309
+ 80-byte `Clip` by value into `emit_node` and `paint_box`, which
310
+ rescaled all twenty floats for every node. It holds the clip's rect
311
+ and id now; a leaf that culls — a text, a grid, an editor — reads
312
+ the scaled entry from the display list, and the table rules look
313
+ the logical clip up by node.
314
+ 2. **The walk carried it too.** `emit_frame` built an 80-byte value
315
+ per node and read its rect. It carries the rect, the cull rect and
316
+ the id; the clip stays in `self.clips`, which is pushed in tree
317
+ order instead of filled and overwritten, and children of one
318
+ clipper share one intersect and one intern through a two-index
319
+ cache (a list's rows were interning the same 80 bytes each).
320
+ 3. **A tenth tween slot on every transitioning node.** 96 bytes a
321
+ node whether it turned or not. The turn's tween lives in
322
+ `AnimStore::turns`, for the nodes that turn.
323
+ 4. **The test for a turn inside `ease_transitioning`.** It is eased
324
+ after the other slots, in `ease_transform`, out of line, behind a
325
+ two-box check in `ease_spec`; `ease_transitioning` is alpha.44's.
326
+ 5. **An inlining cliff.** The drive body shared by the array slots and
327
+ the turn was force-inlined into both callers, so `Tween::eased_at`
328
+ and `spring_step` gained a second caller and stopped being inlined —
329
+ 4% on the transitioning row by itself. The shared body is the
330
+ out-of-line function now, as `NodeAnim::drive` was, and the
331
+ helpers inline into it again.
332
+ 6. **A test that stopped `prepare_spec` inlining.** The check for a
333
+ turn in `ease_spec` scanned the keyframe stops inline, which made
334
+ `prepare_spec` too big to inline into `open_content`, and every node
335
+ paid a call (+2% on 10,000 gradient cells). The scan is out of line.
336
+
337
+ Smaller: the hit region's turn is boxed (36 bytes inline was 1% on a
338
+ thousand buttons), the transform inside the interact group is boxed
339
+ (so a gradient or a hover allocates the group in the class it did),
340
+ the leaves that do not cull by the clip never copy it, and the
341
+ turned-space cull is asked only on a frame that turns. Medians against
342
+ RG154, on mains power:
343
+
344
+ | row | first build | now (median) | now (fastest) |
345
+ | --- | --- | --- | --- |
346
+ | `frame_10k_rects` | +5.9% | +0.5% | +0.5% |
347
+ | `frame_1k_typical` | +4.7% | −0.3% | −0.1% |
348
+ | `frame_10k_rects_with_text_and_hits` | +4.5% | −0.3% | −0.6% |
349
+ | `frame_10k_segments` | +5.2% | +0.2% | +0.6% |
350
+ | `frame_10k_rects_with_access_tree` | +4.1% | −0.1% | −0.5% |
351
+ | `list_10k_rows_virtual` | +8.1% | +0.4% | +2.1% |
352
+ | `frame_1k_curves` | +1.1% | +0.1% | +0.3% |
353
+ | `frame_10k_rects_all_transitioning` | +11.0% | −2.7% | −3.9% |
354
+ | `deep_nesting_64_levels` | +5.7% | +3.0% | +0.5% |
355
+ | `frame_10k_rects_with_gradient` | +6.2% | +1.2% | +1.6% |
356
+ | `frame_10k_rects_all_declaring_exit` | +13.6% | +4.6% | +0.6% |
357
+ | `frame_10k_rects_rounded_clip` | +17.4% | −6.1% | −6.0% |
358
+
359
+ `deep_nesting_64_levels` never clips or turns, and what moved in it is
360
+ layout, whose code is unchanged and the same size: placement, not
361
+ cost. The exit row's medians span 15% on this machine, and its fastest
362
+ samples are level; what it keeps is its entrance and exit, 16 bytes
363
+ bigger each for `rotate` and `scale`, which every node declaring one
364
+ allocates. The rounded clip is faster than before ADR 0043 because a
365
+ clipper's children now share one intersect. Two
366
+ traps for the next one: the Mac on battery, and another session's
367
+ builds, made the transitioning row unreadable (medians spanning 2×)
368
+ until the race waited out `rustc` and read the fastest sample beside
369
+ the median; and the guard's base was alpha.44, which hid that RG154
370
+ sat between it and this build.