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

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,260 @@ 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.37 (2026-10-05)
25
+
26
+ **What breaks.**
27
+
28
+ - Rust: `HitShape::Path` gains a `stroke` field and `Content` a
29
+ `PathFlat` variant (under Fixed), so a pattern over either needs
30
+ them; `HitShapes::path` takes the stroke's width after the rule.
31
+ - A `path` with a stroke over a fill is hit out to the stroke's edge,
32
+ where the half of the stroke outside the outline fell through; and a
33
+ stroked path with no `bg` but a `hover_bg`, `pressed_bg` or
34
+ `focus_bg` is hit inside, where it was hit along its stroke only
35
+ (under Fixed).
36
+ - Lua: `path { ops = }` whose numbers are not the flat form draws
37
+ nothing with a `path-malformed` warning, where it was an error that
38
+ failed the view (under Fixed).
39
+ - A `path` that animated and then held still for 120 frames draws from
40
+ the atlas again — a `GlyphMask` quad where it stayed a `Texture`
41
+ (under Fixed). Nothing an app sees; a host that counts quads by kind
42
+ does.
43
+ - C: `kui_polyline`, `kui_path` and `kui_path_d` take a `dash` before
44
+ `spec` — five floats, or NULL for the solid stroke they drew (under
45
+ Added). `KUI_ABI_VERSION` is 24; pass NULL and recompile. The same
46
+ step appends `gradient` to `KuiSpec` (64-bit size 696).
47
+ - Rust: `schema::Kind`, `Apply` and `Parsed` gain a `Gradient` variant
48
+ and `InteractSpec` a `gradient` field (under Added).
49
+ - Rust: `Stroke` gains a `dash` field, so a struct literal needs it
50
+ (`Stroke::new` does not); `path::MaskPaint` gains `Dashed`.
51
+ - A cell grid's glyph for a character its family lacks is drawn from a
52
+ monospaced face where one has it, in the middle of its cell, and no
53
+ wider than it (F120, under Fixed), where it was the platform's first
54
+ fallback at its own width from the cell's left edge.
55
+ - Rust: `Tree` gains an `any_gradient` field.
56
+
57
+ The Node wire moves to v21 for the dash's five floats on a line and a
58
+ path, which an encoder and addon of one release never see apart.
59
+
60
+ ### Added
61
+
62
+ - **`gradient` on a box, in every binding**
63
+ ([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
64
+ which supersedes ADR 0005's "no gradients"): `gradient={{ to:
65
+ 'bottom', stops: ['#1e2030', '#14161e'] }}`, `{ angle: 0.125, stops }`
66
+ in turns clockwise from east, `{ radial: true, at: [0.5, 0], stops }`;
67
+ the same table in Lua; `NodeSpec::gradient(Gradient::to(Side::Bottom,
68
+ [...]))` with `Gradient::angle` and `Gradient::radial_at`; and a
69
+ `const KuiGradient *` on `KuiSpec`. A stop is a colour — a `$token`
70
+ too — or a colour and a position.
71
+ - **Over `bg`, under the border and the children.** `bg` stays a
72
+ colour and keeps its tweens, tokens and state backgrounds; a
73
+ transparent stop shows it through.
74
+ - **An image the core paints.** Each distinct gradient is rasterized
75
+ once into the glyph atlas — a 256-texel strip along an axis, a
76
+ 128-texel square otherwise, each with a gutter of the gradient
77
+ carried a texel past its edges, since a stretched quad samples
78
+ there — and drawn as one `Image` quad, so no
79
+ renderer changes and a host that draws an image draws a gradient.
80
+ The key is the gradient and not the box: a resize, another scale
81
+ and a thousand boxes sharing one rasterize nothing.
82
+ - **On the box's unit square.** A side or a corner is CSS's; any
83
+ other `angle` runs corner to corner at an eighth of a turn at any
84
+ aspect, where CSS's pixel-measured `45deg` does not. Stops mix in
85
+ straight sRGB with the alpha premultiplied.
86
+ - **What it does not do.** It does not tween and the state
87
+ backgrounds do not replace it; a hard stop is as soft as the raster
88
+ stretched (a 256th of the box along a strip); there is no conic
89
+ one, and none on a `path`, a `line`, a border or a text. Those, and
90
+ anything animated, stay a `fragment`'s.
91
+ - Measured (the ADR's *Measured*): a linear gradient is within half
92
+ an 8-bit level of the mix computed per pixel at every pixel of the
93
+ box, a radial within 1.2; a gradient box costs about 52 ns over a
94
+ flat one, half of it what any `hoverBg` pays; a raster is 4 µs for
95
+ a strip and 25 to 42 for a square.
96
+ - The corpus gains a `gradients` scene; `examples/rust/apps/loaders.rs`
97
+ gains a rainbow — one gradient two tracks long slid under a clip,
98
+ one strip in the atlas for good.
99
+
100
+ - **`dash` on a `line` and on a `path`'s stroke, in every binding**
101
+ (backlog V2, the amendment in
102
+ [ADR 0010](docs/adr/0010-a-segment-primitive.md)): `<line dash={[6, 4]}
103
+ dashOffset/>`, `line { dash = {6, 4}, dash_offset = }`,
104
+ `Stroke::new(w, c).dash(6.0, 4.0).dash_offset(n)` (`Dash::of` for a
105
+ pattern read from data), and a `const float *dash` on `kui_polyline`,
106
+ `kui_path` and `kui_path_d`. One length is marks and gaps alike, two
107
+ are a mark and a gap, four a dash-dot.
108
+ - **The lengths are the ones seen.** Every mark is a short stroke
109
+ with the stroke's own round caps, so `6, 4` is 6 px of ink and 4 px
110
+ of nothing at any width and a mark no longer than the stroke is
111
+ wide is a dot. SVG's `stroke-dasharray` measures the centre line,
112
+ which with round caps makes `4 4` at a width of 4 a solid line;
113
+ this pattern is SVG's `mark − width, gap + width`.
114
+ - **The pattern keeps its phase** along the whole stroke — round the
115
+ corners of a polyline and across the pieces of a curve, which is
116
+ what ADR 0010 would not ship without — and restarts at each subpath
117
+ of a `path`, as SVG's does. `dashOffset` starts that far into it;
118
+ growing it moves the marks towards the first point, a marquee's
119
+ marching ants. Neither tweens.
120
+ - **No renderer changes.** A dashed line is one `Segment` quad per
121
+ mark per piece the mark lies on, cut in the core, so a host that
122
+ draws a stroke draws a dashed one; a dashed path stroke is cut by
123
+ the rasterizer into the mask it already was. A path whose offset
124
+ changes every frame is a shape that changes every frame, and
125
+ leaves the atlas for a texture of its own while it marches.
126
+ - A pattern with no gap, a mark and gap under a physical pixel
127
+ together, or more than 16384 marks draws solid. A dashed stroke is
128
+ hit along its whole length, gaps included.
129
+ - The corpus's `lines` and `paths` scenes carry one each, so the
130
+ segment count pins the cut in four bindings;
131
+ `examples/rust/widgets/line.rs` hangs its planned cards off dashed
132
+ links that march under the pointer.
133
+
134
+ - **The fallback fonts are the app's to name** (backlog F121, from
135
+ kawoosh). `Core::set_fallback_fonts(&[FontId])` — C
136
+ `kui_font_set_fallback`, Node `ctx.setFallbackFonts(ids)` — lists the
137
+ fonts asked, in order, for a character the text's own family has no
138
+ glyph for, before the platform's list, whose first choice on macOS is
139
+ the system's proportional face. For every text in the session — an
140
+ editor already open and a cell grid too — and kept across
141
+ `reload_system_fonts`; an empty list is the platform's alone.
142
+ `Core::fallback_fonts` reads the families back.
143
+
144
+ ### Fixed
145
+
146
+ - **A cell grid's fallback glyph stays in its cell** (backlog F120, from
147
+ kawoosh: Russian text in a terminal whose family has no Cyrillic, `Ю`
148
+ drawn across the letter after it). A character the grid's family has
149
+ no glyph for is asked of a monospaced face before the platform's
150
+ fallback list — on macOS that list opens with the system's
151
+ proportional face; a glyph still wider than its cells, two under
152
+ `wide`, is shaped at the size it fits at; and the room a narrower one
153
+ leaves is shared either side. The family's own glyphs and the private
154
+ use area's icons draw as they did.
155
+
156
+ - **What the `path` reviews left** (backlog RG112, the second review in
157
+ [ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md)):
158
+ - A window closed while it showed an animating or a big path left the
159
+ path's texture with the renderer for the life of the process; the
160
+ core now hands it to the session as it goes, and the next frame any
161
+ window draws drops it. A frame built and never read keeps its
162
+ `dropped_textures` and `dropped_fragments` for the next one.
163
+ - A path that stopped moving stops costing a texture and a draw of
164
+ its own: after 120 still frames its mask is the atlas's again. A
165
+ row of icons keyed by position, whose neighbours came and went
166
+ twice within eight frames, read as animating and stayed so for
167
+ good.
168
+ - A stroke over a fill is hit as far as it is painted
169
+ (`HitShape::Path`'s `stroke`).
170
+ - Ops that are not the flat form raise `path-malformed` under the
171
+ node's key in every binding (`Core::path_flat_node`,
172
+ `Content::PathFlat`); C and Node drew nothing in silence.
173
+ - JSX types `width` on `line` and `path` as a number or a `$length`,
174
+ as the encoder reads it; `Core::image_pixels` answers for the
175
+ handle a path's `Texture` quad names.
176
+
177
+ - **What this release's own pre-tag pass found** (backlog RG114–RG117;
178
+ RG118 holds what it read and left), none of it in a release before
179
+ this one:
180
+ - An editor open when `set_fallback_fonts` was called kept the faces
181
+ its lines were shaped in until they were edited (RG114).
182
+ - The root's `gradient` was dropped while the devtools panel was
183
+ docked (RG115).
184
+ - A dashed `line` whose points coincide drew nothing and was still
185
+ hit; it is the dot its solid one is (RG116).
186
+ - A gradient's `radial` that is not a boolean is an error, where it
187
+ read as linear; its row says that fewer than two stops fail the
188
+ view in JSX and Lua and draw nothing in Rust and C; `kui.h` says
189
+ what a zeroed `KuiGradient` is (RG117).
190
+
191
+ **What you can delete.** A family chosen only because it covers a
192
+ script the preferred one lacks, which can give way to the preferred
193
+ one with the fallback list named; the key an app gave a still icon only so a
194
+ neighbour's coming and going would not move it to a texture; the
195
+ invisible wider path laid under a thick-stroked one to catch the press
196
+ on its edge; the WGSL a card's two-colour fade was written in, and the
197
+ `fragment` that held its children; the loop that walked a connector's points and declared a
198
+ `line` per dash, and the arithmetic that kept its phase round a corner.
199
+
200
+ ### Native verification
201
+
202
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-05 — the
203
+ nine commits after the alpha.36 tag: `dash`, the `gradient` row, F120,
204
+ F121 and RG112 — with a regression pass over them first: three
205
+ read-only reviews (the dash, the gradient, the fonts with this
206
+ release's docs), each claim probed with a test before anything
207
+ changed. RG114–RG117 came of it and are in this release; RG118 holds
208
+ what was read and left. This round ran on the Mac alone; Windows and
209
+ Linux did not run it for this tag.
210
+
211
+ **macOS 27.0.1 on an M3 Pro MacBook Pro, rustc 1.99.0 (the toolchain
212
+ CI runs), Node 26.10.0, nu 0.116.0**, on the release commit's tree,
213
+ the workspace's own artifacts pruned and rebuilt. `cargo fmt --all
214
+ --check` and `cargo clippy --workspace --all-targets -- -D warnings`
215
+ are clean. `scripts/test.nu`, the workspace's tests with the
216
+ conformance feature: **1802 tests over 135 suites, 0 failed** (4
217
+ ignored). The C round, `cbuild --run`, passes its five checks; the
218
+ corpus passes its **57 scenes** in four adapters, `gradients` the new
219
+ one; the ABI is **24**. Node's `node --test test.mjs` under
220
+ `KUI_CONFORMANCE_REQUIRED=1`: **209 of 209**. `npm run gen` leaves no
221
+ diff, the examples typecheck and their lockfile installs, the headless
222
+ round passes all **35 drives**, the book builds and
223
+ `scripts/book-examples.nu --check` passes.
224
+
225
+ **The windowed round**, `smoke -- --node`, three times over: **51 Rust
226
+ examples and the eleven Node examples, each on both bases, 120 frames
227
+ each, every one exiting 0** — 124 windows, eight at a time, in 28 to
228
+ 32 s — and `counter`, `host`, `c_panel` and `lua_panel` by hand under
229
+ `KUI_SMOKE_FRAMES=120`, each exiting 0 with nothing on stderr: **128
230
+ windows over five hosts.** In each of the three runs one window of the
231
+ first eight took the whole round to draw its frames — `cells`, then
232
+ `audio`, then `accessibility`, each a second and a half when run
233
+ alone — which reads as a window covered by the seven launched over it
234
+ and not drawn until they had gone; not compared against alpha.36's
235
+ build. The AX audit: **106/106**, the audited window raised to the
236
+ front by its pid first, and no warning on the fixture's stderr.
237
+
238
+ **The bench guard** against the alpha.36 tag, on the release commit's
239
+ tree: **green**, none of the 8 guarded rows more than 10% slower —
240
+ every one between −0.9% and +2.8% (the worst guarded run-to-run spread
241
+ 2.1%). It was not so at first, twice over, and both are fixed in this
242
+ tree:
243
+
244
+ - `frame_10k_segments` read **+7.7%** (863 → 929 µs, ±1.0%) on solid
245
+ lines, and that was the dash (backlog C52): bisected to V2's commit
246
+ by building the bench at each one. The `Stroke`, twenty bytes wider
247
+ with its `Dash`, was copied at each call from `Ui::line` down to the
248
+ line store, and the dash's cut ran its whole test on every line. The
249
+ stroke is lent and a solid pattern is told by its zeroes; the row
250
+ reads +2.5% in the guard and 875 → 880 µs with the two binaries
251
+ alternated.
252
+ - `frame_10k_rects` read +9.3% and every row built on the bench's grid
253
+ 5 to 9% slower, and that was the bench: bisected to the commit that
254
+ gave its grid the gradient rows' arms in one `match` per cell, with
255
+ no line of the core between it and the commit before. The gradient
256
+ cell is a function of its own now, the plain rows read as
257
+ alpha.36's, and the gradient rows were taken again — 52 ns a node
258
+ over a flat box where the first reading said 55
259
+ ([ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md),
260
+ *Measured*; [docs/performance.md](docs/performance.md) carries the
261
+ four rows from that run, the rest of its table kept as it was).
262
+
263
+ **This tag was cut twice.** The first, at `c7e9793`, published
264
+ nothing: both hosts' `check` failed on F121's own test, which asserted
265
+ that the platform's list draws `字` differently from the app's — and
266
+ on an image with DejaVu alone, both pipelines', the fixture is the one
267
+ face that has it, list or no list. Forgejo's `check` had been red on
268
+ it since F121 was merged, which this round did not read before
269
+ tagging; cargo stops at the first failing suite, so the suites after
270
+ `fonts` had not run there either. The test now tells which machine it
271
+ is on and asserts the platform's half only where there is one, and
272
+ `cargo test -p kui-core` passes its 105 suites in `rust:1-bookworm`
273
+ with `fonts-dejavu-core` alone (the whole workspace would not fit that
274
+ container). With no crate and no package carrying the version, the tag
275
+ was moved to the commit that has the fix and C52's; the mechanical
276
+ round, the windowed round and the guard above are of that commit.
277
+
24
278
  ## 0.1.0-alpha.36 (2026-10-05)
25
279
 
26
280
  **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.
@@ -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,
@@ -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
@@ -1326,7 +1358,28 @@ them sharing an edge show a hairline of the background through it.
1326
1358
  [ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md) ·
1327
1359
  `cargo run --example path` · `cargo run --example polygon`
1328
1360
 
1329
- ### How do I draw a gradient, a ring, or anything the paint props cannot?
1361
+ ### How do I give a box a gradient background?
1362
+
1363
+ `gradient` on the box: `gradient={{ to: 'bottom', stops: ['#1e2030',
1364
+ '#14161e'] }}` runs to a side or a corner, `{ angle: 0.125, stops }` along
1365
+ a direction in turns clockwise from east, and `{ radial: true, at: [0.5,
1366
+ 0], stops }` out from a centre. A stop is a colour or `[colour, position]`.
1367
+ It paints over `bg` and under the border and the children, so a scrim is
1368
+ a gradient with a transparent stop over whatever is beneath. The geometry
1369
+ is the box's unit square stretched to the box — a corner is CSS's corner,
1370
+ and an `angle` runs corner to corner at an eighth of a turn whatever the
1371
+ aspect, which CSS's `45deg` does not.
1372
+
1373
+ It costs one image quad and is rasterized once per distinct gradient, so
1374
+ it does not tween and `hoverBg` does not replace it: to move a gradient,
1375
+ move the box that has it (the rainbow in `loaders` is one gradient slid
1376
+ under a clip); to animate its colours, write a fragment.
1377
+
1378
+ [`gradient` row](props.md#container-props) ·
1379
+ [ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md) ·
1380
+ `cargo run --example loaders`
1381
+
1382
+ ### How do I draw a ring, noise, a shimmer, or anything the paint props cannot?
1330
1383
 
1331
1384
  Write a fragment. `add_fragment(wgsl)` validates one WGSL function and hands
1332
1385
  back a handle; `<fragment src={id} params={[…]} animate>` is a box that
package/index.d.ts CHANGED
@@ -2441,6 +2441,16 @@ export declare class Ctx {
2441
2441
  * every frame.
2442
2442
  */
2443
2443
  reloadSystemFonts(): number
2444
+ /**
2445
+ * The fonts asked, in order, for a character the text's own
2446
+ * family has no glyph for, before the platform's fallback list
2447
+ * — whose first choice on macOS is the system's proportional
2448
+ * face. Ids from `addFont` / `addSystemFont` / `loadFontFile`;
2449
+ * one that names no font is left out, and `[]` is the
2450
+ * platform's list alone. A new list shapes every text again;
2451
+ * the same list twice is nothing.
2452
+ */
2453
+ setFallbackFonts(ids: Array<string>): void
2444
2454
  removeFont(id: string): void
2445
2455
  /**
2446
2456
  * Family names of every font the core can see, installed or
@@ -3613,6 +3623,16 @@ export declare class KuiWindow {
3613
3623
  * every frame.
3614
3624
  */
3615
3625
  reloadSystemFonts(): number
3626
+ /**
3627
+ * The fonts asked, in order, for a character the text's own
3628
+ * family has no glyph for, before the platform's fallback list
3629
+ * — whose first choice on macOS is the system's proportional
3630
+ * face. Ids from `addFont` / `addSystemFont` / `loadFontFile`;
3631
+ * one that names no font is left out, and `[]` is the
3632
+ * platform's list alone. A new list shapes every text again;
3633
+ * the same list twice is nothing.
3634
+ */
3635
+ setFallbackFonts(ids: Array<string>): void
3616
3636
  removeFont(id: string): void
3617
3637
  /**
3618
3638
  * Family names of every font the core can see, installed or
package/jsx-runtime.d.ts CHANGED
@@ -143,6 +143,31 @@ export interface EnterProp {
143
143
  opacity?: number;
144
144
  }
145
145
 
146
+ /** A gradient painted over a box's `bg`, under its border and children
147
+ * (docs/adr/0042-a-gradient-is-an-image-the-core-paints.md). Linear
148
+ * `to` a side or a corner (the default is `'bottom'`) or along `angle`,
149
+ * or `radial` from `at`. Defined on the box's unit square and stretched
150
+ * to it: a side or a corner is CSS's, and any other `angle` runs corner
151
+ * to corner at an eighth of a turn whatever the box's aspect, where
152
+ * CSS's `45deg` does not. Rasterized once per distinct gradient and
153
+ * drawn as one image quad; it does not tween. */
154
+ export interface GradientProp {
155
+ /** The side or corner a linear gradient runs to. */
156
+ to?: 'right' | 'bottom right' | 'bottom' | 'bottom left' | 'left' | 'top left' | 'top' | 'top right';
157
+ /** A linear gradient's direction in turns, clockwise from east: 0.25
158
+ * runs downwards. */
159
+ angle?: number;
160
+ /** Out from `at` to the box's farthest corner instead of along a line. */
161
+ radial?: boolean;
162
+ /** A radial gradient's centre as fractions of the box; the middle,
163
+ * `[0.5, 0.5]`, without one. */
164
+ at?: [number, number];
165
+ /** Two or more colours, each alone or as `[colour, position]` with the
166
+ * position 0 to 1. Stops without one are spaced evenly between those
167
+ * with; two at one position are a hard edge. */
168
+ stops: (ColorProp | [ColorProp, number])[];
169
+ }
170
+
146
171
  export interface FloatProp {
147
172
  /** The preset to start from; every key below overrides one of its
148
173
  * values and leaving one out keeps the preset's own, so
@@ -276,6 +301,8 @@ export interface GeneratedSpecProps {
276
301
  focusable?: boolean;
277
302
  /** Space between children along the main axis. */
278
303
  gap?: LengthProp;
304
+ /** A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. */
305
+ gradient?: GradientProp;
279
306
  /** Vertical size: px | "fit" | "grow" | "N%" | a size expression (see `width`). */
280
307
  height?: SizingProp;
281
308
  /** Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`. */
