@qxuken/kui 0.1.0-alpha.45 → 0.1.0-alpha.47

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,246 @@ 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.47 (2026-10-09)
25
+
26
+ ### Fixed
27
+
28
+ - **Text inside a turned node is read where it is drawn** (backlog
29
+ RG155, ADR 0043). A press on an editor in a tilted or scaled card puts
30
+ the caret under the pointer, and so does the drag that extends it, a
31
+ selection drag over a paragraph or a grid, a double or triple click,
32
+ `text_hit`, and the `line` / `byte` / `cell` a sink's pointer events
33
+ carry; `caret_rect` and the IME's anchor are where the caret is drawn,
34
+ so the candidate window opens beside a tilted field. alpha.46 read all
35
+ of them against the upright node. A square clipper inside a turn now
36
+ smooths its edge as a rounded one does instead of stairing, in the
37
+ renderer and the fragment epilogue.
38
+
39
+ ### Native verification
40
+
41
+ The by-hand round on 2026-10-09, over RG155 — what alpha.46's pre-tag
42
+ pass left — on the Mac: the mechanical round as CI runs it, the Odin
43
+ binding regenerated and type-checked with CI's pinned Odin in a Debian
44
+ container (the lesson of alpha.46's tag), the windowed round, the
45
+ accessibility audit and the bench guard, then one read-only reviewer of
46
+ the diff since alpha.46, each claim probed. It found two of RG155's
47
+ paths written the wrong way — `text_hit` and `caret_rect` turned in the
48
+ host's space where the turns are the window's, which a devtools dock on
49
+ the left made twice its width wrong, and a sink's `line` and `byte` read
50
+ through the sink's turn alone, not a turned column inside it — and a
51
+ lookup every frame with a clip paid; all three were corrected before the
52
+ tag, with tests that fail without them.
53
+
54
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
55
+ scripts/test.nu`: **2003 tests over 150 suites**, 0 failed. The C round
56
+ passes (5 checks), and so do the **58 scenes** through Rust, Lua, C and
57
+ Node; Node's tests under `KUI_CONFORMANCE_REQUIRED=1`, **224 of 224**;
58
+ `npm run gen` with no diff, the examples' typecheck, the headless
59
+ round, the book. The Odin generator's output matches the tree byte for
60
+ byte, and the package and its five examples pass `odin check -vet
61
+ -strict-style`. The windowed round with Node's: **55 examples on both
62
+ bases**, clean on a first run. The accessibility audit: **106 of 106**.
63
+ The bench guard against the alpha.46 tag: **green**, the eight guarded
64
+ rows −0.7% to +3.5% on a machine another session had just been
65
+ building on, and the two high rows re-read at +0.2%
66
+ (`frame_10k_rects`) and +1.1% (`list_10k_rows_virtual`) once it was
67
+ quiet. No Windows or Linux machine ran this round.
68
+
69
+ ## 0.1.0-alpha.46 (2026-10-09)
70
+
71
+ **What breaks.**
72
+
73
+ - C ABI 27 (under Added, F131): `KuiClip` grows from eight words to
74
+ twenty — `transform`, `inner` and `inner_radius` — so a host that
75
+ strides `KuiDrawData.clips` reads the new stride and the conformance
76
+ digest hashes the whole entry; `KuiSpec` appends `rotate`, `scale`,
77
+ `pivot_set`, `pivot_x`, `pivot_y` and `iterations` (F133; 64-bit size
78
+ 744), `KuiKeyframe` and `KuiEnter` append `rotate` and `scale`, and
79
+ `KuiKeyframe` `dx` and `dy` with `KUI_KF_OFFSET` (strides 52 and 48;
80
+ F132). `KuiSpec` holds `KuiEnter` twice by value, as `enter` and
81
+ `exit`, so its fields after `enter` move by 8 bytes and those after
82
+ `exit` by 16: recompile, and re-derive the offsets of a hand-written
83
+ mirror (ctypes, Zig, an Odin of its own) rather than append to it. A
84
+ zeroed field is what every spec, stop and entrance had before. Node's
85
+ `clips()` buffer has the same stride, and `decodeClips` reads
86
+ `transform`, `inner` and `innerRadii`. A renderer of its own that
87
+ ignores the new words draws a turned subtree upright.
88
+ - Rust struct literals: none of these is `#[non_exhaustive]`, so a
89
+ literal that names every field gains the new ones or a `..` base —
90
+ `Clip` (`transform`, `inner`, `inner_radius`; it has no `Default`,
91
+ so `..Clip::NONE`), `Keyframe` (`dx`, `dy`), `Slots` and `Enter`
92
+ (`rotate`, `scale`), `AnimSpec::iterations`,
93
+ `InteractSpec::transform`, `NodeInfo` (`rotate`, `scale`),
94
+ `HitRegion::turn`, `ScrollRegion::turn` and `EnvFacts::now`.
95
+ - Node's `WarningCode` union gains `'ambiguous-name'` (F137): a
96
+ `switch` over it that TypeScript checks for exhaustiveness names it.
97
+
98
+ ### Added
99
+
100
+ - **A node turns about its pivot** (backlog F131, from berainder's
101
+ review; [ADR 0043](docs/adr/0043-a-node-turns-about-its-pivot.md)).
102
+ `rotate` (turns, clockwise), `scale` (a uniform factor) and
103
+ `pivotX` / `pivotY` (fractions of the box, the centre by default) on
104
+ any node — `NodeSpec::rotate` / `scale` / `pivot` in Rust, the rows in
105
+ JSX and Lua, `KuiSpec.rotate` / `scale` / `pivot_set` with
106
+ `KUI_PIVOT_X` / `KUI_PIVOT_Y` in C (ABI 27), the generated `Spec`
107
+ fields in Odin. Paint-only: the node takes the room its upright self
108
+ takes and `onLayout` reports the layout rect; everything the subtree
109
+ draws turns with it — backgrounds, borders, shadows, text, images,
110
+ strokes, fragments — and so does what it clips, so a photo stays
111
+ inside a tilted card's rounded corners. Hit where drawn: a tilted
112
+ card is grabbed on its tilted edge, a press in its box past its edge
113
+ falls through, a scroller inside a turn takes the wheel where it is
114
+ drawn; the access rect is the bounding box. Turns nest by
115
+ composition; a float anchored to its parent turns with it, a viewport
116
+ float does not. A turn and a scale are one slot that tweens with
117
+ `transition` — a card follows the pointer while a drag holds the
118
+ transition off and springs back when it is on — and an entrance, an
119
+ exit and a keyframe stop name `rotate` and `scale` (`enter: { scale:
120
+ 0.8 }`, `keyframes: [{ rotate: 0 }, { rotate: 1 }]` spins a box); a
121
+ departing subtree keeps its turn. On the wire the clip entry carries
122
+ the space (`Clip::transform`, `inner`, `inner_radius`); the quad does
123
+ not change. A `path`'s own `rotate` keeps ADR 0041's meaning and
124
+ composes under the node's. The `transform` example.
125
+ - `Transform`, the similarity a clip entry carries, with `about`,
126
+ `apply`, `unapply`, `then`, `bounds`; `Clip::turned`, `turned_by`,
127
+ `visible`, `shown`; `HitTurn` on a hit region and a scroll region;
128
+ `NodeInfo::rotate` / `scale` in the devtools' facts (F131).
129
+ - **A keyframe stop names a position** (backlog F132, from berainder's
130
+ review). `dx` / `dy` on a stop — `Keyframe::dx` / `dy` / `offset` in
131
+ Rust, the stop's fields in JSX and Lua, `KuiKeyframe.dx` / `dy` with
132
+ `KUI_KF_OFFSET` in C (ABI 27), `Keyframe.dx` / `dy` with `.Offset` in
133
+ Odin — are logical px from where layout put the node, as an entrance's
134
+ are: the node and its subtree are drawn, hit and read by assistive
135
+ technology that far away at the stop, a lane a stop leaves out is 0,
136
+ and the offset adds to a `slide`'s. `[{ dy: 0 }, { dy: -6 }]` with
137
+ `repeat: 'alternate'` bobs a box; three stops drift a sparkle up as it
138
+ fades. Paint, hit and access only: the room the node takes is its
139
+ place's, and an `onLayout` node reports its layout rect, not a cycle
140
+ that would post an event every frame it ran. The `transition` example
141
+ has a bob and a sparkle.
142
+ - **A keyframe cycle plays so many times** (backlog F133, from
143
+ berainder's review). `iterations` — CSS's `animation-iteration-count`:
144
+ `NodeSpec::iterations` in Rust, the row in JSX and Lua, `KuiSpec.iterations`
145
+ in C (0 is for ever; ABI 27), the generated `Spec.iterations` in Odin.
146
+ Left out, a cycle runs for ever and reads the clock as it always did,
147
+ so siblings stay in phase. A finite one plays from the first frame its
148
+ node is declared with it, holds its first stop through its `delay`,
149
+ and rests where its last iteration ended (CSS's fill `both`); then it
150
+ owes no frame, so `animating()`, `owed()` and the frame trace go quiet
151
+ as a settled transition's do. `1` plays a burst or a shake once, `2`
152
+ alternating goes out and back, `0.5` stops halfway; `delay` plus
153
+ `iterations: 1` staggers one-shots. A node that leaves and comes back
154
+ plays again, and so does one whose stops the view drops for a frame
155
+ and declares again: that is how a view replays it.
156
+ - **The view reads the frame clock** (backlog F134). `Ui::now` /
157
+ `Core::now` in Rust, `env.now` in Lua, `kui_now` in C, `now` in Odin,
158
+ `ctx.now()` in Node: the driver's seconds that `transition` and
159
+ `keyframes` read this frame, 0 before a driver sets one. A deadline
160
+ read off it — a toast's expiry, a sequence's beats — agrees with the
161
+ core's easing, and a test's `advance` moves both.
162
+ - **A frame asked for at a time** (backlog F135). `Ui::request_frame_at`
163
+ / `Core::request_frame_at` and `Core::next_frame_at` in Rust,
164
+ `env.request_frame_at` in Lua, `kui_request_frame_at` /
165
+ `kui_next_frame_at` in C (`INFINITY` for none), the same doors in Odin,
166
+ `ctx.requestFrameAt` / `nextFrameAt` in Node; `Waker::wake_at(Instant)`
167
+ for a thread. On the frame clock: the runner sleeps to the earliest
168
+ time asked for and runs the view, with nothing owed until then, so
169
+ `animating()` stays false. A time already past is a frame now; the
170
+ time is kept until a frame reaches it.
171
+ - **The exit named at the removal** (backlog F136). `Ui::exit_with` /
172
+ `Core::set_exit` in Rust, `env.exit_with` in Lua, `kui_exit_with` in
173
+ C, `exit_with` in Odin, `ctx.exitWith` in Node: the exit a node leaves
174
+ by if it leaves in the frame that finishes next, over the one its
175
+ last frame declared, so the handler that removes a card says which way
176
+ it goes. It lapses when that frame finishes; it aims a node that
177
+ declares an `exit`, with that node's `transition`.
178
+ - **A lookup by accessible name** (backlog F137). `Core::key_named` /
179
+ `Drive::key_named` in Rust, `kui_key_named` in C, `key_named` in Odin,
180
+ `ctx.keyNamed` in Node: the first node in the last frame whose
181
+ accessible name — its `label`, else a control's text — is the one
182
+ asked for, so a test presses "the button named Like" as a reader would.
183
+ Two with one name raise the new `ambiguous-name` warning. The docs now
184
+ say "key label" for what `key_of` and `texts_under` read, the name the
185
+ view opened a node under, and "accessible name" for the `label` row.
186
+ - **The runner decodes images, GIFs included** (backlog F138).
187
+ `kui_native::decode_image(bytes)` turns PNG, JPEG, WebP or GIF bytes
188
+ into straight RGBA and a size, the shape `add_image` takes;
189
+ `decode_animation` keeps every frame of an animated GIF, APNG or WebP,
190
+ composited to the whole canvas, with its delay and the loop count, and
191
+ `Animation::at(elapsed)` says which frame shows and when the next is
192
+ due, so a GIF plays on the frame clock through `update_image_with` and
193
+ `request_frame_at` with nothing owed between steps.
194
+ `Launcher::icon_bytes` takes an icon from a PNG
195
+ (`Launcher::try_icon_bytes` for bytes from outside the program). C: `kui_decode_image`,
196
+ `kui_decode_animation`, `kui_animation_at` and `kui_pixels_free`, with
197
+ `runner`; Odin: `decode_image`, `decode_animation`, `animation_at`,
198
+ copied into the context allocator; Node: `decodeImage`,
199
+ `decodeAnimation` and `animationAt` on the package. The runner's
200
+ `image` gains its `gif` feature. The `decode` example plays a shipped
201
+ spinner.
202
+
203
+ **What you can delete.**
204
+
205
+ - A width and a height tweened against each other to fake a card's
206
+ tilt, and a `path` drawn in a box's place so that it could turn
207
+ (F131).
208
+ - A float declared where a drifting thing ends, entering from where it
209
+ starts over a transition as long as its life, to move it along a path
210
+ (F132): its stops say the path.
211
+ - A clock of the app's own and `request_frame` on every frame to time a
212
+ sequence that plays once — a burst, a pop, a row of stars — and the
213
+ frame owed for good by a cycle the app meant to stop (F133).
214
+ - A clock of the app's own, beside the core's, that a test had to push
215
+ forward by hand (F134): read `now()`.
216
+ - A thread per deadline that sleeps and then calls `Waker::wake` (F135):
217
+ `request_frame_at`, or `Waker::wake_at` from off the view.
218
+ - A frame drawn with the exit aimed before the frame that removes the
219
+ card, and the model field and `request_frame` that schedule it
220
+ (F136): `exit_with` in the handler.
221
+ - The `image` dependency an app added to decode its photos, its version
222
+ pin and the `[profile.dev.package."*"]` line that made it fast, and a
223
+ GIF crate beside it (F138): `decode_image` and `decode_animation`.
224
+
225
+ ### Native verification
226
+
227
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-09, over
228
+ F131–F138 from the berainder review, with alpha.46's pre-tag pass over
229
+ it on the Mac: the mechanical round as CI runs it, the windowed round,
230
+ the accessibility audit and the bench guard, then three read-only
231
+ reviewers of the diff since alpha.45 — the core and the renderer, the
232
+ bindings, the docs — each claim probed. What they found was corrected
233
+ before the tag (the backlog's alpha.46 pre-tag section lists it), and
234
+ one entry, RG155 — the caret inside a turned node — is open.
235
+
236
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
237
+ scripts/test.nu`: **1997 tests over 149 suites**, 0 failed. The C round
238
+ passes (5 checks), and so do the **58 scenes** through Rust, Lua, C and
239
+ Node; Node's tests under `KUI_CONFORMANCE_REQUIRED=1`, **224 of 224**;
240
+ `npm run gen` with no diff, the examples' typecheck, the headless round,
241
+ the book, and `cargo audit` over the lockfile the decoder grew. The
242
+ windowed round with Node's: **55 examples on both bases**, clean on a
243
+ first run, twice. The accessibility audit: **106 of 106**. The bench
244
+ guard against the alpha.45 tag: **green**, the eight guarded rows −0.9%
245
+ to +0.9%; the unguarded exit rows read +2% to +4%, the 16 bytes an
246
+ entrance and an exit grew by. The Odin steps did not run: this
247
+ machine's Odin links an LLVM that is gone, so the hand-written Odin
248
+ layer this round needed is checked by CI's `odin.nu` steps on the tag,
249
+ and its layout was compared by script against the C side's asserts. No
250
+ Windows or Linux machine ran this round.
251
+
252
+ Those steps failed on the tag: `odin.nu gen --check` found the
253
+ hand-written `generated.odin` with the `now` door one place out of the
254
+ generator's order, so Forgejo's `check` failed and its publish to the
255
+ drydock9 cargo registry was skipped. GitHub's pipeline had already put
256
+ the crates on crates.io and staged the npm package, from the same
257
+ commit, so the tag stays where it is. Nothing shipped differs: the Odin
258
+ package is in neither registry. The file was regenerated with CI's
259
+ pinned Odin (`dev-2026-09`) in a Debian container, the package and every
260
+ Odin example type-checked there, and the fix is on main after the tag.
261
+ alpha.46 was left off the drydock9 registry; alpha.47 is the next
262
+ release there.
263
+
24
264
  ## 0.1.0-alpha.45 (2026-10-08)
25
265
 
26
266
  **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,376 @@
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. Taken 2026-10-09 (RG155): `frame_1k_turned_rects`
245
+ reads ~136 µs against `frame_1k_rects`' ~79 µs, about 56 ns a turned
246
+ node — its own interned entry (a turn about its own box makes every
247
+ entry distinct), the compose, the bounding-box cull.
248
+
249
+ ## Amendment: what the building changed (2026-10-08)
250
+
251
+ - **`KuiSpec` is 744 bytes, not 728.** The five appended fields land
252
+ after the 8-byte-aligned tail, and the parity test said so.
253
+ - **`pivot_set` is two bits.** A C host setting one axis — the parity
254
+ test's one-row-at-a-time check was the first — needs to say which, so
255
+ `KUI_PIVOT_X` and `KUI_PIVOT_Y` are bits as `KUI_VALUE_*` are, and the
256
+ Odin lowering ORs them in. `scale` stays "0 is 1".
257
+ - **The slot bits are `KUI_KF_ROTATE` 64, `KUI_KF_SCALE` 128,
258
+ `KUI_ENTER_ROTATE` 64, `KUI_ENTER_SCALE` 128.**
259
+ - **A glyph cull reads the clip in the quad's space.** The text, cell
260
+ and editor painters compared glyph positions against the entry's
261
+ framebuffer rect; under a turn that is the wrong space. They read
262
+ `Clip::visible` — the inner clip narrowed by the outer one pulled
263
+ back through the transform — which is the rect itself when nothing
264
+ turns.
265
+ - **The pre-transform position is a varying, not an inverse.** `pre` at
266
+ location 10 of `VsOut`; the fragment epilogue takes it at the same
267
+ location, with `inner` and `inner_radii` at 11 and 12. A fragment
268
+ entry point lists a subset of the vertex outputs, so an app's
269
+ fragment compiled against the old prelude still links.
270
+ - **A scrollbar's thumb under a turn is hit upright.** The bar is drawn
271
+ turned (its quads name the scroller's entry) but its track is tested
272
+ as an upright rect. A turned scroller is a demonstration shape, and
273
+ the wheel over it is right; the thumb waits for an app that drags one.
274
+ - **What a press means for text is read on the upright layout.** The
275
+ caret a press places, the drag that extends it, a selection drag, a
276
+ grid's cell, `text_hit` and a sink's `line` / `byte` pull the pointer
277
+ back through the node's turn first (`Core::unturned`), and
278
+ `caret_rect` and the IME anchor are the caret as drawn. Alpha.46's
279
+ pre-tag pass found them reading the pointer upright; built the day
280
+ after the tag (RG155), with the square inner clip ramped over `AA` as
281
+ a rounded one is. A sink's lines are each read through their own
282
+ turns; a turn nested inside a selectable scope is read against the
283
+ scope's turn.
284
+ - **The Odin layer was regenerated by hand.** No Odin ran on the
285
+ building machine (its binary wants an LLVM the machine does not have),
286
+ so `kui_c.odin`, `layout.odin`, `generated.odin`, `types.odin` and the
287
+ generator's policy were written as the generator writes them, and
288
+ CI's `odin.nu gen --check` is what says whether they match.
289
+ It did not, at first: `KuiSpec` embeds `KuiEnter` twice (`enter`,
290
+ `exit`), whose eight new bytes moved every field after them by 16,
291
+ and the hand-written `layout.odin` moved only the tail. Found while
292
+ building F133 (2026-10-09) and rewritten from the C mirror's own
293
+ asserts (`target/kui-abi-assert.c`), all 77 offsets, with every field
294
+ of every mirrored struct checked to have one.
295
+ - **Lua's `path` strips the row.** Lua reads every key of a table as a
296
+ schema row before an element reads its own, so `path { rotate = }`
297
+ turned the node as well as the mask and the `path` scene's digest
298
+ moved; the arm now drops the node transform the generic walk read.
299
+ Node's encoder had the same fault and the same fix (its `path` case
300
+ writes the rows without `rotate`): the full run that cleared this ADR
301
+ loaded a stale addon and passed, and the next one, building F133,
302
+ caught it. C and Odin take a path's turn as an argument.
303
+ - **The conformance digest hashes twenty words a clip** in all five
304
+ adapters, and the `transform` scene is the first whose digest moves
305
+ if a turn, a scale or an inner clip does.
306
+ - **Measured, and given back (2026-10-09).** The first build passed
307
+ the bench guard against `v0.1.0-alpha.44` with every frame that turns
308
+ nothing 4% to 9% slower. Investigated before merging, against the
309
+ build's own parent (`acbbbb4d`, RG154 — which reads as alpha.44 does,
310
+ so all of it was this ADR's), by alternating the two bench binaries
311
+ and profiling with `sample`. Six costs, each found by an experiment
312
+ before it was fixed:
313
+
314
+ 1. **The paint path carried the whole clip.** `Paint` held the
315
+ 80-byte `Clip` by value into `emit_node` and `paint_box`, which
316
+ rescaled all twenty floats for every node. It holds the clip's rect
317
+ and id now; a leaf that culls — a text, a grid, an editor — reads
318
+ the scaled entry from the display list, and the table rules look
319
+ the logical clip up by node.
320
+ 2. **The walk carried it too.** `emit_frame` built an 80-byte value
321
+ per node and read its rect. It carries the rect, the cull rect and
322
+ the id; the clip stays in `self.clips`, which is pushed in tree
323
+ order instead of filled and overwritten, and children of one
324
+ clipper share one intersect and one intern through a two-index
325
+ cache (a list's rows were interning the same 80 bytes each).
326
+ 3. **A tenth tween slot on every transitioning node.** 96 bytes a
327
+ node whether it turned or not. The turn's tween lives in
328
+ `AnimStore::turns`, for the nodes that turn.
329
+ 4. **The test for a turn inside `ease_transitioning`.** It is eased
330
+ after the other slots, in `ease_transform`, out of line, behind a
331
+ two-box check in `ease_spec`; `ease_transitioning` is alpha.44's.
332
+ 5. **An inlining cliff.** The drive body shared by the array slots and
333
+ the turn was force-inlined into both callers, so `Tween::eased_at`
334
+ and `spring_step` gained a second caller and stopped being inlined —
335
+ 4% on the transitioning row by itself. The shared body is the
336
+ out-of-line function now, as `NodeAnim::drive` was, and the
337
+ helpers inline into it again.
338
+ 6. **A test that stopped `prepare_spec` inlining.** The check for a
339
+ turn in `ease_spec` scanned the keyframe stops inline, which made
340
+ `prepare_spec` too big to inline into `open_content`, and every node
341
+ paid a call (+2% on 10,000 gradient cells). The scan is out of line.
342
+
343
+ Smaller: the hit region's turn is boxed (36 bytes inline was 1% on a
344
+ thousand buttons), the transform inside the interact group is boxed
345
+ (so a gradient or a hover allocates the group in the class it did),
346
+ the leaves that do not cull by the clip never copy it, and the
347
+ turned-space cull is asked only on a frame that turns. Medians against
348
+ RG154, on mains power:
349
+
350
+ | row | first build | now (median) | now (fastest) |
351
+ | --- | --- | --- | --- |
352
+ | `frame_10k_rects` | +5.9% | +0.5% | +0.5% |
353
+ | `frame_1k_typical` | +4.7% | −0.3% | −0.1% |
354
+ | `frame_10k_rects_with_text_and_hits` | +4.5% | −0.3% | −0.6% |
355
+ | `frame_10k_segments` | +5.2% | +0.2% | +0.6% |
356
+ | `frame_10k_rects_with_access_tree` | +4.1% | −0.1% | −0.5% |
357
+ | `list_10k_rows_virtual` | +8.1% | +0.4% | +2.1% |
358
+ | `frame_1k_curves` | +1.1% | +0.1% | +0.3% |
359
+ | `frame_10k_rects_all_transitioning` | +11.0% | −2.7% | −3.9% |
360
+ | `deep_nesting_64_levels` | +5.7% | +3.0% | +0.5% |
361
+ | `frame_10k_rects_with_gradient` | +6.2% | +1.2% | +1.6% |
362
+ | `frame_10k_rects_all_declaring_exit` | +13.6% | +4.6% | +0.6% |
363
+ | `frame_10k_rects_rounded_clip` | +17.4% | −6.1% | −6.0% |
364
+
365
+ `deep_nesting_64_levels` never clips or turns, and what moved in it is
366
+ layout, whose code is unchanged and the same size: placement, not
367
+ cost. The exit row's medians span 15% on this machine, and its fastest
368
+ samples are level; what it keeps is its entrance and exit, 16 bytes
369
+ bigger each for `rotate` and `scale`, which every node declaring one
370
+ allocates. The rounded clip is faster than before ADR 0043 because a
371
+ clipper's children now share one intersect. Two
372
+ traps for the next one: the Mac on battery, and another session's
373
+ builds, made the transitioning row unreadable (medians spanning 2×)
374
+ until the race waited out `rustc` and read the fastest sample beside
375
+ the median; and the guard's base was alpha.44, which hid that RG154
376
+ sat between it and this build.
package/encoder.js CHANGED
@@ -1162,7 +1162,11 @@ export function createEncoder(P) {
1162
1162
  f[fi++] = pivot ? pivot[0] : 0;
1163
1163
  f[fi++] = pivot ? pivot[1] : 0;
1164
1164
  if (pathDash) for (const n of pathDash) f[fi++] = n;
1165
- props(p, el.key, false);
1165
+ // A path's `rotate` is its own turn (ADR 0041), carried above; the
1166
+ // node's `rotate` row (ADR 0043) is another thing, so it is kept
1167
+ // off the rows here, as Lua's `path` arm does.
1168
+ const { rotate: _ownTurn, ...rows } = p;
1169
+ props(rows, el.key, false);
1166
1170
  return;
1167
1171
  }
1168
1172
  case 'line': {