@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.
@@ -0,0 +1,455 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-10-05
4
+ ---
5
+
6
+ # A gradient is an image the core paints: `gradient` on a box, rasterized once into the atlas
7
+
8
+ > **Accepted and built 2026-10-05**, the day it was proposed; the
9
+ > *Amendment* at the end records what the building changed — a malformed
10
+ > gradient is an error where it is declared and not a warning, the
11
+ > atlas's texels are straight alpha, the slot needs a gutter, and the
12
+ > ABI did not bump a second time — and *Measured* what the table of
13
+ > measurements read: the square's size holds, and the bound on a
14
+ > gradient box's cost did not. Asked the
15
+ > day `dash` was (backlog V2): "the next would be gradient backgrounds".
16
+ > [ADR 0005](0005-the-paint-vocabulary.md) declined gradients for v0 and
17
+ > wrote down why — a stop list to parse in five bindings, a type, a
18
+ > geometry, an interpolation space "that looks arbitrary in every
19
+ > choice", and on the wire either a second colour, a direction and a
20
+ > discriminant on the quad or a texture per distinct gradient. Since
21
+ > then [ADR 0015](0015-a-fragment-element-and-the-painter-it-is-not.md)
22
+ > gave the escape hatch (a `fragment` holding children is a gradient
23
+ > card today), [ADR 0025](0025-the-image-is-the-canvas.md) made an image
24
+ > a thing the core stretches over a box with linear sampling, and
25
+ > [ADR 0040](0040-a-path-is-a-mask-in-the-atlas.md) put a shape the core
26
+ > rasterizes into the atlas beside the glyphs. This document takes the
27
+ > second of ADR 0005's two wire shapes and argues it is no longer
28
+ > expensive: the gradient is rasterized on the CPU, once per distinct
29
+ > gradient, into a small image in the atlas, and the box draws it with
30
+ > the `Image` quad every backend already has. No quad kind, no shader
31
+ > change. It supersedes ADR 0005's *Gradients: out of scope for v0* when
32
+ > accepted, and nothing else of that document.
33
+
34
+ ## Context
35
+
36
+ - **What a gradient costs an app today.** `add_fragment(wgsl)` and a
37
+ `<fragment>` box: the app writes WGSL, the stops ride in sixteen
38
+ `params`, and on the wire it is a `Fragment` quad — one pipeline
39
+ switch per run of them, and nothing at all in a host whose renderer
40
+ has no WGSL. That is the right price for a ring, noise or a shimmer,
41
+ which nothing else can draw. It is a high one for "this card fades
42
+ from one colour to another", which is what was asked for, and it is
43
+ why `docs/status.md` has to say "there are no gradient props".
44
+ - **An `Image` quad already does the drawing.** It samples atlas RGBA
45
+ over its `rect`, linearly unless told `nearest`, tinted by `color`
46
+ (which is where group opacity goes) and rounded by `radius` "like a
47
+ solid". A horizontal two-stop gradient *is* a strip of texels
48
+ stretched over a box. Nothing about the quad or the backend needs to
49
+ know the texels were computed and not decoded.
50
+ - **The atlas is `Rgba8Unorm` and filtered linearly**, so what the
51
+ sampler interpolates between two texels is the straight sRGB mix —
52
+ the interpolation `Color::lerp` does for a colour transition and the
53
+ one CSS does by default. A stretched ramp and a tween agree.
54
+ - **The core already rasterizes for the atlas.** Paths are masks keyed
55
+ by a hash, made on first sight, shared by every node that draws the
56
+ same one, under the page's reset-and-copy policy. A gradient is the
57
+ same arrangement with four channels.
58
+ - **`bg` is a colour everywhere.** `VisualStyle::bg` is a `Color` on a
59
+ struct every node copies; it is what `transition`, `enter`, `exit`
60
+ and `keyframes` ease, what `hoverBg`, `pressedBg`, `focusBg` and
61
+ `dropBg` replace, what a `$token` resolves into, and a `uint32_t` in
62
+ `KuiSpec`. Widening it to "a colour or a paint" touches all of that
63
+ and the hot struct the frame benches guard.
64
+ - **Stops are a list, and one list-shaped row exists.** `keyframes` is
65
+ a list of stops parsed once in the core from the shared `Value`
66
+ (`keyframes::parse`), spelled as an array in JSX, a table in Lua and
67
+ a struct array in C. ADR 0005 named exactly this as the cost of a
68
+ stop list; it has been paid once.
69
+
70
+ ## Decisions
71
+
72
+ 1. **`gradient` is a row of its own, on anything that paints a `bg`
73
+ box.** It is not a value of `bg`. The gradient is painted **over**
74
+ `bg` and under the border and the children: `bg` stays a colour,
75
+ shows through transparent stops, and keeps everything it has —
76
+ tweens, state backgrounds, tokens. A box with only a `gradient` is
77
+ one quad; with a visible `bg` under it, two; with a border, one more
78
+ for the ring. `hoverBg` and its siblings replace `bg` as they do
79
+ now and do not touch the gradient: a gradient button's hover is a
80
+ second `gradient` chosen by the view, or a translucent `hoverBg`
81
+ under translucent stops.
82
+ 2. **Linear and radial.** A gradient is a type, a geometry and two or
83
+ more stops:
84
+ - linear — a direction, as `to` (`right`, `bottom`, `bottom right`,
85
+ the eight sides and corners) or as `angle` in turns, 0 pointing
86
+ east and running clockwise, the convention `Path::sector` and
87
+ `rotate` already have;
88
+ - radial — a centre `at` (fractions of the box, default the middle)
89
+ and an ellipse that reaches the farthest corner, CSS's default
90
+ `ellipse farthest-corner`.
91
+
92
+ Conic is not in this document (see *Considered options*).
93
+ 3. **Stops are colours with optional positions.** `[colour, at]` with
94
+ `at` in 0–1; a stop without one is spaced evenly between its
95
+ neighbours that have, the first defaulting to 0 and the last to 1,
96
+ as CSS spaces them. A position less than the one before it is
97
+ raised to it. A colour is whatever the binding's `bg` takes,
98
+ `$tokens` included, resolved where the row is lowered — so a token
99
+ stop follows the theme because the view is rebuilt when it changes,
100
+ like every other token.
101
+ 4. **Geometry is in the box's unit square.** The gradient is defined
102
+ on (0,0)–(1,1) and stretched to the box. For `to right` and
103
+ `to bottom` that is CSS exactly. For a corner it is CSS's corner
104
+ keyword exactly (`to bottom right` puts the 50% line through the
105
+ other two corners, which is what a diagonal in the unit square
106
+ stretched to the box does). For any other `angle` it is **not**
107
+ CSS's `<angle>`, which is measured in pixels and so depends on the
108
+ box's aspect: a kui `angle` of an eighth of a turn runs corner to
109
+ corner at every aspect, where CSS's `45deg` does not. This is the
110
+ decision that makes the rest cheap — see 6 — and the one place the
111
+ spelling means something a web developer has to be told.
112
+ 5. **Rasterized on the CPU, into the atlas, as an RGBA image.** A
113
+ linear gradient along an axis is a strip, 256 × 1 or 1 × 256. Every
114
+ other one — an angle, a corner, a radial — is a square, 128 × 128
115
+ (the sizes are the measurement's to confirm). The box draws it with
116
+ one `Image` quad: the box's rect, its radii, linear sampling, the
117
+ group opacity in `color`. No `QuadKind`, no field with a new
118
+ meaning, nothing for `kui-wgpu` or a C host's renderer to learn — a
119
+ host that draws an image draws a gradient.
120
+ 6. **Keyed by what it is, not where it is.** The key is a hash of the
121
+ type, the geometry and the resolved stops. Because of 4 the box's
122
+ size, aspect and scale are not in it: a gradient is rasterized once
123
+ and then every node that declares it, at any size, on any monitor,
124
+ through any resize or layout animation, draws the same slot. A
125
+ thousand rows with one gradient are one slot; a heat-mapped list
126
+ with a thousand different ones is a thousand strips, 256 texels
127
+ each.
128
+ 7. **Stops interpolate in straight sRGB, alpha premultiplied.** The
129
+ same mix as `Color::lerp`, and CSS's default. The raster computes
130
+ each texel from the stops; the sampler's linear filter between
131
+ texels is the same mix, so the two agree. Alpha is interpolated
132
+ premultiplied, as CSS does, so `transparent` to a colour does not
133
+ pass through grey. Because the raster is the core's and not a
134
+ shader's, another space (`oklab`) is later a value on the row and
135
+ thirty lines in one function, not a branch in every backend — ADR
136
+ 0005's "arbitrary in every choice" stops being a wire decision.
137
+ 8. **It does not tween.** `transition` eases `bg` under it and
138
+ `opacity` fades it with the box; a `gradient` that differs from last
139
+ frame's is simply a different gradient, drawn at once. One that
140
+ changes every frame is a raster a frame (256 texels, or 16k) and a
141
+ slot a frame, which the page's reset policy absorbs and which is not
142
+ what this is for: a shimmer, a moving sheen and anything driven by
143
+ time stay a `fragment`'s, which reads `time` and re-rasterizes
144
+ nothing.
145
+ 9. **Spelled like `keyframes`, parsed once in the core.**
146
+ - JSX: `gradient={{ to: 'bottom', stops: ['#1e2030', '#14161e'] }}`,
147
+ `gradient={{ angle: 0.125, stops: [['$accent', 0], ['#0000', 0.8]] }}`,
148
+ `gradient={{ radial: true, at: [0.5, 0], stops: [...] }}`.
149
+ - Lua: the same table, `at` and stops as `{colour, position}`.
150
+ - Rust: `NodeSpec::gradient(Gradient::linear_to(Side::Bottom, &[...]))`,
151
+ `Gradient::angle(turns, ...)`, `Gradient::radial(...)`, each stop a
152
+ `Color` or `(Color, f32)`.
153
+ - C: `const KuiGradient *gradient` on `KuiSpec` — a type, an angle,
154
+ a centre and a `KuiGradientStop` array, as `keyframes` is a
155
+ `KuiKeyframe` array — NULL for none.
156
+
157
+ A list with fewer than two stops, a `to` nobody spells or a number
158
+ that is not finite draws no gradient and raises
159
+ `gradient-malformed` once per key, as a malformed `d` does.
160
+ 10. **Boxes only.** `gradient` is honoured wherever a `bg` paints a
161
+ box. It is not a `path`'s fill, a `polygon`'s, a `line`'s stroke, a
162
+ border's colour or a text's: a path's fill is a one-channel mask
163
+ tinted by one colour, and a gradient under a mask is two textures
164
+ in one quad, which is a different document. Each is listed under
165
+ *Open questions* with what would build it.
166
+ 11. **Ghosts, hit-testing, access.** A departing box's ghost carries
167
+ its gradient's key and draws the same slot. The hit region and the
168
+ access row are the box's and do not change. The devtools' node
169
+ inspector shows the row as declared.
170
+
171
+ ## Considered options
172
+
173
+ - **Leave it: a gradient is a fragment.** What is built, and still the
174
+ answer for anything animated or procedural. Kept beside this, not
175
+ replaced by it. Rejected as the *only* answer because the commonest
176
+ paint after a flat colour should not need a shader language, a
177
+ pipeline switch per run, or a renderer that compiles WGSL.
178
+ - **A stock gradient fragment**, as `polygon` is a stock fragment: the
179
+ stops in the sixteen params, the WGSL registered by the core. No new
180
+ quad kind, exact at any angle and any stop. But three stops fill the
181
+ params (four floats each, plus geometry), it is still a pipeline
182
+ switch per run — a list of gradient rows interleaved with text is a
183
+ switch per row — and a host without WGSL draws nothing. Not taken.
184
+ - **A `Gradient` quad kind, shaded by the backend.** A ramp strip in
185
+ the atlas, its texels in `uv`, the geometry in the slots a solid does
186
+ not use, and the fragment stage computes `t` per pixel: linear,
187
+ radial and conic are three branches, hard stops are exact, and a CSS
188
+ `<angle>` in pixels is free. This is the better picture. It is also a
189
+ new branch in every renderer of the list, a kind an older host
190
+ silently does not draw, and a second texture read on a path it turns
191
+ hot. Not taken now; it is what *Measurements to take* is deciding
192
+ against, and the row's spelling is the same either way, so it can
193
+ replace decision 5 without changing an app.
194
+ - **A second colour and an angle on the solid quad.** ADR 0005's first
195
+ shape: two stops only, in `blur` and a word of `uv`. Free in quads
196
+ and exact — and three stops are a different mechanism, which is two
197
+ mechanisms. Rejected.
198
+ - **`bg` takes a gradient.** `bg="linear-gradient(…)"`, CSS's own
199
+ spelling. One row instead of two and nothing to learn. But `bg` is a
200
+ `Color` in the style every node carries, the slot five other rows
201
+ replace and four animations ease, and a `uint32_t` in C; every one of
202
+ those then has to say what it does with a paint that is not a colour.
203
+ Rejected for decision 1, which costs a row and leaves them alone.
204
+ - **A CSS string, parsed in the core** as `d` and the size expressions
205
+ are. Attractive for paste-from-a-design-tool, and `$tokens` and the
206
+ four bindings' own colour spellings would each need an answer inside
207
+ the string. Not taken; a `Gradient::parse_css` is an addition the day
208
+ someone pastes one, and does not change the row.
209
+ - **A registered resource**, `addGradient(spec) → id` and
210
+ `gradient={id}`, like a sound or an image. One `u64` a frame and no
211
+ parse. But a gradient is five numbers and two colours, not a megabyte
212
+ of pixels; a handle makes the view name something it could have
213
+ said, makes token stops stale at the next theme change, and makes a
214
+ data-driven list register and remove a thousand of them. Rejected;
215
+ the hash in decision 6 is the handle, and the core holds it.
216
+ - **Rasterize at the box's size**, as a path's mask is, keyed by size
217
+ and scale. Exact hard stops and CSS's pixel angles — and megabytes
218
+ for a window-sized background, a raster on every frame of a resize,
219
+ and the atlas's thrash rule one hero banner away. Rejected for 4
220
+ and 6.
221
+ - **Conic.** A conic gradient's seam is a hard edge at an angle, which
222
+ a 128-texel raster stretched to a box draws as a soft staircase; and
223
+ a conic stretched to a non-square box is not what anyone means. Its
224
+ uses — a colour wheel, a pie, a spinner — are a `path`'s sectors, a
225
+ `rotate` or a fragment already. Not built here; it is the first thing
226
+ the quad-kind option would be reopened for.
227
+
228
+ ## Consequences
229
+
230
+ - **A gradient costs an image quad.** No pipeline switch, no texture of
231
+ its own, the same batch as the glyphs around it. The raster is paid
232
+ the first frame a gradient is seen and after an atlas reset.
233
+ - **No renderer changes and no new quad meaning.** `kui-wgpu` is
234
+ untouched; a C host draws gradients the day it links the new
235
+ library. The wire grows a row, not a kind.
236
+ - **Hard stops are soft.** Two stops at one position are an edge in the
237
+ raster, and the raster is stretched: along a strip the edge is the
238
+ box's length over 256 wide (4 px on a 1000 px box), on a square it
239
+ is a 128th of the box each way. A smooth gradient cannot show this;
240
+ stripes can. Stripes are boxes, or a fragment, and the row's doc
241
+ says so.
242
+ - **`angle` is not CSS's.** Decision 4. The doc comment, `props.md` and
243
+ the how-to each say it in one sentence, with the corner case as the
244
+ example.
245
+ - **8-bit banding is what it is everywhere.** A ramp of 8 levels over
246
+ 1000 px bands in a browser and bands here; the atlas holds what the
247
+ framebuffer holds. No dither.
248
+ - **Up to three quads for a bordered gradient box over a `bg`.** The
249
+ ring is a solid with a transparent fill, drawn after the image so
250
+ the gradient does not cover the border's inner edge. A `quadCount`
251
+ budget should expect it.
252
+ - **A second row to explain beside `bg`.** "The gradient paints over
253
+ the background" is the sentence; the state backgrounds not replacing
254
+ it is the surprise, and gets a how-to entry.
255
+ - **ABI and wire.** `KuiSpec` gains a pointer and two structs are new,
256
+ so `KUI_ABI_VERSION` bumps; the Node wire bumps for the row's value
257
+ kind. No quad or draw-data layout changes.
258
+ - **ADR 0005 is partly superseded**, and `docs/status.md` loses "no
259
+ gradient props" for "linear and radial on a box; conic, animated and
260
+ anything on a shape are a fragment's".
261
+
262
+ ## Open questions
263
+
264
+ - **The atlas's alpha convention.** Decision 7 wants premultiplied
265
+ interpolation; whether the image slots hold straight or premultiplied
266
+ RGBA, and so what the sampler's own filter does between a transparent
267
+ and an opaque texel, is to be read in `atlas.rs` and the shader before
268
+ building, and decides whether the raster stores premultiplied texels
269
+ or straight ones with the colour bled into the transparent side.
270
+ - **The half-texel at each end.** An `Image` quad maps its rect to the
271
+ slot's whole texels, so the outer half-texel of a ramp is flat: a
272
+ 512th of the length on a strip, a 256th on a square. Probably
273
+ invisible; if not, the raster extrapolates its end texels by half a
274
+ step rather than the quad learning fractional `uv`.
275
+ - **Does the slot bleed.** Slots are padded a texel; a stretched image
276
+ samples at its edge. Images already do this, so it should hold — to
277
+ confirm with a coverage test, not by reading.
278
+ - **A gradient under a mask**: a `path`'s fill, and with it a
279
+ polygon's, a text's and a stroke's. One quad sampling a mask and a
280
+ ramp is the quad-kind option again. **Condition:** a view that wants
281
+ a gradient-filled shape and cannot stack a fragment — a chart's area
282
+ fill is the likely first.
283
+ - **`gradient` in `keyframes`, `enter` and `exit`.** Not eased
284
+ (decision 8). A crossfade between two slots is two quads and an
285
+ alpha, cheap and not a true interpolation of stops; worth doing only
286
+ if a view asks for a gradient that changes on hover without popping.
287
+ - **`oklab`.** Decision 7 leaves the door; whether it should have been
288
+ the default is a taste question the default-is-CSS answer sidesteps.
289
+ - **Repeating gradients and `bgImage`.** A box whose background is any
290
+ image, with `fit` — CSS's `background-image` — falls out of the same
291
+ three-quad stack and is a row this document deliberately does not
292
+ add; `repeating-linear-gradient` would want it to tile.
293
+
294
+ ## Measurements to take
295
+
296
+ | bench or test | what | held to |
297
+ |---|---|---|
298
+ | `frame_10k_rects_with_gradient` | the plain 10k grid, every cell declaring one shared gradient | within 10% of `frame_10k_rects`: the row's parse, a hash and an image quad for a solid one |
299
+ | `frame_1k_distinct_gradients` | 1k rows, each its own strip, steady state | within noise of the above per node: all hits |
300
+ | `raster_gradient_strip`, `raster_gradient_square` | one 256 × 1 and one 128 × 128 raster, three stops | recorded; the square is the number that says what a changing gradient costs a frame |
301
+ | `frame_10k_rects` and the other guarded rows | unchanged trees | unchanged: the row must cost nothing where it is not declared (`NodeSpec` and `emit_node` are the risk, as C41 and C48 found) |
302
+ | `kui-wgpu/tests/gradient_coverage.rs`, two stops | a strip stretched to 1000 px against the mix computed per pixel | every pixel within one 8-bit level |
303
+ | the same, a corner and a radial on a 3:1 box | the 128² square against the per-pixel reference | largest difference recorded; proposed bound two levels for a smooth ramp — the number that confirms or reopens the square's size |
304
+ | the same, a hard stop | the width of the soft edge | recorded, and quoted in the row's doc |
305
+ | the same, transparent to opaque | the midpoint's colour | the premultiplied mix, not grey |
306
+
307
+ If the corner and radial rows cannot be held with a square the atlas
308
+ can afford, the answer is the `Gradient` quad kind under *Considered
309
+ options*, and decisions 1–4 and 6–11 stand as written.
310
+
311
+ ## Amendment: what the building changed (2026-10-05)
312
+
313
+ - **A malformed gradient is an error, not a warning.** Decision 9
314
+ proposed `gradient-malformed`, once per key. The row is carried and
315
+ parsed exactly as `keyframes` is, and a `keyframes` list that does not
316
+ parse fails the view in Node and Lua where it is declared; a second
317
+ convention for the row beside it would be the surprise. So: a `to`
318
+ nobody spells, an unknown field, fewer than two stops in the list or
319
+ a number that is not one is an error from `gradient::parse_with`,
320
+ with the field named. What still draws nothing in silence is a
321
+ gradient that *parsed* and has nothing to paint — every stop a token
322
+ that missed (each raised as `unknown-token`), or one built in Rust or
323
+ C with one stop or a NaN. No warning code was added.
324
+ - **The atlas holds straight alpha** (open question 1). An `Image`
325
+ quad's shader multiplies the texel's rgb by the tint and its alpha by
326
+ the coverage separately, so the texels are straight. The raster mixes
327
+ the stops premultiplied, as decision 7 says, and stores the result
328
+ un-premultiplied; a texel with no alpha keeps the straight mix of its
329
+ neighbours' colours, so the sampler does not darken the texel beside
330
+ it. `a_fade_to_transparent_keeps_its_colour` pins the mix.
331
+ - **The slot has a gutter, and it is not empty** (open questions 2
332
+ and 3; see *Measured*). The raster is the strip or the square with
333
+ one texel more all round, each the gradient carried on past the edge,
334
+ and the quad's `uv` is the rect inside. As first built there was no
335
+ gutter, and the coverage test's arithmetic said why there must be
336
+ before the test was written: a stretched quad's sampler reads half a
337
+ texel past the rect it is given, and a strip one texel high, drawn
338
+ over a box forty pixels high, was the gradient only along its middle
339
+ row and faded into whatever the atlas held above and below it.
340
+ - **One table in the atlas, not two.** A gradient's slot lives in the
341
+ keyed table a path's mask does (`get_or_insert_gradient` beside
342
+ `get_or_insert_path`), copied across a reset the same way. The keys
343
+ are hashes of different things; nothing else tells them apart, and
344
+ nothing needs to.
345
+ - **Where the quad goes.** `paint_box` is unchanged — it is on every
346
+ node's path and the frame benches guard it. A cold `paint_gradient`
347
+ runs after it, on frames whose tree has a gradient at all
348
+ (`Tree::any_gradient`), and puts the image where it belongs among the
349
+ quads the box just pushed: after the shadow and the background,
350
+ before the content, with the border lifted off the background's solid
351
+ into a transparent-filled ring above. A ghost reads the same row off
352
+ the spec it kept.
353
+ - **C's stops place themselves with a negative `at`.** `KuiGradientStop`
354
+ is a colour and an `at`; less than zero is "spaced between its
355
+ neighbours", since 0 is a position.
356
+ - **The ABI is 24, not 25.** `dash` had already bumped it in the same
357
+ unreleased section; `gradient` on `KuiSpec` (64-bit size 696) and the
358
+ two structs are in the same step. The Node wire stays v21: the row
359
+ rides as a string reference like `keyframes`, and a new row is not a
360
+ new layout.
361
+ - **`gradient` on a stroke or a fill is ignored**, in the live pass and
362
+ the ghost's: a `line`, a `polygon` and a `path` paint no box.
363
+
364
+ ### Measured (2026-10-05, an M3 Pro; backlog V9)
365
+
366
+ `kui-wgpu/tests/gradient_coverage.rs` mirrors the shader's image branch
367
+ on the CPU — the atlas sampled linearly, nothing clamped to the slot —
368
+ over the quads a core emitted, with other gradients in the atlas on
369
+ either side, and compares every pixel of the box with the gradient
370
+ computed at that pixel, in 8-bit levels on the worst channel:
371
+
372
+ | what | box | worst pixel |
373
+ |---|---|---|
374
+ | a strip, each of the four sides | 1000 × 40 | 0.49 |
375
+ | a strip, black to white | 1000 × 40 | 0.50 |
376
+ | the square, a corner | 600 × 200 | 0.48 |
377
+ | the square, `angle` 0.07 | 600 × 200 | 0.52 |
378
+ | the square, a corner, black to white | 600 × 200 | 0.50 |
379
+ | the square, radial from the middle | 600 × 200 | 1.20 |
380
+ | the square, radial from the top edge | 600 × 200 | 0.75 |
381
+ | three stops, strip and corner | as above | 0.50 |
382
+
383
+ Half a level is the rounding of the texels themselves, so a linear
384
+ gradient through 128 texels is exact — a linear ramp is what a linear
385
+ filter reproduces — and **the square's size holds**: the proposed bound
386
+ was two levels, and the worst case, a radial's curvature between
387
+ texels, is 1.2. A hard stop on a strip over 1000 px is 4 px wide. A
388
+ fade to transparent is its colour at every alpha. Without the gutter
389
+ the same tests read 127 and 169 levels at the edges, which is the bug
390
+ the amendment above records.
391
+
392
+ `benches/frame.rs`, medians:
393
+
394
+ | bench | what | median |
395
+ |---|---|---|
396
+ | `frame_10k_rects` | the plain grid | 770 µs |
397
+ | `frame_10k_rects_with_gradient` | every cell's solid a gradient instead, all the same | 1.29 ms |
398
+ | `frame_1k_rects` | 32 × 32, plain | 78.3 µs |
399
+ | `frame_1k_shared_gradient` | the same, one gradient | 131 µs |
400
+ | `frame_1k_distinct_gradients` | the same, a gradient each | 134 µs |
401
+ | `raster_gradient_strip` | 258 × 3 texels, three stops | 4.0 µs |
402
+ | `raster_gradient_square` | 130 × 130, a corner | 24.8 µs |
403
+ | `raster_gradient_radial` | 130 × 130 | 41.9 µs |
404
+
405
+ (These five were taken again by the alpha.37 pre-tag pass, the same
406
+ day. As first measured they read 831 µs, 1.38 ms, 84.1, 140 and 143 µs:
407
+ the bench's grid chose a cell's paint in one `match` with the gradient
408
+ arms in it, and that cost every row built on the grid about 6 ns a
409
+ cell — `frame_10k_rects` 8% over alpha.36's with no line of the core
410
+ between them. The gradient cell is a function of its own now and the
411
+ plain rows are what they were.)
412
+
413
+ **The proposed bound on the first row was not held, and was the wrong
414
+ bound.** "Within 10% of `frame_10k_rects`" assumed a gradient box costs
415
+ what a solid one does. It costs about **52 ns more a node**: half of it
416
+ is the boxed group of rare rows the gradient lives in, which a
417
+ `hoverBg` pays as well (28 ns, measured by giving the plain grid a row
418
+ from that group), and the rest is the stops built and hashed by the
419
+ view every frame, the atlas lookup and the call out of `emit_node`. As
420
+ first built it was 105 ns; the direction is now read off a table for
421
+ the sides and corners instead of a cosine and a sine three times a
422
+ node, and up to four stops are held in place instead of in two lists.
423
+ The rasters were 90 µs and are 25: a square's texels read a ramp mixed
424
+ once instead of each mixing its own.
425
+
426
+ What it means: a card, a header and a row of buttons are microseconds,
427
+ and ten thousand gradient boxes are half a millisecond over ten
428
+ thousand flat ones — not free, and not what the row is for. Distinct
429
+ gradients cost 3 ns a node over a shared one. None of it is the wire
430
+ shape's doing: the quad-kind option would build, hash and box the same
431
+ row. What would lower it is the registered handle *Considered options*
432
+ set aside, or a `Gradient` an app builds once and clones; neither is
433
+ built, and the condition is a view that declares thousands.
434
+
435
+ The guarded rows are within noise of alpha.36 (`bench-check`): a tree
436
+ with no gradient pays one flag test a node.
437
+
438
+ ## Action items — all done 2026-10-05
439
+
440
+ 1. Read the atlas's alpha convention and its image sampling at a slot's
441
+ edge; settle the first three open questions.
442
+ 2. `gradient.rs` in the core: the type, `parse(&Value)`, the stop
443
+ spacing, the two rasters, the hash; unit tests against a per-pixel
444
+ reference.
445
+ 3. The atlas slot, the `Image` quad and the three-quad order in
446
+ `emit`; the ghost.
447
+ 4. The row in `schema::PROPS` and its four doors; `KuiGradient` and the
448
+ ABI bump; the Node wire bump; `gradient-malformed`.
449
+ 5. A `gradients` scene in the corpus; the benches and coverage tests
450
+ above.
451
+ 6. The rainbow in `examples/rust/apps/loaders.rs` — a gradient two
452
+ tracks long slid under a clip, one strip for good — in place of the
453
+ `features/gradient.rs` first proposed; the how-to
454
+ entry rewritten ("a gradient: the row; a ring, noise or a shimmer:
455
+ a fragment"); `status.md`, ADR 0005's note, the changelog.
package/encoder.js CHANGED
@@ -87,6 +87,23 @@ export function createEncoder(P) {
87
87
  // The wire index of `$name` in the `kind` space, or `undefined` for a
88
88
  // name that resolved to nothing or to the other kind — reported like an
89
89
  // unknown prop, and the slot is left out so the core keeps its default.
90
+ // A stroke's `dash` and `dashOffset` as the five floats the stream
91
+ // carries — a mark, a gap, a mark, a gap, the offset — or null for a
92
+ // solid stroke. `dash` is one length (marks and gaps alike), a
93
+ // [mark, gap] pair, or four lengths for a dash-dot (backlog V2).
94
+ function dashOf(p, tag) {
95
+ const d = p.dash;
96
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
97
+ if (p.dashOffset !== undefined && !finite(p.dashOffset)) throw new Error(`bad dashOffset ${JSON.stringify(p.dashOffset)} for <${tag}> (px, a number)`);
98
+ if (d == null) return null;
99
+ const l = typeof d === 'number' ? [d] : d;
100
+ if (!Array.isArray(l) || !(l.length === 1 || l.length === 2 || l.length === 4) || !l.every(finite)) {
101
+ throw new Error(`bad dash ${JSON.stringify(d)} for <${tag}> (a length, [mark, gap] or [mark, gap, mark, gap], in px)`);
102
+ }
103
+ const [a, b = a, c = a, e = b] = l;
104
+ return [a, b, c, e, p.dashOffset ?? 0];
105
+ }
106
+
90
107
  function tokenRef(v, kind) {
91
108
  const name = v.slice(1);
92
109
  const hit = ROLE_TOKENS.get(name) ?? tokens?.get(name);
@@ -597,6 +614,7 @@ export function createEncoder(P) {
597
614
  case 'tag':
598
615
  case 'keyframes':
599
616
  case 'enter':
617
+ case 'gradient':
600
618
  strRef(JSON.stringify(v));
601
619
  break;
602
620
  case 'str':
@@ -1106,16 +1124,18 @@ export function createEncoder(P) {
1106
1124
  if (p.rotate !== undefined && (typeof p.rotate !== 'number' || !Number.isFinite(p.rotate))) throw new Error(`bad rotate ${JSON.stringify(p.rotate)} for <path> (turns, a number)`);
1107
1125
  const pivot = p.pivot;
1108
1126
  if (pivot !== undefined && !(Array.isArray(pivot) && pivot.length === 2 && pivot.every((n) => typeof n === 'number' && Number.isFinite(n)))) throw new Error(`bad pivot ${JSON.stringify(pivot)} for <path> ([x, y])`);
1109
- reserve(10 + (flat ? flat.length : 0));
1127
+ const pathDash = dashOf(p, 'path');
1128
+ reserve(15 + (flat ? flat.length : 0));
1110
1129
  f[fi++] = OP.path;
1111
1130
  strRef(flat ? null : d);
1112
1131
  f[fi++] = flat ? flat.length : 0;
1113
1132
  if (flat) for (const n of flat) f[fi++] = n;
1114
1133
  f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 0;
1115
- f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0);
1134
+ f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0) | (pathDash ? 16 : 0);
1116
1135
  f[fi++] = p.rotate ?? 0;
1117
1136
  f[fi++] = pivot ? pivot[0] : 0;
1118
1137
  f[fi++] = pivot ? pivot[1] : 0;
1138
+ if (pathDash) for (const n of pathDash) f[fi++] = n;
1119
1139
  props(p, el.key, false);
1120
1140
  return;
1121
1141
  }
@@ -1135,7 +1155,8 @@ export function createEncoder(P) {
1135
1155
  // one that does not resolve is left out, the default stroke.
1136
1156
  const widthRef = isRef(p.width) ? tokenRef(p.width, 'length') : undefined;
1137
1157
  if (p.width !== undefined && typeof p.width !== 'number' && !isRef(p.width)) throw new Error(`bad width ${JSON.stringify(p.width)} for <line> (a stroke width in px, or a "$length")`);
1138
- reserve(6 + pts.length * 2);
1158
+ const lineDash = dashOf(p, 'line');
1159
+ reserve(11 + pts.length * 2);
1139
1160
  f[fi++] = OP.line;
1140
1161
  f[fi++] = pts.length;
1141
1162
  for (const pt of pts) {
@@ -1146,7 +1167,8 @@ export function createEncoder(P) {
1146
1167
  f[fi++] = pt[1];
1147
1168
  }
1148
1169
  f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 1;
1149
- f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0);
1170
+ f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0) | (lineDash ? 4 : 0);
1171
+ if (lineDash) for (const n of lineDash) f[fi++] = n;
1150
1172
  props(p, el.key, false);
1151
1173
  return;
1152
1174
  }
package/howto.md CHANGED
@@ -52,6 +52,21 @@ a nine-point curve over ~50 px spans is ~60 quads.
52
52
  [ADR 0010](docs/adr/0010-a-segment-primitive.md) ·
53
53
  [alpha.7](CHANGELOG.md#010-alpha7-2026-09-06)
54
54
 
55
+ ### How do I draw a dashed line, a dotted one, or a marquee's marching ants?
56
+
57
+ `dash` on a `<line>` or on a `<path>`'s stroke: `dash={[6, 4]}` is 6 px
58
+ marks with 4 px gaps, `dash={4}` the same length for both, and four
59
+ lengths are a dash-dot. The lengths are what you see — every mark has the
60
+ stroke's round caps — so a mark no longer than the stroke is wide is a
61
+ dot: `width={3} dash={[3, 5]}` is a dotted line. The pattern runs along
62
+ the whole stroke, round corners and along a `curve`. For marching ants,
63
+ grow `dashOffset` from a tick; the marks move towards the first point.
64
+ A dashed line costs a quad per mark, and is still hit in its gaps.
65
+
66
+ [`line` element](props.md#elements) ·
67
+ [ADR 0010's amendment](docs/adr/0010-a-segment-primitive.md#amendment-a-dash-is-cut-in-the-core) ·
68
+ `cargo run --example line`
69
+
55
70
  ### How do I make a tab bar whose tabs stop shrinking at their labels?
56
71
 
57
72
  Give every tab `width="grow"` and `minWidth="fit"`: they split the bar evenly
@@ -277,6 +292,23 @@ is the names alone.
277
292
  [Doors](props.md#doors) ·
278
293
  [alpha.21 `### Added`](CHANGELOG.md#010-alpha21-2026-09-26)
279
294
 
295
+ ### How do I choose what stands in for a character my font lacks?
296
+
297
+ Name the fonts, in the order they should be asked:
298
+ `ctx.setFallbackFonts([icons, shipped, mono])` (Rust
299
+ `Core::set_fallback_fonts(&[..])`, C `kui_font_set_fallback`), each an id
300
+ from `addFont`, `addSystemFont` or `loadFontFile`. A character the
301
+ text's own family has no glyph for goes to the first of them that has
302
+ it, and only then to the platform's list — whose first choice on macOS
303
+ is the system's interface face, so Cyrillic in a Latin-only monospaced
304
+ family comes out proportional. The list is the session's, for every
305
+ family and every kind of text; `[]` is the platform's alone. Set it
306
+ when the choice changes, not every frame with a new list: a new list
307
+ shapes every text again (the same one twice costs nothing). A cell grid
308
+ goes one step further by itself: with no fallback of yours that has the
309
+ character it asks a monospaced face before the platform's, and fits and
310
+ centres whatever it got in the cell.
311
+
280
312
  ### How do I see a font the user installed while the app runs?
281
313
 
282
314
  On macOS and Windows a window app does nothing: the runner hears the OS
@@ -743,6 +775,31 @@ rows.
743
775
  [`transition` row](props.md#container-props) ·
744
776
  [alpha.17](CHANGELOG.md#010-alpha17-2026-09-25)
745
777
 
778
+ ### How do I zoom with Ctrl or ⌘ and the wheel?
779
+
780
+ Put an `onScroll` on the node that zooms and say which keys it is for:
781
+ `<box onScroll={{ kind: 'zoom' }} scrollMods="ctrl super">` (Rust
782
+ `.on_scroll(tag).scroll_mods(KeyMods::NONE.with_ctrl().with_super())`,
783
+ C `.scroll_mods = KUI_KMOD_CTRL | KUI_KMOD_SUPER`, Lua `scroll_mods =
784
+ "ctrl super"`). A wheel turned with one of them held is then that
785
+ node's `scroll` event wherever under the pointer it began — over a
786
+ list inside it, and the list does not move — and a plain wheel passes
787
+ the node by, so the lists go on scrolling — the node itself too, if it
788
+ is the list: a scroll container that names a key for its own zoom
789
+ scrolls for every other wheel. On the window's root it is
790
+ the whole window's zoom; a canvas inside can name the same key and take
791
+ the gesture for itself, the innermost winning. Positive `dy` is the
792
+ wheel rolling up, "bigger". A mouse's notch is 40 px and a trackpad's
793
+ swipe comes in small pixel steps, so add `dy` up and step when the sum
794
+ crosses your notch rather than once an event. The event's `mods` are
795
+ the keys held when the gesture began: a swipe begun with the key held
796
+ stays yours to the end of its glide even if the key was let go, and
797
+ one begun without it never becomes yours, so there is nothing to latch
798
+ yourself. Without the row an `onScroll` cannot do this: every scroll
799
+ container inside it takes the wheel first.
800
+
801
+ [`scrollMods` row](props.md#container-props)
802
+
746
803
  ### Why does a swipe down not move the strip sideways?
747
804
 
748
805
  A trackpad swipe keeps to the axis it started on, so nothing is yours
@@ -1326,7 +1383,28 @@ them sharing an edge show a hairline of the background through it.
1326
1383
  [ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md) ·
1327
1384
  `cargo run --example path` · `cargo run --example polygon`
1328
1385
 
1329
- ### How do I draw a gradient, a ring, or anything the paint props cannot?
1386
+ ### How do I give a box a gradient background?
1387
+
1388
+ `gradient` on the box: `gradient={{ to: 'bottom', stops: ['#1e2030',
1389
+ '#14161e'] }}` runs to a side or a corner, `{ angle: 0.125, stops }` along
1390
+ a direction in turns clockwise from east, and `{ radial: true, at: [0.5,
1391
+ 0], stops }` out from a centre. A stop is a colour or `[colour, position]`.
1392
+ It paints over `bg` and under the border and the children, so a scrim is
1393
+ a gradient with a transparent stop over whatever is beneath. The geometry
1394
+ is the box's unit square stretched to the box — a corner is CSS's corner,
1395
+ and an `angle` runs corner to corner at an eighth of a turn whatever the
1396
+ aspect, which CSS's `45deg` does not.
1397
+
1398
+ It costs one image quad and is rasterized once per distinct gradient, so
1399
+ it does not tween and `hoverBg` does not replace it: to move a gradient,
1400
+ move the box that has it (the rainbow in `loaders` is one gradient slid
1401
+ under a clip); to animate its colours, write a fragment.
1402
+
1403
+ [`gradient` row](props.md#container-props) ·
1404
+ [ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md) ·
1405
+ `cargo run --example loaders`
1406
+
1407
+ ### How do I draw a ring, noise, a shimmer, or anything the paint props cannot?
1330
1408
 
1331
1409
  Write a fragment. `add_fragment(wgsl)` validates one WGSL function and hands
1332
1410
  back a handle; `<fragment src={id} params={[…]} animate>` is a box that