@@ -727,7 +754,7 @@ export declare namespace JSX {
727
754
  };
728
755
  /** A box a registered WGSL function paints
729
756
  * (docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md):
730
- * gradients, rings, noise, shimmer — anything the paint vocabulary has
757
+ * a conic or a moving gradient, rings, noise, shimmer — anything the paint vocabulary has
731
758
  * no prop for. An ordinary node otherwise: it lays out, rounds, clips,
732
759
  * fades, takes input and holds children, which paint over it. It has
733
760
  * **no intrinsic size**, so give it a `width`/`height` or `fill`.
@@ -812,7 +839,20 @@ export declare namespace JSX {
812
839
  to?: [number, number];
813
840
  points?: [number, number][];
814
841
  curve?: boolean;
815
- width?: number;
842
+ /** Cuts the stroke into marks and gaps (backlog V2): one length
843
+ * (marks and gaps alike), `[mark, gap]`, or `[mark, gap, mark,
844
+ * gap]` for a dash-dot, in px **as seen** — every mark is
845
+ * round-capped, so a mark no longer than the stroke is wide is a
846
+ * dot (SVG's `stroke-dasharray` measures the centre line; this is
847
+ * its `mark − width, gap + width`). The pattern runs along the
848
+ * whole stroke, corners and curves included. A pattern with no
849
+ * gap, or finer than a pixel, draws solid. */
850
+ dash?: number | [number] | [number, number] | [number, number, number, number];
851
+ /** How far into the pattern the stroke starts, in px: growing it
852
+ * moves the marks towards the first point — a marquee's marching
853
+ * ants. It wraps; it does not tween. */
854
+ dashOffset?: number;
855
+ width?: LengthProp;
816
856
  color?: ColorProp;
