@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +13313 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -3
  4. package/docs/adr/0001-accessibility-as-data.md +414 -0
  5. package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
  6. package/docs/adr/0003-modal-surfaces.md +248 -0
  7. package/docs/adr/0004-multi-window.md +626 -0
  8. package/docs/adr/0005-the-paint-vocabulary.md +601 -0
  9. package/docs/adr/0006-c-abi-versioning.md +257 -0
  10. package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
  11. package/docs/adr/0008-live-regions-and-announcements.md +379 -0
  12. package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
  13. package/docs/adr/0010-a-segment-primitive.md +410 -0
  14. package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
  15. package/docs/adr/0012-the-exit-budget.md +411 -0
  16. package/docs/adr/0013-effects-as-data.md +197 -0
  17. package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
  18. package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
  19. package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
  20. package/docs/adr/0017-selection-as-a-scope.md +696 -0
  21. package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
  22. package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
  23. package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
  24. package/docs/adr/0021-one-subject-per-example.md +729 -0
  25. package/docs/adr/0022-focus-regions.md +205 -0
  26. package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
  27. package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
  28. package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
  29. package/docs/adr/0026-hit-testing-by-shape.md +197 -0
  30. package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
  31. package/docs/adr/0028-derived-tokens.md +572 -0
  32. package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
  33. package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
  34. package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
  35. package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
  36. package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
  37. package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
  38. package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
  39. package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
  40. package/docs/adr/0037-a-family-is-named.md +134 -0
  41. package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
  42. package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
  43. package/encoder.js +1282 -0
  44. package/howto.md +1774 -0
  45. package/index.d.ts +5162 -0
  46. package/index.js +1414 -0
  47. package/jsx-dev-runtime.js +1 -0
  48. package/jsx-runtime.d.ts +905 -0
  49. package/jsx-runtime.js +45 -0
  50. package/native.cjs +118 -0
  51. package/package.json +51 -6
  52. package/prebuilds/darwin-arm64/kui_node.node +0 -0
  53. package/prebuilds/darwin-x64/kui_node.node +0 -0
  54. package/prebuilds/linux-arm64/kui_node.node +0 -0
  55. package/prebuilds/linux-x64/kui_node.node +0 -0
  56. package/prebuilds/win32-x64/kui_node.node +0 -0
  57. package/props.md +636 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nasonov Viktor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,336 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # kui
