@qxuken/kui 0.1.0-alpha.41 → 0.1.0-alpha.43

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,455 @@ 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.43 (2026-10-08)
25
+
26
+ **What breaks.**
27
+
28
+ - The drawn menu — a context menu, a select's list, a drawn menu bar's
29
+ dropdown — is as wide as its widest row needs, where it was always
30
+ `Metrics::menu_width` (now its floor), and no row's label or
31
+ accelerator wraps (under Fixed, F127).
32
+ - A menu's accelerator kui can parse is drawn, and read back through
33
+ `Core::menu` / Node's `menu()` / C's `kui_menu_item`, in the platform's
34
+ spelling: `"mod+shift+n"` is `⇧⌘N` or `Ctrl+Shift+N`, where it was the
35
+ string as declared — as the menu bar's already was (under Fixed,
36
+ F127).
37
+ - Rust: `MenuItem` gains `submenu` (under Added, F128), so a struct
38
+ literal of one needs the field; `MenuItem::KEYS` is seven keys, with
39
+ `items`, where it was six.
40
+ - Rust: `WindowEnv` gains `backdrop` (under Added, F126), so a struct
41
+ literal of one needs the field or `..Default::default()`; so does
42
+ `kui_devtools::Window`, in the examples' harness.
43
+ - Rust: `QuadKind` gains `Backdrop` (under Added, F129), so a `match` on
44
+ a quad's kind needs the arm — a renderer of its own draws nothing for
45
+ it; `InteractSpec` and `NodeInfo` gain `backdrop_blur`, for a struct
46
+ literal of either. The conformance report's `kinds` line has a tenth
47
+ column, `backdrop`.
48
+ - C: ABI 26. `KuiSpec` appends `backdrop_blur` (into the tail padding:
49
+ the 64-bit size stays 704), `KuiMenuItem` appends `submenu` and
50
+ `submenu_count` (an array element, so its stride moved, to 72), and
51
+ `KuiRunConfig` appends `backdrop` (44). `KUI_QUAD_BACKDROP` is a new
52
+ quad kind. `KuiSpan` appends `family`, `size` and `font` (an array
53
+ element, so its stride moved, to 56), with the `KUI_SPAN_FAMILY` flag
54
+ (under Added, F130). Recompile; a zeroed tail is what every spec, row,
55
+ span and config had.
56
+ - Rust: `text::Span` gains `family` and `size` (under Added, F130), for a
57
+ struct literal of one; `Span::new` and the builders are unchanged.
58
+ - Node: the wire is v22 — a span carries its face and size after its
59
+ background's radius — so the addon and the package go together, as
60
+ every wire step has.
61
+ - `Accel::parse` reads a named key's label as that key: `"⌘⌫"` is ⌘
62
+ Backspace and `"Ctrl+Page Up"` parses, where the first was the
63
+ character `⌫` and the second nothing (under Fixed, RG146).
64
+ - A host's report of a dead menu row — `Core::activate_menu_bar_item` /
65
+ `_path`, Node's `activateMenuBarItem`, C's `kui_activate_menu_bar_*`,
66
+ or a row under a dead submenu row through `activate_menu_path` — is
67
+ refused and posts nothing, where it was performed (under Fixed,
68
+ RG147).
69
+
70
+ `backdropBlur` is a number row like any other, and a row's `items` rides
71
+ in the JSON a row already was.
72
+
73
+ ### Added
74
+
75
+ - **A window with the desktop behind it** (backlog F126, from Noticon,
76
+ for a sidebar like an Obsidian theme's on a Mac).
77
+ `Launcher::backdrop(Backdrop)` — `backdrop` in Node's window options —
78
+ asks for what shows through the app's windows, named for the effect:
79
+ `Transparent` (the desktop as it is), `Blur` (a live blur of what is
80
+ behind the window) or `Tinted` (the desktop's colour, steady). Which
81
+ regions show it is the app's, by painting them with alpha and the rest
82
+ opaque. macOS: an `NSVisualEffectView` under the content view, blending
83
+ behind the window and dimmed with it in the background — the sidebar
84
+ material for `Blur`, the window-background one for `Tinted`. Windows 11
85
+ 22H2 and later: Acrylic for `Blur`, Mica for `Tinted`
86
+ (`DWMWA_SYSTEMBACKDROP_TYPE`, the frame extended under the client
87
+ area), the window without a GDI surface and the device presenting D3D12
88
+ through DirectComposition (`kui_wgpu::GpuOptions::transparent`,
89
+ `Renderer::new_with` / `new_in_with`). Linux: `Blur` asked of the
90
+ compositor — `ext-background-effect-v1` where it is advertised with
91
+ blur, KWin's `org_kde_kwin_blur`, `_KDE_NET_WM_BLUR_BEHIND_REGION` under
92
+ X11. Where the OS has no effect to give — GNOME, other Linux, Windows
93
+ 10 — `Blur` and `Tinted` draw the desktop's wallpaper (GNOME's
94
+ `picture-uri`, Plasma's config, `SPI_GETDESKWALLPAPER`), read on a
95
+ thread, scaled down and blurred once and cached by path and modification
96
+ time, as the window's ground under the frame
97
+ (`Renderer::set_ground` / `set_ground_uv`), aligned to where the window
98
+ sits on its monitor (centred on Wayland), and report `Tinted`; with no
99
+ wallpaper, `Opaque`. Where the OS draws the effect the frame is cleared
100
+ to nothing rather than to the theme's `bg`, and glyphs are grayscale
101
+ under `TextAa::Auto`. What the window got is `env.window.backdrop`
102
+ (`window.backdrop` in Node and Lua, `kui_env_set_backdrop` for a C host
103
+ that makes its own), so a view paints opaque when it says `Opaque`.
104
+ Every window of the app takes it but a popup and the devtools' own; an
105
+ app that does not ask opens exactly the window and the swapchain it
106
+ always did. `KUI_BACKDROP_EMULATE=1` draws the wallpaper for `Blur` and
107
+ `Tinted` on Windows and Linux, to look at it (macOS reads no wallpaper,
108
+ so a window there reads `Opaque`). Seen in windows on Windows 11:
109
+ Acrylic blurring a red window behind the translucent region, Mica, the
110
+ bare desktop through `Transparent`, the wallpaper drawn by kui under
111
+ `KUI_BACKDROP_EMULATE`, and an opaque devtools window beside them; on
112
+ WSLg (Wayland, no blur protocol, no gsettings) a `Blur` reading
113
+ `Opaque`. On a Mac, built through Noticon, the library column shows the
114
+ vibrancy. No KDE or GNOME session was at hand: the Linux half compiles
115
+ and passes clippy there, untried on either desktop. C asks with
116
+ `KuiRunConfig.backdrop` (a `KUI_BACKDROP_*`) and a view reads what the
117
+ window got with `kui_ctx_backdrop`, the answer and not the ask; Odin's
118
+ `Run_Config.backdrop` and `kui.ctx_backdrop`. The `backdrop` example
119
+ paints a translucent library and an opaque page from the reading.
120
+ *What you can delete:* nothing — this is new.
121
+ - **A node blurs what is drawn beneath it** (backlog F129, from Noticon,
122
+ for a frosted toolbar over a scrolling note): CSS's `backdrop-filter:
123
+ blur()`. `NodeSpec::backdrop_blur(radius)` — `backdropBlur` in JSX,
124
+ `backdrop_blur` in Lua and Odin, `KuiSpec.backdrop_blur` in C — blurs
125
+ everything painted before the node, inside its rounded box, by that
126
+ radius in logical px (the Gaussian's standard deviation, as CSS's):
127
+ the ancestors' backgrounds, the siblings and the content scrolling
128
+ under it, and the window's backdrop where it has one. The node's own
129
+ `bg`, border and children paint over the blur, so a translucent `bg`
130
+ is frosted glass; it is clipped as the node is and faded by its
131
+ `opacity`. The core emits a `QuadKind::Backdrop` quad
132
+ (`KUI_QUAD_BACKDROP`) just before the node's own paint, carrying the
133
+ shape, the clip, the radius in physical px and the group opacity.
134
+ kui-wgpu draws a frame that has one into an offscreen copy of the
135
+ surface, breaks the pass at each, copies out the region under the node
136
+ plus three radii around it, averages it down by a power of two that
137
+ leaves a kernel of two to four texels, blurs it along each axis, and
138
+ writes it back inside the node's rounded rect and its clip, mixing by
139
+ coverage and opacity with blending off so a transparent window stays
140
+ premultiplied; then blits the frame to the surface. A frame without
141
+ one is drawn exactly as before, and the offscreen textures go after
142
+ 120 frames without one. A renderer that cannot read back what it drew
143
+ draws nothing for the quad, which leaves the node over an unblurred
144
+ backdrop; a headless core has the quad in its display list and no
145
+ pixels. `diag::BACKDROP_BLUR_HIDDEN` warns of one under an opaque `bg`
146
+ of its own, which hides all of it. The devtools inspector and
147
+ `Core::nodes` (`backdropBlur` in Node) read the radius. Seen on Windows
148
+ 11 in the new `backdrop_blur` example, in an opaque window and over
149
+ Acrylic: the cards under the toolbar smeared, the edge below it sharp,
150
+ the badge's blur inside its corners. *What you can delete:* a
151
+ toolbar's opaque fill over content that scrolls under it.
152
+ - **Submenus** (backlog F128, from Noticon, for "Move to ▸" and "Sort by
153
+ ▸"). `MenuItem::submenu(label, rows)` — `items` on a row in Node and
154
+ Lua, read by the one row parser — is a row with a chevron that opens
155
+ its rows in a menu beside it: when the pointer rests on it, on a click,
156
+ on Enter or the Right arrow (focus on its first row). Left or Escape
157
+ closes it, focus back on its row, and Escape again closes the menu.
158
+ They nest, in the context menu, a select's list and the drawn menu
159
+ bar's menus alike, and a chosen row inside posts its own `{kind:"menu",
160
+ role, item}` on the node the menu is about, as any row does; the row
161
+ that opens one is never chosen. On macOS the context menu and the menu
162
+ bar build an `NSMenu` submenu, which AppKit opens itself, and report a
163
+ row inside by its path. A host showing menus itself reports one with
164
+ `Core::activate_menu_path` / `activate_menu_bar_path` (Node
165
+ `activateMenuPath` / `activateMenuBarPath`); `menu()` reads a row's
166
+ `items` back. `Core::menu_submenus` / `menu_bar_submenus` say what is
167
+ open. The pointer opens and closes on a change of row, with no timer,
168
+ so a pointer resting on one row does not undo what the keyboard opened.
169
+ In C a row's `submenu` / `submenu_count` (ABI 26) nest the same
170
+ `KuiMenuItem`s, in `kui_open_menu`, `kui_select` and `kui_menu_bar`; a
171
+ row's flags carry `KUI_MENU_ITEM_SUBMENU`, and a host showing its own
172
+ menus reads and reports a row inside by its path —
173
+ `kui_menu_submenu_count` / `kui_menu_item_path` /
174
+ `kui_activate_menu_path`, and the bar's `kui_menu_bar_submenu_count` /
175
+ `kui_menu_bar_item_path` / `kui_activate_menu_bar_path`; Odin's
176
+ `Menu_Item.submenu` and the same doors. A C menu nested past 32 levels
177
+ — a row that is its own submenu — is refused, as an unknown role is.
178
+ *What you can delete:* a list cut short because a menu could not
179
+ nest — Noticon's `MOVE_TARGETS` cap on the folders a note can move to.
180
+
181
+ - **A span in a face and a size of its own** (backlog F130, from
182
+ Noticon, whose inline `code` was drawn in the body face with a wash).
183
+ `Span::family(FontFamily)` / `Span::mono()` and `Span::size(px)` —
184
+ `family`, `font` and `size` on a JSX `<span>` and in a Lua span table,
185
+ `KuiSpan.family` (with `KUI_SPAN_FAMILY`), `.font` and `.size` in C,
186
+ the same fields on Odin's `Span`. The paragraph still shapes as one
187
+ flow; each span's glyphs are shaped in its face, at that family's
188
+ weights, so a caret, a hit, a selection and the measurement read the
189
+ glyphs that are drawn and byte positions stay exact across a change of
190
+ face mid-line. A sized span's line height scales at the paragraph's
191
+ ratio, a line is as tall as its tallest span (every span then carries
192
+ its metrics, so a line of only smaller ones never comes out shorter),
193
+ measurement sums the lines' own heights, a selection and a caret are
194
+ their line's height, and a span's background is its own height around
195
+ its glyphs rather than the line's. A paragraph with a sized span is
196
+ shaped whole, never chunked as a long line. Pinned by
197
+ `tests/span_face.rs` (the mono width, carets and hits across the
198
+ change, line heights, the caret, the wash), Node's `a span takes a face
199
+ and a size of its own`, and the corpus's first scene, which gained a
200
+ mono span and a sized one in all five adapters; the `text` example
201
+ shows inline code and a larger word. *What you can delete:* inline code
202
+ drawn in the body face, or as a box beside the text.
203
+
204
+ ### Fixed
205
+
206
+ - **A menu's accelerator reads as the platform writes it** (backlog
207
+ F127, from Noticon). `MenuItem::accel` drew its string verbatim in a
208
+ context menu, so a row declared with the portable `"mod+shift+n"` —
209
+ the spelling the docs give for a menu bar, where it was already
210
+ rewritten — showed those eleven characters. `open_menu` now rewrites
211
+ an accelerator it can parse into `Accel::display`'s spelling, as
212
+ `declare_menu_bar` does, and the drawn rows read every accelerator
213
+ through `Accel::label`, so a menu an app draws itself with
214
+ `widgets::context_menu` gets the same. A spelling kui cannot parse
215
+ (`"gd"`) is still drawn exactly as written. `MenuItem::accel_label`
216
+ is the drawn string; `accel_text` stays the declared one. *What you
217
+ can delete:* the `Accel::parse(..).display()` an app ran over its own
218
+ accelerators before handing them to a menu.
219
+ - **A menu is as wide as its rows** (backlog F127). The panel was the
220
+ metric's 200 px whatever it held, so a long label beside a long
221
+ accelerator wrapped one of them onto a second line. It is now as wide
222
+ as its widest label plus its widest accelerator, with at least
223
+ `widgets::MENU_ACCEL_GAP` between them, and never narrower than
224
+ `Metrics::menu_width`; labels and accelerators are one line each.
225
+ - **`scripts/npm-approve.nu` takes the version as npm spells it.**
226
+ Approving alpha.42 as `nu scripts/npm-approve.nu
227
+ @qxuken/kui@0.1.0-alpha.42` looked for a stage whose version was the
228
+ whole spec, found none, and said "nothing staged for
229
+ @qxuken/kui@@qxuken/kui@0.1.0-alpha.42" while the stage sat there. The
230
+ package prefix is cut off now, and a spec naming another package is
231
+ refused by name. A release script, so nothing an app sees.
232
+ - **A macOS menu shortcut on a named key works** (backlog RG146). The
233
+ bar (since alpha.11) and now the context menu build each item's key
234
+ equivalent from the accelerator in the platform's spelling, and
235
+ `Accel::parse` read `⌘⌫`'s `⌫` as a character: AppKit drew ⌘⌫ beside
236
+ the row and the chord did nothing, as with ⌘↩, ⌘← and every named
237
+ key. `parse` now reads a key as `Accel::display` writes it, in either
238
+ platform's spelling.
239
+ - **A dead menu row cannot be chosen by reporting it** (backlog RG147).
240
+ The bar's activate doors performed a disabled row, or one in a
241
+ disabled menu — kui.h said they refused it — and `activate_menu_path`
242
+ a row under a disabled submenu row. Every row on the path must be
243
+ enabled now, as the drawn menus need.
244
+
245
+ **What you can delete.**
246
+
247
+ - The `Accel::parse(..).display()` an app ran over its accelerators
248
+ before handing them to a context menu (F127).
249
+ - A menu cut short, or flattened, because menus could not nest: "Move to
250
+ …" rows one per folder up to a cap (F128).
251
+ - A toolbar's opaque fill over content that scrolls under it (F129).
252
+ - Inline code drawn in the body face, or as a box beside the text (F130).
253
+ - A toolbar's opaque fill over content that scrolls under it (F129).
254
+ - Inline code drawn in the body face, or as a box beside the text (F130).
255
+
256
+ ### Native verification
257
+
258
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-08, over
259
+ F126–F130 from the Noticon wish list, with alpha.43's pre-tag pass over
260
+ them on the Mac: the mechanical round, then three read-only reviews of
261
+ the diff since alpha.42 (the window backdrop and the blur; the menus and
262
+ the span's face; the docs), each claim probed. They filed RG146–RG149,
263
+ built before the tag (a macOS menu shortcut on a named key binding no
264
+ key, a host's report of a dead menu row performed, an Escape spent on a
265
+ submenu an app-drawn menu had noted, a Node span's family losing to an
266
+ inherited font handle), and RG150, open.
267
+
268
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
269
+ scripts/test.nu`: **1911 tests over 142 suites**, 0 failed. The C round
270
+ passes, and so do the **57 scenes**; Node's tests under
271
+ `KUI_CONFORMANCE_REQUIRED=1` (**218 of 218**), `npm run gen` with no
272
+ diff, the examples' typecheck, the headless round (with `backdrop_blur`'s
273
+ drive, which the roster had left out) and the book. The windowed round
274
+ with Node's: **128 windows** (one `transition` window hung at the
275
+ timeout on a first run four at a time and drew on eight runs alone and a
276
+ whole second round). The AX audit: **106/106**. The bench guard against the alpha.42 tag:
277
+ **green**, the guarded rows −0.4 to +3.1% (`deep_nesting_64_levels`,
278
+ −2.1% on a second run); the stream, long-line and cell grid rows flat.
279
+ The Odin
280
+ binding's four steps did not run here — the machine's `odin` links an
281
+ `llvm@22` no longer installed — so CI's check on the tag is their run.
282
+ F126's macOS half and F128's macOS menus were compiled and run on a Mac
283
+ the day they were built, through Noticon; F126 has not met KDE or GNOME.
284
+
285
+ ## 0.1.0-alpha.42 (2026-10-07)
286
+
287
+ **What breaks.**
288
+
289
+ - A press after a dead key it does not combine with carries what the
290
+ platform typed in its `text`: German's `^ space` is a Space whose
291
+ `text` is `^`, and `^ z` a `z` whose `text` is `^z`, where they said `" "`
292
+ and `z` — in a key sink's payload and in what an editor inserts (under
293
+ Fixed, RG127). A keymap that binds Space by its `text` rather than its
294
+ `code` sees the accent there.
295
+ - Node: a whole number past 2^53 in a message comes back a `number`,
296
+ where it came back a `BigInt` (RG138).
297
+ - C: `kui_take_menu_action` with a NULL or too-small `out` leaves the
298
+ action queued, where it dropped it; `kui_take_warnings` keeps what does
299
+ not fit `cap` for the next call, where it dropped it; `kui_draw_data`'s
300
+ `fragments` and `textures` are NULL on a frame that draws none, where
301
+ they were a non-NULL pointer to nothing (RG133, RG134, RG131).
302
+ - A text style whose size or line height is under a pixel — 0, negative,
303
+ NaN — shapes at a pixel, where Node and Lua aborted or hung and Rust
304
+ panicked (RG136); a cell grid's rows are a pixel apart at an infinite
305
+ one, where they went to the layout's limit (RG144).
306
+ - Rust: `schema::Door`, `schema::CustomProp` and `schema::ElementDef` gain
307
+ `odin` (under Added, the Odin binding), so a struct literal of one needs
308
+ the field.
309
+
310
+ C stays at ABI 25 (kui.h gains no function and no field; four doors now
311
+ keep what it always promised) and the Node wire at v21 (no frame version).
312
+
313
+ ### Added
314
+
315
+ - **An Odin binding, experimental** (`packages/odin`). `kui/c` mirrors
316
+ kui.h declaration by declaration, pinned to the C compiler's layout by
317
+ 848 generated `#assert`s. `kui` is the typed layer, generated from
318
+ kui.h and the prop schema:
319
+ - `Spec` and `Text_Style`, one field per schema row;
320
+ - an enum or bit set per family of constants;
321
+ - a door per C function: out-params as results, arrays as slices,
322
+ messages as plain Odin values with `#[derive(Message)]`'s `kind`
323
+ rule.
324
+
325
+ The elements are written by hand: containers close at the end of their
326
+ `if` through `@(deferred_in)`. A C function the hand-written files do
327
+ not call gets a generated door, so the binding covers kui.h whole: 219
328
+ generated, 51 by hand, 2 skipped with their reason.
329
+ `examples/odin/tools/surface.odin` is `surface.c` through it, and calls
330
+ every door, and `examples/odin/tools/conformance.odin` rebuilds the
331
+ scene corpus through `Spec` and the doors, matching the reference report
332
+ byte for byte, as the Rust, Lua, C and Node adapters do.
333
+ `polyline` and `polygon` take points (`[][2]f32`), the count kui.h
334
+ means; a dash or a pivot is a fixed array in a `Maybe` (`[5]f32`,
335
+ `[2]f32`), so a short one cannot be written; and pixels are checked
336
+ against the size beside them.
337
+
338
+ It covers extensions both ways. An Odin host loads a plugin with
339
+ `ctx_add_extension` and `slot`. An Odin plugin is a shared library whose
340
+ seven `kui_ext_*` exports are one line each over `extension.odin`.
341
+ Built with `KUI_PLUGIN` on macOS and Linux, it links no kui and loads
342
+ into any host (on Windows it imports `kui_ffi.dll`, as a C plugin does):
343
+ `nu scripts/odin.nu slots` drives the Odin panel in the C and Rust
344
+ hosts and the C panel in the Odin host.
345
+
346
+ Run it with `nu scripts/odin.nu gen | test | slots | run counter`. It is
347
+ not published. CI's `check` installs a pinned Odin release (`ODIN_VERSION`)
348
+ and runs `gen --check`, `check`, `test` and `slots`, so a header or schema
349
+ change that was not regenerated goes red there. All four also run green
350
+ by hand on Windows x64 and Linux x64. On Windows the binding links
351
+ `kui_ffi.dll.lib`, odin.nu puts `kui_ffi.dll` beside the programs, and
352
+ `gen --check` reads a CRLF checkout as current.
353
+ - `scripts/pack-ffi.nu`: libkui_ffi for every platform kui ships, dynamic
354
+ and static, with `kui.h`, rustc's `native-static-libs` as `link.txt`, a
355
+ tarball each and `SHA256SUMS`. It is for a C or Odin host that links a
356
+ library instead of building the workspace. The machine's own platform
357
+ is built natively. Linux x64 and arm64 (glibc 2.28) and Windows x64 are
358
+ built in a pinned Docker image, with cargo-zigbuild at the release
359
+ workflow's pins and with cargo-xwin. Windows also asks for
360
+ `--accept-msvc-license`, for the CRT and SDK xwin downloads. macOS is
361
+ built on a Mac only. A Windows pack carries windows-targets' import
362
+ libraries (`windows.0.53.0.lib`, `windows.0.52.0.lib`), which its
363
+ `link.txt` names and no SDK has, so a static link finds them. Every
364
+ leg has been run, and a C host linked against each x64 pack alone,
365
+ dynamically and statically, and run; the arm64 pack was read, with no
366
+ arm64 machine to run it on. `kui.h` ships with LF line ends from any
367
+ checkout.
368
+ - `examples/rust/tools/schema-dump.rs`: the prop schema (props,
369
+ composites, elements, events, the verb table, the name lists) as JSON,
370
+ for a binding generated outside Rust.
371
+ - The verb table (`schema::DOORS`) and `docs/props.md` have an Odin
372
+ column beside C's, each spelling held by the Odin generator both ways;
373
+ Node's `protocol()` carries it (`Door.odin`).
374
+
375
+ ### Fixed
376
+
377
+ - **A dead key types its accent before a key it does not combine with**
378
+ (backlog RG127, from the Windows and Linux round after alpha.41). With
379
+ German, `^ space` typed a space where it types `^`, `´ space` a space
380
+ where it types `´`, and `^ z` a `z` where it types `^z` — on Windows
381
+ always, and under X11 with `imeOff` on; on US-International, where `'`
382
+ and `"` are dead, a quote could not be typed at all. winit composed it
383
+ right: the runner read a press's text off the logical key, which is
384
+ the key the accent fell back to, and Space's insert was a space
385
+ whatever the press carried. A press's text is now what the platform
386
+ composed, the layout's character where it composed nothing, and Space
387
+ inserts its own. `^ e` was right and is unchanged; with the input
388
+ method on, X11 composes through XIM as before.
389
+ - The docs of F103 (alpha.41) said an animating Windows window behind
390
+ other windows skips its frames. It does not: DWM composes a covered
391
+ window, and it draws at the display's rate, 241 frames a second here,
392
+ as an uncovered one does; minimized, it asks for none (RG45). F103's
393
+ wait holds there only for an acquire that times out. `retry.rs` says so
394
+ now (backlog RG128).
395
+ - **Text is read aloud on Windows and Linux** (backlog RG129, from the
396
+ regression and smoke round after RG127). Every static text — a line of
397
+ text, a list row's content, a drawn title — reached UI Automation with
398
+ an empty Name and AT-SPI with an empty name: AccessKit names a label by
399
+ its value, and kui set only its label. macOS read it all along.
400
+ - **A disclosure or a select opens from Narrator** (RG130). UI
401
+ Automation gives a node with `expanded` its ExpandCollapse pattern in
402
+ place of Invoke, and its Expand and Collapse reached nothing. They are
403
+ the node's click.
404
+ - **A text size or line height of 0 no longer ends the process** (RG136).
405
+ `lineHeight: 0`, `size: 0`, or a size under 0.4 (whose line height
406
+ rounds to 0) aborted a Node or Lua process — cosmic-text asserts a line
407
+ height is not 0 — and a negative one spun its layout; on an `edit`, a
408
+ `cells` and in `measureText` too. Every buffer is shaped at a pixel at
409
+ least. C's door had always read them as unset.
410
+ - C: what kui.h says a door hands out lives as long as it says (RG131–
411
+ RG134). A second `kui_draw_data` in a frame freed the arrays the first
412
+ handed out; `kui_access_runs` freed the strings `kui_access_tree`
413
+ handed out; reading a menu row or asking for a copy freed a taken menu
414
+ action's text; and `kui_take_warnings` dropped what did not fit `cap`,
415
+ where kui.h says the rest wait.
416
+ - A custom editor whose `caret` or `selection_anchor` falls inside a
417
+ character has an access tree (RG135): the run was sliced there and
418
+ panicked, which under C emptied the tree every frame; the position is
419
+ the character's start.
420
+ - Node: `measureText` inside a `<devtoolsTab>` function child no longer
421
+ breaks the frame being encoded (RG137); a message, a `<select>` option
422
+ or a `menuBar` row holding a string cut through an emoji draws its lone
423
+ surrogate as U+FFFD rather than failing the frame, and `dir: null` is
424
+ absent like every other null prop (RG138, RG140).
425
+
426
+ ### Native verification
427
+
428
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-07, over
429
+ the rounds after the alpha.41 tag — the Odin binding and `pack-ffi.nu`,
430
+ RG127 and RG128 from the Windows and Linux round after alpha.41, and the
431
+ regression and smoke round after RG127 (RG129–RG138) — with alpha.42's
432
+ pre-tag pass over them on the Mac: the mechanical round, then four
433
+ read-only reviews of the diff since alpha.41 (the Odin binding; the C
434
+ doors and `pack-ffi.nu`; the runner's keys, the access bridge, the text
435
+ floor and Node; the docs), each claim probed. They filed RG140–RG144,
436
+ built before the tag (a `<select>` option or menu bar row cut through an
437
+ emoji failing the Node frame, `kui_draw_data` while a frame built losing
438
+ the finished frame's fragment draws, the Odin drains freeing the strings
439
+ they returned, Odin's popups, unchecked slices and acronym kinds, a cell
440
+ grid's infinite line height), and RG145, open.
441
+
442
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `cargo test
443
+ --workspace --features kui-core/conformance`: **1856 tests over 139
444
+ suites**. The C round passes, and so do the **57 scenes**, the Odin
445
+ binding's `gen --check`, `check`, `test` and `slots`, Node's tests under
446
+ `KUI_CONFORMANCE_REQUIRED=1` (**215 of 215**), `npm run gen` with no
447
+ diff, the headless round and the book. The windowed round with Node's:
448
+ **124 windows**. The AX audit: **106/106**. RG127's dead keys, which the
449
+ Windows and Linux round could not reach on a Mac: twelve sequences typed
450
+ into the `edit` example through `CGEventPostToPid` and read back through
451
+ AX, each as AppKit composes it (`⌥e e` is `é`, `⌥e space` `´`, `⌥e z`
452
+ `´z`). The bench guard against the alpha.41 tag: **green**, the guarded
453
+ rows −1.2 to −0.3%, RG126's warm cell grid −0.4%.
454
+
455
+ **Windows 11 x64 (MSVC) and Linux x64 (WSL Ubuntu 24.04), rustc
456
+ 1.99.0**, on the tree with the pre-tag fixes and `pack-ffi.nu`'s. fmt
457
+ and `cargo clippy --workspace --all-targets --features
458
+ kui-core/conformance -- -D warnings` are clean on both. `cargo test
459
+ --workspace --features kui-core/conformance`: **1857 tests over 140
460
+ suites** on Windows (5 ignored), **1853 over 139** on Linux (4
461
+ ignored), **0 failed**. `cargo audit --deny warnings` is clean over
462
+ `Cargo.lock`'s 444 crates. `pack-ffi.nu` ran every leg but macOS's: native
463
+ on both, and linux-x64, linux-arm64 and win32-x64 (cargo-xwin) in its
464
+ container. A C host linked against each x64 pack alone, dynamically and
465
+ statically, ran; the arm64 pack was read, not run. It found three things,
466
+ fixed before the tag (RG145): a CRLF `kui.h` from a CRLF checkout, the
467
+ closing table lost off a terminal, and a `docker` that could not be
468
+ spawned stopping the script. Earlier the same day, before the pre-tag
469
+ fixes, the Odin binding's four steps passed on both, and the regression
470
+ and smoke round after RG127 ran the windowed smoke and walked the
471
+ accessibility tree through UI Automation and AT-SPI.
472
+
24
473
  ## 0.1.0-alpha.41 (2026-10-07)
25
474
 
26
475
  **What breaks.**
@@ -331,7 +331,14 @@ derived orientation are the whole surface.
331
331
  decision 5's warning is where an app feels the missing semantic half.
332
332
  - **Submenus.** Left / Right in a `menu` should close and open them. kui
333
333
  has no submenu relation (a `menu` inside a `menuItem`), so today those
334
- arrows step like any other menu's.
334
+ arrows step like any other menu's. *Built 2026-10-08 for the core's own
335
+ menus (backlog F128):* Right on a row with a submenu opens it with focus
336
+ on its first row, Left inside one closes it with focus back on its row,
337
+ and Escape closes the innermost before the menu. The relation is the
338
+ core's state rather than a derived one — a submenu's panel is a `menu`
339
+ beside its row, not inside the `menuItem` (which is named from its
340
+ content), and the item walk already stops at a nested `menu` — so an
341
+ app's own `menu` composites step on Left / Right as before.
335
342
  - **`radio-without-group`.** A lone `radio` is invalid ARIA and now also
336
343
  means "no arrows here". A warning in the shape of `control-without-name`
337
344
  once something in the repo declares radios — the fixture will, so this
@@ -189,6 +189,16 @@ does. An app that has to write the menu twice has not been given a menu.
189
189
  something wants one, it wants it in the context menu too, and that is one
190
190
  change to `MenuItem` rather than two features.
191
191
 
192
+ *Amended 2026-10-08 (backlog F128).* Something wanted one — Noticon's
193
+ "Move to ▸" and "Sort by ▸", in the context menu first — and it was that
194
+ one change: `MenuItem::submenu` (`items` in plain data, so every
195
+ binding's one parser reads it), drawn by the same `menu_panel` beside
196
+ its row, in the context menu and the drawn bar alike, and an `NSMenu`
197
+ submenu where the platform draws them. `BarMenu` is still one level;
198
+ its rows nest. There is no hover timer: the core opens or closes on a
199
+ *change* of hovered row, which is what keeps a pointer resting on one
200
+ row from undoing what the keyboard just opened.
201
+
192
202
  - **Standard application-menu roles** (`about`, `quit`, `services`, and
193
203
  macOS's `NSApplication` responders). They change what an item *is* — one
194
204
  the platform performs rather than one the app hears — so they need their
package/encoder.js CHANGED
@@ -402,7 +402,8 @@ export function createEncoder(P) {
402
402
  const np = fi++;
403
403
  let n = 0;
404
404
  // dir and size first: the decoder constructs spec/style from them.
405
- if (p.dir !== undefined && p.dir !== 'column') {
405
+ // `!= null`: a null dir is absent, as every other null prop is.
406
+ if (p.dir != null && p.dir !== 'column') {
406
407
  // `table` is a column whose rows' cells line up (ADR 0033).
407
408
  const dir = p.dir === 'row' ? 1 : p.dir === 'table' ? 2 : undefined;
408
409
  if (dir === undefined) throw new Error(`bad dir ${JSON.stringify(p.dir)} (row | column | table)`);
@@ -734,6 +735,8 @@ export function createEncoder(P) {
734
735
  // dotted (v13, backlog K4). A span's own underline colour and style
735
736
  // beat the enclosing span's; either implies the underline. Then the
736
737
  // background's radius (v17, backlog F101), 0 for a square one; a span's
738
+ // own beats the enclosing span's. Then the span's own face — `family`
739
+ // by name, `font` by handle, which wins — and `size` (v22); a span's
737
740
  // own beats the enclosing span's.
738
741
  function collectSpans(node, st, out) {
739
742
  if (node == null || typeof node === 'boolean') return;
@@ -751,7 +754,7 @@ export function createEncoder(P) {
751
754
  (st.ul?.ref ? 512 : 0) |
752
755
  (st.ulStyle === 'wavy' ? 1024 : 0) |
753
756
  (st.ulStyle === 'dotted' ? 2048 : 0);
754
- out.push([String(node), flags, st.color?.v ?? 0, st.bg?.v ?? 0, st.ul?.v ?? 0, st.bgRadius ?? 0]);
757
+ out.push([String(node), flags, st.color?.v ?? 0, st.bg?.v ?? 0, st.ul?.v ?? 0, st.bgRadius ?? 0, st.family ?? null, st.font ?? null, st.size ?? 0]);
755
758
  return;
756
759
  }
757
760
  if (Array.isArray(node)) {
@@ -766,6 +769,12 @@ export function createEncoder(P) {
766
769
  if (p.bgRadius != null && !(typeof p.bgRadius === 'number' && p.bgRadius >= 0)) {
767
770
  throw new Error(`bad bgRadius ${JSON.stringify(p.bgRadius)} on <span> (a number of logical px, 0 or more)`);
768
771
  }
772
+ if (p.family != null && typeof p.family !== 'string') {
773
+ throw new Error(`bad family ${JSON.stringify(p.family)} on <span> (sans, serif, mono or an installed family's name)`);
774
+ }
775
+ if (p.size != null && !(typeof p.size === 'number' && p.size > 0)) {
776
+ throw new Error(`bad size ${JSON.stringify(p.size)} on <span> (a number of logical px, above 0)`);
777
+ }
769
778
  collectSpans(
770
779
  node.children,
771
780
  {
@@ -778,6 +787,11 @@ export function createEncoder(P) {
778
787
  ul: spanColor(p.underlineColor, st.ul),
779
788
  ulStyle: p.underlineStyle ?? st.ulStyle,
780
789
  bgRadius: p.bgRadius ?? st.bgRadius,
790
+ family: p.family ?? st.family,
791
+ // A span naming a family drops the handle it inherited, which
792
+ // would otherwise win over it.
793
+ font: p.font != null ? String(p.font) : p.family != null ? null : st.font,
794
+ size: p.size ?? st.size,
781
795
  },
782
796
  out,
783
797
  );
@@ -828,15 +842,18 @@ export function createEncoder(P) {
828
842
  collectSpans(el.children, {}, spans);
829
843
  f[fi++] = OP.richText;
830
844
  props(p, null, false);
831
- reserve(8 + spans.length * 8);
845
+ reserve(8 + spans.length * 14);
832
846
  f[fi++] = spans.length;
833
- for (const [text, flags, c, bg, ul, radius] of spans) {
847
+ for (const [text, flags, c, bg, ul, radius, family, font, size] of spans) {
834
848
  strRef(text);
835
849
  f[fi++] = flags;
836
850
  f[fi++] = c;
837
851
  f[fi++] = bg;
838
852
  f[fi++] = ul;
839
853
  f[fi++] = radius;
854
+ strRef(family);
855
+ strRef(font);
856
+ f[fi++] = size;
840
857
  }
841
858
  } else {
842
859
  f[fi++] = OP.text;