817
857
  float?: 'parent' | 'viewport';
818
858
  };
@@ -889,8 +929,21 @@ export declare namespace JSX {
889
929
  * the centre of its box without one. A path with `rotate` or
890
930
  * `pivot` is boxed by the square the turn sweeps. */
891
931
  pivot?: [number, number];
932
+ /** Cuts the stroke into marks and gaps (backlog V2): one length
933
+ * (marks and gaps alike), `[mark, gap]`, or `[mark, gap, mark,
934
+ * gap]` for a dash-dot, in px **as seen** — every mark is
935
+ * round-capped, so a mark no longer than the stroke is wide is a
936
+ * dot (SVG's `stroke-dasharray` measures the centre line; this is
937
+ * its `mark − width, gap + width`). The pattern runs along the
938
+ * outline, restarting at every subpath as SVG's does. A pattern with no
939
+ * gap, or finer than a pixel, draws solid. */
940
+ dash?: number | [number] | [number, number] | [number, number, number, number];
941
+ /** How far into the pattern the stroke starts, in px: growing it
942
+ * moves the marks towards the first point — a marquee's marching
943
+ * ants. It wraps; it does not tween. */
944
+ dashOffset?: number;
892
945
  bg?: ColorProp;
893
- width?: number;
946
+ width?: LengthProp;
894
947
  color?: ColorProp;
895
948
  float?: 'parent' | 'viewport';
896
949
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qxuken/kui",
3
- "version": "0.1.0-alpha.36",
3
+ "version": "0.1.0-alpha.37",
4
4
  "description": "kui for Node: JSX views lowered into the kui IR, Elm-style messages as data",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
package/props.md CHANGED
@@ -40,6 +40,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
40
40
  | `focusRegion` | `focus_region` | `focus_region` | boolean | Makes this node's subtree a focus region: a Tab ring of its own that the ring outside never enters and that never leaves — a devtools dock, an inspector beside the app (`docs/adr/0022-focus-regions.md`). Entered on purpose: `focusRegion(name)` (`Ui::focus_region`, `env.focus_region`, `kui_focus_region`) moves focus in — to the focus the region last held, else its `initialFocus`, else its first stop — and `focusRegion(null)` moves it back to the main ring the same way; a press inside the region, or an explicit focus on a node in it, enters it too. Tab then walks that ring alone, wrapping inside it; with nothing focused, Tab enters the ring of the region in effect (`region()`). A region that stops being declared hands focus back to what the main ring last held. Only the ring is scoped: keys still bubble through the boundary to the sink above (a region that wants its own keymap is an `onKey` sink), the pointer and assistive technology see a plain node, and a `modal` in effect is the ring wherever it sits. Nested regions are skipped by the outer ring the way the main ring skips them. |
41
41
  | `focusable` | `focusable` | `focusable` | boolean | Reachable by Tab (and focused by a click) without a click payload or a control role — a row that opens on Enter. Editors, key sinks, `onClick` boxes and the control roles are focusable already. |
42
42
  | `gap` | `gap` | `gap` | number, or a `"$length"` token | Space between children along the main axis. |
43
+ | `gradient` | `gradient` | `gradient` (`const KuiGradient *`) | gradient (`{ to? \\| angle? \\| radial?, at?, stops: [color \\| [color, at], …] }`) | A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. |
43
44
  | `height` | `height` | `height` (KuiSizing) | sizing (`number` \\| `"fit"` \\| `"grow"` \\| `"N%"` \\| a size expression \\| `"$length"`) | Vertical size: px \| "fit" \| "grow" \| "N%" \| a size expression (see `width`). |
44
45
  | `hoverBg` | `hover_bg` | `hover_bg` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`. |
45
46
  | `hoverGroup` | `hover_group` | `hover_group` (KuiStr) | string | Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape). |
@@ -163,10 +164,10 @@ where they make sense); text props apply to `<text>` and `<edit>`.
163
164
  | `<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>` | `slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }` | `kui_slider` | The stock slider (`widgets::slider_with`, ADR 0034): a track, a fill to `valueNow` and a thumb, as wide as a menu (`width` sizes it), keyed by its `label`, which is also its accessible name. With `onChange` the core does the arithmetic: a press proposes the value under the pointer, a drag each new step, the arrows one `valueStep`, PageUp / PageDown ten, Home / End the ends, all clamped to `valueMin`..`valueMax` (0..100 unset) and snapped to the step, as `{kind:"change", value, phase:"move"\|"end", tag}`. The value is proposed, never applied: the view stores it and declares it as `valueNow`. Its look is its spec, so the rows it reads are the value rows, the access rows and its width. |
164
165
  | `<image src={id} sampling fit>` | `image { id=, sampling=, fit= }` | `kui_image`, `kui_image_with` | A registered RGBA image. Sizing: `width="fit"` takes the pixel size, a fit height against a resolved width keeps the aspect, `radius` rounds it. Two rows say how the pixels meet the box (`docs/adr/0025-the-image-is-the-canvas.md`): `sampling` is `linear` (the default) or `nearest` — pixel art, an emulator, a data grid that must stay square under zoom; `fit` is `fill` (the default: the pixels stretch to the box), `contain` (the largest rect of the image's aspect that fits, centred, the rest of the box showing what is behind) or `cover` (the box filled and the pixels that do not fit cropped, centred). The box — its layout, its hit region, its access rect — is the same in every mode. The pixels come from the atlas, or from a texture of the image's own once `updateImage` has replaced them or when no atlas page could hold them; the node cannot tell and need not. |
165
166
  | `<polygon points={[[x,y],…]} bg/>` | `polygon { points={{x,y},…}, bg= }` | `kui_polygon` | A filled polygon through up to eight `points`, the fill in `bg` (`docs/adr/0025-the-image-is-the-canvas.md`, decision 6): an arrowhead, a pie slice, the area under a curve. Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box a pixel out on each side, so it takes no room in a row or column. A polygon in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` polygon escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `transition` eases the fill and, with `slide`, its position. The outline may be concave; a self-intersecting one fills even-odd, its overlaps unfilled. Hit by its outline (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside the outline hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable wedge is a button), so name it. A ninth point and later are dropped with `polygon-points-truncated`; fewer than three draw nothing; no `bg`, no fill. On the wire it is one `fragment` quad painted by a WGSL function the core registers itself, so a host that draws the list gets its source from `kui_fragment_source` like any other; what it costs is that quad and one pipeline switch per run of polygons. A stroked outline is a closed `line` over it. |