2
+
3
+ kui for Node: JSX views (a custom jsx-runtime, no React) lowered into the kui
4
+ IR in one call per frame, Elm-style messages as data. The library itself is
5
+ documented in the [kui repository](https://github.com/qxuken/kui).
6
+
7
+ Install it from npm:
8
+
9
+ ```
10
+ npm install @qxuken/kui@alpha
11
+ ```
12
+
13
+ Every release is also on the Forgejo npm registry the project grew up on
14
+ (`npm config set @qxuken:registry https://drydock9.qxuken.dev/api/packages/qxuken/npm/`
15
+ first; it does not proxy npmjs, so scope the setting rather than replacing
16
+ the default registry).
17
+
18
+ Or start from the template: `npm create @qxuken/kui-node my-app`.
19
+
20
+ Every release so far is a prerelease, so `latest` and `alpha` point at the same
21
+ newest alpha and a bare `npm install @qxuken/kui` gets it; `npm view
22
+ @qxuken/kui@alpha version` is the query that still answers if `latest` is ever
23
+ absent. Ranges do not pin a prerelease — `^0.1.0-alpha.8` and `~0.1.0-alpha.8`
24
+ both admit every later alpha of the same `0.1.0` — so an app that wants the
25
+ version it tested writes that version exactly and commits its lockfile.
26
+
27
+ The tarball bundles the native addon for linux-x64, linux-arm64, darwin-arm64,
28
+ darwin-x64 and win32-x64 under `prebuilds/`; `native.cjs` picks the one matching
29
+ `process.platform`-`process.arch`. On any other platform build it from the
30
+ repo (`cargo build -p kui-node --release`) and set `KUI_NODE_LIB` to the
31
+ resulting library.
32
+
33
+ ```tsx
34
+ // tsconfig: "jsx": "react-jsx", "jsxImportSource": "@qxuken/kui"
35
+ import { createApp, runWindowed } from '@qxuken/kui';
36
+ import type { CoreMsg, Ctx, KuiWindow, UiEvent } from '@qxuken/kui';
37
+
38
+ type Model = { count: number };
39
+ // This app's own messages plus the ones the core sends by itself.
40
+ type Msg = { kind: 'add'; by: number } | { kind: 'reset' } | CoreMsg;
41
+
42
+ // The fourth argument is the surface the loop drives — the headless `Ctx`
43
+ // under `createApp`, the `KuiWindow` under `runWindowed` — for `editText`,
44
+ // `focus`, `play` and the rest. Both drivers pass it.
45
+ function update(model: Model, msg: Msg, ev: UiEvent<Msg>, ui: Ctx | KuiWindow): Model | undefined {
46
+ switch (msg.kind) { // one union, no casts
47
+ case 'add': return { count: model.count + msg.by };
48
+ case 'reset': return { count: 0 };
49
+ }
50
+ }
51
+
52
+ // `init` is the first model, or a function the surface is handed to — after
53
+ // `setup`, so the fonts and images it registered are there to measure
54
+ // against. Under a window that argument is the window, so a first model can
55
+ // be built at the size it really opened at instead of at a constant
56
+ // corrected on the first `resize`:
57
+ // runWindowed({ init: (win) => ({ count: 0, size: win.size() }), ... })
58
+ const init = (): Model => ({ count: 0 });
59
+
60
+ // `view(model, window, surface)`: the window's name (`'main'` unless
61
+ // `windows` declared others) and the surface, for the measurement a tree
62
+ // needs while it is being built. A single-window app that measures nothing
63
+ // takes `model` alone.
64
+ const view = (model: Model, _window: string, ui: Ctx | KuiWindow) => (
65
+ <box pad={24} gap={16}>
66
+ <button onClick={{ kind: 'add', by: 1 }}>+1</button>
67
+ {/* Wide enough for the widest count it will ever show, so the button
68
+ beside it does not shift as the number grows. */}
69
+ <box width={ui.measureText('count = 0000', { size: 20 }).width}>
70
+ <text size={20}>{`count = ${model.count}`}</text>
71
+ </box>
72
+ </box>
73
+ );
74
+ const app = createApp({ init, update, view }, { width: 640, height: 480 }); // headless
75
+ const final = await runWindowed({ init, update, view }, { title: 'counter' }); // a window
76
+ ```
77
+
78
+ Both drivers run one loop over one surface, so what differs between them is
79
+ only what really differs: `runWindowed` pumps the OS and resolves with the
80
+ final model, `createApp` is synchronous. Everything else — the clock, the
81
+ diagnostics gate, the test affordances — is the same code either way. To
82
+ drive the window one of these opens — a smoke test reading `win.quads()`,
83
+ say — press with `access(key, 'click')`: against a real window that is not
84
+ only the screen reader's path but the only synthetic input it takes, since
85
+ `click`, `type` and `key` are refused on a surface the OS drives.
86
+
87
+ That loop takes a clock — `tick: { every: 250, msg: (now) => ({ kind: 'tick',
88
+ now }) }` — and re-renders on a tick only when `update` returns a new model,
89
+ so a countdown is free between displayed seconds. That contract cuts both
90
+ ways; see **A clock** below. A window fires the ticks off its own timer; a
91
+ test moves the hands itself with `app.advance(ms)`, so an app with a clock
92
+ still runs headless.
93
+
94
+ An effect the app defines and kui knows nothing about — a file write, a
95
+ request, the clipboard — is data on the same terms as everything else the
96
+ loop handles ([ADR 0013](../../docs/adr/0013-effects-as-data.md)).
97
+ `update` returns it beside the model:
98
+
99
+ ```ts
100
+ case 'save': return withEffects({ ...model, dirty: false }, { kind: 'write', path: model.path, text: model.text });
101
+ ```
102
+
103
+ and the app says once, in the options, what performing one means —
104
+ `effects: (effect, dispatch, surface) => { … }` — which the loop calls
105
+ after the frame, so an effect that dispatches its result
106
+ (`dispatch({ kind: 'saved' })`) lands in the next turn, and one that reads
107
+ the surface sees the frame its cause produced. `update` stays pure, the
108
+ same handler serves `createApp` and `runWindowed`, and headless
109
+ `app.effects()` drains what `update` returned whether or not a handler
110
+ ran, so a test asserts the effect the way it asserts an audio command.
111
+ `withEffects(undefined, …)` keeps the model, and a function `init` may
112
+ return one too. kui's own effects stay where they are: a sound is
113
+ `surface.play` or an `<audio>` node, a window is `windows`.
114
+
115
+ ## Testing
116
+
117
+ `createApp` runs the same app headless, and everything a frame produces
118
+ comes back as data, so a test drives the app the way a user would and
119
+ asserts on what the core produced:
120
+
121
+ - **Input**: `app.click(x, y)`, `app.type(s)`, `app.press(code)` settle the
122
+ loop for you; `app.ctx.cursor` / `mouse` / `scroll` / `modifiers` are the
123
+ raw events (a drag is cursor, mouse down, cursor, mouse up).
124
+ **`press` is the key one.** A real key press is two channels and a window
125
+ drives both — the raw press an `onKey` sink hears, and then what the core
126
+ is asked to do with that key (Escape dismisses a modal, Tab walks the
127
+ ring, an arrow nudges a focused slider, Space presses a focused control).
128
+ `press('escape')` does both; `app.key(name)` and `app.ctx.keyDown(code)`
129
+ are its two halves, for a test that means to drive one channel and not
130
+ the other. `app.release(code)` is the key coming up. The loop sets the frame clock before every frame, so a
131
+ `transition` eases from the frame that changes it and `app.advance(ms)`
132
+ is what moves it; a test that wants only the end state advances past
133
+ the duration. (A bare `Ctx` whose `setTime` is never called snaps.)
134
+ - **Time**: `app.advance(ms)` is the window's timer by hand. It fires every
135
+ `tick` that falls inside the span, moves the frame clock behind
136
+ `transition` with it, and re-renders — so a ticking app (a countdown, a
137
+ clock, a game loop) is driven from a test the same way a user's window
138
+ drives it, and a transition can be watched a step at a time
139
+ (`app.ctx.animating()` says when it has settled). `startTime` in the
140
+ options pins where that clock starts, so assertions on `tick.msg(now)`
141
+ are exact.
142
+ - **Effects**: `app.effects()` drains what `update` returned with
143
+ `withEffects` since the last drain — the file write or request the app
144
+ asked for, as a value — whether or not an `effects` handler was
145
+ registered; with one, the handler already ran after the frame and its
146
+ `dispatch` has already gone through `update`.
147
+ - **Either surface**: `settle`, `access`, `accessTree`, `dispatch`, `render`
148
+ and `step` are the loop's, not the headless driver's, so they work against
149
+ a real window too — `runWindowed`'s `setup(win, app)` hands you the same
150
+ object. Synthetic input (`click` / `type` / `key`) needs a surface that
151
+ takes it: a `Ctx` does, and a window, which the OS drives, says so rather
152
+ than pretending.
153
+ - **The frame**: `decodeQuads(app.ctx.quads())` is the display list
154
+ (`x`, `y`, `w`, `h`, `color`, `radii`, `kind`), so "the compact tier fits
155
+ its window" is `every((q) => q.x + q.w <= width)`.
156
+ - **Events and sound**: `app.ctx.pollEvents()`, and `app.ctx.audioCommands()`
157
+ is what a window would have played.
158
+ - **Measurement**: `app.ctx.measureText(content, style, maxWidth)` is what
159
+ layout gives the same `<text>`, so a breakpoint assertion is arithmetic.
160
+ It is the same object `view`'s third argument is, so a test measures what
161
+ the view measured.
162
+ - **Warnings**: `app.warnings` is every silent misconfiguration the core
163
+ noticed (a `grow` weight with nothing to split, a transition on an unkeyed
164
+ list item, a duplicate key); assert it is empty.
165
+
166
+ ## Windowed app checklist
167
+
168
+ Things the package already does that are easy to miss when building a
169
+ real window. The full prop / element / event reference ships with the
170
+ package as [props.md](props.md) (`docs/props.md` in the repository), and so
171
+ do [CHANGELOG.md](CHANGELOG.md) — every release lists what it adds and,
172
+ separately, what you can delete — and the ADRs under `docs/adr/`, which
173
+ is where a doc comment pointing at `docs/adr/0003-modal-surfaces.md`
174
+ resolves from inside `node_modules`. [howto.md](howto.md) ships with them:
175
+ about twenty questions — playing a sound, animating a removal, resetting an
176
+ editor, driving a real window in a test — answered in two sentences each and
177
+ pointing into the other three.
178
+
179
+ - **Hover and pressed colors** are props, not queries: `hoverBg`,
180
+ `pressedBg`, and `hoverGroup="name"` to light connected pieces together.
181
+ Add `transition={150}` and the swap eases. `<button>` is exactly that data.
182
+ For hover-dependent *layout* use `onHover={tag}` and react to
183
+ `{kind: 'hover', phase: 'enter' | 'leave'}` events; `win.isHovered(key)`
184
+ and `isPressed` answer for keys you got from events.
185
+ - **The pointer shape is declared**, not derived: `cursor="pointer"` on a
186
+ control, `cursor="grab"` on a handle (and `'grabbing'` while its drag
187
+ runs), `cursor="ewResize"` on a splitter. A node that declares nothing is
188
+ `default` — an `onClick`, `focusable` or `onDrag` box included, as a
189
+ native button is — except over an editor or a `selectable` scope, where
190
+ it is `text`. The stock `<button>` declares its own hand. A window applies
191
+ it by itself; `ctx.cursorShape()` reads it back for a test.
192
+ - **Motion that never settles** is data too: `keyframes` takes CSS-style
193
+ stops for `width` / `height` / `bg` / `radius`, cycled over
194
+ `transition` ms in CSS's `animation-direction` (`repeat="alternate"`)
195
+ and held back by `delay` ms so siblings stagger —
196
+ `<box transition={1100} easing="easeInOut" repeat="alternate" delay={i * 550}
197
+ width={{ grow: 0 }} keyframes={[{ width: { grow: 1 } }]} />` slides
198
+ forever without the app ever waking up to flip it. Stops spread evenly
199
+ unless they name `at` (0..1); a slot a stop leaves out falls back to the
200
+ node's own prop, so `[{ at: 0.5, bg: '#f5a97f' }]` is a pulse.
201
+ - **Arrivals** are data too. A node's first frame snaps, so a slide-in
202
+ needed an off-screen frame and a second render; `enter` states the
203
+ starting point instead: `<box key="toast" float="viewport" transition={200}
204
+ enter={{ dx: 320, bg: '#00000000' }} …/>` slides in from the right and
205
+ fades up on the frame it appears, and enters again after being dismissed.
206
+ Add `slide` if it should also glide when layout moves it later — a float
207
+ whose `dx`/`dy` changes eases to the new offset with `slide` alone.
208
+ - **Frame timing without the overlay**: `win.frameStats()` is the latency
209
+ HUD as data (`last.viewMs`, `avgWorkMs`, …), `win.stats()` the display
210
+ list summary, and `win.animating()` tells a test when motion has settled.
211
+ - **Tooltips**: `tooltip="hint"` on any box (implies hover tracking).
212
+ - **Per-corner radius**: `radius` for all four, `radiusTL` / `radiusTR` /
213
+ `radiusBR` / `radiusBL` after it for the exceptions.
214
+ - **Sound**: register bytes once (`win.addSound(buf)` in `setup`, any
215
+ wav/ogg/mp3/flac), then reach for it three ways. As props — `clickSound`
216
+ and `hoverSound` on any box, the audio equivalent of `hoverBg`. As an
217
+ element — `<audio key="music" src={id} loop volume={0.3} />` is a playback
218
+ retained by key: it plays while the view declares it, stops when the view
219
+ drops it, and `volume` / `paused` changes apply to the running sound
220
+ rather than restarting it, so `{model.music && <audio … />}` is the whole
221
+ on/off story. Or imperatively — `win.play(id, { volume, loop, fadeIn,
222
+ tag })` returns a playback id for `stop` / `setVolume` / `pause` /
223
+ `resume`, and a `tag` comes back as a `SoundMsg`
224
+ (`{kind: 'sound', phase: 'ended', tag}`) when that playback finishes on
225
+ its own, which is how a chime chains into the next state. Volumes are
226
+ linear amplitude. Headless (`createApp`) nothing plays: `ctx.audioCommands()`
227
+ hands you what a window would have played, which is what to assert on.
228
+ - **Fonts**: `win.loadFontsDir('fonts')` then `win.addSystemFont('Antonio')`,
229
+ or `win.loadFontFile('fonts/Antonio.ttf')`, `win.addFont(bytes)`, or an
230
+ installed family by name (see `systemFontFamilies()`, or `systemFonts()`
231
+ for which are monospaced, their weights and italics); then
232
+ `<text font={id}>`. Register in `setup(win)` before the first frame.
233
+ The installed ones are rescanned when macOS or Windows says a font was
234
+ installed or removed, and a `FontsMsg` follows for a model that keeps
235
+ the list; on Linux `win.reloadSystemFonts()` scans again.
236
+ - **Window chrome**: open with `chrome: 'custom'`, put a `<titlebar>` (or
237
+ your own strip with `window="drag"` plus `<windowButtons/>`) in the root;
238
+ on macOS it insets past the traffic lights itself. The root box's
239
+ `title` prop names the window each frame.
240
+ - **Overlays**: `float="below" | "above"` or
241
+ `float={{ anchor: 'viewport', at: ['end','end'], self: ['end','end'] }}`
242
+ draws on top without shifting anything.
243
+ - **Sliders and dividers**: `onDrag={tag}` gives `{ x, y, dx, dy, parent }`
244
+ — `parent` is the container rect, so a fraction needs no geometry query.
245
+ - **Measuring text**: `win.measureText('1,234', { size: 48, font })` (and
246
+ `ctx.measureText` headless) returns `{ width, height, lines }` — what
247
+ layout gives a `<text>` with that content and those props, at the
248
+ window's scale; pass a `maxWidth` to see it wrapped, and `wrap` /
249
+ `maxLines` / `ellipsis` apply. Size a column to its widest label, or pick
250
+ the tier whose labels fit, from these numbers; they follow the font. Both
251
+ are reachable where the sizing happens: `view(model, window, surface)`
252
+ gets the surface third, and `init(surface)` gets it before the first
253
+ model, so neither needs the surface parked in a module-level variable.
254
+ - **Where did layout put it**: `onLayout={tag}` on a keyed box brings back
255
+ `{ kind: 'layout', x, y, w, h, parent, tag }` — on its first frame and
256
+ whenever the rect changes, never on a frame that left it alone, so
257
+ keeping it in the model and re-rendering does not loop. A `slide` reports
258
+ every frame it moves. It is the numbers layout already computed, handed
259
+ back, for the case no prop covers yet.
260
+ - **Warnings**: the core notices what used to fail silently — a `{ grow: 2 }`
261
+ on the only grow child (or across the parent's main axis), a `transition`
262
+ on an unkeyed list item whose siblings changed count, two nodes on one
263
+ `key` — and `runWindowed` prints each once (`warnings: false` in the
264
+ options to stop it; `win.warnings()` drains them yourself). The checks
265
+ are a development aid: under `NODE_ENV=production` they do not run at
266
+ all (`diagnostics: true` forces them on).
267
+ - **Window size**: `win.size()` gives `{width, height, scale}` (logical px)
268
+ — in `setup(win)` before the first frame, in `init(win)` while the first
269
+ model is built, and any time after. It is the viewport the app lays out
270
+ into: the window's inner size, less the devtools' dock while the panel is
271
+ docked (`KUI_DEVTOOLS=1`), and the same number `env().viewport` reads once
272
+ a frame has run. `win.hostArea()` gives that viewport's place in the
273
+ window after a frame, `{x, y, w, h}` — right of the pane under a left
274
+ dock — which is what tells the app's quads from the dock's.
275
+ Headless, `ctx.size()` answers the same shape: the size
276
+ `createApp` frames at (its `width` / `height` / `scale`) before the first
277
+ frame, and the last frame's after. Changes
278
+ arrive as `{kind: 'resize', width, height, scale}` events — a dock coming,
279
+ going or being dragged among them — so a model that
280
+ tracks the size updates in `update` like anything else. Bound what the
281
+ user can resize to with `minWidth` / `minHeight` / `maxWidth` / `maxHeight`
282
+ next to `width` / `height` at open; either half of a pair may stand alone,
283
+ and `width`/`height` are clamped into the bounds the OS will enforce.
284
+ - **A clock**: `tick: { every, msg }` on either driver (`app.advance(ms)`
285
+ fires them headless), or `setTimeout` toward the next boundary in your own
286
+ loop; do not call `update` every pump. Ticks are frequent, so unlike UI events **a tick re-renders only
287
+ when `update` returns a new model** — a countdown that returns
288
+ `undefined` until the displayed second changes costs nothing in
289
+ between. The same rule read backwards is the trap: a tick handler that
290
+ mutates the model in place and returns `undefined` (the escape hatch
291
+ the rest of `update` allows) never reaches the screen. Any
292
+ non-`undefined` return renders, so `return model` after a mutation is
293
+ the whole fix. `every` may read the model — `every: (m) => m.running
294
+ ? 16 : 1000` — for an app whose cadence depends on its state: the loop
295
+ re-reads it after every `update`, and since the windowed driver never
296
+ sleeps through a tick, a stopped countdown then costs a pump a second
297
+ instead of pinning the idle backoff at 16 ms.
298
+ - **Keys**: `onKey` on the root plus `keyFocus`; presses arrive as
299
+ `{ kind: 'key', phase: 'down', code, ... }` with `code` a character or a
300
+ name (`'space'`, `'enter'`, `'f5'`), and that is all a keymap needs — no
301
+ `phase` check. A held-key interaction (WASD, press-and-hold) adds `keyUp`
302
+ to the sink and hears releases too, as the same shape with
303
+ `phase: 'up'`. `repeat` marks an auto-repeat, and a release carries a
304
+ null `text`. A key only comes up where it went down — focus moving
305
+ delivers the release first — so nothing is left stuck. `location`
306
+ (`'numpad'`, `'left'`, `'right'`, else `'standard'`) says which of a
307
+ key's twins it was, `caps_lock` and `num_lock` what was locked, and a
308
+ sink that adds `modifierKeys` hears the modifier keys themselves
309
+ (`'shift'`, `'capslock'`, …).
310
+ `onKey={null}` is a sink whose events carry no `tag` (the same goes for
311
+ `onDrag`, `onHover` and `onLayout`), so a root sink needs no inert
312
+ message in the app's union.
313
+ - **Messages are yours**: annotate `update` and the loop follows —
314
+ `createApp` / `runWindowed` infer the union, so `ev`, `dispatch` and
315
+ `tick.msg` speak it too (a `tick.msg` written inline needs the union named
316
+ — `runWindowed<Model, Msg>(...)`, or a `msg` annotated where it is
317
+ written — since its return is what would be inferred from). `CoreMsg` is what the core sends on its own
318
+ (`DragMsg`, `KeyMsg`, `HoverMsg`, `ModifiersMsg`, `changed` / `submit`),
319
+ each with the payload fields spelled out; `pollEvents<KeyMsg<Tag>>()`
320
+ types a raw poll the same way. To have the *payload props* checked at the
321
+ node as well, register the app's own union once:
322
+
323
+ ```ts
324
+ declare module '@qxuken/kui/jsx-runtime' {
325
+ interface KuiMsg { msg: MyMsg }
326
+ }
327
+ ```
328
+
329
+ `onClick` then takes exactly `MyMsg` (and `onDrag` / `onHover` / `onKey` /
330
+ `onLayout` take `MyMsg | null`) rather than any plain data, so a typo
331
+ fails where it is written. Register the messages you wrote, not
332
+ `MyMsg | CoreMsg`: `CoreMsg` is typed in terms of the registration (its
333
+ `tag` fields carry your messages), so naming it there makes the alias
334
+ circular — keep that union for `update`. It is a program-wide
335
+ declaration (one app per tsconfig); left out, payload props stay untyped
336
+ and nothing else changes.