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

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,164 @@ 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.48 (2026-10-09)
25
+
26
+ **What breaks.**
27
+
28
+ - C ABI 28 (under Added, W22): `KuiRunConfig` appends `titlebar`, a
29
+ `KUI_TITLEBAR_*` (64-bit size 48). Recompile; zeroed, the window is
30
+ what it was. A hand-written mirror (ctypes, Zig) appends one `u32`.
31
+ - `ui.metrics().titlebar_h` under macOS custom chrome (under Added,
32
+ W22) reads the measured titlebar, 32 on macOS 27, where it read the
33
+ stock 34; the strip itself was already drawn at 32. A view that laid
34
+ something out against the metric beside the strip moves up 2 px.
35
+
36
+ ### Added
37
+
38
+ - **The traffic lights sit lower in a taller titlebar** (backlog W22).
39
+ `Launcher::titlebar(Titlebar::Medium | Tall)` under `Chrome::Custom`
40
+ makes macOS's own titlebar a compact toolbar's height (40 pt, the
41
+ lights at 12,13 on macOS 27) or a full toolbar's (52 pt about the
42
+ lights, at 19,19), against the plain one's 32 at 9,9 — the look of a
43
+ Mac app with a toolbar, with the app's own strip in it. kui gives the
44
+ window an empty `NSToolbar` in that style and leaves the buttons to
45
+ AppKit, which keeps them in place through resizing, fullscreen and
46
+ focus; nothing runs per frame. `env.window.native_controls` measures
47
+ where they landed, so `widgets::titlebar` grows and insets with them,
48
+ and the toolbar takes no clicks: tabs in the strip press and the empty
49
+ strip drags as before. `titlebar: 'standard' | 'medium' | 'tall'` in
50
+ Node's window options, `KuiRunConfig.titlebar` in C,
51
+ `Run_Config.titlebar` in Odin. Under macOS custom chrome the
52
+ window's `titlebar_h` metric is now the strip AppKit drew, as
53
+ measured — 32, 40 or 52 on macOS 27 — so `$titlebar_h` says the
54
+ strip's height. On Windows and Linux, where the strip
55
+ is the app's, the same ask makes the window's `titlebar_h` metric 40
56
+ or 52 in place of the caption's 32 or 34, so one setting draws one
57
+ strip on all three: `ui.metrics().titlebar_h` and `$titlebar_h` read
58
+ it, the drawn buttons grow to it, and an app's own metrics keep it
59
+ while their `titlebar_h` is the stock number
60
+ (`Core::set_platform_titlebar_h`). The `titlebar` example takes
61
+ `--titlebar tall`.
62
+
63
+ ||||||| parent of 2ba22215 (A slot replayed by its host: the extension spared when the host says nothing it feeds it changed, and the core checks everything else (backlog F142, ADR 0045 amending ADR 0016; F143 filed))
64
+ - **A slot replayed by its host** (backlog F142, ADR 0045, amending ADR
65
+ 0016). `Ui::slot_kept` fills a slot as `slot_with` does and keeps what
66
+ the fill built — every node as its door saw it, before hover, accent
67
+ and easing touched the spec; the labels, indices and hints beside
68
+ them; the slots it declared inside; and every fact of the frame it
69
+ read while it ran. `Ui::slot_replay` is the host's claim that nothing
70
+ *it* feeds the extension has changed; the core checks everything it
71
+ can see — the params, each fact read against its value now, that the
72
+ slot is where it was — and pushes the kept nodes again through the
73
+ same doors without asking the extension (`SlotFill::Replayed`), or
74
+ runs it as `slot_kept` would and says why (`NotKept`, `Params`,
75
+ `Reads`, `NotReplayable`, `Moved`); `Core::slot_fill(name)` reads the
76
+ answer back. A nested slot is declared again and filled fresh, so an
77
+ editor's field blinks inside a pane that is not rebuilt. A fill that
78
+ declared something of the frame (a title, a window, a frame it wants,
79
+ a devtools tab, audio, a loaded extension), drew a `cells` grid, or
80
+ failed is kept as not replayable, and so is one that pushed a node
81
+ through a door the journal does not know — by count, so a door added
82
+ later is a fresh fill and never a wrong one. Nothing of the frame is
83
+ cached: layout and emission run as always; what is skipped is the
84
+ extension's `view` and, for a Lua or C fill, the binding's walk from
85
+ its tables to the tree, which is where such a fill spends most of its
86
+ time (about 2 µs a node, sixty times the push). C: `kui_slot_kept`,
87
+ `kui_slot_replay`, `kui_slot_fill` and the `KUI_SLOT_*` codes (new
88
+ symbols; the ABI version stays). Lua: `fill { keep = true }`, `fill {
89
+ replay = true }`, `env.slot_fill(name)`. Node: `<slot keep>`, `<slot
90
+ replay>`, `ctx.slotFill(name)` (stream v23: `slot` carries a flags
91
+ word). A reading handed out whole (Lua's `env`) cannot say whether a
92
+ script used its clock or caret phase, so those two are left out of
93
+ the comparison and a view that draws from them is one its host must
94
+ not replay; the explicit doors still note the read. Tests:
95
+ `tests/slot_replay.rs` (nine), kui-lua's `slots.rs`, the C slots
96
+ host's `--headless`, Node's `test.mjs` with the C plugin; the bench
97
+ `slot_1500_nodes_fresh` against `slot_1500_nodes_replayed` (287 against
98
+ 214 µs a frame for a Rust fill, the gap its own pushes; a Lua fill's
99
+ gap is its whole walk).
100
+ - **An image drawn smaller is drawn from a level the core halves**
101
+ (backlog V6, ADR 0044). An `image` drawn at less than half its
102
+ texels a pixel — a photo on a card, a thumbnail — samples a level
103
+ of the image halved on the CPU, 2×2 means in linear light with the
104
+ alpha premultiplied, instead of skipping texels: no shimmer as it
105
+ moves, no false patterns in fine detail. The level is the deepest
106
+ with at least a texel a pixel, counting the display's scale and any
107
+ `scale` the node is drawn through; it is made once per session at
108
+ the first draw that wants it (about 10 ms for a 12-megapixel photo's
109
+ first level) and kept in the atlas in place of the whole image, so a
110
+ photo only ever shown small never puts its full size in the page.
111
+ `sampling: nearest` and an image ever updated (a stream) draw the
112
+ whole image, as before. Nothing to declare and no binding changed:
113
+ a C host drawing `KuiDrawData` finds the level in the page it already
114
+ uploads. The `image` example shows a zone plate both ways.
115
+
116
+ ### Fixed
117
+
118
+ - **A handler reads the clock at the time it runs** (backlog F139).
119
+ The windowed runner set the frame clock only when it drew, and the
120
+ loop parks between frames, so after an idle stretch `now()` in an
121
+ event handler read the last frame's time: a toast stamped in a click
122
+ handler counted from a frame drawn seconds before. The runner now
123
+ stamps every window's clock before it handles input and before
124
+ events reach the app, and `Drive` does the same after `advance`. A
125
+ composite's type-ahead ages at the keystroke as well as at the frame,
126
+ so a letter typed after a quiet second starts a new search, and a
127
+ Space after the pause presses the item rather than extending the old
128
+ one.
129
+ - **`ambiguous-name` points a caption at `role="none"`** (backlog
130
+ F140). When the nodes sharing a name are a control and the text
131
+ beside it that repeats it — a round "like" button over the word
132
+ "like" — the warning says the caption is decorative and that
133
+ `role="none"` on the box around it takes it out of the tree, or that
134
+ the control's `label` can say what it does. The `role` row calls
135
+ `none` the decorative door.
136
+ - **The docs say what a spring does between keyframe stops** (backlog
137
+ F141): it is drawn as `easeOut`, and `bounce` does nothing there,
138
+ since a cycle is sampled off the clock; an overshoot in a cycle is a
139
+ stop past the target. Unchanged behaviour, now on the `keyframes`,
140
+ `easing` and `bounce` rows and pinned by a test.
141
+
142
+ **What you can delete.**
143
+
144
+ - Throttling or skipping a plugin pane's frames on the host's side —
145
+ drawing it every other frame, or only on its own events — to keep a
146
+ Lua or C extension's `view` off the frames it draws nothing new in:
147
+ `slot_replay` with what the pane reads from the host as the condition.
148
+ - Stamping deadlines in `view` because a handler's `now()` was stale:
149
+ `core.now()` read in `on_event_with` is the time of the event.
150
+ - Resampling a photo to the size it is shown at before `add_image`:
151
+ register it as decoded and declare the box.
152
+
153
+ ### Native verification
154
+
155
+ The by-hand round on 2026-10-09, over F142 and W22 — the slot replayed
156
+ by its host, and the taller macOS titlebar with the metric that follows
157
+ it — on the Mac: the mechanical round as CI runs it, the Odin binding
158
+ regenerated and type-checked with CI's pinned Odin (`dev-2026-09`, in a
159
+ Debian image with the checkout mounted at its own path, since the Mac
160
+ has no `odin`), the windowed round, the accessibility audit and the
161
+ bench guard. The first attempt of the round filled the disk — 118 MB
162
+ left under three build trees — and was run again whole once 61 GB were
163
+ cleared; nothing of it is read from the partial run.
164
+
165
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
166
+ scripts/test.nu`: **2032 tests over 152 suites**, 0 failed. The C round
167
+ passes (5 checks), and so do the **58 scenes** through Rust, Lua, C and
168
+ Node; Node's tests under `KUI_CONFORMANCE_REQUIRED=1`, **225 of 225**;
169
+ `npm run gen` with no diff beyond the three `DOORS` rows of this
170
+ release, the examples' typecheck, the headless round (3.4 s). `nu
171
+ scripts/odin.nu gen --check` says the binding is current and `odin.nu
172
+ check` vets both packages and every example; its `test` and `slots`
173
+ rounds run Linux binaries and are CI's. The windowed round with Node's:
174
+ **55 examples on both bases**, clean on a first run, and `counter`,
175
+ `host`, `c_panel` and `lua_panel` for 120 frames each. The accessibility audit:
176
+ **106 of 106**. The bench guard against the alpha.47 tag: **green**, the
177
+ eight guarded rows within tolerance, and the two new rows
178
+ `slot_1500_nodes_fresh` / `slot_1500_nodes_replayed` at 277 and 208 µs
179
+ (now in docs/performance.md). No Windows or Linux machine ran this
180
+ round.
181
+
24
182
  ## 0.1.0-alpha.47 (2026-10-09)
25
183
 
26
184
  ### Fixed
@@ -5,6 +5,23 @@ date: 2026-09-09
5
5
 
6
6
  # Caching against the last frame: the access tree yes, the frame no
7
7
 
8
+ > **Amended 2026-10-09 by [ADR 0045](0045-a-slot-replayed-by-its-host.md).**
9
+ > Decision 2 below refuses a `memo` element in the form that asks the
10
+ > *view* to declare its own dependencies, and it stands as written. ADR
11
+ > 0045 admits a different form: the *host* declares an extension's slot
12
+ > unchanged (`Ui::slot_replay`), vouching only for what the extension
13
+ > reads from the host itself, and the core checks everything it can see
14
+ > — the params, every fact of the frame the kept fill read, with the
15
+ > value read compared against the value now — before pushing the kept
16
+ > fill's nodes again through the doors they came in by. Nothing of the
17
+ > frame is cached: layout and emission run as they always did, and
18
+ > decision 1 is untouched. What moved the line is a measurement that
19
+ > this document did not have: a Lua extension's fill costs about 2 µs a
20
+ > node in the binding's walk from its tables to the tree, sixty times the
21
+ > push, on frames where the pane did nothing — the half of the bill
22
+ > option C alone addressed, which is why "it will keep being proposed"
23
+ > was written here and why its proposal now has a document.
24
+
8
25
  > **Accepted (2026-09-09), and its one yes is built.** Out of the
9
26
  > performance round that shipped the quad shrink and closed C24. It asks
10
27
  > one question — may a frame reuse work from the frame before it, and
@@ -249,7 +249,11 @@ date: 2026-09-11
249
249
  one at another; it is the node's to say, like `radius`.
250
250
  - **Mipmaps.** Deferred: `contain`/`cover` plus an app that renders at
251
251
  `w × scale` pixels needs none; a photo viewer minifying a 12-megapixel
252
- texture does, and that is the view that files it.
252
+ texture does, and that is the view that files it. Filed by a card game
253
+ instead (backlog V6, 2026-10-09) and built as [ADR
254
+ 0044](0044-an-image-drawn-smaller-is-drawn-from-a-level.md): levels the
255
+ core halves on the CPU and keeps in the atlas, for every image but a
256
+ stream.
253
257
  - **A core `zoom` row** — a per-subtree scale as scroll's sibling, layout
254
258
  in the subtree's own logical space, `env.scale × zoom` composed at emit
255
259
  (`runtime/emit.rs:418` is the one seam), local-space payloads and
@@ -346,7 +350,11 @@ the one to read carefully: at a hundred boxes the texture case is slower
346
350
  than the fragment case not because of the split but because each box
347
351
  samples a 1920×1080 texture minified into 320×180 pixels with no mips —
348
352
  that is the fill's bill, and the mipmap deferral (V6) is what it argues
349
- for once a view minifies a large stream. At the sizes an app draws a
353
+ for once a view minifies a large stream. (Measured again on 2026-10-09
354
+ for ADR 0044 on an M-series Mac, with texels a GPU cannot compress:
355
+ the same hundred boxes drawn from the texture halved twice saved 0.03
356
+ ms of the 0.79 — minification is a small part of that bill there, and
357
+ the rest of the gap to the fragments' 0.46 is not minification.) At the sizes an app draws a
350
358
  stream (one box, near its own size) the row that matters is 1: within
351
359
  noise of no split at all.
352
360
 
@@ -0,0 +1,229 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-10-09
4
+ ---
5
+
6
+ # An image drawn smaller is drawn from a level the core halves
7
+
8
+ > **Accepted and built 2026-10-09**, the day it was proposed; the
9
+ > *Amendment* at the end records what the building measured and
10
+ > changed. Backlog V6's second half, its condition met
11
+ > by berainder's alpha.47 upgrade: the app shows its bear photos on
12
+ > cards smaller than the files, and smaller again on the 0.9-scaled
13
+ > card underneath, and keeps a 25-line crop-and-resample in `bears.rs`
14
+ > to bring each photo down to card size before `add_image`, because the
15
+ > renderer samples the whole image with no mip chain. [ADR
16
+ > 0025](0025-the-image-is-the-canvas.md) deferred mipmaps until "a
17
+ > photo viewer minifying a 12-megapixel texture" filed them; this is
18
+ > that, from a card game. V6's first half, dirty rects on
19
+ > `update_image`, keeps its own condition and is not here.
20
+
21
+ ## Context
22
+
23
+ - **What a minified image costs today.** An `image` node's quad samples
24
+ the image's texel rect bilinearly — four texels around each pixel
25
+ centre — whatever the ratio of texels to pixels. At 1:1 to 2:1 that
26
+ is every texel read. At 4:1 three texels in four are skipped and the
27
+ ones read are an accident of where the pixel centres land: a photo
28
+ shrunk to a card shimmers as it moves, and its fine lines break up.
29
+ ADR 0025's split bench shows the GPU bill as well: a hundred 320×180
30
+ boxes each sampling a 1920×1080 texture ran at 0.59 ms against 0.36
31
+ for fragments of the same size, the difference being cache misses
32
+ from minified reads with no mip chain.
33
+ - **Where an image lives.** An image that fits a `MAX_ATLAS_SIZE`
34
+ (4096) page and was never updated is blitted into the window's glyph
35
+ atlas at first draw, whole, and drawn as an `Image` quad whose `uv` is
36
+ its texel rect in the page (`atlas.rs`, `get_or_insert_image`). One
37
+ past a page, or one ever updated, is texture-backed: its own texture,
38
+ a `Texture` quad and an entry in `DisplayList::textures`
39
+ (`ImageBacking`). A 4032×3024 phone photo is atlas-backed, and takes
40
+ 48 MB of a page that also holds every glyph the window draws.
41
+ - **Who draws.** `kui-wgpu` is the renderer this repo ships; a C host
42
+ that draws `KuiDrawData` itself (ADR 0012's contract) uploads the
43
+ atlas page and the texture side list and samples what the quads
44
+ name. Whatever answers V6 has to reach that host too
45
+ ([`feedback: bindings first`] — a capability goes where every binding
46
+ and every backend gets it).
47
+ - **What the core knows at emission.** The image's size, the texel rect
48
+ `fit` picked, the rect the quad is drawn at in physical px, and —
49
+ since ADR 0043 — the clip entry's composed `Transform`, whose `scale`
50
+ is the factor every ancestor's `scale` multiplied into. That is the
51
+ whole input to "how many texels per pixel", per quad, on the CPU.
52
+
53
+ ## Decisions
54
+
55
+ 1. **A level is the image halved, and halved again.** Level 0 is the
56
+ image; level *n* + 1 is level *n* with each 2×2 block averaged into
57
+ one texel, `⌈w/2⌉ × ⌈h/2⌉`, the last row and column of an odd size
58
+ repeated. The average is taken in linear light with the alpha
59
+ premultiplied — sRGB decoded through a 256-entry table, the result
60
+ encoded through a 4096-entry one — so a level is neither darker
61
+ than its source nor fringed where a transparent edge meets colour.
62
+ The chain stops at 1×1.
63
+ 2. **The core picks the level per quad.** For an image drawn with
64
+ `sampling: linear` (the default), the ratio is
65
+ `r = min(texels_w / px_w, texels_h / px_h)`: the texel rect `fit`
66
+ picked over the drawn rect in physical px, times the clip entry's
67
+ composed `scale`. The level is `⌊log₂ r⌋`, at least 0, at most the
68
+ chain's last — the deepest level still at least as many texels as
69
+ pixels on both axes, so a level never magnifies and the bilinear
70
+ sample inside it minifies by less than two. The smaller ratio of the
71
+ two axes decides, so a `fill` stretched in one direction is never
72
+ blurred in the other. `sampling: nearest` always draws level 0 — it
73
+ asked for the texels as they are. Nothing new in any binding: no
74
+ row, no door, no ABI. The level is a fact the core derives, as
75
+ `ImageBacking` is.
76
+ 3. **A level lives where level 0 would, in the atlas.** The page keys
77
+ image slots by `(image, level)`; a level is blitted at the first
78
+ draw that wants it and its slot named by the quad, an `Image` quad
79
+ as before. An image only ever drawn small never puts level 0 in the
80
+ page at all — the 48 MB photo drawn on a 400-point card at 2× takes
81
+ the 1008×756 level's 3 MB. `fit`'s crop is computed on the level's own
82
+ size, so `cover` crops whole texels of the level. A host that draws
83
+ `KuiDrawData` gets the level in the page it already uploads, and
84
+ changes nothing.
85
+ 4. **A texture-backed image is levelled until it is updated.** One that
86
+ is texture-backed because it is larger than a page, and was never
87
+ updated, draws a level that fits a page from the atlas like any
88
+ other, and level 0 from its texture as before. One ever updated —
89
+ a stream — draws level 0, as today: halving a 1080p frame on every
90
+ update is milliseconds a frame, and a stream minified on screen is
91
+ V6's other half's condition, still unmet.
92
+ 5. **Levels are made once per session, lazily, and shared.** The
93
+ halved pixels are kept on the image's entry in the session's
94
+ resources, so a second window, or the same window after its atlas
95
+ page was reset, blits them again without halving again. Level *n* is
96
+ made from level *n* − 1, which is kept too, so a chain costs a third
97
+ of the image again at most, and only the levels some draw asked for
98
+ (and those above them) are ever made. An `update_image` drops them;
99
+ `remove_image` drops them with the entry.
100
+ 6. **Fragments sample level 0.** A `fragment`'s `image` input is
101
+ sampled at coordinates the shader computes, and the core does not
102
+ know the ratio there; it keeps the whole image. A fragment that
103
+ wants a smaller one is handed a smaller one.
104
+
105
+ ## Consequences
106
+
107
+ - berainder deletes its resampler: a photo is registered as decoded and
108
+ declared at the size it is shown.
109
+ - A photo that animates its size across a power of two switches level
110
+ at the crossing — the image sharpens or softens by a step there,
111
+ where trilinear sampling would blend the two. At the ratios a card's
112
+ hover or its 0.9 under-scale move through, it does not cross; a
113
+ pinch-zoom would, and is where trilinear comes back (see *Open
114
+ questions*).
115
+ - The first draw of a large photo at a small size pays the halving on
116
+ the frame that draws it: about a quarter of the image's pixels a
117
+ level, so a 12-megapixel photo's first level is three million output
118
+ texels. Measured below. berainder paid the same in its own
119
+ resampler, before the first frame.
120
+ - A window's atlas holds less for a photo drawn small, and holds a
121
+ second slot for a photo drawn at two sizes at once (a card and its
122
+ thumbnail).
123
+ - Hit-testing, layout, `image_size`, access and the texture side list
124
+ are unchanged: a level changes which texels a quad names and nothing
125
+ else.
126
+
127
+ ## Considered options
128
+
129
+ - **A mip chain on the GPU, made by the renderer.** The usual answer:
130
+ `mip_level_count` on the texture, a blit pass per level at upload, a
131
+ sampler with a mip filter, the hardware choosing per pixel from the
132
+ derivatives. Declined as the answer for atlas images: mip levels of
133
+ a page that packs glyphs, paths and images side by side bleed every
134
+ neighbour into every item from the second level down, unless every
135
+ item is padded to its level's alignment — and the C host that draws
136
+ `KuiDrawData` would need the same chain built its own way. For a
137
+ texture-backed *stream* it is still the right shape, and is what V6's
138
+ stream half would build; it composes with this.
139
+ - **Trilinear, two levels blended in the shader.** Declined for now:
140
+ the quad would carry two texel rects (the instance grows) and the
141
+ fragment stage would sample twice for every image, to smooth a
142
+ switch that only a size animated across a power of two shows.
143
+ - **A row, `mipmap` or `minify`, that the app sets.** Declined: there
144
+ is no image an app wants aliased when it is drawn smaller, and
145
+ `sampling: nearest` already says "these texels, as they are".
146
+ - **The app resamples, as berainder does.** The status quo. It works,
147
+ and every app that shows a photo writes it, picks a filter, and gets
148
+ sRGB wrong.
149
+
150
+ ## Measurements to take
151
+
152
+ - The halving: a 4032×3024 level 0 to its first level, and the whole
153
+ chain, in release, on the Mac.
154
+ - The frame: a frame drawing a 1920×1080 photo in a hundred 320×180
155
+ boxes, before (level 0, every box) and after (level 2), CPU and GPU —
156
+ the row of ADR 0025's split bench that argued for this.
157
+ - The frame bench guard, which draws no minified image, unchanged.
158
+
159
+ All three are taken; see the *Amendment*.
160
+
161
+ ## Open questions
162
+
163
+ - Trilinear for a size that animates through a power of two, when an
164
+ app pinch-zooms a photo.
165
+ - Making levels off the frame thread for a photo large enough that its
166
+ first draw is a dropped frame.
167
+
168
+ ## Amendment (2026-10-09, built)
169
+
170
+ Built the day it was proposed, as decided, in `kui-core` alone:
171
+ `mip.rs` (`halve`, `depth`, `level_for` and the two sRGB tables),
172
+ `ImageEntry::level(n)` holding the chain behind a mutex on the
173
+ session's entry (`levels_allowed` is `rev == 0`; both updates drop it),
174
+ the page's image slots keyed `(ImageId, level)` with
175
+ `get_or_insert_image_level` taking the pixels as a closure so a level
176
+ already in the page is never made, and `Emit::image_level` choosing per
177
+ quad from the `fit` rect, the display's scale and the clip entry's
178
+ composed `scale`. No binding, no door and no ABI moved; `kui-wgpu` did
179
+ not change. What the building measured and changed:
180
+
181
+ - **The halving has an opaque fast path.** Written first with the
182
+ premultiplied weights for every block, a 4032×3024 photo's first
183
+ level took 13.4 ms; a block whose four alphas are 255 now takes the
184
+ plain mean, and the same level takes **10.1 ms**
185
+ (`benches/levels.rs`, `halve_4032x3024`). The first frame that draws
186
+ the photo on a 400×300 card at 2× — levels 1 to 3 made and level 3
187
+ blitted — is **13.2 ms**, once per photo per session; every frame
188
+ after reads **0.33 µs** for the whole one-image frame. That first
189
+ frame drops at 120 Hz; *Open questions* keeps making levels off the
190
+ frame thread.
191
+ - **The GPU half of the argument was smaller than ADR 0025 read it.**
192
+ `benches/split.rs` had not run since ADR 0043 grew the instance (its
193
+ mirror of `Instance` was 128 bytes against the renderer's 176, so the
194
+ pipeline refused to build); it is mirrored again, with `LEVEL=n`
195
+ (the 1080p texture halved `n` times) and `NOISE=1` (texels a GPU
196
+ cannot compress — the flat orange one read the same at level 0 and
197
+ level 2, 0.757 ms against 0.756, because a flat texture is free
198
+ however it is minified). With noise, a hundred 320×180 boxes
199
+ saturated at **0.786 ms from level 0 and 0.758 ms from level 2**:
200
+ about 0.3 µs a box on an M-series GPU. The texture boxes' gap to
201
+ fragments of the same size (0.46 ms) is mostly not minification. The
202
+ case for this ADR is what is drawn, and the page's memory, not the
203
+ GPU's time.
204
+ - **What is drawn.** The `image` example gained a 768-px zone plate
205
+ drawn at 128 px, from a level beside `nearest`. Drawn whole and
206
+ bilinear (levels switched off for the comparison), its outer rings
207
+ alias into false rings as strong as `nearest`'s; from the level they
208
+ fade to the grey they average to, with faint ghost rings left at the
209
+ corners. Those are decision 2's choice showing: at 2× the plate is
210
+ three texels a pixel, so it is drawn from level 1 at 1.5 texels a
211
+ pixel, and bilinear sampling at 1.5 still folds the top half-octave
212
+ of a pattern made of nothing else. Rounding the level instead, as a
213
+ GPU's nearest-mip filter does, would draw level 2 magnified by 1.33
214
+ — no ghosts, and every icon drawn a little smaller than its file
215
+ blurred by the same factor. A UI has more icons than zone plates;
216
+ the rule stays "never magnify", and trilinear is what removes the
217
+ ghosts without the blur.
218
+ - **The guard.** `nu scripts/bench-check.nu` against alpha.47: the eight
219
+ guarded rows within −0.6% to +0.8% (run-to-run ±2.8%). The two 1080p
220
+ `update_image` rows read +4.4% and +6.9% in that run and −0.8% and
221
+ −1.6% run alone; memcpy-bound rows, noise.
222
+ - **Tests.** `mip.rs`'s six (a flat image halves to itself, the linear
223
+ light mean of black and white is sRGB 188, red beside clear stays red
224
+ at half alpha, an odd side repeats its edge, the chain's depth, the
225
+ level never magnifies) and `tests/image_levels.rs`'s six (the deepest
226
+ level that covers, the display's scale and a node's `scale`, `cover`
227
+ on the level, `nearest` and a stream at level 0, an image past a page
228
+ drawn from the page, the page holding the halved texel). The `image`
229
+ example's headless drive checks the zone plate's two quads.
@@ -0,0 +1,237 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-10-09
4
+ ---
5
+
6
+ # A slot replayed by its host: the extension is spared when the host says nothing it feeds it changed, and the core checks everything else
7
+
8
+ > **Accepted and built (2026-10-09), for alpha.48; backlog F142.** This
9
+ > amends [ADR 0016](0016-caching-against-the-last-frame.md) — decision 2
10
+ > there refuses a `memo` element "in the form that asks the view to
11
+ > declare its own dependencies", and this is not that form, but it is the
12
+ > same move one level up and the refusal's reasons are answered here one
13
+ > by one rather than waved at. What changed between the two documents is
14
+ > a measurement: ADR 0016 priced the app's own building at 40% of a
15
+ > 717 µs frame of Rust pushes, about 0.03 µs a node; a Lua extension's
16
+ > fill costs about 2 µs a node *before* it reaches `Tree::push`, in the
17
+ > binding's walk from its tables to the tree, and that is the half of the
18
+ > bill no primitive of kui's can reduce, because the nodes are not the
19
+ > cost — the conversion is.
20
+
21
+ A host that puts a plugin's pane beside its own content runs the plugin's
22
+ `view` every frame the window draws, whatever brought the frame: a key in
23
+ the host's own editor, a caret blinking in a field, a pointer moving over
24
+ a title bar. For a Rust extension that is the same tenth of a microsecond
25
+ a node the host pays for its own tree. For a Lua one it is not: kawoosh's
26
+ settings pane — 1,561 tables for a dozen settings on show — spends
27
+ 1.25 ms running the view and **3.0 ms** in kui-lua turning the tables it
28
+ returned into nodes, on frames where the pane did nothing, against 0.3 ms
29
+ for kui's whole layout and 0.25 ms for the editor beside it; a frame after
30
+ a pause runs cold and the same pane costs 14–22 ms at the caret's 2 Hz.
31
+ (The kawoosh perf log of 2026-10-09, release build, median over forty
32
+ frames each: settings 5.9 ms a frame against 1.1 ms with no pane; themes
33
+ 6.2; theme lab 4.6; a native pane would pay the C ABI's per-node calls the
34
+ same way, smaller.) We decided that **the host may declare a slot as
35
+ unchanged** (`Ui::slot_replay`), that **the core then checks everything
36
+ it can see itself** — the slot's params, every fact of the frame the kept
37
+ fill read, where the slot sits — and **pushes the kept fill's nodes again
38
+ through the doors they came in by** without asking the extension, or
39
+ runs the extension and says why; that **a fill is kept only when the host
40
+ asked** (`Ui::slot_kept`) and forgotten the frame it is not declared;
41
+ that **a nested slot is declared again and filled fresh on a replay**;
42
+ and that **a fill the journal cannot vouch for is refused whole**, by a
43
+ count of the nodes it pushed against the nodes it journaled.
44
+
45
+ ## Context
46
+
47
+ - **ADR 0016's decision 2 and its reasons.** "An element that means
48
+ 'trust me, nothing under here changed' moves the correctness obligation
49
+ from the core to every app, and the class of bug it admits — a view that
50
+ forgot a dependency, so the screen shows something that stopped being
51
+ true — is exactly the class immediate mode exists to make impossible."
52
+ And: "kui's answer to 'my view is too expensive to run every frame'
53
+ stays *build less*." Both reasons stand and both are addressed below;
54
+ neither is overruled.
55
+ - **What a slot already is.** A position the host declares with params
56
+ "declared every frame and never retained" (ADR 0014), filled then and
57
+ there by an extension the host loaded, its nodes keyed under the slot's
58
+ own key and tagged with the extension's origin, its events routed back
59
+ by that origin. The host is already the one who says what the extension
60
+ reads from it; the core already trusts the host for every other
61
+ per-frame fact (the title, the focus, the declared windows).
62
+ - **What the core can see of a fill's inputs, and what it cannot.** A
63
+ fill reads two kinds of things: facts of the frame, through the core's
64
+ own doors — `is_hovered`, `focus`, `scroll_offset`, `layout_of`,
65
+ `caret_visible`, `now`, `measure_text`, the env reading — and facts of
66
+ the host, through the host's own API (an editor's buffers, a settings
67
+ table), which the core never sees. The first kind can be *recorded with
68
+ the value read* and compared next frame, exactly; the second kind is
69
+ the host's, and only the host can say whether it moved.
70
+ - **What a replay can and cannot re-issue.** A fill pushes nodes, and may
71
+ also declare things of the frame — a window title, a frame it wants
72
+ next, a devtools tab, a loaded extension — or draw through a door that
73
+ carries a picture rather than a spec (`cells`). The nodes come back from
74
+ a journal; the declarations do not, and a fill that makes them is one
75
+ whose next frame is its own business.
76
+ - **Keys are the slot's, not the frame's.** Every node under a fill is
77
+ keyed from the slot's key (`fill_within` sets the namespace), so a
78
+ replay of the same ops under the same slot key yields the same keys —
79
+ which is what keeps hover, focus, scroll offsets, transitions, edit
80
+ buffers and exit diffs where they were, since all of those are keyed
81
+ state the core already retains outside the spec.
82
+ - **Hover, accent and easing are resolved at push, not in the view.**
83
+ `prepare_spec` folds the declared `hover_bg`, the OS accent and a
84
+ transition's eased values into the spec on the way into the tree. A
85
+ journal that records the spec *before* that step and replays it through
86
+ the same door resolves all three for the frame being built, so a
87
+ replayed button lights under the pointer and a replayed card keeps
88
+ easing, with no refill and nothing stale.
89
+
90
+ ## Decision
91
+
92
+ 1. **Two doors on `Ui`, the second a claim.** `slot_kept(name, params)`
93
+ is `slot_with` and keeps what the fill built. `slot_replay(name,
94
+ params)` is the host's claim that nothing *it* feeds the extension has
95
+ changed since; it answers [`SlotFill`]: `Replayed`, or why it ran the
96
+ extension instead — `NotKept`, `Params`, `Reads`, `NotReplayable`,
97
+ `Moved` — having filled and kept it either way, so the next frame may
98
+ replay. `None` when the slot was not declared. The C side is
99
+ `kui_slot_kept` / `kui_slot_replay` / `kui_slot_fill` with
100
+ `KUI_SLOT_*` codes; Lua's `fill { keep = true }` / `fill { replay =
101
+ true }` and `env.slot_fill(name)`; Node's `<slot keep>` / `<slot
102
+ replay>` and `ctx.slotFill(name)`. New symbols only: the C ABI version
103
+ stays, the Node stream version moves (v23) because `slot` gained a
104
+ word.
105
+
106
+ 2. **The host vouches for its side only; the core checks the rest.**
107
+ Before a replay the core compares the params (`Value` equality), every
108
+ fact of the frame the kept fill read against its value now — a hover,
109
+ a press, a focus, the caret phase, a scroll offset or geometry, a
110
+ layout rect, a text hit, an editor's text, the modifiers, the pointer,
111
+ the fonts' revision behind a measurement, the env reading less its
112
+ clock and caret phase — and that the slot's key is the kept one. The
113
+ clock is never the same twice, and the selection's text is not
114
+ compared: a fill that read either runs every frame. The read is noted
115
+ where the door is, in `Core`, so a stock widget's `is_hovered` inside
116
+ the fill is caught as surely as the script's `env.is_hovered`.
117
+
118
+ 3. **What is kept is the fill's ops, as the doors saw them.** Every node
119
+ push records the key, the spec *as declared* (before `prepare_spec`),
120
+ and what the door needs to push it again — a text's string and style,
121
+ a stroke's points, a path's ops, an editor's label and initial, a
122
+ fragment's handle and params, an image's handle and fit — beside the
123
+ labels, data indices, row counts, hints and the `key_focus` asks the
124
+ fill made, and each nested slot it declared with its params. A replay
125
+ pushes these through the same `*_with_key` doors under the kept
126
+ origin, inside `fill_within` as a fresh fill would be, so the tree's
127
+ fill ranges (`ev.slot`), the key labels, the hints and the event
128
+ routing come out as they would have.
129
+
130
+ 4. **A nested slot is declared again, and filled fresh.** `Op::Slot`
131
+ replays as `slot_with`, so whoever fills it runs. This is what makes
132
+ the engine's field inside a script's pane blink its caret at the
133
+ field's cost while the pane around it is not rebuilt. A `slot_kept` or
134
+ `slot_replay` *inside* a fill being kept is a plain slot: the outer
135
+ journal holds the slot, not the inner's nodes.
136
+
137
+ 5. **A fill the journal cannot vouch for is refused whole, by count.**
138
+ Every node a fill pushes is journaled by its door, pushed by a nested
139
+ fill, or pushed by the core on its own behalf (a hover hint) while the
140
+ journal is paused; at the end the nodes the tree gained less the
141
+ nested and the core's own must equal the nodes journaled, or the kept
142
+ fill is `NotReplayable`. So is one that declared something of the frame
143
+ — a title, always-on-top, secure input, option-as-alt, the input
144
+ method, a window, a devtools tab, a frame now or at a time, a widget's
145
+ owed frame, audio, a loaded extension — or drew a `cells` grid, or
146
+ whose view failed. The valve is what lets the journal start narrow:
147
+ a door it does not know is a fresh fill, never a wrong one.
148
+
149
+ 6. **Nothing of the frame is cached.** The tree is built from scratch,
150
+ laid out and emitted as it always was; a replayed node is a node, hit,
151
+ read and departed like any other. What is skipped is the extension's
152
+ `view` and the binding's work before the first push. ADR 0016's
153
+ decision 1 — no subtree cache of layout and emission — is untouched,
154
+ and its list of what a digest cannot see does not apply, because
155
+ nothing downstream of the push is reused.
156
+
157
+ ## How this answers ADR 0016
158
+
159
+ - **"Moves the correctness obligation to every app."** It moves *half* of
160
+ it, and the half it moves is the half only the app has: what the
161
+ extension reads from the host. The other half — everything read through
162
+ kui — the core carries, with values compared rather than inputs
163
+ enumerated, which is the form ADR 0016's own measurement section
164
+ recommends ("compare, do not hash"). An app that vouches wrongly draws
165
+ last frame's pane until the next frame it vouches rightly; that is a
166
+ real bug and a quiet one, and the answers `slot_replay` gives and
167
+ `slot_fill` reads back are what make it a line in a ledger rather than a
168
+ feeling. A host that cannot track what its extension reads has
169
+ `slot_with`, and nothing changed for it.
170
+ - **"Build less."** The host did: the settings pane is a dozen rows on
171
+ show. The cost is not the rows, it is that a Lua table becomes a node
172
+ at 2 µs each, and `virtual_column` does not make a table cheaper to
173
+ read. The binding's walk can be made cheaper (backlog F143), by a
174
+ factor; a replay removes it, on the frames where the view would have
175
+ built the same thing.
176
+ - **"The frame stays honest."** It does: no part of the core keeps a copy
177
+ of a frame it might serve instead. It keeps what a fill *declared*,
178
+ which is what the host would have declared again, and declares it
179
+ again on the host's word — the same word the core takes for the title
180
+ and the focus.
181
+
182
+ ## Consequences
183
+
184
+ - A host with a plugin pane gets the pane's steady frames for the price
185
+ of its own, and a view that reads nothing of the frame is never asked
186
+ twice for the same picture. Measured in the bench
187
+ (`slot_1500_nodes_fresh` against `slot_1500_nodes_replayed`): the
188
+ Rust stand-in's fill of 1,500 nodes is 287 µs a frame fresh and 214 µs
189
+ replayed (medians, M3 Pro), the 73 µs between them the extension's own
190
+ pushes and nothing else — the two are close, which is the point: the
191
+ saving is the extension's own time and the binding's, and a Rust
192
+ extension has little of either. kawoosh's numbers are in its own log.
193
+ - **The hole, written down.** A binding that hands the env reading out
194
+ whole (Lua's `env` table, Node's `ctx.env()`) cannot say which fields
195
+ the script used, so the reading's clock and caret phase are left out of
196
+ the comparison; a script that draws from `env.now` or
197
+ `env.caret_visible` is one its host must not replay. The explicit doors
198
+ (`Ui::now`, `Ui::caret_visible`, Lua's functions, C's) still note the
199
+ read. A host that lends the script a proxy over `env` can close the
200
+ hole on its own side.
201
+ - **What a kept fill costs.** One clone of each declared spec into the
202
+ journal (a `NodeSpec` is 224 bytes plus its boxed parts) and a second
203
+ into the tree on replay; a `Value` clone of the params; and a `Read`
204
+ per fact read. The journal is dropped the frame its slot is not
205
+ declared, so a pane closed costs nothing after.
206
+ - **A door added later must journal or taint.** A new way to push a node
207
+ that does neither is caught by the count — the fill is refused, never
208
+ wrong — but it is caught as a slot that stopped replaying, which a
209
+ host will report as a regression. The doors are listed in
210
+ `runtime/replay.rs`'s `Op`.
211
+ - The devtools do not yet say which slots were replayed; `slot_fill` does,
212
+ `slot_fill_why` names the fact that moved or what the fill declared, and
213
+ a host's own ledger can carry both.
214
+
215
+ ## Considered options
216
+
217
+ **A. Make the binding's walk cheaper and leave the contract alone.**
218
+ Honest, bounded, and worth doing (F143); it does not reach the frames
219
+ where the view would have produced the same tree, which are most frames.
220
+
221
+ **B. A `memo` element the view declares with its dependencies.** ADR
222
+ 0016's decision 2, refused there and refused here for the same reason:
223
+ the view is the party least able to enumerate what it read, and the core
224
+ can enumerate most of it.
225
+
226
+ **C. A core-side digest of the extension's output.** The extension would
227
+ have run to produce the thing digested; nothing is saved.
228
+
229
+ **D. The host declares the slot unchanged; the core checks what it can.
230
+ (Chosen.)** The claim sits with the party that owns the facts the core
231
+ cannot see, and nowhere else.
232
+
233
+ ## Amendment to ADR 0016
234
+
235
+ A note at the head of ADR 0016 points here. Decision 2 there stands as
236
+ written for the view-declared form; the host-declared form, with the
237
+ core's own checks, is this document.
package/encoder.js CHANGED
@@ -875,14 +875,22 @@ export function createEncoder(P) {
875
875
  throw new Error(`bad slot name ${JSON.stringify(p.name)} (a full "namespace/slot")`);
876
876
  }
877
877
  for (const k of Object.keys(p)) {
878
- if (k !== 'name' && k !== 'params' && k !== 'children') {
879
- throw new Error(`<slot> takes name and params, not ${JSON.stringify(k)} — it is a position, not a box`);
878
+ if (k !== 'name' && k !== 'params' && k !== 'keep' && k !== 'replay' && k !== 'children') {
879
+ throw new Error(`<slot> takes name, params, keep and replay, not ${JSON.stringify(k)} — it is a position, not a box`);
880
880
  }
881
881
  }
882
- reserve(6);
882
+ // A slot replayed by its host (ADR 0045): `keep` keeps what the
883
+ // fill builds, `replay` asks for last frame's back when the view's
884
+ // own side of it is unchanged; `ctx.slotFill(name)` says which it
885
+ // got. One or the other.
886
+ if (p.keep && p.replay) {
887
+ throw new Error('<slot> takes keep or replay, not both');
888
+ }
889
+ reserve(7);
883
890
  f[fi++] = OP.slot;
884
891
  strRef(p.name);
885
892
  strRef(p.params != null ? JSON.stringify(p.params) : null);
893
+ f[fi++] = p.replay ? 2 : p.keep ? 1 : 0;
886
894
  return;
887
895
  }
888
896
  case 'devtoolsTab': {
package/howto.md CHANGED
@@ -116,7 +116,11 @@ Decode it with the runner, which links the decoder it draws its
116
116
  wallpaper with: `kui_native::decode_image(bytes)` turns PNG, JPEG, WebP
117
117
  or GIF bytes into straight RGBA and a size, which `add_image` takes as it
118
118
  is (`decodeImage` in Node, `kui_decode_image` in C, freed with
119
- `kui_pixels_free`). No `image` dependency of the app's own. For an
119
+ `kui_pixels_free`). No `image` dependency of the app's own, and no
120
+ resampling to the size it is shown at: register the photo as decoded
121
+ and declare the box, and a photo drawn smaller is drawn from a level the
122
+ core halves it to (ADR 0044), the page holding that level and not the
123
+ whole photo. For an
120
124
  animated GIF, APNG or WebP, `decode_animation` keeps every frame — the
121
125
  whole canvas each, as a browser composites it — with the seconds each
122
126
  shows. Play it on the frame clock: keep when it started, ask
@@ -696,6 +700,38 @@ backdrop; `KUI_BACKDROP_EMULATE=1` shows the wallpaper path on Windows and Linux
696
700
  In C, `KuiRunConfig.backdrop` asks (`KUI_BACKDROP_BLUR`) and
697
701
  `kui_ctx_backdrop(ctx)` in the view reads what the window got.
698
702
 
703
+ ### How do I move the traffic lights down, like a Mac app with a toolbar?
704
+
705
+ Ask for a taller titlebar: `kui_native::app("Notes").custom_titlebar()
706
+ .titlebar(Titlebar::Tall)`, or `titlebar: 'tall'` beside `chrome:
707
+ 'custom'` in Node's window options. `Medium` is a compact toolbar's
708
+ titlebar (40 pt, the lights at 12,13 on macOS 27) and `Tall` a full
709
+ toolbar's (52 pt about the lights, at 19,19), against `Standard`'s 32 pt
710
+ at 9,9. kui does not move the buttons: it gives the window an empty
711
+ `NSToolbar` in the style that makes AppKit's own titlebar that tall, so
712
+ AppKit keeps the lights where they belong through resizing, fullscreen
713
+ and focus. The runner measures where they landed into
714
+ `env.window.native_controls`, and `widgets::titlebar` takes its height
715
+ and inset from it, so a strip of tabs or a search field centres on the
716
+ lights with nothing else to change. The toolbar takes no clicks: a
717
+ button in the strip is pressed as before, and the empty strip still
718
+ drags. The window's `titlebar_h` metric is the measured height, so
719
+ `ui.metrics().titlebar_h` and `$titlebar_h` say how tall the strip is
720
+ (32 for `Standard` on macOS 27, where the stock number is 34). On
721
+ Windows and Linux the same ask draws the same strip: there it
722
+ is the app's, as tall as the `titlebar_h` metric, and `Medium` and
723
+ `Tall` make that window's metric 40 and 52 in place of the caption's 32
724
+ or 34 — `ui.metrics().titlebar_h` and `$titlebar_h` read it, and the
725
+ drawn buttons grow to it. An app that sets its own metrics keeps it as
726
+ long as their `titlebar_h` is the stock number (`Metrics::compact()`,
727
+ Node's `base`); a number of its own wins.
728
+
729
+ [`titlebar.rs`](../examples/rust/widgets/titlebar.rs) (`-- --titlebar tall`) ·
730
+ [`window.native_controls` row](props.md#env)
731
+
732
+ In C, `KuiRunConfig.titlebar` asks (`KUI_TITLEBAR_TALL`); in Odin,
733
+ `Run_Config.titlebar`.
734
+
699
735
  ### How do I frost a toolbar over content that scrolls under it?
700
736
 
701
737
  Give the toolbar a `backdropBlur` and a translucent `bg`:
@@ -1736,6 +1772,37 @@ extension stamping its payloads.
1736
1772
  [alpha.9](CHANGELOG.md#010-alpha9-2026-09-08) ·
1737
1773
  [alpha.13](CHANGELOG.md#010-alpha13-2026-09-15)
1738
1774
 
1775
+ ### My plugin's pane costs every frame, even when nothing in it moved — how do I stop asking it?
1776
+
1777
+ Declare the slot with `ui.slot_kept("fs/panel", &params)` the frame you
1778
+ first show it, and `ui.slot_replay("fs/panel", &params)` after, for as
1779
+ long as nothing *you* feed the plugin has changed — the buffers, the
1780
+ settings, whatever it reads from you outside kui. That is all you vouch
1781
+ for. The core checks the rest itself before it replays: the params are
1782
+ the same, every fact of the frame the kept fill read is the same (which
1783
+ node was hovered, where its scroller stood, the theme, the focus), and
1784
+ the slot is where it was. When all of that holds it pushes last frame's
1785
+ nodes again through the doors they came in by, resolving hover and a
1786
+ transition's easing for this frame, and never calls the plugin's `view`;
1787
+ when any of it does not, it runs the plugin as `slot_kept` would and tells
1788
+ you why in the answer (`SlotFill::Params`, `Reads`, `Moved`, `NotKept`,
1789
+ `NotReplayable`) and in `core.slot_fill(name)` after. A slot inside the
1790
+ plugin's tree — an editor's field, a legend — is declared again and
1791
+ filled fresh on a replay, so a caret still blinks inside a pane that is
1792
+ not rebuilt. A fill that declares something of the frame beyond its
1793
+ nodes (a title, a window, a frame it wants next, a devtools tab), draws
1794
+ a `cells` grid, or fails, is kept as not replayable and runs every frame
1795
+ as before. In Lua, `fill { name =, params =, keep = true }` then
1796
+ `replay = true` and `env.slot_fill(name)`; in C, `kui_slot_kept`,
1797
+ `kui_slot_replay` and `kui_slot_fill`; in Node, `<slot keep>` then
1798
+ `<slot replay>` and `ctx.slotFill(name)`. A view that reads the clock or
1799
+ the caret phase off `env` as a plain field is one you must not replay —
1800
+ a reading handed out whole cannot say which fields were used — and the
1801
+ explicit doors (`ui.now()`, `env.caret_visible()` as a call) still count.
1802
+
1803
+ [ADR 0045](docs/adr/0045-a-slot-replayed-by-its-host.md) ·
1804
+ `crates/kui-core/tests/slot_replay.rs`
1805
+
1739
1806
  ### How do I redraw when a thread has new data?
1740
1807
 
1741
1808
  Take the `Waker` the loop hands `App::setup` and clone it into the thread —
@@ -1834,9 +1901,16 @@ that a screen reader can. `key_of` asks a different question: the *key
1834
1901
  label*, the name the view opened the node under (`with_keyed("like",
1835
1902
  ..)`, a `key` prop), which a reader never hears and `texts_under` reads
1836
1903
  too. Two nodes with one name resolve to the first in tree order and raise
1837
- `ambiguous-name` — the same two a reader cannot tell apart.
1904
+ `ambiguous-name` — the same two a reader cannot tell apart. When one of
1905
+ them is a caption under the control it repeats (a round "like" button
1906
+ over the word "like"), either the caption is decorative — `role="none"`
1907
+ on the box around it, `NodeSpec::role(Role::None)` in Rust, takes it out
1908
+ of the tree — or the control's `label` says what it does ("like this
1909
+ bear"), which a reader is better off with when the caption is all the
1910
+ button says.
1838
1911
 
1839
1912
  [`label` row](props.md#container-props) ·
1913
+ [`role` row](props.md#container-props) ·
1840
1914
  [`ambiguous-name`](props.md#warnings)
1841
1915
 
1842
1916
  ### How do I test the real window, not a headless core?
package/index.d.ts CHANGED
@@ -810,7 +810,10 @@ export type WarningCode =
810
810
  * `kui_key_named`) found more than one node with that name in the last frame
811
811
  * — two buttons both read as "Delete" — and used the first in tree order. A
812
812
  * reader hears the same name twice too: give each a `label` that says which,
813
- * or look the one meant up by its key label. */
813
+ * or look the one meant up by its key label. When one of them is static text
814
+ * and another is not — a caption under the button it repeats — the message
815
+ * says so: a caption a reader need not hear is decorative, and `role="none"`
816
+ * on the box around it takes it out of the tree (backlog F140). */
814
817
  | 'ambiguous-name'
815
818
  /** A `focusRegion(name)` (`Core::focus_region`, `env.focus_region`,
816
819
  * `kui_focus_region`) named a node the frame after it did not declare as a
@@ -1589,11 +1592,15 @@ export interface Metrics {
1589
1592
  menuWidth: number;
1590
1593
  /** The drawn menu bar's height. */
1591
1594
  menuBarH: number;
1592
- /** The titlebar's height where the strip is the app's alone: the platform's
1593
- * caption height, 32 on Windows and 34 elsewhere. Under macOS custom
1594
- * chrome the strip is the OS's own titlebar, as tall as
1595
- * `window.native_controls` measures it (32 on macOS 27, 28 before), and
1596
- * this row is not read (`widgets::titlebar_height`). */
1595
+ /** The titlebar strip's height: the platform's caption height, 32 on
1596
+ * Windows and 34 elsewhere, or the window's own where the runner knows it
1597
+ * (backlog W22) — 40 and 52 off macOS for a launcher's `medium` or `tall`
1598
+ * titlebar under custom chrome, and under macOS custom chrome the OS's own
1599
+ * titlebar as `window.native_controls` measures it (32, 40 or 52 on macOS
1600
+ * 27). The stock number stands for that window's height, so a set of the
1601
+ * app's keeps it unless it names a number of its own. Under macOS custom
1602
+ * chrome the strip is drawn at the measured height whatever this row says
1603
+ * (`widgets::titlebar_height`). */
1597
1604
  titlebarH: number;
1598
1605
  // -- end generated --
1599
1606
  }
@@ -1864,6 +1871,18 @@ export interface WindowOptions {
1864
1871
  maxWidth?: number;
1865
1872
  maxHeight?: number;
1866
1873
  chrome?: 'native' | 'custom' | 'borderless';
1874
+ /** How tall the titlebar strip is under `chrome: 'custom'` (the
1875
+ * launcher's `titlebar`, backlog W22): `'standard'` (the default),
1876
+ * `'medium'` or `'tall'`. On macOS it is AppKit's own titlebar, made
1877
+ * with an empty toolbar, and so where the traffic lights sit: 32 px
1878
+ * with them at 9,9 on macOS 27, 40 at 12,13, or 52 about them at
1879
+ * 19,19; what the window got is `env().window.nativeControls`, and
1880
+ * the window's `titlebarH` metric is that height. On Windows and Linux
1881
+ * the strip is the app's: `'medium'` and `'tall'` make the window's
1882
+ * `titlebarH` metric 40 and 52 in place of the caption's 32 or 34.
1883
+ * Either way a `setMetrics` keeps it unless it names a `titlebarH` of
1884
+ * its own, and `titlebar` lays out against it. */
1885
+ titlebar?: 'standard' | 'medium' | 'tall';
1867
1886
  /** What shows through the window where a frame paints nothing or paints
1868
1887
  * with alpha (the launcher's `backdrop`), by effect: `'opaque'` (the
1869
1888
  * default), `'transparent'` (the desktop as it is), `'blur'` (macOS
@@ -3133,6 +3152,14 @@ export declare class Ctx {
3133
3152
  * plugin filling a slot is answered from its own nodes only.
3134
3153
  */
3135
3154
  keyOf(label: string): string | null
3155
+ /**
3156
+ * What a `<slot replay>` of `name` got this frame, or the
3157
+ * frame before while this one is being built (ADR 0045):
3158
+ * `'replayed'`, or why it was filled fresh — `'not-kept'`,
3159
+ * `'params'`, `'reads'`, `'not-replayable'`, `'moved'` — or
3160
+ * null when nothing asked.
3161
+ */
3162
+ slotFill(name: string): string | null
3136
3163
  /**
3137
3164
  * The hex key of the first node in the last finished frame
3138
3165
  * whose accessible name is `name` (backlog F137) — its
@@ -3641,7 +3668,8 @@ export declare class KuiWindow {
3641
3668
  /**
3642
3669
  * Options: `{width, height, minWidth, minHeight, maxWidth, maxHeight,
3643
3670
  * chrome: "native" | "custom" | "borderless", textAa: "auto" | "gray"
3644
- * | "subpixel", frameLatency, system, icon, backdrop}`. The min/max pairs bound what the user can
3671
+ * | "subpixel", frameLatency, system, icon, backdrop, titlebar:
3672
+ * "standard" | "medium" | "tall"}`. The min/max pairs bound what the user can
3645
3673
  * resize the window to; either half may stand alone. `system` pins part of `env.system` over what the OS