166
- | `<path d="M … Z" bg width color fillRule rotate pivot/>` | `path { d = "M … Z", bg=, width=, color=, fill_rule=, rotate=, pivot= }` | `kui_path` | Any outline — SVG's `d`, a pie wedge with a round arc, a map's region, an icon — filled with `bg` by `fillRule` (`nonzero`, the default, or `evenodd`) and stroked `width` wide in `color` when `width` is given, the stroke over the fill (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`). `d` is SVG path data (`M L H V C S Q T A Z`, absolute or relative), parsed by one parser in the core, so every binding draws the same shape; one that does not parse raises `path-malformed` and draws nothing. JSX also takes `d` as a flat number array of op codes and operands, Lua the same as `ops`, and C only that form (`kui_path_parse` turns a string into it). Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box two pixels out on each side (half the stroke's width further), so it takes no room in a row or column, held by the parent's clip as a child is. `transition` eases the fill and, with `slide`, its position; the stroke's colour does not tween, as a box's border does not. Hit by its outline under the fill rule (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; a stroke with no fill is hit by its stroke as a line is; with input it derives an access row as a box would (a clickable wedge is a button), so name it. On the wire it is one glyph-mask quad per paint, fill and stroke: the outline is rasterized once per shape, scale and quarter-pixel position into the glyph atlas and tinted like a glyph, so a host that draws text draws paths, and nothing is re-rasterized for a colour tween, a hover or a slide. The fill bleeds half a pixel, so two paths sharing an edge meet without the background showing through; a chart that wants separators gaps its own geometry. A mask a quarter of the biggest atlas page or more, or a path whose ops change twice within a few frames, draws from a texture of its own instead (a `texture` quad), and one past 8192 px on a side draws nothing, with `path-too-large`. `rotate` turns the path, in turns clockwise, about `pivot` — a point in the path's own coordinates, the centre of its box without one (`docs/adr/0041-a-mask-turns-about-its-centre.md`): the turn is the quad's and not the mask's, so a path that only turns — a spinner's arc about its circle's centre — is rasterized once and stays in the atlas at every angle. A path with `rotate` or `pivot` is boxed by the square the turn sweeps, its mask centred on the pivot on a whole pixel, and it is hit where it is drawn; `rotate` does not tween. |
167
- | `<fragment src={id} image={id} params={[…]} animate>` | `fragment { id=, image=, params={…}, animate= }` | `kui_fragment`, `kui_fragment_with` | A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): gradients, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare. |
168
- | `<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>` | `cells { rows=, cols=, lines={"row text", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }` | `kui_cells` | A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value. |
169
- | `<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve/>` | `line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true }` | `kui_line`, `kui_polyline` | A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it. |
167
+ | `<path d="M … Z" bg width color fillRule rotate pivot dash dashOffset/>` | `path { d = "M … Z", bg=, width=, color=, fill_rule=, rotate=, pivot=, dash=, dash_offset= }` | `kui_path` | Any outline — SVG's `d`, a pie wedge with a round arc, a map's region, an icon — filled with `bg` by `fillRule` (`nonzero`, the default, or `evenodd`) and stroked `width` wide in `color` when `width` is given, the stroke over the fill (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`). `d` is SVG path data (`M L H V C S Q T A Z`, absolute or relative), parsed by one parser in the core, so every binding draws the same shape; one that does not parse raises `path-malformed` and draws nothing. JSX also takes `d` as a flat number array of op codes and operands, Lua the same as `ops`, and C only that form (`kui_path_parse` turns a string into it). Placed as a `line` is — always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box two pixels out on each side (half the stroke's width further), so it takes no room in a row or column, held by the parent's clip as a child is. `transition` eases the fill and, with `slide`, its position; the stroke's colour does not tween, as a box's border does not. Hit by its outline under the fill rule (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; a stroke with no fill is hit by its stroke as a line is; with input it derives an access row as a box would (a clickable wedge is a button), so name it. On the wire it is one glyph-mask quad per paint, fill and stroke: the outline is rasterized once per shape, scale and quarter-pixel position into the glyph atlas and tinted like a glyph, so a host that draws text draws paths, and nothing is re-rasterized for a colour tween, a hover or a slide. The fill bleeds half a pixel, so two paths sharing an edge meet without the background showing through; a chart that wants separators gaps its own geometry. A mask a quarter of the biggest atlas page or more, or a path whose ops change twice within a few frames, draws from a texture of its own instead (a `texture` quad), and one past 8192 px on a side draws nothing, with `path-too-large`. `rotate` turns the path, in turns clockwise, about `pivot` — a point in the path's own coordinates, the centre of its box without one (`docs/adr/0041-a-mask-turns-about-its-centre.md`): the turn is the quad's and not the mask's, so a path that only turns — a spinner's arc about its circle's centre — is rasterized once and stays in the atlas at every angle. A path with `rotate` or `pivot` is boxed by the square the turn sweeps, its mask centred on the pivot on a whole pixel, and it is hit where it is drawn; `rotate` does not tween. `dash` and `dashOffset` cut the stroke into marks and gaps as a `line`'s do (backlog V2) — lengths as seen, round-capped marks — restarting at every subpath as SVG's do; the pattern is part of the stroke's mask, so a dashed stroke costs what a solid one does, a stroke with no fill is still hit along its gaps, and a `dashOffset` that changes every frame is a shape that changes every frame: the path leaves the atlas for a texture of its own while it marches. |
168
+ | `<fragment src={id} image={id} params={[…]} animate>` | `fragment { id=, image=, params={…}, animate= }` | `kui_fragment`, `kui_fragment_with` | A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): a conic or a moving gradient, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare. |
169
+ | `<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>` | `cells { rows=, cols=, lines={"row text", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }` | `kui_cells` | A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. A character the family has no glyph for is asked of a monospaced face before the platform's fallback list, shaped smaller where it is still wider than its cells (two under wide), and drawn in their middle (backlog F120); the private use area's icons are left as they fall. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value. |
170
+ | `<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve dash={[6, 4]} dashOffset/>` | `line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true, dash={6, 4}, dash_offset= }` | `kui_line`, `kui_polyline` | A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float="viewport"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float="viewport"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it. `dash` cuts the stroke into marks and gaps (backlog V2): one length (marks and gaps alike), a mark and a gap, or four lengths for a dash-dot, in px **as seen** — every mark is a short stroke with the stroke's round caps, so `dash` 6, 4 is 6 px of ink and 4 px of nothing at any width, and a mark no longer than the stroke is wide is a dot (SVG's `stroke-dasharray` measures the centre line instead, so with round caps its `4 4` at a width of 4 is solid; this pattern is SVG's `mark − width, gap + width`). The pattern runs along the stroke's whole length, so it keeps its phase round the corners of a polyline and the pieces of a curve, and `dashOffset` starts that far into it — growing it moves the marks towards the first point, a marquee's marching ants; neither tweens. A pattern with no gap, a mark and gap under a physical pixel together, or more than 16384 marks draws solid. A dashed stroke is hit along its whole length, gaps included, and costs a quad per mark per piece the mark lies on. |
170
171
  | `<titlebar title>` or `<titlebar>…</titlebar>` | `titlebar { title= }` / `titlebar { … }` | `kui_titlebar`, `kui_titlebar_with` | Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons. |
171
172
  | `<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked? }] }]}/>` | `menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked= } } } } }` | `kui_menu_bar` | The application menu (`docs/adr/0018-a-menu-bar-the-app-declares.md`): `menu` is what it *is*, and where this element sits is where its titles go when they have to be drawn in the window. One call and not two, because declaring the menu and placing the strip are one decision. It draws **nothing** where the platform owns the bar — macOS, where the driver hands the same declaration to the OS — so the frame has still said what the app's menu is and the strip simply is not there; that is the contract `windowButtons` has under native decorations, and it is what makes one view portable. Its rows are the rows a context menu has: the same `role`s the core performs itself (`copy`, `paste`, `selectAll`, `cut`, `lookUp`), the same `id` payload, the same `accel` text, plus `checked` for a setting — and choosing one posts the same `{kind:"menu", role, item}` event, so an app handles one thing whichever menu it came from. Declared every frame and diffed: an unchanged menu costs a comparison, and an empty list takes it away. An accelerator kui can parse is rewritten into the platform's spelling, so `"mod+s"` reads as `⌘S` on macOS and `Ctrl+S` elsewhere and binds that key in the platform's own bar. On macOS the first menu is the application menu, which the OS titles with the app's own name whatever the label says. While a menu is open the bar is the frame's modal scope, so hovering across the titles moves the open menu, a press on the open title closes it, and Escape or a press in the app below closes it and reaches nothing else. |
172
173
  | `<windowButtons/>` | `window_buttons()` | `kui_window_buttons` | Just the min/max/close buttons, for fully custom titlebars. |
@@ -481,6 +482,8 @@ binding is a row with its three other cells, or a red test.
481
482
  | `Core::load_font_file` | `kui_font_load_file` | `loadFontFile` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Registers a font file by path. |
482
483
  | `Core::load_fonts_dir` | `kui_font_load_dir` | `loadFontsDir` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Registers every font file in a directory. |
483
484
  | `Core::reload_system_fonts` | `kui_font_reload_system` | `reloadSystemFonts` | *none: a script is a guest in the host's frame (ADR 0014): its env is the view's reading, and registering, driving, pacing and reading back are the host's* | Scans the system's fonts again, so a font installed while the app runs is found (the scan is otherwise once a process); returns how many faces came and went. |
485
+ | `Core::set_fallback_fonts` | `kui_font_set_fallback` | `setFallbackFonts` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The fonts asked, in order, for a character the text's own family lacks, before the platform's fallback list (backlog F121). |
486
+ | `Core::fallback_fonts` | *none: the list is the one the host set* | *none: the list is the one the host set* | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The families `set_fallback_fonts` named, in order. |
484
487
  | `Core::remove_font` | `kui_font_remove` | `removeFont` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | Drops a font. |
485
488
  | `Core::system_font_families` | `kui_font_families` | `systemFontFamilies` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The installed family names `add_system_font` accepts. |
486
489
  | `Core::system_fonts` | `kui_system_fonts` | `systemFonts` | *none: a script owns no handle: the host registers and the script names the id it was given (`image { id = }`, `font = id`, `audio { src = id }`)* | The same families, each with what the font database read off its faces: `monospaced` (every face fixed-pitch), `weights`, `italic` (backlog F97) — a font picker's monospaced-first list without a file loaded or a glyph shaped. |