@qxuken/kui 0.1.0-alpha.36 → 0.1.0-alpha.38

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,358 @@ 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.38 (2026-10-05)
25
+
26
+ **What breaks.**
27
+
28
+ - C: `KuiSpec` gains `scroll_mods` at its end (64-bit size 704), so
29
+ `KUI_ABI_VERSION` is 25; recompile. A zeroed spec is what it was.
30
+ - Rust: `event::Scroll` gains a `mods` field and `EventSpec` a
31
+ `scroll_mods` one (under Added), so a struct literal of either needs
32
+ them.
33
+
34
+ The Node wire stays v21: `scrollMods` is one more string row.
35
+
36
+ ### Added
37
+
38
+ - **An `onScroll` node can be for a modified wheel** (backlog F122,
39
+ from kawoosh). `scrollMods` — `"ctrl super"`, any of `shift`, `ctrl`,
40
+ `alt`, `super`; Rust `NodeSpec::scroll_mods(KeyMods)`, C
41
+ `KuiSpec.scroll_mods` in `KUI_KMOD_*` bits, Lua `scroll_mods` — and
42
+ the node hears only a scroll gesture that began with one of them
43
+ held, and hears it first: ahead of every scroll container and every
44
+ `onScroll` that names none, wherever under the pointer the gesture
45
+ began, the innermost such node winning. A Ctrl-wheel zoom declared
46
+ on the window's root is heard over a list, and the list does not
47
+ scroll; a canvas inside can name the same key and take it for
48
+ itself. A wheel with none of them held passes the node by — and
49
+ scrolls it, if it is a scroll container too: a list can name a key
50
+ for its own zoom and scroll for every other wheel (backlog RG119,
51
+ from this release's pre-tag pass; as merged, such a list stood still
52
+ for a plain wheel). Its
53
+ `scroll` events carry `mods` (`Scroll::mods`), the modifiers held
54
+ when the gesture began: a gesture stays what it began as to the end
55
+ of its glide, whatever is let go or pressed meanwhile.
56
+
57
+ **What you can delete.** An `onScroll` handler that read the modifiers
58
+ to tell a zoom from a scroll, and the scroll it then had to do itself
59
+ for the plain wheel — and the knowledge that it only ever worked over
60
+ that handler's own node, every scroll container inside it taking the
61
+ same wheel first.
62
+
63
+ ### Fixed
64
+
65
+ - **`scripts/npm-approve.nu` no longer fails a release it has just
66
+ made.** Approving alpha.37 went through, and the script then waited
67
+ a minute for `npm view` to list the version, gave up with "approved,
68
+ but npmjs does not list it yet; run this again", and on the second
69
+ run — no stage left, the cached packument still without the
70
+ version — said nothing was staged and asked whether the release
71
+ workflow had run. `latest` was set by hand. What is live is now read
72
+ off the dist-tags, which a version takes the moment it is approved,
73
+ and `latest` follows the approval at once. A release script, so
74
+ nothing an app sees.
75
+
76
+ **What you can delete.** Nothing.
77
+
78
+ ### Native verification
79
+
80
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-05 — the
81
+ two commits after the alpha.37 tag: `scrollMods` (F122) and the
82
+ approval script — with a regression pass over them first: the diff
83
+ read whole, each claim probed with a test before anything changed.
84
+ RG119 came of it and is in this release. Before any of it the last
85
+ `check` on main was read on both hosts, green at `3d4fff6` — the step
86
+ alpha.37's first tag went without. This round ran on the Mac alone;
87
+ Windows and Linux did not run it for this tag.
88
+
89
+ **macOS 27.0.1 on an M3 Pro MacBook Pro, rustc 1.99.0 (the toolchain
90
+ CI runs), Node 26.10.0, nu 0.116.0**, on the release commit's tree,
91
+ the workspace's own artifacts pruned and rebuilt. `cargo fmt --all
92
+ --check` and `cargo clippy --workspace --all-targets -- -D warnings`
93
+ are clean. `scripts/test.nu`, the workspace's tests with the
94
+ conformance feature: **1808 tests over 136 suites, 0 failed** (4
95
+ ignored). The C round, `cbuild --run`, passes its five checks; the
96
+ corpus passes its **57 scenes** in four adapters, `scroll-gestures`
97
+ now holding a Ctrl-wheel over a contained list at its limit; the ABI
98
+ is **25**. Node's `node --test test.mjs` under
99
+ `KUI_CONFORMANCE_REQUIRED=1`: **210 of 210**. `npm run gen` leaves no
100
+ diff, the examples typecheck and their lockfile installs, the headless
101
+ round passes all **35 drives**, the book builds and
102
+ `scripts/book-examples.nu --check`
103
+ passes.
104
+
105
+ **The windowed round**, `smoke -- --node`, twice over: **51 Rust
106
+ examples and the eleven Node examples, each on both bases, 120 frames
107
+ each, every one exiting 0** — 124 windows, eight at a time, in 34 and
108
+ 35 s — and `counter`, `host`, `c_panel` and `lua_panel` by hand under
109
+ `KUI_SMOKE_FRAMES=120`, each exiting 0 with nothing on stderr: **128
110
+ windows over five hosts.** The AX audit: **106/106**, the audited
111
+ window raised to the front by its pid first, and no warning on the
112
+ fixture's stderr. `scrollMods` itself was not driven in a window in
113
+ this round: it was tried in kawoosh's before the merge, and RG119's
114
+ case is pinned in the core's tests alone.
115
+
116
+ **The bench guard** against the alpha.37 tag, on the release commit's
117
+ tree: **green**, none of the 8 guarded rows more than 10% slower —
118
+ every one between −4.1% and +0.6% (the worst guarded run-to-run spread
119
+ 4.3%), and no row of the run more than 5% slower. `README.md`'s table
120
+ is kept as it was.
121
+
122
+ ## 0.1.0-alpha.37 (2026-10-05)
123
+
124
+ **What breaks.**
125
+
126
+ - Rust: `HitShape::Path` gains a `stroke` field and `Content` a
127
+ `PathFlat` variant (under Fixed), so a pattern over either needs
128
+ them; `HitShapes::path` takes the stroke's width after the rule.
129
+ - A `path` with a stroke over a fill is hit out to the stroke's edge,
130
+ where the half of the stroke outside the outline fell through; and a
131
+ stroked path with no `bg` but a `hover_bg`, `pressed_bg` or
132
+ `focus_bg` is hit inside, where it was hit along its stroke only
133
+ (under Fixed).
134
+ - Lua: `path { ops = }` whose numbers are not the flat form draws
135
+ nothing with a `path-malformed` warning, where it was an error that
136
+ failed the view (under Fixed).
137
+ - A `path` that animated and then held still for 120 frames draws from
138
+ the atlas again — a `GlyphMask` quad where it stayed a `Texture`
139
+ (under Fixed). Nothing an app sees; a host that counts quads by kind
140
+ does.
141
+ - C: `kui_polyline`, `kui_path` and `kui_path_d` take a `dash` before
142
+ `spec` — five floats, or NULL for the solid stroke they drew (under
143
+ Added). `KUI_ABI_VERSION` is 24; pass NULL and recompile. The same
144
+ step appends `gradient` to `KuiSpec` (64-bit size 696).
145
+ - Rust: `schema::Kind`, `Apply` and `Parsed` gain a `Gradient` variant
146
+ and `InteractSpec` a `gradient` field (under Added).
147
+ - Rust: `Stroke` gains a `dash` field, so a struct literal needs it
148
+ (`Stroke::new` does not); `path::MaskPaint` gains `Dashed`.
149
+ - A cell grid's glyph for a character its family lacks is drawn from a
150
+ monospaced face where one has it, in the middle of its cell, and no
151
+ wider than it (F120, under Fixed), where it was the platform's first
152
+ fallback at its own width from the cell's left edge.
153
+ - Rust: `Tree` gains an `any_gradient` field.
154
+
155
+ The Node wire moves to v21 for the dash's five floats on a line and a
156
+ path, which an encoder and addon of one release never see apart.
157
+
158
+ ### Added
159
+
160
+ - **`gradient` on a box, in every binding**
161
+ ([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
162
+ which supersedes ADR 0005's "no gradients"): `gradient={{ to:
163
+ 'bottom', stops: ['#1e2030', '#14161e'] }}`, `{ angle: 0.125, stops }`
164
+ in turns clockwise from east, `{ radial: true, at: [0.5, 0], stops }`;
165
+ the same table in Lua; `NodeSpec::gradient(Gradient::to(Side::Bottom,
166
+ [...]))` with `Gradient::angle` and `Gradient::radial_at`; and a
167
+ `const KuiGradient *` on `KuiSpec`. A stop is a colour — a `$token`
168
+ too — or a colour and a position.
169
+ - **Over `bg`, under the border and the children.** `bg` stays a
170
+ colour and keeps its tweens, tokens and state backgrounds; a
171
+ transparent stop shows it through.
172
+ - **An image the core paints.** Each distinct gradient is rasterized
173
+ once into the glyph atlas — a 256-texel strip along an axis, a
174
+ 128-texel square otherwise, each with a gutter of the gradient
175
+ carried a texel past its edges, since a stretched quad samples
176
+ there — and drawn as one `Image` quad, so no
177
+ renderer changes and a host that draws an image draws a gradient.
178
+ The key is the gradient and not the box: a resize, another scale
179
+ and a thousand boxes sharing one rasterize nothing.
180
+ - **On the box's unit square.** A side or a corner is CSS's; any
181
+ other `angle` runs corner to corner at an eighth of a turn at any
182
+ aspect, where CSS's pixel-measured `45deg` does not. Stops mix in
183
+ straight sRGB with the alpha premultiplied.
184
+ - **What it does not do.** It does not tween and the state
185
+ backgrounds do not replace it; a hard stop is as soft as the raster
186
+ stretched (a 256th of the box along a strip); there is no conic
187
+ one, and none on a `path`, a `line`, a border or a text. Those, and
188
+ anything animated, stay a `fragment`'s.
189
+ - Measured (the ADR's *Measured*): a linear gradient is within half
190
+ an 8-bit level of the mix computed per pixel at every pixel of the
191
+ box, a radial within 1.2; a gradient box costs about 52 ns over a
192
+ flat one, half of it what any `hoverBg` pays; a raster is 4 µs for
193
+ a strip and 25 to 42 for a square.
194
+ - The corpus gains a `gradients` scene; `examples/rust/apps/loaders.rs`
195
+ gains a rainbow — one gradient two tracks long slid under a clip,
196
+ one strip in the atlas for good.
197
+
198
+ - **`dash` on a `line` and on a `path`'s stroke, in every binding**
199
+ (backlog V2, the amendment in
200
+ [ADR 0010](docs/adr/0010-a-segment-primitive.md)): `<line dash={[6, 4]}
201
+ dashOffset/>`, `line { dash = {6, 4}, dash_offset = }`,
202
+ `Stroke::new(w, c).dash(6.0, 4.0).dash_offset(n)` (`Dash::of` for a
203
+ pattern read from data), and a `const float *dash` on `kui_polyline`,
204
+ `kui_path` and `kui_path_d`. One length is marks and gaps alike, two
205
+ are a mark and a gap, four a dash-dot.
206
+ - **The lengths are the ones seen.** Every mark is a short stroke
207
+ with the stroke's own round caps, so `6, 4` is 6 px of ink and 4 px
208
+ of nothing at any width and a mark no longer than the stroke is
209
+ wide is a dot. SVG's `stroke-dasharray` measures the centre line,
210
+ which with round caps makes `4 4` at a width of 4 a solid line;
211
+ this pattern is SVG's `mark − width, gap + width`.
212
+ - **The pattern keeps its phase** along the whole stroke — round the
213
+ corners of a polyline and across the pieces of a curve, which is
214
+ what ADR 0010 would not ship without — and restarts at each subpath
215
+ of a `path`, as SVG's does. `dashOffset` starts that far into it;
216
+ growing it moves the marks towards the first point, a marquee's
217
+ marching ants. Neither tweens.
218
+ - **No renderer changes.** A dashed line is one `Segment` quad per
219
+ mark per piece the mark lies on, cut in the core, so a host that
220
+ draws a stroke draws a dashed one; a dashed path stroke is cut by
221
+ the rasterizer into the mask it already was. A path whose offset
222
+ changes every frame is a shape that changes every frame, and
223
+ leaves the atlas for a texture of its own while it marches.
224
+ - A pattern with no gap, a mark and gap under a physical pixel
225
+ together, or more than 16384 marks draws solid. A dashed stroke is
226
+ hit along its whole length, gaps included.
227
+ - The corpus's `lines` and `paths` scenes carry one each, so the
228
+ segment count pins the cut in four bindings;
229
+ `examples/rust/widgets/line.rs` hangs its planned cards off dashed
230
+ links that march under the pointer.
231
+
232
+ - **The fallback fonts are the app's to name** (backlog F121, from
233
+ kawoosh). `Core::set_fallback_fonts(&[FontId])` — C
234
+ `kui_font_set_fallback`, Node `ctx.setFallbackFonts(ids)` — lists the
235
+ fonts asked, in order, for a character the text's own family has no
236
+ glyph for, before the platform's list, whose first choice on macOS is
237
+ the system's proportional face. For every text in the session — an
238
+ editor already open and a cell grid too — and kept across
239
+ `reload_system_fonts`; an empty list is the platform's alone.
240
+ `Core::fallback_fonts` reads the families back.
241
+
242
+ ### Fixed
243
+
244
+ - **A cell grid's fallback glyph stays in its cell** (backlog F120, from
245
+ kawoosh: Russian text in a terminal whose family has no Cyrillic, `Ю`
246
+ drawn across the letter after it). A character the grid's family has
247
+ no glyph for is asked of a monospaced face before the platform's
248
+ fallback list — on macOS that list opens with the system's
249
+ proportional face; a glyph still wider than its cells, two under
250
+ `wide`, is shaped at the size it fits at; and the room a narrower one
251
+ leaves is shared either side. The family's own glyphs and the private
252
+ use area's icons draw as they did.
253
+
254
+ - **What the `path` reviews left** (backlog RG112, the second review in
255
+ [ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md)):
256
+ - A window closed while it showed an animating or a big path left the
257
+ path's texture with the renderer for the life of the process; the
258
+ core now hands it to the session as it goes, and the next frame any
259
+ window draws drops it. A frame built and never read keeps its
260
+ `dropped_textures` and `dropped_fragments` for the next one.
261
+ - A path that stopped moving stops costing a texture and a draw of
262
+ its own: after 120 still frames its mask is the atlas's again. A
263
+ row of icons keyed by position, whose neighbours came and went
264
+ twice within eight frames, read as animating and stayed so for
265
+ good.
266
+ - A stroke over a fill is hit as far as it is painted
267
+ (`HitShape::Path`'s `stroke`).
268
+ - Ops that are not the flat form raise `path-malformed` under the
269
+ node's key in every binding (`Core::path_flat_node`,
270
+ `Content::PathFlat`); C and Node drew nothing in silence.
271
+ - JSX types `width` on `line` and `path` as a number or a `$length`,
272
+ as the encoder reads it; `Core::image_pixels` answers for the
273
+ handle a path's `Texture` quad names.
274
+
275
+ - **What this release's own pre-tag pass found** (backlog RG114–RG117;
276
+ RG118 holds what it read and left), none of it in a release before
277
+ this one:
278
+ - An editor open when `set_fallback_fonts` was called kept the faces
279
+ its lines were shaped in until they were edited (RG114).
280
+ - The root's `gradient` was dropped while the devtools panel was
281
+ docked (RG115).
282
+ - A dashed `line` whose points coincide drew nothing and was still
283
+ hit; it is the dot its solid one is (RG116).
284
+ - A gradient's `radial` that is not a boolean is an error, where it
285
+ read as linear; its row says that fewer than two stops fail the
286
+ view in JSX and Lua and draw nothing in Rust and C; `kui.h` says
287
+ what a zeroed `KuiGradient` is (RG117).
288
+
289
+ **What you can delete.** A family chosen only because it covers a
290
+ script the preferred one lacks, which can give way to the preferred
291
+ one with the fallback list named; the key an app gave a still icon only so a
292
+ neighbour's coming and going would not move it to a texture; the
293
+ invisible wider path laid under a thick-stroked one to catch the press
294
+ on its edge; the WGSL a card's two-colour fade was written in, and the
295
+ `fragment` that held its children; the loop that walked a connector's points and declared a
296
+ `line` per dash, and the arithmetic that kept its phase round a corner.
297
+
298
+ ### Native verification
299
+
300
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-05 — the
301
+ nine commits after the alpha.36 tag: `dash`, the `gradient` row, F120,
302
+ F121 and RG112 — with a regression pass over them first: three
303
+ read-only reviews (the dash, the gradient, the fonts with this
304
+ release's docs), each claim probed with a test before anything
305
+ changed. RG114–RG117 came of it and are in this release; RG118 holds
306
+ what was read and left. This round ran on the Mac alone; Windows and
307
+ Linux did not run it for this tag.
308
+
309
+ **macOS 27.0.1 on an M3 Pro MacBook Pro, rustc 1.99.0 (the toolchain
310
+ CI runs), Node 26.10.0, nu 0.116.0**, on the release commit's tree,
311
+ the workspace's own artifacts pruned and rebuilt. `cargo fmt --all
312
+ --check` and `cargo clippy --workspace --all-targets -- -D warnings`
313
+ are clean. `scripts/test.nu`, the workspace's tests with the
314
+ conformance feature: **1802 tests over 135 suites, 0 failed** (4
315
+ ignored). The C round, `cbuild --run`, passes its five checks; the
316
+ corpus passes its **57 scenes** in four adapters, `gradients` the new
317
+ one; the ABI is **24**. Node's `node --test test.mjs` under
318
+ `KUI_CONFORMANCE_REQUIRED=1`: **209 of 209**. `npm run gen` leaves no
319
+ diff, the examples typecheck and their lockfile installs, the headless
320
+ round passes all **35 drives**, the book builds and
321
+ `scripts/book-examples.nu --check` passes.
322
+
323
+ **The windowed round**, `smoke -- --node`, three times over: **51 Rust
324
+ examples and the eleven Node examples, each on both bases, 120 frames
325
+ each, every one exiting 0** — 124 windows, eight at a time, in 28 to
326
+ 32 s — and `counter`, `host`, `c_panel` and `lua_panel` by hand under
327
+ `KUI_SMOKE_FRAMES=120`, each exiting 0 with nothing on stderr: **128
328
+ windows over five hosts.** In each of the three runs one window of the
329
+ first eight took the whole round to draw its frames — `cells`, then
330
+ `audio`, then `accessibility`, each a second and a half when run
331
+ alone — which reads as a window covered by the seven launched over it
332
+ and not drawn until they had gone; not compared against alpha.36's
333
+ build. The AX audit: **106/106**, the audited window raised to the
334
+ front by its pid first, and no warning on the fixture's stderr.
335
+
336
+ **The bench guard** against the alpha.36 tag, on the release commit's
337
+ tree: **green**, none of the 8 guarded rows more than 10% slower —
338
+ every one between −0.9% and +2.8% (the worst guarded run-to-run spread
339
+ 2.1%). It was not so at first, twice over, and both are fixed in this
340
+ tree:
341
+
342
+ - `frame_10k_segments` read **+7.7%** (863 → 929 µs, ±1.0%) on solid
343
+ lines, and that was the dash (backlog C52): bisected to V2's commit
344
+ by building the bench at each one. The `Stroke`, twenty bytes wider
345
+ with its `Dash`, was copied at each call from `Ui::line` down to the
346
+ line store, and the dash's cut ran its whole test on every line. The
347
+ stroke is lent and a solid pattern is told by its zeroes; the row
348
+ reads +2.5% in the guard and 875 → 880 µs with the two binaries
349
+ alternated.
350
+ - `frame_10k_rects` read +9.3% and every row built on the bench's grid
351
+ 5 to 9% slower, and that was the bench: bisected to the commit that
352
+ gave its grid the gradient rows' arms in one `match` per cell, with
353
+ no line of the core between it and the commit before. The gradient
354
+ cell is a function of its own now, the plain rows read as
355
+ alpha.36's, and the gradient rows were taken again — 52 ns a node
356
+ over a flat box where the first reading said 55
357
+ ([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
358
+ *Measured*; [docs/performance.md](docs/performance.md) carries the
359
+ four rows from that run, the rest of its table kept as it was).
360
+
361
+ **This tag was cut twice.** The first, at `c7e9793`, published
362
+ nothing: both hosts' `check` failed on F121's own test, which asserted
363
+ that the platform's list draws `字` differently from the app's — and
364
+ on an image with DejaVu alone, both pipelines', the fixture is the one
365
+ face that has it, list or no list. Forgejo's `check` had been red on
366
+ it since F121 was merged, which this round did not read before
367
+ tagging; cargo stops at the first failing suite, so the suites after
368
+ `fonts` had not run there either. The test now tells which machine it
369
+ is on and asserts the platform's half only where there is one, and
370
+ `cargo test -p kui-core` passes its 105 suites in `rust:1-bookworm`
371
+ with `fonts-dejavu-core` alone (the whole workspace would not fit that
372
+ container). With no crate and no package carrying the version, the tag
373
+ was moved to the commit that has the fix and C52's; the mechanical
374
+ round, the windowed round and the guard above are of that commit.
375
+
24
376
  ## 0.1.0-alpha.36 (2026-10-05)
25
377
 
26
378
  **What breaks.**
@@ -127,6 +127,11 @@ silent surprise.
127
127
 
128
128
  ### Gradients: out of scope for v0
129
129
 
130
+ > **Superseded by [ADR 0042](0042-a-gradient-is-an-image-the-core-paints.md)
131
+ > (2026-10-05):** a box takes a `gradient`, linear or radial, rasterized
132
+ > once into the atlas and drawn as an image quad. What follows is why it
133
+ > was not in v0.
134
+
130
135
  Two colors and a direction sound like one more `PROPS` row and are not. A
131
136
  gradient needs a stop list (so: a `Keyframes`-shaped parse, in five
132
137
  bindings), a type (linear, radial, conic), a geometry (angle or two points,
@@ -408,3 +408,49 @@ binding: two nodes on a clipping canvas are panned half past its top edge,
408
408
  one clipped and one not. A press over the toolbar where the clipped node's
409
409
  cut half would be reaches the toolbar, and the same press on the other
410
410
  node reaches that node.
411
+
412
+ ## Amendment: a dash is cut in the core
413
+
414
+ *2026-10-05, backlog V2.* Decision 9 left dashes out, and the options
415
+ said why: a `dash` that restarted at every join of a curve would read as
416
+ a bug. A view asked, so it is built, and not the way the option sketched.
417
+
418
+ **The sketch was a phase per quad.** `params.w` would carry how far
419
+ along the stroke each segment starts, and the backend would cut the
420
+ capsule by the pattern. That is one quad per piece whatever the pattern,
421
+ and it is a change to every renderer: the wgpu shader, and each host
422
+ that draws the list itself, which would draw a dashed stroke solid until
423
+ it caught up — with nothing to tell it so.
424
+
425
+ **What is built is a cut in the core.** A dashed stroke's run is walked
426
+ along its arc length (`line::Cut::marks`) and each mark is emitted as
427
+ what it is, a short round-capped stroke: one `Segment` quad per mark per
428
+ piece the mark lies on, two meeting at the corner for a mark that turns
429
+ one. No backend changed, the corpus pins the cut by its segment count,
430
+ and the phase is kept by construction, since the walk does not know
431
+ where the pieces end. It costs quads in proportion to the marks rather
432
+ than the pieces — a 1000 px line at a 10 px period is 100 — and a
433
+ stroke that would be more than 16384 marks, or whose mark and gap come
434
+ to under a physical pixel, draws solid.
435
+
436
+ **The lengths are the ones seen.** Caps are round (decision 2), so a
437
+ mark `on` long is a capsule with a centre line of `on − width`, and the
438
+ gap's centre line takes the difference; a mark no longer than the stroke
439
+ is wide is a dot. SVG's `stroke-dasharray` measures the centre line,
440
+ and with a round cap its `4 4` at a width of 4 is a solid line, which
441
+ is the first thing anyone writes. The first mark's cap sits where the
442
+ solid stroke's would.
443
+
444
+ **The same pattern on a `path`.** A path's stroke is a mask
445
+ ([ADR 0040](0040-a-path-is-a-mask-in-the-atlas.md), whose decision 3
446
+ left `dash` to V2), so the centre lengths go to the rasterizer and the
447
+ mask is keyed by them. The pattern restarts at each subpath, as SVG's
448
+ does. An offset that changes every frame is read as the path's shape
449
+ changing, so a marching outline takes a texture of its own instead of
450
+ an atlas slot a frame.
451
+
452
+ **What it does not do.** Neither `dash` nor `dashOffset` tweens; a
453
+ translucent dashed polyline double-blends where two halves of one mark
454
+ meet at a corner, as a solid one does at every join; and the hit region
455
+ is the whole stroke, gaps included — the target is the stroke, not the
456
+ ink.
@@ -186,3 +186,11 @@ date: 2026-09-28
186
186
  the C door, and by the corpus's `scroll-gestures` scene in four
187
187
  adapters. The scene holds a contained list at its limit and a y-only
188
188
  handler met by a sideways notch.
189
+ - *Amended (F122, RG119):* a handler that names modifiers
190
+ (`scroll_mods`) is asked before this walk, of the modifiers the
191
+ gesture began with, the innermost such handler taking it whatever
192
+ room or `contain` the regions inside it have. To a gesture it does
193
+ not hear it is what it would be with no `on_scroll`: the container
194
+ it may also be, asked like any other, and otherwise passed. The
195
+ latch keeps the modifiers with the targets. Pinned by
196
+ `tests/scroll_mods.rs`.
@@ -127,7 +127,9 @@ date: 2026-10-04
127
127
  wedge; the stroke's colour tweens as a line's does. `fillRule` is
128
128
  `nonzero` (SVG's default; a self-intersecting outline fills its
129
129
  overlaps) or `evenodd` (the `polygon`'s rule, kept there unchanged).
130
- Joins and caps are round, as a `line`'s are; `dash` stays V2's.
130
+ Joins and caps are round, as a `line`'s are; `dash` stays V2's
131
+ (built 2026-10-05: the stroke's mask is cut by the pattern, see
132
+ [ADR 0010's amendment](0010-a-segment-primitive.md#amendment-a-dash-is-cut-in-the-core)).
131
133
  4. **Rasterized on the CPU, through zeno, at the node's physical scale.**
132
134
  The ops are transformed to physical px and rasterized into an 8-bit
133
135
  coverage mask the size of the outline's bounding box plus a pixel on
@@ -181,7 +183,8 @@ date: 2026-10-04
181
183
  that is different each frame; a shape that must move cheaply every
182
184
  frame at any size stays a `polygon`, whose SDF costs the quad and
183
185
  nothing else. A path still for two frames after animating stays
184
- texture-backed; the rule is one-way, as the image's is.
186
+ texture-backed; the rule is one-way, as the image's is. *(Amended:
187
+ it returns after 120 still frames — see the second review below.)*
185
188
  9. **Hit by its outline, nonzero or even-odd as it fills.** Emission
186
189
  flattens the ops once — the same flattening the rasterizer gets — into
187
190
  `Interaction::shape_points`, and `HitShape::Polygon` gains the fill
@@ -399,6 +402,45 @@ allocation, not the core, was the difference, and the bench now builds
399
402
  its geometry once, as the polygon bench's points are. An app keeps its
400
403
  geometry too.
401
404
 
405
+ ### Second review (2026-10-05, before the alpha.36 tag and after it)
406
+
407
+ The pre-tag regression pass read the built element again (backlog
408
+ RG107–RG112). What it changed in the decisions above:
409
+
410
+ - **Decision 8 is not one-way.** An animating key whose shape has held
411
+ for 120 frames (`path::SETTLED_AFTER`, a second at 120 Hz) is a shape
412
+ again: its mask goes back to the atlas and its texture is dropped.
413
+ The latch was for what moves; held for good it also caught a key from
414
+ the tree position whose siblings came and went twice within the
415
+ window, and a spinner that had stopped — each a texture, a bind group
416
+ and a draw of its own for the life of the node. A pause shorter than
417
+ that stays out, so a spinner that stutters does not churn the page,
418
+ and moving again takes two changes, as the first time. The image's
419
+ rule is unchanged: an image's backing is declared by its updates, a
420
+ path's is inferred.
421
+ - **Decision 9, with a stroke over a fill.** A path that may paint a
422
+ fill — a `bg`, or one a hover, a press or the focus brings — and has
423
+ a stroke is hit by the fill *or* within half the stroke's width of
424
+ the stroke's pieces (`HitShape::Path`'s `stroke`), so the outer half
425
+ of a thick stroke is hit where it is painted. A stroke with no fill
426
+ at all is still `HitShape::Segments`, with its minimum grab.
427
+ - **A texture of its own is the session's to drop when its window
428
+ goes.** A core's `PathTextures` hands what it still holds to the
429
+ session as it is dropped, so the next list any window builds carries
430
+ the drop, as a removed image's does; and a frame whose list nobody
431
+ read (`Core::output`) hands its drops to the next, since an animating
432
+ path sweeps a texture every frame and a frame built twice before a
433
+ render lost one each time.
434
+ - **The flat form is checked in the core.** `Core::path_flat_node`
435
+ (and `Content::PathFlat` for a binding that lowers props) reads the
436
+ floats and raises `path-malformed` under the node's key when they are
437
+ not the form — what C and Node drew nothing for in silence and Lua
438
+ failed the view for. A number that is not finite, in either form or
439
+ in the turn, is `path-malformed` too (RG107).
440
+ - **The limit is the device's.** `kui-wgpu` opens its device with
441
+ wgpu's default limits, so 8192 is what every device it draws on
442
+ holds; the gate counts the mask's own margin (RG111).
443
+
402
444
  ## Action items — all done 2026-10-05
403
445
 
404
446
  - [x] `crates/kui-core`: `path.rs` (ops, store, `Path` builder,