3646
3674
  * says, for the life of the window — `{motion: 'reduced'}` is what a
3647
3675
  * user who asked for less motion would get, on a machine whose owner
@@ -4432,6 +4460,14 @@ export declare class KuiWindow {
4432
4460
  * plugin filling a slot is answered from its own nodes only.
4433
4461
  */
4434
4462
  keyOf(label: string): string | null
4463
+ /**
4464
+ * What a `<slot replay>` of `name` got this frame, or the
4465
+ * frame before while this one is being built (ADR 0045):
4466
+ * `'replayed'`, or why it was filled fresh — `'not-kept'`,
4467
+ * `'params'`, `'reads'`, `'not-replayable'`, `'moved'` — or
4468
+ * null when nothing asked.
4469
+ */
4470
+ slotFill(name: string): string | null
4435
4471
  /**
4436
4472
  * The hex key of the first node in the last finished frame
4437
4473
  * whose accessible name is `name` (backlog F137) — its
package/index.js CHANGED
@@ -1047,8 +1047,8 @@ runWindowed[PACE] = pacer;
1047
1047
  * is opened as a user who asked for less motion would see it.
1048
1048
  */
1049
1049
  export function windowOptions(opts = {}) {
1050
- const { width, height, minWidth, minHeight, maxWidth, maxHeight, chrome, textAa, frameLatency, system, icon, backdrop } = opts;
1051
- return { width, height, minWidth, minHeight, maxWidth, maxHeight, chrome, textAa, frameLatency, system, icon, backdrop };
1050
+ const { width, height, minWidth, minHeight, maxWidth, maxHeight, chrome, textAa, frameLatency, system, icon, backdrop, titlebar } = opts;
1051
+ return { width, height, minWidth, minHeight, maxWidth, maxHeight, chrome, textAa, frameLatency, system, icon, backdrop, titlebar };
1052
1052
  }
1053
1053
 
1054
1054
  /**
package/jsx-runtime.d.ts CHANGED
@@ -278,7 +278,7 @@ export interface GeneratedSpecProps {
278
278
  backdropBlur?: LengthProp;
279
279
  /** Background fill. */
280
280
  bg?: ColorProp;
281
- /** How far a spring overshoots its target: 0 glides in with none, 0.5 bounces visibly, and values past 0.9 are held there (a spring at 1 would never settle). It replaces a spring `easing`'s own bounce (`smooth` 0, `snappy` 0.15, `spring` 0.25, `bouncy` 0.5), and on a timed easing makes the transition a spring — so `transition` plus `bounce` is a spring of that length and bounce. */
281
+ /** How far a spring overshoots its target: 0 glides in with none, 0.5 bounces visibly, and values past 0.9 are held there (a spring at 1 would never settle). It replaces a spring `easing`'s own bounce (`smooth` 0, `snappy` 0.15, `spring` 0.25, `bouncy` 0.5), and on a timed easing makes the transition a spring — so `transition` plus `bounce` is a spring of that length and bounce. It does nothing between `keyframes` stops, which are sampled off the clock (see that row). */
282
282
  bounce?: LengthProp;
283
283
  /** Which non-primary buttons `onButton` claims (backlog F105): `"secondary"`, `"middle"` and `"other"` (every button past those), separated by spaces or commas — `"middle"`, `"secondary middle"`. Unset, all three: a node that wants the middle button and leaves the secondary one to its context menu says `"middle"`. A word that is none of the three is skipped, so a string of none of them claims nothing, and a typo never takes the secondary button from a context menu. Meaningless without `onButton`. */
284
284
  buttons?: string;
@@ -306,7 +306,7 @@ export interface GeneratedSpecProps {
306
306
  disabled?: boolean;
307
307
  /** Background while files dragged in from the OS are over this node (ADR 0031); wins over pressedBg, focusBg and hoverBg, clears when they leave, land or the drag is cancelled. Implies hover tracking, eases with `transition`. */
308
308
  dropBg?: ColorProp;
309
- /** Easing for `transition` (default easeOut). The springs — `smooth` (no overshoot), `snappy`, `spring` and `bouncy` (the most), each a `bounce` of its own — integrate with momentum, so a value retargeted mid-flight keeps moving the way it was; `transition` is then about how long one takes to get there. */
309
+ /** Easing for `transition` (default easeOut). The springs — `smooth` (no overshoot), `snappy`, `spring` and `bouncy` (the most), each a `bounce` of its own — integrate with momentum, so a value retargeted mid-flight keeps moving the way it was; `transition` is then about how long one takes to get there. Between `keyframes` stops a spring is drawn as `easeOut` (see that row). */
310
310
  easing?: 'easeOut' | 'linear' | 'easeIn' | 'easeInOut' | 'spring' | 'bouncy' | 'smooth' | 'snappy';
311
311
  /** Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }`: those slots ease in from there over `transition` ms instead of snapping (`dx`/`dy` slide it in from that far away, `opacity: 0` fades the whole subtree in, `scale: 0.8` settles it in). */
312
312
  enter?: EnterProp;
@@ -342,7 +342,7 @@ export interface GeneratedSpecProps {
342
342
  keepFocus?: boolean;
343
343
  /** With `onKey`: releases arrive too, as the same payload with phase:"up" (`text` null, `repeat` false) — for a held-key interaction (WASD, press-and-hold, a key that arms a mode while it is down). A key only comes up where it went down: a release whose press the sink never got is dropped, and focus leaving while a key is held delivers the `up` first, so nothing is left stuck down. Without it a sink hears presses only, which is what a keymap wants — one that heard both halves would run every binding twice. */
344
344
  keyUp?: boolean;
345
- /** CSS-style stops `[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`: the slots they name cycle through them over `transition` ms, for ever unless `iterations` says how many times, without the view redrawing; `at` is 0..1 and spreads evenly when omitted. `dx` / `dy` are logical px from where layout put the node (backlog F132), as an entrance's are: the node and its subtree are drawn and hit that far away at the stop, a lane a stop leaves out is 0, and the offset adds to a `slide`'s, so `[{ dy: 0 }, { dy: -6 }]` with `repeat: 'alternate'` bobs a box and a sparkle drifts up its stops. Paint, hit and access only: layout and the room the node takes are its own place's, and an `onLayout` node reports its layout rect, not the cycle, which would post an event every frame it runs. */
345
+ /** CSS-style stops `[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`: the slots they name cycle through them over `transition` ms, for ever unless `iterations` says how many times, without the view redrawing; `at` is 0..1 and spreads evenly when omitted. `dx` / `dy` are logical px from where layout put the node (backlog F132), as an entrance's are: the node and its subtree are drawn and hit that far away at the stop, a lane a stop leaves out is 0, and the offset adds to a `slide`'s, so `[{ dy: 0 }, { dy: -6 }]` with `repeat: 'alternate'` bobs a box and a sparkle drifts up its stops. Paint, hit and access only: layout and the room the node takes are its own place's, and an `onLayout` node reports its layout rect, not the cycle, which would post an event every frame it runs. The `easing` applies to each step between two stops, as CSS applies its timing function per keyframe; a spring easing there is drawn as `easeOut` and `bounce` does nothing, since a cycle is sampled off the clock and a spring has to be integrated (backlog F141) — an overshoot in a cycle is a stop past the target, `[{ scale: 1 }, { scale: 1.15, at: 0.6 }, { scale: 1 }]`. */
346
346
  keyframes?: KeyframeProp[];
347
347
  /** The accessible name. Without one a button, link, tab or heading is named by the text inside it; an image, an icon-only button and a `modal` dialog have none, and the core warns (`image-without-label`, `control-without-name`, `modal-without-name`). Not the key label a `key` prop or `with_keyed` declares, which `key_of` looks up and a reader never hears; `key_named` looks a node up by this name. */
348
348
  label?: string;
@@ -412,7 +412,7 @@ export interface GeneratedSpecProps {
412
412
  radiusTR?: LengthProp;
413
413
  /** How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword. */
414
414
  repeat?: 'normal' | 'reverse' | 'alternate' | 'alternateReverse';
415
- /** What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading "2 of 3". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way. */
415
+ /** What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree — the decorative door, for what a reader need not hear: an icon beside the text that says the same, or a caption under the button it repeats. A text takes no `role` (it has no box), so `none` goes on the box around it; `ambiguous-name` points at it when a caption and its control share a name (backlog F140). Where the caption is all a control says, a `label` on the control that says what it does is the better answer. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading "2 of 3". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way. */
416
416
  role?: 'none' | 'button' | 'checkbox' | 'radio' | 'switch' | 'slider' | 'tab' | 'tabList' | 'link' | 'heading' | 'list' | 'listItem' | 'image' | 'dialog' | 'group' | 'textInput' | 'multilineTextInput' | 'line' | 'radioGroup' | 'menu' | 'menuItem' | 'terminal';
417
417
  /** Turns this node and everything under it, in turns clockwise (0.25 is a quarter turn right), about its pivot — the centre unless `pivotX` / `pivotY` say — after layout (`docs/adr/0043-a-node-turns-about-its-pivot.md`). Paint-only: the node takes the room its upright self takes, nothing around it moves, `onLayout` reports the layout rect. Everything the subtree draws turns with it — backgrounds, borders, shadows, text, images, strokes, fragments — and so does what it clips: a child cut by a turned card's rounded corners stays inside them. Hit where drawn: a tilted card is grabbed on its tilted edge and a press in its box past its edge falls through; drag payloads stay in viewport px. The access rect is the bounding box. Nests by composition. Tweens with `transition` as one slot with `scale`, and an entrance, an exit or a keyframe stop may name it (`enter: { rotate: -0.02 }`, `keyframes: [{ rotate: 0 }, { rotate: 1 }]` spins a box). A float anchored to the parent turns with it; a viewport float does not. On a `path` this is the path's own turn (ADR 0041), which does not tween — wrap it in a box for one that does. Text under a turn leaves the pixel grid, as a turned mask does. `backdropBlur` under a turn blurs the upright box. */
418
418
  rotate?: LengthProp;
@@ -838,8 +838,16 @@ export declare namespace JSX {
838
838
  *
839
839
  * A position, not a box: it takes no other props, and with nothing
840
840
  * loaded under that namespace it places an empty node so a view can
841
- * declare its layout before it has a plugin to put in it. */
842
- slot: { name: string; params?: unknown };
841
+ * declare its layout before it has a plugin to put in it.
842
+ *
843
+ * `keep` keeps what the fill builds, and `replay` is the view's claim
844
+ * that nothing it feeds the plugin has changed since: the core checks
845
+ * what it can see — the params, every fact of the frame the kept fill
846
+ * read, that the slot is where it was — and pushes last frame's nodes
847
+ * again without asking the plugin, or fills and keeps it and says why
848
+ * in `ctx.slotFill(name)` (docs/adr/0045-a-slot-replayed-by-its-host.md).
849
+ * One or the other. */
850
+ slot: { name: string; params?: unknown; keep?: boolean; replay?: boolean };
843
851
  /** A tab in the core's devtools panel, beside facts, events and tree
844
852
  * (docs/adr/0032-a-devtools-tab-mounts-a-slot.md). `name` is the tab's
845
853
  * identity, `label` what the strip shows (the name when left out).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qxuken/kui",
3
- "version": "0.1.0-alpha.47",
3
+ "version": "0.1.0-alpha.48",
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
package/props.md CHANGED
@@ -20,7 +20,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
20
20
  | `aspectRatio` | `aspect_ratio` | `aspect_ratio` | `Spec.aspect_ratio` | number, or a `"$length"` token | Width over height — `16/9`, `1` for a square — CSS's `aspect-ratio`. It sizes the axis left `fit`: a fit height is the final width over the ratio (so `width: grow` and a ratio is a box that keeps its shape as the window resizes), and a fit width under a fixed height is that height times it. With both axes declared, or a fit width under a `grow` or percent height, it has nothing it can set and warns. The derived axis is neither shrunk nor fitted to the children, which overflow it; `minHeight: 'fit'` floors it at them. On an image it wins over the pixels' own aspect. |
21
21
  | `backdropBlur` | `backdrop_blur` | `backdrop_blur` | `Spec.backdrop_blur` | number, or a `"$length"` token | Blur what was drawn beneath the node, inside its rounded box, by this radius in logical px — CSS's `backdrop-filter: blur()`, the radius its standard deviation (backlog F129). What blurs is everything painted before the node: its ancestors' backgrounds, the siblings under it, content scrolling beneath it, the window's `backdrop` where the window has one. The node's own `bg`, border and children paint over the blur, so a translucent `bg` (`#ffffff40`) makes frosted glass and an opaque one hides it. Clipped as the node is, faded by its `opacity`; 0 is none. The GPU renderer reads back only the box (and a margin of three radii around it) and blurs it at reduced resolution, so it costs a copy and three small passes per blurred node on a frame that has one and nothing on a frame that does not. A renderer that cannot read back what it drew — a host's own, or anything older — leaves the node over an unblurred backdrop; the display list carries it as a `backdrop` quad (`KUI_QUAD_BACKDROP` in C) either way. |
22
22
  | `bg` | `bg` | `bg` | `Spec.bg` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | Background fill. |
23
- | `bounce` | `bounce` | `bounce` | `Spec.bounce` | number, or a `"$length"` token | How far a spring overshoots its target: 0 glides in with none, 0.5 bounces visibly, and values past 0.9 are held there (a spring at 1 would never settle). It replaces a spring `easing`'s own bounce (`smooth` 0, `snappy` 0.15, `spring` 0.25, `bouncy` 0.5), and on a timed easing makes the transition a spring — so `transition` plus `bounce` is a spring of that length and bounce. |
23
+ | `bounce` | `bounce` | `bounce` | `Spec.bounce` | number, or a `"$length"` token | How far a spring overshoots its target: 0 glides in with none, 0.5 bounces visibly, and values past 0.9 are held there (a spring at 1 would never settle). It replaces a spring `easing`'s own bounce (`smooth` 0, `snappy` 0.15, `spring` 0.25, `bouncy` 0.5), and on a timed easing makes the transition a spring — so `transition` plus `bounce` is a spring of that length and bounce. It does nothing between `keyframes` stops, which are sampled off the clock (see that row). |
24
24
  | `buttons` | `buttons` | `buttons` (`KUI_BUTTONS_*` bits; zeroed, all three) | `Spec.buttons` | string | Which non-primary buttons `onButton` claims (backlog F105): `"secondary"`, `"middle"` and `"other"` (every button past those), separated by spaces or commas — `"middle"`, `"secondary middle"`. Unset, all three: a node that wants the middle button and leaves the secondary one to its context menu says `"middle"`. A word that is none of the three is skipped, so a string of none of them claims nothing, and a typo never takes the secondary button from a context menu. Meaningless without `onButton`. |
25
25
  | `caret` | `caret` | `caret` with `KUI_VALUE_CARET` in `value_set` | `Spec.caret` | number, or a `"$length"` token | On a `line` of a custom editor (a `textInput` / `multilineTextInput` role drawn by the app): the caret's byte offset into that line's text. |
26
26
  | `caretSolid` | `caret_solid` | `caret_solid` | `Spec.caret_solid` | boolean | On a `line` declaring `caret`: the caret is solid — a block caret in a modal editor's normal mode — so the driver's blink clock is not armed on it and `caretVisible` stays true, while the offset still anchors the IME and reads to assistive technology. Without it a declared `caret` is a caret to blink, and the one thing that asks an idle app for a frame twice a second; an editor whose caret only blinks while typing declares this on every other mode's line. |
@@ -34,7 +34,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
34
34
  | `description` | `description` | `description` | `Spec.description` | string | The accessible description: the extra sentence a reader says after the name, for what the name cannot say on its own — what a button will do, why a control is disabled, what format a field wants. `tooltip` is the shorthand that also draws the string and hover-tracks the node; this is the description alone, for a hint that is spoken and never drawn. Both write the one slot, so a node declaring both keeps whichever its binding applied last. It reads only on a node that reaches the access tree — a role, a label, a control — since a plain box is elided and takes its description with it. |
35
35
  | `disabled` | `disabled` | `disabled` | `Spec.disabled` | boolean | Inert: no click, drag or key sink, no hover / pressed / focus background, skipped by Tab, reported disabled to assistive technology; hover tracking stays so a `tooltip` can say why. |
36
36
  | `dropBg` | `drop_bg` | `drop_bg` | `Spec.drop_bg` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | Background while files dragged in from the OS are over this node (ADR 0031); wins over pressedBg, focusBg and hoverBg, clears when they leave, land or the drag is cancelled. Implies hover tracking, eases with `transition`. |
37
- | `easing` | `easing` | `easing` (`KUI_EASE_*`) | `Spec.easing` | `easeOut` \\| `linear` \\| `easeIn` \\| `easeInOut` \\| `spring` \\| `bouncy` \\| `smooth` \\| `snappy` | Easing for `transition` (default easeOut). The springs — `smooth` (no overshoot), `snappy`, `spring` and `bouncy` (the most), each a `bounce` of its own — integrate with momentum, so a value retargeted mid-flight keeps moving the way it was; `transition` is then about how long one takes to get there. |
37
+ | `easing` | `easing` | `easing` (`KUI_EASE_*`) | `Spec.easing` | `easeOut` \\| `linear` \\| `easeIn` \\| `easeInOut` \\| `spring` \\| `bouncy` \\| `smooth` \\| `snappy` | Easing for `transition` (default easeOut). The springs — `smooth` (no overshoot), `snappy`, `spring` and `bouncy` (the most), each a `bounce` of its own — integrate with momentum, so a value retargeted mid-flight keeps moving the way it was; `transition` is then about how long one takes to get there. Between `keyframes` stops a spring is drawn as `easeOut` (see that row). |
38
38
  | `enter` | `enter` | `enter` (`KuiEnter`, with `set` bits) | `Spec.enter` | entrance (`{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }`) | Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }`: those slots ease in from there over `transition` ms instead of snapping (`dx`/`dy` slide it in from that far away, `opacity: 0` fades the whole subtree in, `scale: 0.8` settles it in). |
39
39
  | `exit` | `exit` | `exit` (`KuiEnter`, with `set` bits) | `Spec.exit` | entrance (`{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }`) | Where the node ends the frame after the view stops declaring it `{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }` — an `enter` read the other way. It plays when the node itself is removed, its parent still declared; a node that goes because an ancestor went — a tab switched away, a panel closed around it — goes at once with it, unless that ancestor has an `exit` of its own, whose picture carries it (backlog DX19; React's `AnimatePresence` rule). With a `transition`, the departing subtree is copied out of the last frame that had it and replayed frozen, in its place (the pass it painted in, just under the node that painted after it — a panel under a HUD leaves under it) and inert (no clicks, no Tab stop, no access row) while those slots ease from where they were, then dropped; without one it vanishes at once as it always did. `width`/`height` resize the departing node's own box only — the subtree inside it is a picture and is not laid out again. The exit read is the one the last frame that had the node declared, unless `exit_with` named another for the frame it went in: a card a button throws left or right is aimed by the handler that removes it, with no frame drawn first to point it. Needs a stable key across frames. |
40
40
  | `expanded` | `expanded` | `expanded` (`KUI_EXPANDED_*`) | `Spec.expanded` | `collapsed` \\| `expanded` | A disclosure's state: what a node that shows and hides something (a twisty, an accordion header, a menu button) reads as. Unset, the node does not expand at all — which is why this names its state instead of being a flag. |
@@ -52,7 +52,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
52
52
  | `iterations` | `iterations` | `iterations` (0 is for ever) | `Spec.iterations` | number, or a `"$length"` token | How many times the `keyframes` cycle runs (CSS `animation-iteration-count`, backlog F133); left out, for ever. A finite cycle plays from the first frame the node is declared with it — a node that leaves and comes back plays again — holds its first stop through its `delay`, and rests where its last iteration ended (CSS's fill `both`): `1` plays a burst or a shake once, `2` with `repeat: "alternate"` goes out and back and ends where it began, `0.5` stops halfway. Once it is over the node owes no frame, so `animating()` and `owed()` go quiet as a settled transition's do; `delay` plus `iterations: 1` staggers one-shots. A count that is not a positive number is for ever. C: 0 is for ever. |
53
53
  | `keepFocus` | `keep_focus` | `keep_focus` | `Spec.keep_focus` | boolean | A press on this node, or anywhere inside it, leaves keyboard focus where it was: a toolbar button, a tab or a divider that acts without taking the keyboard from the editor or key sink that had it. Without it a press on an `onClick` node focuses the node, and the app's keys stop reaching the sink until it takes focus back. The press also leaves a text or cell selection and the Tab ring where they were, so a Copy button copies what was selected. An `<edit>` inside still takes its caret and focus, as the keyboard's own owner. The click, drag and hover are unchanged, and Tab and assistive technology still reach the node. |
54
54
  | `keyUp` | `key_up` | `key_up` | `Spec.key_up` | boolean | With `onKey`: releases arrive too, as the same payload with phase:"up" (`text` null, `repeat` false) — for a held-key interaction (WASD, press-and-hold, a key that arms a mode while it is down). A key only comes up where it went down: a release whose press the sink never got is dropped, and focus leaving while a key is held delivers the `up` first, so nothing is left stuck down. Without it a sink hears presses only, which is what a keymap wants — one that heard both halves would run every binding twice. |
55
- | `keyframes` | `keyframes` | `keyframes` + `keyframes_len` (`KuiKeyframe[]`) | `Spec.keyframes` | keyframe list (`[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`) | CSS-style stops `[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`: the slots they name cycle through them over `transition` ms, for ever unless `iterations` says how many times, without the view redrawing; `at` is 0..1 and spreads evenly when omitted. `dx` / `dy` are logical px from where layout put the node (backlog F132), as an entrance's are: the node and its subtree are drawn and hit that far away at the stop, a lane a stop leaves out is 0, and the offset adds to a `slide`'s, so `[{ dy: 0 }, { dy: -6 }]` with `repeat: 'alternate'` bobs a box and a sparkle drifts up its stops. Paint, hit and access only: layout and the room the node takes are its own place's, and an `onLayout` node reports its layout rect, not the cycle, which would post an event every frame it runs. |
55
+ | `keyframes` | `keyframes` | `keyframes` + `keyframes_len` (`KuiKeyframe[]`) | `Spec.keyframes` | keyframe list (`[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`) | CSS-style stops `[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`: the slots they name cycle through them over `transition` ms, for ever unless `iterations` says how many times, without the view redrawing; `at` is 0..1 and spreads evenly when omitted. `dx` / `dy` are logical px from where layout put the node (backlog F132), as an entrance's are: the node and its subtree are drawn and hit that far away at the stop, a lane a stop leaves out is 0, and the offset adds to a `slide`'s, so `[{ dy: 0 }, { dy: -6 }]` with `repeat: 'alternate'` bobs a box and a sparkle drifts up its stops. Paint, hit and access only: layout and the room the node takes are its own place's, and an `onLayout` node reports its layout rect, not the cycle, which would post an event every frame it runs. The `easing` applies to each step between two stops, as CSS applies its timing function per keyframe; a spring easing there is drawn as `easeOut` and `bounce` does nothing, since a cycle is sampled off the clock and a spring has to be integrated (backlog F141) — an overshoot in a cycle is a stop past the target, `[{ scale: 1 }, { scale: 1.15, at: 0.6 }, { scale: 1 }]`. |
56
56
  | `label` | `label` | `label` (KuiStr) | `Spec.label` | string | The accessible name. Without one a button, link, tab or heading is named by the text inside it; an image, an icon-only button and a `modal` dialog have none, and the core warns (`image-without-label`, `control-without-name`, `modal-without-name`). Not the key label a `key` prop or `with_keyed` declares, which `key_of` looks up and a reader never hears; `key_named` looks a node up by this name. |
57
57
  | `live` | `live` | `live` (`KUI_LIVE_*`) | `Spec.live` | `off` \\| `polite` \\| `assertive` | Marks this node a live region: when the text inside it changes, a screen reader reads the change without being asked — `polite` at the next pause, `assertive` interrupting. Put it on the smallest node that holds the message, since everything inside a live node is live. For a one-off with no node behind it ("Saved") the binding's `announce` verb is the other half. |
58
58
  | `mainAlign` | `main_align` | `main_align` | `Spec.main_align` | `start` \\| `center` \\| `end` \\| `spaceBetween` \\| `spaceAround` \\| `spaceEvenly` \\| `baseline` | Child alignment along the main axis. `start`, `center` and `end` put the children together; `spaceBetween` deals the free space out between them (none at the ends), `spaceAround` gives each child an equal share split to its two sides, and `spaceEvenly` makes every gap and both ends equal — CSS's `justify-content`. The spread is added to `gap`, and there is none when nothing is free: a `grow` child takes it all, and an overflowing run keeps its gaps. `baseline` means nothing here and lays out as `start`, with a warning. |
@@ -87,7 +87,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
87
87
  | `radiusTL` | `radius_tl` | `radius_tl` with `per_corner` | `Spec.radius_tl` | number, or a `"$length"` token | Top-left corner radius (logical px). |
88
88
  | `radiusTR` | `radius_tr` | `radius_tr` with `per_corner` | `Spec.radius_tr` | number, or a `"$length"` token | Top-right corner radius (logical px). |
89
89
  | `repeat` | `repeat` | `repeat` (`KUI_REPEAT_*`) | `Spec.repeat` | `normal` \\| `reverse` \\| `alternate` \\| `alternateReverse` | How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword. |
90
- | `role` | `role` | `role` (`KUI_ROLE_*`) | `Spec.role` | `none` \\| `button` \\| `checkbox` \\| `radio` \\| `switch` \\| `slider` \\| `tab` \\| `tabList` \\| `link` \\| `heading` \\| `list` \\| `listItem` \\| `image` \\| `dialog` \\| `group` \\| `textInput` \\| `multilineTextInput` \\| `line` \\| `radioGroup` \\| `menu` \\| `menuItem` \\| `terminal` | What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading "2 of 3". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way. |
90
+ | `role` | `role` | `role` (`KUI_ROLE_*`) | `Spec.role` | `none` \\| `button` \\| `checkbox` \\| `radio` \\| `switch` \\| `slider` \\| `tab` \\| `tabList` \\| `link` \\| `heading` \\| `list` \\| `listItem` \\| `image` \\| `dialog` \\| `group` \\| `textInput` \\| `multilineTextInput` \\| `line` \\| `radioGroup` \\| `menu` \\| `menuItem` \\| `terminal` | What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree — the decorative door, for what a reader need not hear: an icon beside the text that says the same, or a caption under the button it repeats. A text takes no `role` (it has no box), so `none` goes on the box around it; `ambiguous-name` points at it when a caption and its control share a name (backlog F140). Where the caption is all a control says, a `label` on the control that says what it does is the better answer. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading "2 of 3". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way. |
91
91
  | `rotate` | `rotate` | `rotate` | `Spec.rotate` | number, or a `"$length"` token | Turns this node and everything under it, in turns clockwise (0.25 is a quarter turn right), about its pivot — the centre unless `pivotX` / `pivotY` say — after layout (`docs/adr/0043-a-node-turns-about-its-pivot.md`). Paint-only: the node takes the room its upright self takes, nothing around it moves, `onLayout` reports the layout rect. Everything the subtree draws turns with it — backgrounds, borders, shadows, text, images, strokes, fragments — and so does what it clips: a child cut by a turned card's rounded corners stays inside them. Hit where drawn: a tilted card is grabbed on its tilted edge and a press in its box past its edge falls through; drag payloads stay in viewport px. The access rect is the bounding box. Nests by composition. Tweens with `transition` as one slot with `scale`, and an entrance, an exit or a keyframe stop may name it (`enter: { rotate: -0.02 }`, `keyframes: [{ rotate: 0 }, { rotate: 1 }]` spins a box). A float anchored to the parent turns with it; a viewport float does not. On a `path` this is the path's own turn (ADR 0041), which does not tween — wrap it in a box for one that does. Text under a turn leaves the pixel grid, as a turned mask does. `backdropBlur` under a turn blurs the upright box. |
92
92
  | `ruleWidth` | `rule_width` | `rule_w` | `Spec.rule_width` | number, or a `"$length"` token | The width of a table's `rules` in logical px; 1 when unset. |
93
93
  | `rules` | `rules` | `rules` | `Spec.rules` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | On a table (`dir="table"`, ADR 0033): grid lines of this colour between its columns and between its rows (backlog DX21) — down the middle of each gap between the columns of its widest row, from the first row's top to the last row's bottom, and across the middle of each gap between rows, the content box wide. Drawn with the table's box, under its cells and on whole pixels, so give the table and its rows a `gap` at least `ruleWidth` for the lines to show between cells; the outer edge is the table's `border`. Ignored on anything but a table. |
@@ -171,7 +171,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
171
171
  | `<radioGroup label>…radios…</radioGroup>` | `radio_group { label=, … }` | `kui_radio_group_open` … `kui_close` | `kui.radio_group` in an `if`, closed at its end | A container of radios (`widgets::radio_group_with`, ADR 0034): the `radioGroup` role, named by its `label`, laid out as a column with the stock gap — a `dir="row"` lays the radios across, and its arrows run across with it. It reads every box row; the role and the name are its own whatever the rows say. |
172
172
  | `<switch checked onClick key label description tooltip disabled>text</switch>` | `switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }` | `kui_switch` | `kui.toggle` | The stock switch (`widgets::toggle_with`, ADR 0034): a track and a knob drawn from `checked`, the knob sliding across when it changes, and its label; read as a switch, on or off. The state is the app's, as a checkbox's is; the rows are the checkbox's, `mixed` aside. |
173
173
  | `<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` | `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. |
174
- | `<image src={id} sampling fit>` | `image { id=, sampling=, fit= }` | `kui_image`, `kui_image_with` | `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. |
174
+ | `<image src={id} sampling fit>` | `image { id=, sampling=, fit= }` | `kui_image`, `kui_image_with` | `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. Drawn at less than half its texels a pixel, a `linear` image is drawn from a level the core halved it to (`docs/adr/0044-an-image-drawn-smaller-is-drawn-from-a-level.md`): the deepest level with at least a texel a pixel, the 2×2 means taken in linear light, made once per session at the first draw that wants it and kept in the atlas in place of the whole image — so a photo registered as decoded and shown on a card is neither resampled by the app nor aliased on screen. The display's scale and any `scale` the node is drawn through count. `nearest` and an image ever updated (a stream) draw the whole image. |
175
175
  | `<polygon points={[[x,y],…]} bg/>` | `polygon { points={{x,y},…}, bg= }` | `kui_polygon` | `kui.polygon`, its points a `[][2]f32` | 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, and painted in the parent's layer at its place in the tree, over the siblings before it and under those after (backlog F123). 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. |
176
176
  | `<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` | `kui.path`, `kui.path_d` | 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 and painted in the parent's layer at its place in the tree (backlog F123). `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, a gap the dots overlap closed — 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. |
177
177
  | `<fragment src={id} image={id} params={[…]} animate>` | `fragment { id=, image=, params={…}, animate= }` | `kui_fragment`, `kui_fragment_with` | `kui.fragment`, `kui.fragment_with`; `kui.fragment_open` in an `if` holds children | 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. |
@@ -256,7 +256,7 @@ people. In Node the codes are the `WarningCode` union.
256
256
  | `transition-auto-key` | The child count of a node changed while one of its children carries a transition under an auto-assigned key. Auto keys are sibling positions, so the children that shifted became new nodes and snapped instead of easing. Give list items a key. |
257
257
  | `duplicate-key` | Two nodes in one frame share a key: everything retained per key (transitions, scroll offsets, editors, layout events, hover state) is mixed between them. Siblings need distinct keys. |
258
258
  | `ambiguous-key` | A label resolved by name (`focus("beta")` in Node, `env.set_focus("beta")` in Lua, `kui_key_of` in C) is declared by more than one node in the frame, under different parents, so they have distinct keys and the name picked the first in tree order. Labels are unique among siblings, not across a tree. An extension asking from inside its fill is answered from the nodes it opened and no one else's, and the host from its own first — so this is a clash among the asker's own. Give the node meant a label nothing else declares, or pass the hex key an event carried. Two nodes with the *same* key are `duplicate-key`. |
259
- | `ambiguous-name` | A lookup by accessible name (`Core::key_named`, `ctx.keyNamed`, `kui_key_named`) found more than one node with that name in the last frame — two buttons both read as "Delete" — and used the first in tree order. A reader hears the same name twice too: give each a `label` that says which, or look the one meant up by its key label. |
259
+ | `ambiguous-name` | A lookup by accessible name (`Core::key_named`, `ctx.keyNamed`, `kui_key_named`) found more than one node with that name in the last frame — two buttons both read as "Delete" — and used the first in tree order. A reader hears the same name twice too: give each a `label` that says which, or look the one meant up by its key label. When one of them is static text and another is not — a caption under the button it repeats — the message says so: a caption a reader need not hear is decorative, and `role="none"` on the box around it takes it out of the tree (backlog F140). |
260
260
  | `focus-region-without-node` | A `focusRegion(name)` (`Core::focus_region`, `env.focus_region`, `kui_focus_region`) named a node the frame after it did not declare as a `focusRegion` — no node under the label, or a node without the row — so nothing was entered and focus stayed where it was. The call is resolved against the frame it lands on, so an `update` that toggles a dock on and enters it in one go is fine; this is that call with the view half missing, with a name the view spells differently, or naming a node that is not a region. |
261
261
  | `label-without-node` | A `reveal` or `setScroll` by label (`env.reveal("rows")`, `win.reveal("rows")`, `Core::reveal_label`) named a label the frame it resolved against did not declare, so nothing moved. A label is resolved when the frame finishes, so a view may name a node it is declaring right now, or one the next frame declares; this is the name spelled differently from the `key` that declares it, or the node not declared at all. |
262
262
  | `unknown-family` | A text's `family` named a family no installed or loaded font has, so it shaped as sans. `sans`, `serif` and `mono` are kui's own; any other name is matched as `addSystemFont` matches it, and `systemFonts()` lists the names a machine has. |
@@ -460,7 +460,7 @@ everywhere else — and `compact` leaves it alone.
460
460
  | `menu_pad_y` | `menuPadY` | 5 | 3 | A menu row's vertical padding; a menu-bar title's is two px less. |
461
461
  | `menu_width` | `menuWidth` | 200 | 180 | A menu panel's width. |
462
462
  | `menu_bar_h` | `menuBarH` | 26 | 22 | The drawn menu bar's height. |
463
- | `titlebar_h` | `titlebarH` | 32 / 34 | 32 / 34 | The titlebar's height where the strip is the app's alone: the platform's caption height, 32 on Windows and 34 elsewhere. Under macOS custom chrome the strip is the OS's own titlebar, as tall as `window.native_controls` measures it (32 on macOS 27, 28 before), and this row is not read (`widgets::titlebar_height`). |
463
+ | `titlebar_h` | `titlebarH` | 32 / 34 | 32 / 34 | The titlebar strip's height: the platform's caption height, 32 on Windows and 34 elsewhere, or the window's own where the runner knows it (backlog W22) — 40 and 52 off macOS for a launcher's `medium` or `tall` titlebar under custom chrome, and under macOS custom chrome the OS's own titlebar as `window.native_controls` measures it (32, 40 or 52 on macOS 27). The stock number stands for that window's height, so a set of the app's keeps it unless it names a number of its own. Under macOS custom chrome the strip is drawn at the measured height whatever this row says (`widgets::titlebar_height`). |
464
464
 
465
465
  ## Doors
466
466
 
@@ -647,6 +647,9 @@ its generator (`nu scripts/odin.nu gen --check`).
647
647
  | `Core::set_inspect` | `kui_set_inspect` | `set_inspect` | `setInspect` | *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* | Turns the per-frame node snapshot behind `nodes` on. |
648
648
  | `Core::nodes` | `kui_nodes` | `nodes` | `nodes` | *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* | The last frame's nodes with what layout and the declarations made of them — a tree view's and an inspector's data. |
649
649
  | `Ui::add_extension` | `kui_ctx_add_extension` | `ctx_add_extension` | `Ctx.addExtension` | `add_extension` | Loads a plugin under a namespace; a `KuiWindow` takes its list at construction (`extensions`). |
650
+ | `Ui::slot_kept` | `kui_slot_kept` | `slot_kept` | `<slot name params keep/>` | `fill { name=, params=, keep = true }` | Declares a slot as `slot` does and keeps what the fill built — every node as its door saw it, the slots inside, every fact of the frame it read — for `slot_replay` to push again (ADR 0045). |
651
+ | `Ui::slot_replay` | `kui_slot_replay` | `slot_replay` | `<slot name params replay/>` | `fill { name=, params=, replay = true }` | The host's claim that nothing it feeds the extension changed: the core checks the params, every fact the kept fill read and the slot's place, and pushes the kept nodes again without the extension, or fills and keeps and says why (ADR 0045). |
652
+ | `Core::slot_fill` | `kui_slot_fill` | `slot_fill` | `slotFill` | `slot_fill` | What the last `slot_replay` of a name answered this frame, or the frame before while this one is being built: replayed, or why it was filled fresh (ADR 0045). |
650
653
  | `Launcher::extensions` | `kui_ctx_extension_count` / `kui_ctx_extension_namespace`, one at a time | `ctx_extension_count` / `ctx_extension_namespace`, one at a time | `Ctx.extensionNamespaces` | `extension_namespaces` | The namespaces loaded. |
651
654
  | `Core::frame` | `kui_frame_begin` … `kui_frame_finish` | `frame_begin` … `frame_finish`, or `kui.frame` around a view | `Ctx.frame` | *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* | Runs one frame: the view, layout, the draw list; `KuiWindow.setView` is the windowed form, the runner calling it. |
652
655
  | `Core::output` | `kui_draw_data` | `draw_data` | `quads` | *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* | The draw list: quads, clips, fragment and texture draws (`clips`, `fragmentDraws`, `textureDraws` beside `quads` in Node) and the frame's stats. |