Skip to main content

kui_core/
schema.rs

1//! The prop schema: the one table of node props, elements, events and
2//! readings that every kui binding is generated from or checked against.
3//!
4//! A Rust app does not read this module; it builds a `NodeSpec` with its
5//! methods. The tables here are for the bindings and for tooling. Each
6//! [`PROPS`] row names a prop (camelCase, with the snake_case spelling
7//! derived), its wire id, its value kind, how it applies to a `NodeSpec`
8//! and its documentation: `kui-node` parses JSON and its binary stream by
9//! kind and generates the TypeScript types from the rows, `kui-lua` looks
10//! each table key up by name, and `kui-ffi` mirrors the rows in a C struct
11//! that a parity test pins to this table. [`CUSTOM`] lists the composite
12//! props a binding extracts itself (padding shorthands, border, overflow,
13//! floats), [`ELEMENTS`] the props each element lowers, [`DOORS`] the
14//! verbs (calls rather than props) with their spelling in every binding,
15//! and [`known_prop`] answers whether a name is any of these, which is
16//! what an `unknown-prop` warning checks against.
17//!
18//! ```rust
19//! use kui_core::schema::{PROPS, P_WIDTH};
20//!
21//! let width = PROPS.iter().find(|p| p.id == P_WIDTH).expect("a core prop");
22//! assert_eq!(width.name, "width");
23//! assert!(!width.doc.is_empty());
24//! ```
25//!
26//! Adding a simple prop is one row here (plus `npm run gen` for the TS
27//! types, and a field in the C struct when the parity test says so).
28//! Composite props need per-binding extraction but not per-binding
29//! decisions: what a shorthand means lives in one place in `spec`, and
30//! `crate::conformance` makes every binding agree on behaviour.
31
32use std::sync::LazyLock;
33
34use rustc_hash::{FxHashMap, FxHashSet};
35
36use crate::access::Role;
37use crate::anim::{Easing, Repeat};
38use crate::color::Color;
39use crate::cursor::CursorShape;
40use crate::enter::Enter;
41use crate::keyframes::Keyframe;
42use crate::spec::{
43    Align, Bound, FontFamily, NodeSpec, PadShorthand, Sizing, TextStyle, TextWrap, UnderlineStyle,
44};
45use crate::value::Value;
46use crate::window::{WindowButton, WindowConfig};
47
48mod doors;
49pub use doors::{Cell, DOORS, Door, GUEST};
50
51// Wire ids, stable within a binary protocol version (see kui-node).
52pub const P_DIR: u32 = 1;
53pub const P_WIDTH: u32 = 2;
54pub const P_HEIGHT: u32 = 3;
55pub const P_MIN_W: u32 = 4;
56pub const P_MAX_W: u32 = 5;
57pub const P_MIN_H: u32 = 6;
58pub const P_MAX_H: u32 = 7;
59pub const P_PAD: u32 = 8;
60pub const P_GAP: u32 = 9;
61pub const P_MAIN_ALIGN: u32 = 10;
62pub const P_CROSS_ALIGN: u32 = 11;
63pub const P_BG: u32 = 12;
64pub const P_BORDER: u32 = 13;
65pub const P_RADIUS: u32 = 14;
66pub const P_OVERFLOW: u32 = 15;
67pub const P_FLOAT: u32 = 16;
68pub const P_HOVERABLE: u32 = 17;
69pub const P_ON_CLICK: u32 = 18;
70pub const P_ON_DRAG: u32 = 19;
71pub const P_ON_KEY: u32 = 20;
72pub const P_WINDOW: u32 = 21;
73pub const P_KEY_FOCUS: u32 = 22;
74pub const P_CENTER: u32 = 23;
75pub const P_SIZE: u32 = 24;
76pub const P_LINE_HEIGHT: u32 = 25;
77pub const P_COLOR: u32 = 26;
78pub const P_FAMILY: u32 = 27;
79pub const P_KEY: u32 = 28;
80pub const P_TITLE: u32 = 29;
81pub const P_TOOLTIP: u32 = 30;
82pub const P_TRANSITION: u32 = 31;
83pub const P_EASING: u32 = 32;
84pub const P_SLIDE: u32 = 33;
85pub const P_HOVER_BG: u32 = 34;
86pub const P_PRESSED_BG: u32 = 35;
87pub const P_HOVER_GROUP: u32 = 36;
88pub const P_ON_HOVER: u32 = 37;
89pub const P_RADIUS_TL: u32 = 38;
90pub const P_RADIUS_TR: u32 = 39;
91pub const P_RADIUS_BR: u32 = 40;
92pub const P_RADIUS_BL: u32 = 41;
93pub const P_FONT: u32 = 42;
94pub const P_KEYFRAMES: u32 = 43;
95pub const P_REPEAT: u32 = 44;
96pub const P_DELAY: u32 = 45;
97pub const P_WRAP: u32 = 46;
98pub const P_MAX_LINES: u32 = 47;
99pub const P_ELLIPSIS: u32 = 48;
100pub const P_ENTER: u32 = 49;
101pub const P_CLICK_SOUND: u32 = 50;
102pub const P_HOVER_SOUND: u32 = 51;
103pub const P_ON_LAYOUT: u32 = 52;
104pub const P_ROLE: u32 = 53;
105pub const P_LABEL: u32 = 54;
106pub const P_CHECKED: u32 = 55;
107pub const P_VALUE_NOW: u32 = 56;
108pub const P_VALUE_MIN: u32 = 57;
109pub const P_VALUE_MAX: u32 = 58;
110pub const P_CARET: u32 = 59;
111pub const P_SELECTION_ANCHOR: u32 = 60;
112pub const P_FOCUSABLE: u32 = 61;
113pub const P_DISABLED: u32 = 62;
114pub const P_FOCUS_BG: u32 = 63;
115pub const P_MODAL: u32 = 64;
116pub const P_ON_CONTEXT_MENU: u32 = 65;
117pub const P_CURSOR: u32 = 66;
118pub const P_SELECTED: u32 = 67;
119pub const P_EXPANDED: u32 = 68;
120pub const P_OPACITY: u32 = 69;
121pub const P_SHADOW_COLOR: u32 = 70;
122pub const P_SHADOW_BLUR: u32 = 71;
123pub const P_SHADOW_X: u32 = 72;
124pub const P_SHADOW_Y: u32 = 73;
125pub const P_SHADOW_SPREAD: u32 = 74;
126pub const P_WRAP_CHILDREN: u32 = 75;
127pub const P_CROSS_GAP: u32 = 76;
128pub const P_INITIAL_FOCUS: u32 = 77;
129pub const P_EXIT: u32 = 78;
130pub const P_WINDOWS: u32 = 79;
131pub const P_LIVE: u32 = 80;
132pub const P_KEY_UP: u32 = 81;
133pub const P_VALUE_TEXT: u32 = 82;
134pub const P_DESCRIPTION: u32 = 83;
135pub const P_FEATURES: u32 = 84;
136pub const P_UNDERLINE: u32 = 85;
137pub const P_STRIKETHROUGH: u32 = 86;
138pub const P_ANIMATE: u32 = 87;
139pub const P_ACCENT: u32 = 88;
140pub const P_INDEX: u32 = 89;
141pub const P_SELECTABLE: u32 = 90;
142pub const P_ON_FORCE_CLICK: u32 = 91;
143pub const P_FOCUS_REGION: u32 = 92;
144pub const P_SCROLLBAR: u32 = 93;
145pub const P_SCROLLBAR_WIDTH: u32 = 94;
146pub const P_SCROLLBAR_COLOR: u32 = 95;
147pub const P_SCROLLBAR_ACTIVE_COLOR: u32 = 96;
148pub const P_ANCHOR: u32 = 97;
149pub const P_ALWAYS_ON_TOP: u32 = 98;
150pub const P_ON_SCROLL: u32 = 99;
151pub const P_ROW_COUNT: u32 = 100;
152pub const P_UNDERLINE_COLOR: u32 = 101;
153pub const P_UNDERLINE_STYLE: u32 = 102;
154pub const P_ON_DROP: u32 = 103;
155pub const P_DROP_BG: u32 = 104;
156pub const P_CARET_SOLID: u32 = 105;
157pub const P_SECURE_INPUT: u32 = 106;
158pub const P_ASPECT_RATIO: u32 = 107;
159pub const P_MIXED: u32 = 108;
160pub const P_VALUE_STEP: u32 = 109;
161pub const P_ON_CHANGE: u32 = 110;
162pub const P_PIXEL_SNAP: u32 = 111;
163pub const P_KEEP_FOCUS: u32 = 112;
164pub const P_ON_FOCUS: u32 = 113;
165pub const P_RULES: u32 = 114;
166pub const P_RULE_WIDTH: u32 = 115;
167pub const P_ON_BUTTON: u32 = 116;
168pub const P_BUTTONS: u32 = 117;
169pub const P_OVERSCROLL: u32 = 118;
170pub const P_SCROLL_AXES: u32 = 119;
171pub const P_MODIFIER_KEYS: u32 = 120;
172pub const P_OPTION_AS_ALT: u32 = 121;
173pub const P_BOUNCE: u32 = 122;
174pub const P_GRADIENT: u32 = 123;
175pub const P_SCROLL_MODS: u32 = 124;
176pub const P_IME_OFF: u32 = 125;
177pub const P_BACKDROP_BLUR: u32 = 126;
178pub const P_ROTATE: u32 = 127;
179pub const P_SCALE: u32 = 128;
180pub const P_PIVOT_X: u32 = 129;
181pub const P_PIVOT_Y: u32 = 130;
182pub const P_ITERATIONS: u32 = 131;
183pub const P_BOLD: u32 = 132;
184pub const P_OVER_SIBLINGS: u32 = 133;
185pub const P_INITIAL_LETTER: u32 = 134;
186
187/// The `mainAlign` / `crossAlign` rows and a float's attach points, in
188/// `Align`'s order. Append-only: the Lua and Node wires carry the index,
189/// and C's `KUI_ALIGN_*` is it. The spreads mean something on `mainAlign`
190/// and `baseline` on a row's `crossAlign` only.
191pub const ALIGNS: &[&str] = &[
192    "start",
193    "center",
194    "end",
195    "spaceBetween",
196    "spaceAround",
197    "spaceEvenly",
198    "baseline",
199];
200pub const WINDOW_ROLES: &[&str] = &["drag", "close", "minimize", "maximize"];
201/// The `scrollbar` row, in `ScrollbarMode::ALL`'s order: the stock
202/// overlay bar, none, or one that fades out when the scroll state has not
203/// changed. C spells it as the index plus one (`KUI_SCROLLBAR_*`), so a
204/// zeroed field is "unset".
205pub const SCROLLBARS: &[&str] = &["visible", "hidden", "auto"];
206/// The `overscroll` row, in `Overscroll::ALL`'s order: a
207/// scroll gesture starting over a scroller at its limit goes on to the one
208/// around it, or stays. C spells it as the index plus one
209/// (`KUI_OVERSCROLL_*`), a zeroed field being `auto`.
210pub const OVERSCROLLS: &[&str] = &["auto", "contain"];
211/// The `scrollAxes` row, in `ScrollAxes::ALL`'s order: the
212/// axes an `onScroll` node takes. C spells it as the index plus one
213/// (`KUI_SCROLL_AXES_*`), a zeroed field being `both`.
214pub const SCROLL_AXES: &[&str] = &["both", "x", "y"];
215/// The stock families (`FontFamily::name` spellings, in `FontFamily::ALL`
216/// order); a registered font travels as the `font` row's handle instead.
217pub const FAMILIES: &[&str] = &["sans", "serif", "mono"];
218/// The pointer shapes a view can declare (`CursorShape::name` spellings, in
219/// `CursorShape::ALL` order — a `cursor.rs` test pins the two together).
220pub const CURSORS: &[&str] = &[
221    "default",
222    "text",
223    "pointer",
224    "grab",
225    "grabbing",
226    "notAllowed",
227    "ewResize",
228    "nsResize",
229    "nwseResize",
230    "neswResize",
231];
232
233pub fn cursor_idx(i: usize) -> CursorShape {
234    CURSORS
235        .get(i)
236        .and_then(|n| CursorShape::parse(n))
237        .unwrap_or(CursorShape::Default)
238}
239pub const WRAPS: &[&str] = &["word", "glyph", "none", "break-spaces"];
240/// `underlineStyle` / `underline_style`; `UnderlineStyle::NAMES`.
241pub const UNDERLINE_STYLES: &[&str] = UnderlineStyle::NAMES;
242/// `expanded` names its state rather than being a flag: a disclosure that
243/// is shut has to say "collapsed", and an absent flag cannot — absent has
244/// to keep meaning "this node does not expand" (AccessKit's `expanded`,
245/// ARIA's `aria-expanded`, are three-state for the same reason).
246pub const EXPANDED: &[&str] = &["collapsed", "expanded"];
247/// How urgently a reader should read a change it was not asked to read
248/// (`crate::access::Live::name` spellings, in wire order — a binding
249/// sends the index). `off` is the default and means "not a live region".
250pub const LIVE: &[&str] = &["off", "polite", "assertive"];
251/// The roles a view can declare (`crate::access::Role::name` spellings),
252/// in wire order — a binding sends the index. The purely derived roles are
253/// the ones [`DERIVED_ONLY`] names, and every other `Role::ALL` variant is
254/// here; `textInput`, `multilineTextInput` and `line` are, because an app
255/// that draws its own text declares them.
256pub const ROLES: &[&str] = &[
257    "none",
258    "button",
259    "checkbox",
260    "radio",
261    "switch",
262    "slider",
263    "tab",
264    "tabList",
265    "link",
266    "heading",
267    "list",
268    "listItem",
269    "image",
270    "dialog",
271    "group",
272    "textInput",
273    "multilineTextInput",
274    "line",
275    // Appended later. The tail is the only free position,
276    // which is what makes "the first fifteen can be declared" a list rather
277    // than a range.
278    "radioGroup",
279    "menu",
280    "menuItem",
281    // Appended later: a cell grid's derived role.
282    "terminal",
283];
284
285/// How a composite container arranges its items
286/// (`crate::access::Orientation::name` spellings), in wire order. Derived
287/// from the container's `dir` and reported on its access node, never
288/// declared — so unlike [`ROLES`] this is not a prop's enum, only a list
289/// the C header restates.
290pub const ORIENTATIONS: &[&str] = &["horizontal", "vertical"];
291
292/// The OS light/dark setting (`crate::env::Appearance::name` spellings, in
293/// `Appearance::ALL` order — an `env.rs` test pins the two together). Like
294/// [`ORIENTATIONS`] this is not a prop's enum: it is a fact a host pushes
295/// and every binding spells the same way. Index 0 is `unknown`, so a
296/// zeroed C call reports what it actually knows.
297pub const APPEARANCES: &[&str] = &["unknown", "light", "dark"];
298
299/// The OS reduce-motion setting (`crate::env::MotionPref::name` spellings, in
300/// `MotionPref::ALL` order), `unknown` first for the same reason.
301pub const MOTIONS: &[&str] = &["unknown", "full", "reduced"];
302
303/// Whether assistive technology is listening (`crate::env::Assistive::name`
304/// spellings, in `Assistive::ALL` order), `unknown` first for the same
305/// reason: a host with no bridge reports that it cannot tell.
306pub const ASSISTIVE: &[&str] = &["unknown", "none", "listening"];
307
308/// The audio output device's state (`crate::env::AudioDevice::name`
309/// spellings, in `AudioDevice::ALL` order), `closed` first so a zeroed C
310/// call reports the default.
311pub const AUDIO_DEVICES: &[&str] = &["closed", "opening", "open", "failed"];
312
313/// What is behind a window's transparent pixels
314/// (`crate::window::Backdrop::name` spellings, in `Backdrop::ALL` order),
315/// `opaque` first so a zeroed C call reports the default.
316pub const BACKDROPS: &[&str] = &["opaque", "transparent", "blur", "tinted"];
317
318/// The roles no view can declare, because the core derives them itself,
319/// with what derives each one. Every
320/// [`Role::ALL`] variant is on this list or in [`ROLES`], and
321/// `every_role_is_declarable_or_derived` keeps both halves honest: a role
322/// exempted here has to be one a frame really does derive, so the list
323/// cannot absorb a variant that was simply forgotten from `ROLES`.
324pub const DERIVED_ONLY: &[(&str, &str)] = &[
325    (
326        "window",
327        "the root node of a frame, named by the window title",
328    ),
329    ("titleBar", "a `windowDrag` node"),
330    ("staticText", "a text node"),
331    ("scrollView", "a `scrollX` / `scrollY` node"),
332    ("passwordInput", "an `edit` declared `secure`"),
333];
334
335/// The role at wire index `i` — `ROLES`' order is the protocol, so this
336/// and [`ROLES`] are pinned to each other by
337/// `every_declarable_role_name_is_a_real_role`.
338///
339/// Neither way of missing can happen today. A name in `ROLES` that no
340/// longer parses is what that test catches, by construction rather than
341/// at run time. Out of range cannot arrive through a transport: Node's
342/// binary reader rejects `i >= names.len()` before it builds a
343/// `Parsed::Enum`, Node's JSON and Lua resolve a *name* through
344/// [`enum_index`], and C carries `Role::ALL` positions that
345/// [`crate::access::Role`] itself bounds.
346///
347/// So the fallback is reachable only from a future transport that forgets
348/// its check, and it is `Role::Group` rather than `Role::None` for that
349/// reader: `None` takes the node *and its whole subtree* out of the access
350/// tree, which is a destructive answer to "an index I do not have", while
351/// a group is what the core already derives for a box that is merely
352/// somewhere focus can land — the node keeps its children, and a wrong
353/// role is recoverable where a missing subtree is not. It stays silent
354/// because this is a pure schema function with no warning sink, and the
355/// transports' own errors name the prop and the index, which is a better
356/// report than a warning here.
357pub fn role_idx(i: usize) -> Role {
358    ROLES
359        .get(i)
360        .and_then(|n| Role::parse(n))
361        .unwrap_or(Role::Group)
362}
363/// The easing curves (`Easing::name` spellings, in `Easing::ALL` order —
364/// the test below pins the two together, as `CURSORS` is pinned).
365pub const EASINGS: &[&str] = &[
366    "easeOut",
367    "linear",
368    "easeIn",
369    "easeInOut",
370    "spring",
371    "bouncy",
372    "smooth",
373    "snappy",
374];
375
376/// CSS's `animation-direction` values, in `Repeat::ALL`'s order.
377pub const REPEATS: &[&str] = &["normal", "reverse", "alternate", "alternateReverse"];
378
379pub fn repeat_idx(i: usize) -> Repeat {
380    Repeat::from_index(i)
381}
382
383pub fn easing_idx(i: usize) -> Easing {
384    Easing::from_index(i)
385}
386
387/// How a prop's value is parsed (per transport) and encoded (binary slots).
388pub enum Kind {
389    /// One number, or a `"$length"` token. Binary: 1 slot.
390    F32,
391    /// One color: `0xRRGGBBAA` number or `#hex` string, or a `"$color"`
392    /// token. Binary: 1 slot (u32).
393    Color,
394    /// Marker, present-or-absent (`false` = absent). Binary: 0 slots.
395    Flag,
396    /// One of a closed name list. Binary: 1 slot (index).
397    Enum(&'static [&'static str]),
398    /// A sizing: number | "fit" | "grow" | "N%" | a size expression
399    /// (`"clamp(400px, 80%, 1000px)"`, [`crate::calc`]) | {grow} |
400    /// {percent}, or a `"$length"` token (a fixed length). Binary: 2 slots
401    /// (mode, value), or with mode [`SIZE_MODE_CALC`] the mode and a
402    /// strref, the expression's spelling (v19); tagged, 1 slot (the index).
403    Sizing,
404    /// A lower clamp: number | "fit" (the node's own fit size on that axis)
405    /// | a size expression, or a `"$length"` token. Binary: 2 slots (mode,
406    /// value), a sizing's first two modes, or [`SIZE_MODE_CALC`] and a
407    /// strref; tagged, 1 slot (the index).
408    Min,
409    /// An upper clamp: number | a size expression, or a `"$length"` token.
410    /// Binary as a `Min` (v19; a plain number before): mode 0 and px, or
411    /// [`SIZE_MODE_CALC`] and a strref; tagged, 1 slot (the index).
412    Max,
413    /// An arbitrary message payload (a `Value`). Binary: strref to JSON.
414    Msg,
415    /// A message merged into the core's own event under `tag` (`onDrag`,
416    /// `onKey`, `onHover`, `onLayout`). Parsed and carried exactly like a
417    /// `Msg`, but null is a legal value: the node still gets the behaviour
418    /// (a key sink, a drag source) and its events simply carry no `tag` —
419    /// so an app whose messages are a typed union needs no inert member
420    /// just to name a sink.
421    Tag,
422    /// A plain string (a name, not a message). Binary: strref.
423    Str,
424    /// A font family: one of the stock names (`FAMILIES`) or an installed
425    /// or loaded family's name, which the parser registers and turns into
426    /// its handle. Binary: strref (v18).
427    Family,
428    /// A registered resource handle (a font or sound id): the integer form
429    /// of the slotmap key. JSON/binary carry it as the 16-hex string the
430    /// addon hands out; Lua as an integer; C as a `uint64_t`.
431    Resource,
432    /// A keyframe stop list (`crate::keyframes::parse` reads the plain-data
433    /// form). JSON/binary/Lua carry it like a `Msg` and parse it in the
434    /// core; C passes a `KuiKeyframe` array.
435    Keyframes,
436    /// An entrance (`crate::enter::parse` reads the plain-data form).
437    /// Carried like a `Msg` and parsed in the core; C fills a `KuiEnter`.
438    Enter,
439    /// A gradient (`crate::gradient::parse` reads the plain-data form).
440    /// Carried like a `Msg` and parsed in the core; C fills a
441    /// `KuiGradient`.
442    Gradient,
443}
444
445/// Where a parsed value lands. `PropDef::target` derives from this.
446pub enum Apply {
447    SpecF32(fn(NodeSpec, f32) -> NodeSpec),
448    SpecColor(fn(NodeSpec, Color) -> NodeSpec),
449    SpecFlag(fn(NodeSpec) -> NodeSpec),
450    SpecEnum(fn(NodeSpec, usize) -> NodeSpec),
451    SpecSizing(fn(NodeSpec, Sizing) -> NodeSpec),
452    SpecBound(fn(NodeSpec, Bound) -> NodeSpec),
453    SpecMsg(fn(NodeSpec, Value) -> NodeSpec),
454    SpecStr(fn(NodeSpec, &str) -> NodeSpec),
455    SpecKeyframes(fn(NodeSpec, Vec<Keyframe>) -> NodeSpec),
456    SpecEnter(fn(NodeSpec, Enter) -> NodeSpec),
457    SpecGradient(fn(NodeSpec, crate::gradient::Gradient) -> NodeSpec),
458    SpecResource(fn(NodeSpec, u64) -> NodeSpec),
459    StyleF32(fn(TextStyle, f32) -> TextStyle),
460    StyleColor(fn(TextStyle, Color) -> TextStyle),
461    StyleEnum(fn(TextStyle, usize) -> TextStyle),
462    StyleFlag(fn(TextStyle) -> TextStyle),
463    StyleResource(fn(TextStyle, u64) -> TextStyle),
464    StyleStr(fn(TextStyle, &str) -> TextStyle),
465    StyleFamily(fn(TextStyle, FontFamily) -> TextStyle),
466}
467
468#[derive(Clone, Copy, Debug, PartialEq, Eq)]
469pub enum Target {
470    /// Lands on the node's `NodeSpec` (containers, edits, images).
471    Spec,
472    /// Lands on a `TextStyle` (text, rich text, edits).
473    Style,
474}
475
476pub struct PropDef {
477    /// Canonical camelCase name (the JSX prop). Lua uses `snake_name()`.
478    pub name: &'static str,
479    pub id: u32,
480    pub kind: Kind,
481    pub apply: Apply,
482    pub doc: &'static str,
483}
484
485impl PropDef {
486    pub fn target(&self) -> Target {
487        match self.apply {
488            Apply::StyleF32(_)
489            | Apply::StyleColor(_)
490            | Apply::StyleEnum(_)
491            | Apply::StyleFlag(_)
492            | Apply::StyleResource(_)
493            | Apply::StyleStr(_)
494            | Apply::StyleFamily(_) => Target::Style,
495            _ => Target::Spec,
496        }
497    }
498
499    /// The Lua-side name: `minWidth` → `min_width`.
500    pub fn snake_name(&self) -> &'static str {
501        SNAKE_NAMES[self.index()]
502    }
503
504    fn index(&self) -> usize {
505        // By name, not by address: `PROPS` is a const, so another crate
506        // iterating it sees its own copy of the rows.
507        PROPS
508            .iter()
509            .position(|d| d.name == self.name)
510            .expect("PropDef not from PROPS")
511    }
512}
513
514pub fn align_idx(i: usize) -> Align {
515    match i {
516        1 => Align::Center,
517        2 => Align::End,
518        3 => Align::SpaceBetween,
519        4 => Align::SpaceAround,
520        5 => Align::SpaceEvenly,
521        6 => Align::Baseline,
522        _ => Align::Start,
523    }
524}
525
526/// Binary min or max decode: (mode, value) → a clamp, the first two
527/// sizing modes ([`SIZE_MODE_CALC`] is read by the transport, which
528/// holds the strref).
529pub fn min_num(mode: u32, value: f64) -> Bound {
530    match mode {
531        1 => Bound::Fit,
532        _ => Bound::Px(value as f32),
533    }
534}
535
536/// Binary sizing decode: (mode, value) → Sizing.
537pub fn sizing_num(mode: u32, value: f64) -> Sizing {
538    match mode {
539        1 => Sizing::Fit,
540        2 => Sizing::Grow(value as f32),
541        3 => Sizing::Percent(value as f32),
542        _ => Sizing::Fixed(value as f32),
543    }
544}
545
546pub const PROPS: &[PropDef] = &[
547    PropDef {
548        name: "width",
549        id: P_WIDTH,
550        kind: Kind::Sizing,
551        apply: Apply::SpecSizing(|s, v| s.width(v)),
552        doc: "Horizontal size: px | \"fit\" | \"grow\" | \"N%\" | a size expression — \
553              `\"clamp(400px, 80%, 1000px)\"`, `\"min(720px, 100%)\"`, `\"max(50%, 300)\"`, \
554              nested — which layout resolves against the parent's content box, the box a \
555              percentage takes its cut of (backlog F109). An expression with no percentage \
556              in it is a length; a calc does not ease under `transition`. A percentage \
557              or an expression gives, with the fit children, when its parent overflows \
558              — two `\"50%\"` children and a gap fit their row (backlog F110) — where a \
559              px size keeps its own. A row holding one gives as CSS's flex items do: \
560              every child that can give gives in proportion to its size, and stops at \
561              its content — the widest thing in it that cannot wrap, a label's longest \
562              word — unless `minWidth` says otherwise (backlog RG92). The process keeps \
563              65 536 distinct expressions and never lets one go: past that a new one leaves \
564              its prop at its default, with a `size-expressions-full` warning, so declare \
565              one per layout — a px size for the part that moves each frame, a splitter's \
566              drag — not one per frame (backlog RG93).",
567    },
568    PropDef {
569        name: "height",
570        id: P_HEIGHT,
571        kind: Kind::Sizing,
572        apply: Apply::SpecSizing(|s, v| s.height(v)),
573        doc: "Vertical size: px | \"fit\" | \"grow\" | \"N%\" | a size expression (see `width`).",
574    },
575    PropDef {
576        name: "minWidth",
577        id: P_MIN_W,
578        kind: Kind::Min,
579        apply: Apply::SpecBound(|s, v| s.min_width(v)),
580        doc: "Lower width clamp: logical px, a size expression (see `width`; a percentage \
581              clamp is none until the parent's width is known, as in CSS), or \"fit\" for \
582              the node's own fit width. \
583              \"fit\" under `width=\"grow\"` is a content floor — CSS's `flex: 1 0 auto` — \
584              which is what an i3-style tab bar is: tabs that split the bar evenly \
585              while they fit and sit at their label's width, scrolling, once they do \
586              not. Opt-in, because a fit width is the unwrapped one: a paragraph in a \
587              grow column would stop wrapping under it. Left out, a child giving in an \
588              overflowing row that holds a percentage or a size expression stops at its \
589              content, CSS's `min-width: auto`; `0` lets it go below, CSS's \
590              `min-width: 0` (backlog RG92). A fit node across a column is no wider \
591              than the column's box — CSS's `fit-content` — down to this floor, 0 left \
592              out, unless the column scrolls x, so a text one wrapper deep in a capped \
593              card wraps there; \"fit\" keeps its content's width and runs past \
594              (backlog F116).",
595    },
596    PropDef {
597        name: "maxWidth",
598        id: P_MAX_W,
599        kind: Kind::Max,
600        apply: Apply::SpecBound(|s, v| s.max_width(v)),
601        doc: "Upper width clamp: logical px or a size expression (see `width`); \
602              grow+maxWidth is the responsive-width pattern.",
603    },
604    PropDef {
605        name: "minHeight",
606        id: P_MIN_H,
607        kind: Kind::Min,
608        apply: Apply::SpecBound(|s, v| s.min_height(v)),
609        doc: "Lower height clamp: logical px, a size expression, or \"fit\" for the node's own fit height. Undeclared, it is the node's content where its column overflows — CSS's `min-height: auto`, none for a node that scrolls or clips — so a row keeps the height of its text; `0` asks for the squeeze back (see `minWidth`).",
610    },
611    PropDef {
612        name: "maxHeight",
613        id: P_MAX_H,
614        kind: Kind::Max,
615        apply: Apply::SpecBound(|s, v| s.max_height(v)),
616        doc: "Upper height clamp: logical px or a size expression (see `width`).",
617    },
618    PropDef {
619        name: "gap",
620        id: P_GAP,
621        kind: Kind::F32,
622        apply: Apply::SpecF32(|s, v| s.gap(v)),
623        doc: "Space between children along the main axis.",
624    },
625    // Not `wrap`: that name is taken, by the text prop that picks where a
626    // line breaks inside one paragraph. One name cannot mean both, and the
627    // two meet on `<edit>`, which takes container and text props at once.
628    PropDef {
629        name: "wrapChildren",
630        id: P_WRAP_CHILDREN,
631        kind: Kind::Flag,
632        apply: Apply::SpecFlag(NodeSpec::wrap),
633        doc: "Children that don't fit the main axis start a new line instead of overflowing or shrinking. Rows only (a column is ignored, with a warning), and never on a scrollX row.",
634    },
635    PropDef {
636        name: "crossGap",
637        id: P_CROSS_GAP,
638        kind: Kind::F32,
639        apply: Apply::SpecF32(|s, v| s.cross_gap(v)),
640        doc: "Space between wrap lines, across the main axis (`gap` stays the space along it).",
641    },
642    PropDef {
643        name: "mainAlign",
644        id: P_MAIN_ALIGN,
645        kind: Kind::Enum(ALIGNS),
646        apply: Apply::SpecEnum(|s, i| s.main_align(align_idx(i))),
647        doc: "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.",
648    },
649    PropDef {
650        name: "crossAlign",
651        id: P_CROSS_ALIGN,
652        kind: Kind::Enum(ALIGNS),
653        apply: Apply::SpecEnum(|s, i| s.cross_align(align_idx(i))),
654        doc: "Child alignment across the main axis. On a row, `baseline` lines up the first baselines of the children's text, so a label and a larger value read as one line; a child with no text aligns by its bottom edge, a `grow` or percent height fills the line from its top, and a fit-height row grows to hold the aligned children. A column lays `baseline` out as `start` (as CSS does), and the three spreads mean nothing across an axis — each with a warning.",
655    },
656    PropDef {
657        name: "aspectRatio",
658        id: P_ASPECT_RATIO,
659        kind: Kind::F32,
660        apply: Apply::SpecF32(|s, v| s.aspect_ratio(v)),
661        doc: "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.",
662    },
663    PropDef {
664        name: "bg",
665        id: P_BG,
666        kind: Kind::Color,
667        apply: Apply::SpecColor(|s, c| s.bg(c)),
668        doc: "Background fill.",
669    },
670    PropDef {
671        name: "radius",
672        id: P_RADIUS,
673        kind: Kind::F32,
674        apply: Apply::SpecF32(|s, v| s.radius(v)),
675        doc: "Corner radius for all four corners (logical px); the per-corner props override it when listed after it. On a node that also clips or scrolls it rounds the clip as well, so children stay inside the corners.",
676    },
677    PropDef {
678        name: "opacity",
679        id: P_OPACITY,
680        kind: Kind::F32,
681        apply: Apply::SpecF32(|s, v| s.opacity(v)),
682        doc: "Group opacity 0..1 (default 1): fades this node and its whole subtree. A per-quad alpha multiply rather than an offscreen composite, so overlapping pieces of one subtree show their seams through the fade. Layout, hit-testing and the access tree are untouched; eases with `transition`, and `enter: { opacity: 0 }` fades a panel in.",
683    },
684    PropDef {
685        name: "pixelSnap",
686        id: P_PIXEL_SNAP,
687        kind: Kind::Flag,
688        apply: Apply::SpecFlag(|s| s.pixel_snap()),
689        doc: "Paint this node's background, border, shadow and fragment with each edge on a whole physical pixel: `x` and `x + width` rounded on their own, from where layout put them, as a text's span backgrounds are. Off by default, and a box is drawn where layout put it, so a 1 px `gap` between boxes is there at any scale. On, boxes that share an edge in layout meet on one pixel line, where a join inside a pixel was drawn by halves and left a seam — rows of a band stacked at a pitch that is not whole pixels, or a box that continues a text's selection. Layout, hit-testing, the clip and the children are untouched. A snapped box can draw up to half a pixel from its layout edge and its size can differ by a pixel, so a snapped hairline is 1 or 2 px thick by where it sits.",
690    },
691    PropDef {
692        name: "shadowColor",
693        id: P_SHADOW_COLOR,
694        kind: Kind::Color,
695        apply: Apply::SpecColor(|s, c| s.shadow_color(c)),
696        doc: "Drop-shadow color; nothing else about a shadow draws without it. On its own it is a hard shadow exactly behind the node — add `shadowBlur` / `shadowY` to lift it. Outer shadows only, and the shape is not knocked out of the middle, so a translucent background shows it through.",
697    },
698    PropDef {
699        name: "shadowBlur",
700        id: P_SHADOW_BLUR,
701        kind: Kind::F32,
702        apply: Apply::SpecF32(|s, v| s.shadow_blur(v)),
703        doc: "Drop-shadow blur radius (logical px): the edge ramps over this distance and reaches this far past the shape. 0 = a hard edge.",
704    },
705    PropDef {
706        name: "shadowX",
707        id: P_SHADOW_X,
708        kind: Kind::F32,
709        apply: Apply::SpecF32(|s, v| s.shadow_x(v)),
710        doc: "Drop-shadow horizontal offset (logical px).",
711    },
712    PropDef {
713        name: "shadowY",
714        id: P_SHADOW_Y,
715        kind: Kind::F32,
716        apply: Apply::SpecF32(|s, v| s.shadow_y(v)),
717        doc: "Drop-shadow vertical offset (logical px); positive casts downward.",
718    },
719    PropDef {
720        name: "shadowSpread",
721        id: P_SHADOW_SPREAD,
722        kind: Kind::F32,
723        apply: Apply::SpecF32(|s, v| s.shadow_spread(v)),
724        doc: "Grows (or, negative, shrinks) the drop shadow's shape before blurring (logical px).",
725    },
726    PropDef {
727        name: "radiusTL",
728        id: P_RADIUS_TL,
729        kind: Kind::F32,
730        apply: Apply::SpecF32(|s, v| s.radius_tl(v)),
731        doc: "Top-left corner radius (logical px).",
732    },
733    PropDef {
734        name: "radiusTR",
735        id: P_RADIUS_TR,
736        kind: Kind::F32,
737        apply: Apply::SpecF32(|s, v| s.radius_tr(v)),
738        doc: "Top-right corner radius (logical px).",
739    },
740    PropDef {
741        name: "radiusBR",
742        id: P_RADIUS_BR,
743        kind: Kind::F32,
744        apply: Apply::SpecF32(|s, v| s.radius_br(v)),
745        doc: "Bottom-right corner radius (logical px).",
746    },
747    PropDef {
748        name: "radiusBL",
749        id: P_RADIUS_BL,
750        kind: Kind::F32,
751        apply: Apply::SpecF32(|s, v| s.radius_bl(v)),
752        doc: "Bottom-left corner radius (logical px).",
753    },
754    PropDef {
755        name: "center",
756        id: P_CENTER,
757        kind: Kind::Flag,
758        apply: Apply::SpecFlag(|s| s.center()),
759        doc: "Center children on both axes.",
760    },
761    PropDef {
762        name: "hoverable",
763        id: P_HOVERABLE,
764        kind: Kind::Flag,
765        apply: Apply::SpecFlag(|s| s.hoverable()),
766        doc: "Hover-track without a click payload (for isHovered-driven styling).",
767    },
768    PropDef {
769        name: "animate",
770        id: P_ANIMATE,
771        kind: Kind::Flag,
772        apply: Apply::SpecFlag(|s| s.animate()),
773        doc: "Ask for another frame after this one, every frame this node is declared. What a `fragment` that reads `time` needs, and what anything driving itself off the clock rather than off input needs. Opt-in like `exit`, and for the same reason: it takes the loop off input-driven and onto the display's cadence for as long as it is declared, so a still node must not carry it. One node asking is enough for the whole window.",
774    },
775    PropDef {
776        name: "accent",
777        id: P_ACCENT,
778        kind: Kind::Flag,
779        apply: Apply::SpecFlag(|s| s.accent()),
780        doc: "Paint this node's background in the OS accent colour — `env.system.accent` — keeping the declared `bg` on a host that cannot tell what it is. The one prop whose paint depends on the environment, which is why it is opt-in: the same tree is a different colour on two machines, and that is the point here and a surprise anywhere else. On the stock button it does the whole job — the hover and pressed shades are derived from the accent, and the label goes black or white by its luminance, so a yellow accent is still readable — which is what `<button accent>` is for.",
781    },
782    PropDef {
783        name: "selectable",
784        id: P_SELECTABLE,
785        kind: Kind::Flag,
786        apply: Apply::SpecFlag(|s| s.selectable()),
787        doc: "Makes this node a selection scope: the text of every node inside it is one selectable run, in tree order, and a press-drag across them selects the lot — as do Shift with Left / Right / Home / End on a focused node inside it, a character or a word at a time, a scope with nothing selected anchoring at its start (`docs/adr/0017-selection-as-a-scope.md`). Declared on the container and not on each label, because what a reader selects is a paragraph or a card rather than one run of it — three labels in a column under one `selectable` select as three lines of one text. The selection is the window's: starting one anywhere clears the last, an editor's included. Scopes do not nest; an outer one around an inner one is warned about (`nested-selection-scope`) and the innermost owns the text. Text scrolled out of view inside the scope is still part of it — selection and copy reach it, hit-testing does not. On a `cells` grid the scope selects in cells rather than in bytes: a drag takes lines (with a modifier, a rectangle), a double click the word under the pointer and a triple click the whole row, its ends are absolute lines so a scroll does not move them, and a copy trims each line's trailing blanks.",
788    },
789    PropDef {
790        name: "focusable",
791        id: P_FOCUSABLE,
792        kind: Kind::Flag,
793        apply: Apply::SpecFlag(|s| s.focusable()),
794        doc: "Reachable by Tab (and focused by a click) without a click payload or a control role — a row that opens on Enter. Editors, key sinks, `onClick` boxes and the control roles are focusable already.",
795    },
796    PropDef {
797        name: "keepFocus",
798        id: P_KEEP_FOCUS,
799        kind: Kind::Flag,
800        apply: Apply::SpecFlag(|s| s.keep_focus()),
801        doc: "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.",
802    },
803    PropDef {
804        name: "focusRegion",
805        id: P_FOCUS_REGION,
806        kind: Kind::Flag,
807        apply: Apply::SpecFlag(|s| s.focus_region()),
808        doc: "Makes this node's subtree a focus region: a Tab ring of its own that the ring outside never enters and that never leaves — a devtools dock, an inspector beside the app (`docs/adr/0022-focus-regions.md`). Entered on purpose: `focusRegion(name)` (`Ui::focus_region`, `env.focus_region`, `kui_focus_region`) moves focus in — to the focus the region last held, else its `initialFocus`, else its first stop — and `focusRegion(null)` moves it back to the main ring the same way; a press inside the region, or an explicit focus on a node in it, enters it too. Tab then walks that ring alone, wrapping inside it; with nothing focused, Tab enters the ring of the region in effect (`region()`). A region that stops being declared hands focus back to what the main ring last held. Only the ring is scoped: keys still bubble through the boundary to the sink above (a region that wants its own keymap is an `onKey` sink), the pointer and assistive technology see a plain node, and a `modal` in effect is the ring wherever it sits. Nested regions are skipped by the outer ring the way the main ring skips them.",
809    },
810    PropDef {
811        name: "scrollbar",
812        id: P_SCROLLBAR,
813        kind: Kind::Enum(SCROLLBARS),
814        apply: Apply::SpecEnum(|s, i| s.scrollbar(crate::spec::ScrollbarMode::ALL[i])),
815        doc: "When a scrolling node draws its bars: `visible` (the default — the stock overlay thumb, drawn while the content overflows), `hidden` (no thumb, no track to press; the wheel, the keyboard, `reveal` and the caret still scroll it — for a list that draws its own indicator, or a pane whose bar would sit on a border), or `auto` (shown while the scroll state is changing — the offset or the content's extent moved, the pointer is on the track, a thumb is dragged — and for a second after, then faded out over a quarter of one; a node first seen shows it the same second; what an overlay bar does on macOS). `auto` needs the driver's clock and is `visible` without one. The bars are overlays and take no layout space in any mode. From the last change until it has faded — a second and a quarter — an `auto` bar asks for frames the way a transition of that length would (nothing else could wake the core when the hold ends); while the pointer holds it, it asks for none.",
816    },
817    PropDef {
818        name: "anchor",
819        id: P_ANCHOR,
820        kind: Kind::Flag,
821        apply: Apply::SpecFlag(|s| s.anchor()),
822        doc: "Scroll anchoring on a scrolling node (backlog C26, CSS's `overflow-anchor`): the first child in view keeps its place on screen when the content before it changes size — a chat that prepends history, a log that inserts rows above the viewport, a list whose row heights are corrected as they are measured — with no `setScroll` and no arithmetic in the view. The core remembers which child was first in view and where its edge was, and moves the offset by however far that edge moved in the next layout, before the offset is clamped; a wheel notch or a `setScroll` between the frames is kept and the correction added to it. The child is found by key, so give the rows stable keys (a `key` or an `index`); a child that is gone anchors nothing that frame. On the scroll axis that is the node's main axis only — `scrollY` on a column, `scrollX` on a row — and content appended *after* the anchor moves nothing, so a log that is tailing still asks for the end itself.",
823    },
824    PropDef {
825        name: "scrollbarWidth",
826        id: P_SCROLLBAR_WIDTH,
827        kind: Kind::F32,
828        apply: Apply::SpecF32(|s, v| s.scrollbar_width(v)),
829        doc: "The thumb's width at rest, logical px (default 4); under the pointer or dragged it is 2 px wider. The grabbable track grows to fit a wide thumb.",
830    },
831    PropDef {
832        name: "scrollbarColor",
833        id: P_SCROLLBAR_COLOR,
834        kind: Kind::Color,
835        apply: Apply::SpecColor(|s, c| s.scrollbar_color(c)),
836        doc: "The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on.",
837    },
838    PropDef {
839        name: "scrollbarActiveColor",
840        id: P_SCROLLBAR_ACTIVE_COLOR,
841        kind: Kind::Color,
842        apply: Apply::SpecColor(|s, c| s.scrollbar_active_color(c)),
843        doc: "The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role.",
844    },
845    PropDef {
846        name: "overscroll",
847        id: P_OVERSCROLL,
848        kind: Kind::Enum(OVERSCROLLS),
849        apply: Apply::SpecEnum(|s, i| s.overscroll(crate::spec::Overscroll::ALL[i])),
850        doc: "What a scroll gesture that starts over this scroller does when it is already at its limit that way (backlog F107, CSS's `overscroll-behavior`): `auto` (the default) passes the gesture on to the scroller around it, `contain` keeps it here, moving nothing until it turns back. A gesture picks its target once, when it starts — the innermost scroller under the pointer that can still move the way it goes — and keeps it until it ends, wherever the pointer or the content has gone; one that reaches a limit midway stops there, whatever this says. Only on the axes the node scrolls: a `scrollY` list that contains still passes a sideways swipe to the strip around it. For a panel or a popup's list whose scrolling must never move what is behind it.",
851    },
852    PropDef {
853        name: "disabled",
854        id: P_DISABLED,
855        kind: Kind::Flag,
856        apply: Apply::SpecFlag(|s| s.disabled(true)),
857        doc: "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.",
858    },
859    PropDef {
860        name: "hoverBg",
861        id: P_HOVER_BG,
862        kind: Kind::Color,
863        apply: Apply::SpecColor(|s, c| s.hover_bg(c)),
864        doc: "Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`.",
865    },
866    PropDef {
867        name: "gradient",
868        id: P_GRADIENT,
869        kind: Kind::Gradient,
870        apply: Apply::SpecGradient(|s, g| s.gradient(g)),
871        doc: "A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. A stop whose `$token` misses is not an error: it is raised as `unknown-token` and left out, as a miss leaves any slot unset, and the rest are spaced as if it had not been declared — so a gradient left with fewer than two stops, a two-stop one with a typo, draws nothing over its `bg` (backlog RG118).",
872    },
873    PropDef {
874        name: "backdropBlur",
875        id: P_BACKDROP_BLUR,
876        kind: Kind::F32,
877        apply: Apply::SpecF32(|s, v| s.backdrop_blur(v)),
878        doc: "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.",
879    },
880    PropDef {
881        name: "rotate",
882        id: P_ROTATE,
883        kind: Kind::F32,
884        apply: Apply::SpecF32(|s, v| s.rotate(v)),
885        doc: "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.",
886    },
887    PropDef {
888        name: "scale",
889        id: P_SCALE,
890        kind: Kind::F32,
891        apply: Apply::SpecF32(|s, v| s.scale(v)),
892        doc: "Scales this node and everything under it by this factor, uniformly, about its pivot, after layout (`docs/adr/0043-a-node-turns-about-its-pivot.md`); 1 is none. 0 draws nothing in Rust, Node and Lua; in C and Odin, where a zeroed field is unset, 0 is 1. Paint-only, as `rotate` is: layout, the room taken and `onLayout` are the upright node's; what it draws, clips and hits scales. Tweens with `transition` as one slot with `rotate`; `enter: { scale: 0.8 }` settles a chip in, `keyframes: [{ scale: 1.05, at: 0.5 }]` pulses. The edge ramps scale with the box, so a box scaled far up reads soft.",
893    },
894    PropDef {
895        name: "pivotX",
896        id: P_PIVOT_X,
897        kind: Kind::F32,
898        apply: Apply::SpecF32(|s, v| {
899            let fy = s.transform_spec().map_or(0.5, |t| t.pivot.y);
900            s.pivot(v, fy)
901        }),
902        doc: "Where across the box `rotate` and `scale` are about, as a fraction of its width: 0 the left edge, 0.5 (the default) the middle, 1 the right edge; outside 0..1 is a point past the box. C: `pivot_x` with `pivot_set`.",
903    },
904    PropDef {
905        name: "pivotY",
906        id: P_PIVOT_Y,
907        kind: Kind::F32,
908        apply: Apply::SpecF32(|s, v| {
909            let fx = s.transform_spec().map_or(0.5, |t| t.pivot.x);
910            s.pivot(fx, v)
911        }),
912        doc: "Where down the box `rotate` and `scale` are about, as a fraction of its height: 0 the top, 0.5 (the default) the middle, 1 the bottom. C: `pivot_y` with `pivot_set`.",
913    },
914    PropDef {
915        name: "rules",
916        id: P_RULES,
917        kind: Kind::Color,
918        apply: Apply::SpecColor(|s, c| s.rules(c)),
919        doc: "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.",
920    },
921    PropDef {
922        name: "ruleWidth",
923        id: P_RULE_WIDTH,
924        kind: Kind::F32,
925        apply: Apply::SpecF32(|s, v| s.rule_width(v)),
926        doc: "The width of a table's `rules` in logical px; 1 when unset.",
927    },
928    PropDef {
929        name: "dropBg",
930        id: P_DROP_BG,
931        kind: Kind::Color,
932        apply: Apply::SpecColor(|s, c| s.drop_bg(c)),
933        doc: "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`.",
934    },
935    PropDef {
936        name: "pressedBg",
937        id: P_PRESSED_BG,
938        kind: Kind::Color,
939        apply: Apply::SpecColor(|s, c| s.pressed_bg(c)),
940        doc: "Background while pressed (or while its hoverGroup is); implies hover tracking.",
941    },
942    PropDef {
943        name: "focusBg",
944        id: P_FOCUS_BG,
945        kind: Kind::Color,
946        apply: Apply::SpecColor(|s, c| s.focus_bg(c)),
947        doc: "Background while the node holds keyboard-visible focus (moved there by Tab or assistive technology, not a click); replaces the default focus ring. Pressed wins over focus wins over hover; eases with `transition`.",
948    },
949    PropDef {
950        name: "modal",
951        id: P_MODAL,
952        kind: Kind::Tag,
953        apply: Apply::SpecMsg(|s, v| s.modal(v)),
954        doc: "Modal surface: the Tab ring becomes this node's subtree, everything outside it is inert to the pointer, the wheel and assistive technology, and Escape or a press outside emits {kind:\"dismiss\", reason:\"escape\"|\"outside\", tag} on it — the app stops declaring the node. The last one declared in tree order is the one in effect (a confirm inside a dialog); a modal that must cover the app is a float. The access tree is not pruned to the modal: it keeps every node of the frame and marks the one in effect `modal` (`docs/adr/0003-modal-surfaces.md`, decision 7), which is what assistive technology acts on.",
955    },
956    PropDef {
957        name: "initialFocus",
958        id: P_INITIAL_FOCUS,
959        kind: Kind::Flag,
960        apply: Apply::SpecFlag(|s| s.initial_focus()),
961        doc: "Where focus lands when the enclosing `modal` scope is entered: the first node in the modal\'s Tab ring declaring it, so a destructive confirm opens on its Cancel rather than on whichever control is declared first. Read on entry only — a Tab press afterwards stands, and the scope re-entered (a nested confirm closing) leaves focus where it was. Declared on nothing, or only on nodes the ring skips (disabled, `role=\"none\"`, not focusable), entry stays the ring\'s first node.",
962    },
963    PropDef {
964        name: "hoverGroup",
965        id: P_HOVER_GROUP,
966        kind: Kind::Str,
967        apply: Apply::SpecStr(|s, name| s.hover_group(name)),
968        doc: "Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape).",
969    },
970    PropDef {
971        name: "onHover",
972        id: P_ON_HOVER,
973        kind: Kind::Tag,
974        apply: Apply::SpecMsg(|s, v| s.on_hover(v)),
975        doc: "Hover tag: the pointer entering/leaving emits {kind:\"hover\", phase:\"enter\"|\"leave\", tag} events.",
976    },
977    PropDef {
978        name: "onDrop",
979        id: P_ON_DROP,
980        kind: Kind::Tag,
981        apply: Apply::SpecMsg(|s, v| s.on_drop(v)),
982        doc: "Drop-zone tag (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`): files dragged in from the OS over this node emit {kind:\"drop\", phase:\"enter\"|\"move\"|\"leave\"|\"drop\", paths, x, y, tag} — `paths` the OS paths as strings, `x`/`y` the pointer in logical viewport coordinates (absent on `leave`). The zone under the files is the topmost zone by paint order: a node inside a zone is the zone's (a button in it, a field in it), and a node that is no zone and has none enclosing it is looked past, so an overlay shown on `enter` cannot make the zone lose the files. No `leave` follows a `drop`; a drop off every zone is refused by the driver. Implies hover tracking. No access row — a screen-reader user's way in is a button beside the zone. On Windows and Linux the position is the OS cursor at enter and release only, so `move` never fires there.",
983    },
984    PropDef {
985        name: "onLayout",
986        id: P_ON_LAYOUT,
987        kind: Kind::Tag,
988        apply: Apply::SpecMsg(|s, v| s.on_layout(v)),
989        doc: "Layout tag: the node's laid-out rect arrives as {kind:\"layout\", x, y, w, h, parent, tag} on its first frame and whenever it changes (needs a stable key).",
990    },
991    PropDef {
992        name: "onClick",
993        id: P_ON_CLICK,
994        kind: Kind::Msg,
995        apply: Apply::SpecMsg(|s, v| s.on_click(v)),
996        doc: "Message emitted when clicked (data, not a callback).",
997    },
998    PropDef {
999        name: "onDrag",
1000        id: P_ON_DRAG,
1001        kind: Kind::Tag,
1002        apply: Apply::SpecMsg(|s, v| s.on_drag(v)),
1003        doc: "Drag tag: emits {kind:\"drag\", phase, x, y, dx, dy, parent, tag} events, `dx`/`dy` measured from the press point in every phase. On a `cells` grid the events also carry `cell: {row, col}`; inside an `onKey` sink that draws `role=\"line\"` rows they carry `line`, `byte` and `clicks` — see the events table.",
1004    },
1005    PropDef {
1006        name: "onKey",
1007        id: P_ON_KEY,
1008        kind: Kind::Tag,
1009        apply: Apply::SpecMsg(|s, v| s.on_key(v)),
1010        doc: "Key-sink tag: with key focus held, presses arrive as {kind:\"key\", phase:\"down\", code, ...} events. Releases only with `keyUp` beside it.",
1011    },
1012    PropDef {
1013        name: "keyUp",
1014        id: P_KEY_UP,
1015        kind: Kind::Flag,
1016        apply: Apply::SpecFlag(|s| s.key_up()),
1017        doc: "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.",
1018    },
1019    PropDef {
1020        name: "modifierKeys",
1021        id: P_MODIFIER_KEYS,
1022        kind: Kind::Flag,
1023        apply: Apply::SpecFlag(|s| s.modifier_keys()),
1024        doc: "With `onKey`: the modifier and lock keys arrive as keys of their own (backlog F108) — `code` \"shift\", \"ctrl\", \"alt\", \"super\", \"capslock\", \"numlock\", \"scrolllock\", which side in `location` (\"left\" / \"right\"), releases too with `keyUp`. Without it a modifier is only ever held — the next key's `shift`, `ctrl`, … and the `modifiers` event — so a keymap mid-sequence never reads a Shift as a key between two others. For a terminal speaking kitty's keyboard protocol, or a game that binds a lone Shift.",
1025    },
1026    PropDef {
1027        name: "onContextMenu",
1028        id: P_ON_CONTEXT_MENU,
1029        kind: Kind::Tag,
1030        apply: Apply::SpecMsg(|s, v| s.on_context_menu(v)),
1031        doc: "Context-menu tag: a secondary-button (right) press emits {kind:\"contextmenu\", x, y, tag} on the node, at the logical viewport point to open the menu at. The press moves no focus, places no caret and produces no click, so right-clicking a selection keeps it. Asked of the topmost node under the pointer, and when that node offers no menu the press reaches the nearest enclosing node that does — a container declaring a menu for everything inside it is the common case — the way an unclaimed key reaches the enclosing sink (`docs/adr/0011`): the event carries the *owner's* key and tag, a nested declaration wins over its ancestor's, a disabled node's own is skipped, and the walk stops at the modal boundary.",
1032    },
1033    PropDef {
1034        name: "onForceClick",
1035        id: P_ON_FORCE_CLICK,
1036        kind: Kind::Tag,
1037        apply: Apply::SpecMsg(|s, v| s.on_force_click(v)),
1038        doc: "Force-click tag: a press that deepens past the second stage of a Force Touch trackpad emits {kind:\"forceclick\", x, y, tag} on the node, at the logical viewport point it happened at (`docs/adr/0017-selection-as-a-scope.md`). Routed as a secondary press is — no focus moved, no caret placed, no click — but asked of the topmost node only, with no walk to an enclosing declaration — and the ordinary click the press is still producing arrives afterwards, as it does on macOS. Text needs none of this: a force click over an `edit` or a `selectable` scope selects the word under it and asks the host for its Look Up panel. macOS-only in practice, and there the user can switch the gesture off, so nothing may declare itself the only way to reach something.",
1039    },
1040    PropDef {
1041        name: "onButton",
1042        id: P_ON_BUTTON,
1043        kind: Kind::Tag,
1044        apply: Apply::SpecMsg(|s, v| s.on_button(v)),
1045        doc: "Button tag (backlog F105): a press of a non-primary button — middle, secondary, or one past those — emits {kind:\"button\", phase:\"press\", button, x, y, clicks, tag} on the node, and the button is then captured by it: every pointer move while it is held arrives as phase:\"move\" and its release as phase:\"release\", on this node wherever the pointer is. `button` is `\"secondary\"`, `\"middle\"` or a further button's number (3 and up); `x`/`y` are logical viewport coordinates, and on a `cells` grid each event carries `cell: {row, col}` as a click does. Several buttons can be held at once, each its own capture, and a primary drag is untouched. `buttons` says which buttons it claims — all of them unless it narrows them. Asked of the topmost node under the pointer, and when that node claims no such button the press reaches the nearest enclosing node that does, the way a context menu's does: a disabled node's own is skipped and the walk stops at the modal boundary. A claimed secondary press is this event *instead of* a `contextmenu` event and the stock menu (a nearer `onContextMenu` still wins, being the nested declaration). Like every non-primary press it moves no focus, places no caret and touches no selection or scrollbar. For a terminal's middle-click paste, and the mouse reports a program in it asked for.",
1046    },
1047    PropDef {
1048        name: "buttons",
1049        id: P_BUTTONS,
1050        kind: Kind::Str,
1051        apply: Apply::SpecStr(|s, v| s.buttons(crate::input::Buttons::parse(v))),
1052        doc: "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`.",
1053    },
1054    PropDef {
1055        name: "onScroll",
1056        id: P_ON_SCROLL,
1057        kind: Kind::Tag,
1058        apply: Apply::SpecMsg(|s, v| s.on_scroll(v)),
1059        doc: "Scroll tag: the wheel over this node emits {kind:\"scroll\", x, y, dx, dy, lines, tag} on it instead of scrolling anything — `dx`/`dy` the delta in logical px as the driver reported it (positive `dy` is the wheel rolling up, toward earlier content), `x`/`y` the pointer, and `lines` on a `cells` grid the whole lines the delta covers (positive = later history, the sign `originLine` grows in; the fraction is carried to the next notch so a trackpad's small steps add up) and null on any other node. The node takes the wheel on the axes `scrollAxes` names (both unless it narrows them): a gesture that starts over it is its own whether or not it has anywhere to go — except on an axis the node also scrolls (`scrollX`/`scrollY`, its offset the app's to set), where it is answered by its room as a container is, so at its edge a gesture that way passes to the scroller around it (backlog F118) — and stays its own until it ends, wherever the pointer goes (backlog F107); it reaches no scroll container above it, and a scroller inside it still takes the axes it scrolls while it can move that way, passing this node the rest — the other axis, and a gesture that begins with that scroller at its limit (`overscroll: \"contain\"` on the scroller keeps it there). The core moves nothing — a grid re-declares `originLine`, a canvas zooms. A drag-select held past a `cells` grid's top or bottom edge arrives here too, once a frame with the lines that frame scrolled by (`docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md`).",
1060    },
1061    PropDef {
1062        name: "scrollAxes",
1063        id: P_SCROLL_AXES,
1064        kind: Kind::Enum(SCROLL_AXES),
1065        apply: Apply::SpecEnum(|s, i| s.scroll_axes(crate::spec::ScrollAxes::ALL[i])),
1066        doc: "Which axes `onScroll` takes (backlog F107): `both` (the default), `x` or `y`. A scroll gesture on an axis the node does not take passes it by, to the scroller around it, and hears nothing here: a terminal that scrolls its history says `y`, and a sideways swipe that starts over it moves the strip it sits in. (A swipe that started elsewhere is not the node's either way: a gesture keeps the target it started with.) Meaningless without `onScroll`.",
1067    },
1068    PropDef {
1069        name: "scrollMods",
1070        id: P_SCROLL_MODS,
1071        kind: Kind::Str,
1072        apply: Apply::SpecStr(|s, v| s.scroll_mods(crate::input::KeyMods::parse(v))),
1073        doc: "The modifiers `onScroll` is for (backlog F122): `\"shift\"`, `\"ctrl\"`, `\"alt\"` and `\"super\"` (⌘, the Windows key), separated by spaces or commas — `\"ctrl super\"`. With any named, the node hears only a scroll gesture that began with one of them held, and hears it first: ahead of every scroll container and every `onScroll` that names none, wherever under the pointer the gesture began, the innermost such node winning — so a Ctrl-wheel zoom declared on the window's root is heard over a list, and the list does not scroll. A wheel with none of them held passes the node by, as if it had no `onScroll`: a node that scrolls as well (`overflow`) scrolls for it as any container does. Its `scroll` events carry `mods`, the modifiers held when the gesture began; the gesture stays the node's to the end of its glide, whatever is let go meanwhile, and one begun without them never becomes its. A word that is none of the four is skipped. Unset, a handler like any other. Meaningless without `onScroll`.",
1074    },
1075    PropDef {
1076        name: "window",
1077        id: P_WINDOW,
1078        kind: Kind::Enum(WINDOW_ROLES),
1079        apply: Apply::SpecEnum(|s, i| match i {
1080            0 => s.window_drag(),
1081            1 => s.window_button(WindowButton::Close),
1082            2 => s.window_button(WindowButton::Minimize),
1083            3 => s.window_button(WindowButton::Maximize),
1084            _ => s,
1085        }),
1086        doc: "Window-chrome role: interactions become window commands, not events.",
1087    },
1088    PropDef {
1089        name: "cursor",
1090        id: P_CURSOR,
1091        kind: Kind::Enum(CURSORS),
1092        apply: Apply::SpecEnum(|s, i| s.cursor(cursor_idx(i))),
1093        doc: "The pointer shape over this node. Unset, the pointer is `text` over an editor or a `selectable` scope and `default` over everything else — an `onClick`, `focusable` or `onDrag` node included, as a native button is — so a hand (`pointer`) over a control, a `grab` over a handle (and `grabbing` while its drag runs, which the view declares as its drag state changes), a splitter's `ewResize` / `nsResize` and a `disabled` control's `notAllowed` are all declared. The stock `button` declares `pointer` itself. A captured drag keeps the dragged node's shape wherever the pointer goes.",
1094    },
1095    PropDef {
1096        name: "transition",
1097        id: P_TRANSITION,
1098        kind: Kind::F32,
1099        apply: Apply::SpecF32(|s, v| s.transition(v)),
1100        doc: "Animate sizing/colors/radius changes over this many ms — and, on a scroll container, the offset a reveal or a set_scroll moves it to (needs a stable key).",
1101    },
1102    PropDef {
1103        name: "easing",
1104        id: P_EASING,
1105        kind: Kind::Enum(EASINGS),
1106        apply: Apply::SpecEnum(|s, i| s.easing(easing_idx(i))),
1107        doc: "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).",
1108    },
1109    PropDef {
1110        name: "bounce",
1111        id: P_BOUNCE,
1112        kind: Kind::F32,
1113        apply: Apply::SpecF32(|s, v| s.bounce(v)),
1114        doc: "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).",
1115    },
1116    PropDef {
1117        name: "slide",
1118        id: P_SLIDE,
1119        kind: Kind::Flag,
1120        apply: Apply::SpecFlag(|s| s.slide()),
1121        doc: "With transition: also ease the node's position (reordered siblings slide). While it eases, the node is drawn between where it was and where this frame put it — not at the declared `dx`/`dy`, or its slot in the row — so anything else positioned from those numbers drifts for the transition's length: a canvas of floats eases everything or nothing. Siblings that cross on their way paint in tree order, the one in the later place on top; `overSiblings` keeps one over the others.",
1122    },
1123    PropDef {
1124        name: "overSiblings",
1125        id: P_OVER_SIBLINGS,
1126        kind: Kind::Flag,
1127        apply: Apply::SpecFlag(|s| s.over_siblings()),
1128        doc: "Paint this node and its subtree after its siblings, over any it overlaps: a tab that glides past its neighbour under `slide` passes over it whichever way it moved, so the selected tab declared `overSiblings` stays on top without the view tracking when it moves. Siblings otherwise paint in tree order, a later place over an earlier. Only the sibling order changes: the parent still paints under it, what follows the parent over it, it keeps the parent's clip, and a float still opens above it. The hit test reads the same order, so where it overlaps a sibling it takes the press. Two siblings that both declare it paint in tree order between themselves. Not a z-index: there is no number to outbid (`docs/adr/0046-a-node-over-its-siblings-paints-after-them.md`).",
1129    },
1130    PropDef {
1131        name: "keyframes",
1132        id: P_KEYFRAMES,
1133        kind: Kind::Keyframes,
1134        apply: Apply::SpecKeyframes(|s, k| s.keyframes(k)),
1135        doc: "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 }]`.",
1136    },
1137    PropDef {
1138        name: "enter",
1139        id: P_ENTER,
1140        kind: Kind::Enter,
1141        apply: Apply::SpecEnter(|s, e| s.enter(e)),
1142        doc: "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).",
1143    },
1144    PropDef {
1145        name: "exit",
1146        id: P_EXIT,
1147        kind: Kind::Enter,
1148        apply: Apply::SpecEnter(|s, e| s.exit(e)),
1149        doc: "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.",
1150    },
1151    PropDef {
1152        name: "repeat",
1153        id: P_REPEAT,
1154        kind: Kind::Enum(REPEATS),
1155        apply: Apply::SpecEnum(|s, i| s.repeat(repeat_idx(i))),
1156        doc: "How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword.",
1157    },
1158    PropDef {
1159        name: "delay",
1160        id: P_DELAY,
1161        kind: Kind::F32,
1162        apply: Apply::SpecF32(|s, v| s.delay(v)),
1163        doc: "Holds the `keyframes` cycle back by this many ms (CSS `animation-delay`); siblings with different delays run out of phase.",
1164    },
1165    PropDef {
1166        name: "iterations",
1167        id: P_ITERATIONS,
1168        kind: Kind::F32,
1169        apply: Apply::SpecF32(|s, v| s.iterations(v)),
1170        doc: "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.",
1171    },
1172    PropDef {
1173        name: "clickSound",
1174        id: P_CLICK_SOUND,
1175        kind: Kind::Resource,
1176        apply: Apply::SpecResource(|s, id| s.click_sound(crate::resources::SoundId::from_ffi(id))),
1177        doc: "A registered sound (addSound) played when the node is clicked; implies hover tracking.",
1178    },
1179    PropDef {
1180        name: "hoverSound",
1181        id: P_HOVER_SOUND,
1182        kind: Kind::Resource,
1183        apply: Apply::SpecResource(|s, id| s.hover_sound(crate::resources::SoundId::from_ffi(id))),
1184        doc: "A registered sound (addSound) played when the pointer enters the node; implies hover tracking.",
1185    },
1186    PropDef {
1187        name: "lineHeight",
1188        id: P_LINE_HEIGHT,
1189        kind: Kind::F32,
1190        apply: Apply::StyleF32(|t, v| t.line_height(v)),
1191        doc: "Line height (logical px); default size * 1.35.",
1192    },
1193    PropDef {
1194        name: "color",
1195        id: P_COLOR,
1196        kind: Kind::Color,
1197        apply: Apply::StyleColor(|t, c| t.color(c)),
1198        doc: "Text color; default foreground when omitted.",
1199    },
1200    PropDef {
1201        name: "family",
1202        id: P_FAMILY,
1203        kind: Kind::Family,
1204        apply: Apply::StyleFamily(|t, f| t.family(f)),
1205        doc: "Font family: `sans`, `serif` or `mono`, kui's own, or the name of an installed family or one loaded with `loadFontsDir` / `loadFontFile` — `\"Berkeley Mono\"` — drawn in its face in the frame that names it (ADR 0037). A name is matched as `addSystemFont` matches it and registered in the session on first sight, exactly as the font database spells it (`\"menlo\"` is not `\"Menlo\"`); the session's first registration of any font maps the installed font files once (~30 ms on a Mac, backlog DX24), which a family named in a view pays in that frame. `systemFonts()` lists the names there are. A name nothing matches shapes as sans and raises `unknown-family`. It and `font` set the same thing, so declare one.",
1206    },
1207    PropDef {
1208        name: "font",
1209        id: P_FONT,
1210        kind: Kind::Resource,
1211        apply: Apply::StyleResource(|t, id| t.font(crate::resources::FontId::from_ffi(id))),
1212        doc: "A registered font handle (addFont / addSystemFont); overrides `family`.",
1213    },
1214    PropDef {
1215        name: "wrap",
1216        id: P_WRAP,
1217        kind: Kind::Enum(WRAPS),
1218        apply: Apply::StyleEnum(|t, i| match i {
1219            1 => t.wrap(TextWrap::Glyph),
1220            2 => t.wrap(TextWrap::None),
1221            3 => t.wrap(TextWrap::BreakSpaces),
1222            _ => t.wrap(TextWrap::Word),
1223        }),
1224        doc: "Line breaking at the node's width: between words (default), anywhere, never (one line per paragraph, clipped to the node), or between words with whitespace taking its room (`break-spaces`: a space that does not fit starts the next row rather than hanging past the edge — an editor's wrapped line). On a single-line `edit` — a field, which otherwise takes one line and scrolls it — declaring it is what makes the field fold to its width like a document, by this mode, while Enter still submits (see `edit`).",
1225    },
1226    PropDef {
1227        name: "maxLines",
1228        id: P_MAX_LINES,
1229        kind: Kind::F32,
1230        apply: Apply::StyleF32(|t, v| t.max_lines(v.max(0.0) as u32)),
1231        doc: "Lay out at most this many lines (0 = unlimited); with `ellipsis`, a line clamp.",
1232    },
1233    PropDef {
1234        name: "ellipsis",
1235        id: P_ELLIPSIS,
1236        kind: Kind::Flag,
1237        apply: Apply::StyleFlag(|t| t.ellipsis()),
1238        doc: "End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise.",
1239    },
1240    PropDef {
1241        name: "initialLetter",
1242        id: P_INITIAL_LETTER,
1243        kind: Kind::F32,
1244        apply: Apply::StyleF32(|t, v| t.initial_letter(v.max(0.0) as u32)),
1245        doc: "The first letter as an initial — a drop cap — this many lines tall (backlog F164), as CSS's `initial-letter`: its top level with the first line's capitals, its foot on the last one's baseline, the lines beside it moved in past it and the ones below at full width again. The letter is the text's first, with any opening quote or bracket before it; it keeps its span's face and colour and is sized to the drop. Still one text: a caret, a hit, a selection and the access tree read it where it is drawn, and the text is at least as tall as the lines it drops through. 0 and 1 are none (the default); a text with `maxLines` or `ellipsis`, a right-to-left one and an `edit` have none.",
1246    },
1247    PropDef {
1248        name: "underline",
1249        id: P_UNDERLINE,
1250        kind: Kind::Flag,
1251        apply: Apply::StyleFlag(|t| t.underline()),
1252        doc: "A line under the text, where the face puts its underline and as thick as it says, in the text colour. Paint only. On a `<span>` it covers the span alone and follows it across a wrap, one rect per line. `underlineColor` gives it a colour of its own and `underlineStyle` a shape; either implies it.",
1253    },
1254    PropDef {
1255        name: "underlineColor",
1256        id: P_UNDERLINE_COLOR,
1257        kind: Kind::Color,
1258        apply: Apply::StyleColor(|t, c| t.underline_color(c)),
1259        doc: "The underline's own colour — a diagnostic's red under keyword-coloured text (backlog K4). Implies `underline`. On a `<span>` the span's; a span with no colour of its own takes the text's.",
1260    },
1261    PropDef {
1262        name: "underlineStyle",
1263        id: P_UNDERLINE_STYLE,
1264        kind: Kind::Enum(UNDERLINE_STYLES),
1265        apply: Apply::StyleEnum(|t, i| t.underline_style(UnderlineStyle::from_index(i as u32))),
1266        doc: "The underline's shape (backlog K4): `solid` (the face's line), `wavy` (three strokes tall around the line, a six-stroke period — a diagnostic's squiggle, a terminal's undercurl) or `dotted` (dots two strokes across, four apart). Implies `underline`. A wave or dots are runs of the segment primitive a `line` draws, so no backend learns a kind; the cost is two quads per period.",
1267    },
1268    PropDef {
1269        name: "strikethrough",
1270        id: P_STRIKETHROUGH,
1271        kind: Kind::Flag,
1272        apply: Apply::StyleFlag(|t| t.strikethrough()),
1273        doc: "A line through the text, where the face puts its strikeout. Paint only; on a `<span>` the span alone, per line.",
1274    },
1275    PropDef {
1276        name: "bold",
1277        id: P_BOLD,
1278        kind: Kind::Flag,
1279        apply: Apply::StyleFlag(|t| t.bold()),
1280        doc: "The family's bold, on a whole text or an editor (backlog F150) — a heading, a table's header, a title field: its bold face, or its regular drawn bold where the family has none, as a `<span bold>` is. A span inside a bold text is bold too.",
1281    },
1282    PropDef {
1283        name: "features",
1284        id: P_FEATURES,
1285        kind: Kind::Str,
1286        apply: Apply::StyleStr(|t, s| t.features(crate::spec::FontFeatures::parse(s))),
1287        doc: "OpenType features for the shaper, as `tag=value` pairs separated by spaces or commas — a bare `tag` is 1, `-tag` is 0: `\"liga=0 calt=0\"` keeps a coding font from joining `->` and `!=` (what a terminal built on runs needs to hold its grid), `\"tnum\"` lines figures up in a gutter, `\"ss01\"` picks a stylistic set. Unset, the font's own defaults apply. At most 8; part of what the text is shaped as, so two texts differing only here are shaped twice.",
1288    },
1289    PropDef {
1290        name: "role",
1291        id: P_ROLE,
1292        kind: Kind::Enum(ROLES),
1293        apply: Apply::SpecEnum(|s, i| s.role(role_idx(i))),
1294        doc: "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.",
1295    },
1296    PropDef {
1297        name: "label",
1298        id: P_LABEL,
1299        kind: Kind::Str,
1300        apply: Apply::SpecStr(|s, v| s.label(v)),
1301        doc: "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.",
1302    },
1303    PropDef {
1304        name: "description",
1305        id: P_DESCRIPTION,
1306        kind: Kind::Str,
1307        apply: Apply::SpecStr(|s, v| s.description(v)),
1308        doc: "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.",
1309    },
1310    PropDef {
1311        name: "checked",
1312        id: P_CHECKED,
1313        kind: Kind::Flag,
1314        apply: Apply::SpecFlag(|s| s.checked(true)),
1315        doc: "The on state of a `checkbox` / `radio` / `switch` role.",
1316    },
1317    PropDef {
1318        name: "mixed",
1319        id: P_MIXED,
1320        kind: Kind::Flag,
1321        apply: Apply::SpecFlag(|s| s.mixed(true)),
1322        doc: "A `checkbox` that is neither on nor off — the select-all box over a list some of whose rows are selected (ADR 0034). Read as mixed by assistive technology whatever `checked` says, and drawn as a dash by the stock `<checkbox>`. Meaningful on the checkbox role alone.",
1323    },
1324    PropDef {
1325        name: "selected",
1326        id: P_SELECTED,
1327        kind: Kind::Flag,
1328        apply: Apply::SpecFlag(|s| s.selected(true)),
1329        doc: "The current one of a set: which `tab` a `tabList` shows, which `listItem` a list has picked, which `link` is the page you are on. A `tab` always carries the state — its siblings read as \"not selected\" — while a list row or a link carries it only where it is set, since an ordinary list or navigation bar is not a selection and a reader saying \"not selected\" on every row of it is noise.",
1330    },
1331    PropDef {
1332        name: "expanded",
1333        id: P_EXPANDED,
1334        kind: Kind::Enum(EXPANDED),
1335        apply: Apply::SpecEnum(|s, i| s.expanded(i == 1)),
1336        doc: "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.",
1337    },
1338    PropDef {
1339        name: "live",
1340        id: P_LIVE,
1341        kind: Kind::Enum(LIVE),
1342        apply: Apply::SpecEnum(|s, i| s.live(crate::access::Live::from_index(i))),
1343        doc: "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.",
1344    },
1345    PropDef {
1346        name: "valueNow",
1347        id: P_VALUE_NOW,
1348        kind: Kind::F32,
1349        apply: Apply::SpecF32(|s, v| s.value_now(v)),
1350        doc: "A `slider` role's current value (the drawing stays yours; this is what assistive technology reads).",
1351    },
1352    PropDef {
1353        name: "valueMin",
1354        id: P_VALUE_MIN,
1355        kind: Kind::F32,
1356        apply: Apply::SpecF32(|s, v| s.value_min(v)),
1357        doc: "A `slider` role's minimum.",
1358    },
1359    PropDef {
1360        name: "valueMax",
1361        id: P_VALUE_MAX,
1362        kind: Kind::F32,
1363        apply: Apply::SpecF32(|s, v| s.value_max(v)),
1364        doc: "A `slider` role's maximum.",
1365    },
1366    PropDef {
1367        name: "valueText",
1368        id: P_VALUE_TEXT,
1369        kind: Kind::Str,
1370        apply: Apply::SpecStr(|s, v| s.value_text(v)),
1371        doc: "What a `slider` role's position reads as (ARIA's `aria-valuetext`). Without one a reader has only `valueNow` and the range and says a percentage — 25 in [5..60] is \"36 percent\" — so a value whose unit carries the meaning says it here: \"25 minutes\". It replaces the number in the reading rather than joining it, and a nudge announces the new text. Meaningful on the slider role alone, like the three numbers; putting the reading in `label` instead renames the control on every nudge, which is the wrong attribute.",
1372    },
1373    PropDef {
1374        name: "valueStep",
1375        id: P_VALUE_STEP,
1376        kind: Kind::F32,
1377        apply: Apply::SpecF32(|s, v| s.value_step(v)),
1378        doc: "How far one arrow key moves a `slider` role, and the grid a value the pointer sets snaps to (ADR 0034). Unset, a hundredth of the range. PageUp / PageDown move ten steps. Read by the core only where the slider declares `onChange`; reported to assistive technology either way.",
1379    },
1380    PropDef {
1381        name: "onFocus",
1382        id: P_ON_FOCUS,
1383        kind: Kind::Tag,
1384        apply: Apply::SpecMsg(|s, v| s.on_focus(v)),
1385        doc: "Keyboard focus entering or leaving this node's subtree — the node itself, or anything focused inside it — emits `{kind:\"focus\", phase:\"in\"|\"out\", by, tag}` (backlog DX18). `by` is what moved it: `pointer` (a press), `keyboard` (Tab, a key a control answered), `assistive` (a screen reader's request) or `program` (the view or the app — `keyFocus`, `setFocus`, a modal's entry). Reported once the move settles, after the input that made it or at the end of the frame that declared it, so an app hears a pane taking the keyboard instead of diffing the focused key every frame. Leaving is reported innermost first, entering outermost first. It makes nothing focusable or interactive.",
1386    },
1387    PropDef {
1388        name: "onChange",
1389        id: P_ON_CHANGE,
1390        kind: Kind::Tag,
1391        apply: Apply::SpecMsg(|s, v| s.on_change(v)),
1392        doc: "A `slider` role's changes (ADR 0034): the core turns a press on the node into the value under the pointer, a drag into the value under it, the arrows and assistive technology's increment / decrement into one `valueStep`, PageUp / PageDown into ten, Home / End into the range's ends — clamped to `valueMin`..`valueMax` (0..100 unset) and snapped to the step — and emits `{kind:\"change\", value, phase, tag}`: `phase` is `\"move\"` while the pointer holds the slider and `\"end\"` when it lets go or a key moved it. The value is proposed and never applied; the slider moves when the view declares it as `valueNow`. A key that lands where the slider already is proposes nothing. The pointer reads the node's content box along its main axis, so a `dir=\"column\"` slider runs bottom to top. Without it a slider's arrows reach the app as `{kind:\"access\", action}`. Ignored on any other role.",
1393    },
1394    PropDef {
1395        name: "caret",
1396        id: P_CARET,
1397        kind: Kind::F32,
1398        apply: Apply::SpecF32(|s, v| s.caret(v.max(0.0) as u32)),
1399        doc: "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.",
1400    },
1401    PropDef {
1402        name: "selectionAnchor",
1403        id: P_SELECTION_ANCHOR,
1404        kind: Kind::F32,
1405        apply: Apply::SpecF32(|s, v| s.selection_anchor(v.max(0.0) as u32)),
1406        doc: "On a `line` of a custom editor: the byte offset where the selection's other end sits (the caret is `caret`, possibly on another line).",
1407    },
1408    PropDef {
1409        name: "caretSolid",
1410        id: P_CARET_SOLID,
1411        kind: Kind::Flag,
1412        apply: Apply::SpecFlag(|s| s.caret_solid()),
1413        doc: "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.",
1414    },
1415];
1416
1417/// A prop every binding handles by hand (a composite with real logic, or a
1418/// constructor-order special), with its wire id so transports agree on
1419/// identity and its per-binding spelling so the docs can say so.
1420pub struct CustomProp {
1421    pub name: &'static str,
1422    pub id: u32,
1423    /// Every prop name a JSX view may write for it — the machine-readable
1424    /// half of `jsx`, which is prose for the docs. A composite is spelled
1425    /// differently in each binding (`borderW` here, `border = {…}` there),
1426    /// so the two lists are separate; together with `PROPS` and an element's
1427    /// own props they are the whole allow-list a binding checks a view
1428    /// against (see [`known_prop`]).
1429    pub jsx_names: &'static [&'static str],
1430    /// The same for a Lua node table.
1431    pub lua_names: &'static [&'static str],
1432    /// How JSX spells it.
1433    pub jsx: &'static str,
1434    /// How a Lua table spells it.
1435    pub lua: &'static str,
1436    /// Where it lands in C.
1437    pub c: &'static str,
1438    /// Where it lands in Odin (packages/odin): a `Spec` / `Text_Style`
1439    /// field, or a `kui.` procedure for a window-level row. The Odin
1440    /// generator checks every name here is one its package has.
1441    pub odin: &'static str,
1442    pub doc: &'static str,
1443}
1444
1445pub const CUSTOM: &[CustomProp] = &[
1446    CustomProp {
1447        name: "dir",
1448        id: P_DIR,
1449        jsx_names: &["dir"],
1450        lua_names: &[],
1451        jsx: "`dir=\"row\" | \"column\" | \"table\"`",
1452        lua: "`row { }` / `column { }` / `grid { }`",
1453        c: "`dir` (`KUI_ROW` / `KUI_COLUMN` / `KUI_TABLE`)",
1454        odin: "`Spec.dir` (`.Column` / `.Row` / `.Table`); `kui.row` and `kui.column` set it",
1455        doc: "Main axis; column is the default. `table` is a column whose rows' children line up in columns (the `table` element).",
1456    },
1457    CustomProp {
1458        name: "size",
1459        id: P_SIZE,
1460        jsx_names: &["size"],
1461        lua_names: &["size"],
1462        jsx: "`size` (text)",
1463        lua: "`size`",
1464        c: "`KuiTextStyle.size`; `KuiSpan.size`",
1465        odin: "`Text_Style.size`",
1466        doc: "Font size in logical px; the text style is constructed from it, so declare it for the other style props to apply at that size.",
1467    },
1468    CustomProp {
1469        name: "pad",
1470        id: P_PAD,
1471        jsx_names: &["pad", "padX", "padY", "padL", "padR", "padT", "padB"],
1472        lua_names: &["pad"],
1473        jsx: "`pad`, `padX`, `padY`, `padL`, `padR`, `padT`, `padB`",
1474        lua: "`pad = n` or `pad = { all=, x=, y=, l=, r=, t=, b= }`",
1475        c: "`pad_l`, `pad_r`, `pad_t`, `pad_b`",
1476        odin: "`Spec.pad`: `kui.pad(16)`, `kui.pad(16, 8)`, or `{l = .., r = .., t = .., b = ..}`",
1477        doc: "Padding; a frontend reports the names it saw and `PadShorthand::resolve` turns them into four edges — an edge falls back to its axis, an axis to the all-round `pad`, and the specific one always wins.",
1478    },
1479    CustomProp {
1480        name: "border",
1481        id: P_BORDER,
1482        jsx_names: &["borderW", "borderColor"],
1483        lua_names: &["border"],
1484        jsx: "`borderW`, `borderColor`",
1485        lua: "`border = { w=, color= }`",
1486        c: "`border_w`, `border_color`",
1487        odin: "`Spec.border_w`, `Spec.border_color`",
1488        doc: "Border width and color (drawn inside the rect).",
1489    },
1490    CustomProp {
1491        name: "overflow",
1492        id: P_OVERFLOW,
1493        jsx_names: &["clip", "scrollX", "scrollY"],
1494        lua_names: &["clip", "scroll", "scroll_x", "scroll_y"],
1495        jsx: "`clip`, `scrollX`, `scrollY`",
1496        lua: "`clip`, `scroll_x`, `scroll_y` (`scroll` = `scroll_y`)",
1497        c: "`overflow` bits `KUI_CLIP` | `KUI_SCROLL_X` | `KUI_SCROLL_Y`",
1498        odin: "`Spec.overflow`: `{.Clip}`, `{.Scroll_X}`, `{.Scroll_Y}`",
1499        doc: "Clip children; scroll (implies clip) with retained offsets and live scrollbars. A `radius` on the same node rounds the clip, so a rounded card does not show square corners poking out of it; nesting two rounded clippers keeps only the corners neither of them moved, and hit-testing stays rectangular. Every frontend ORs the same bits and hands them to `NodeSpec::overflow_bits`. The wheel goes to the scroller under the pointer on the axes it scrolls, and the rest of the notch to the one around it: a `scrollY` list inside a `scrollX` strip moves the strip on a sideways swipe (backlog DX13). A scroll gesture — a swipe and its glide, a wheel spun without a pause — picks that scroller when it starts, skipping one already at its limit that way for the one around it (unless it says `overscroll: contain`), and keeps it until it ends, so content moving under a still pointer does not hand the rest of a swipe to what came under it (backlog F107). An offset is kept while the key is declared; an undeclared one is kept until the budget needs the room (1024 undeclared entries, longest-undeclared evicted first).",
1500    },
1501    CustomProp {
1502        name: "float",
1503        id: P_FLOAT,
1504        jsx_names: &["float"],
1505        lua_names: &["float"],
1506        jsx: "`float=\"below\" | \"above\" | \"parent\" | \"viewport\"` or `{ anchor, at, self, dx, dy, fit, clip }`",
1507        lua: "`float = \"below\"` or `float = { anchor=, at=, self=, dx=, dy=, fit=, clip= }`",
1508        c: "`float_mode`, `float_anchor_x/y`, `float_self_x/y`, `float_dx/dy`, `float_fit`, `float_clip`; `kui_spec_float_preset` fills them from a preset name",
1509        odin: "`Spec.float`, a `Float` (`mode`, `anchor_x/y`, `self_x/y`, `dx/dy`, `fit`, `clip`); `kui.float_preset` fills one from a preset name",
1510        doc: "Out-of-flow positioning against the parent or the viewport; `fit` flips/clamps to stay on screen. A float escapes every ancestor's clip — a tooltip is not cut by the scroller it hangs from — unless it declares `clip` and is anchored to its parent (`parent`, `below`, `above`): then the parent's clip holds it as it holds a child, so a node on a `clip` canvas panned past the canvas's edge is cut there and cannot be hit past it; it still paints as a layer over its in-flow siblings. A `line`, `polygon` or `path` in its parent's box is always clipped this way. The four preset names resolve in `FloatConfig::preset`, and `anchor` takes any of them — an override left out keeps the preset's own value, so `{ anchor: \"below\", dx: 4 }` still hangs below with its 6px gap. A float is a layer of its own: above the in-flow tree and every float that opened before it, under every float that opened after, and hit-tested in the same order — so a tooltip that appears over an open menu is over it, and a popover over a scroller's bar takes the press there. A scroller's bars and the focus ring belong to the layer that owns them. There is no z-index; a float declared under a fresh key reopens on top (`docs/adr/0023-layers-stack-in-the-order-they-open.md`).",
1511    },
1512    CustomProp {
1513        name: "keyFocus",
1514        id: P_KEY_FOCUS,
1515        jsx_names: &["keyFocus"],
1516        lua_names: &["key_focus"],
1517        jsx: "`keyFocus`",
1518        lua: "`key_focus`",
1519        c: "`kui_set_key_focus`",
1520        odin: "`Spec.key_focus`, which calls `kui.set_key_focus` on the node",
1521        doc: "Focuses this node (an `onKey` sink, an editor, any focusable node) when it starts being declared: declared every frame it takes focus once, so a later Tab press is not clobbered. Declaring it on the frame a `modal` stops being declared is how a view says where focus lands on the way out — the edge stands, and the focus the modal displaced is not handed back over it (`docs/adr/0003-modal-surfaces.md`, decision 4). To move focus at any time call the binding's focus verb (`ctx.focus`, `kui_focus`, `env.set_focus`).",
1522    },
1523    CustomProp {
1524        name: "key",
1525        id: P_KEY,
1526        jsx_names: &["key"],
1527        lua_names: &["key"],
1528        jsx: "`key`",
1529        lua: "`key`",
1530        c: "`kui_open_keyed` label",
1531        odin: "`Spec.key`, which opens the node keyed",
1532        doc: "Stable identity for retained state (scroll offsets, editors, transitions; keys are hashes of the path from the root). Retained state outlives the key's absence, under a budget on the states nobody declares (see `<edit>` and the overflow props).",
1533    },
1534    CustomProp {
1535        name: "index",
1536        id: P_INDEX,
1537        jsx_names: &["index"],
1538        lua_names: &["index"],
1539        jsx: "`index`",
1540        lua: "`index`",
1541        c: "`kui_open_indexed`",
1542        odin: "`Spec.index`, a `Maybe(u64)`, which opens the node indexed",
1543        doc: "Stable identity by *data* index rather than by name: the key auto-keying would have given this node as the `i`th child, given to it wherever it actually sits. What a virtualised list is for — a view that builds rows 900..930 of ten thousand opens each with its own row number, so the row keeps its hover, focus, edit buffer and tweens as the built range slides over it, and a list that builds every row agrees with one that builds a screenful. Wherever `key` names a node this numbers it (a box, a `line`, a `cells`, a `fragment`); declared beside `key` the index wins. Indices and names are separate namespaces, so a spacer keyed `\"lead\"` cannot collide with row 0 — but two rows on one index do, exactly as two on one name would.",
1544    },
1545    CustomProp {
1546        name: "rowCount",
1547        id: P_ROW_COUNT,
1548        jsx_names: &["rowCount"],
1549        lua_names: &["row_count"],
1550        jsx: "`rowCount`",
1551        lua: "`row_count`",
1552        c: "`kui_row_count`",
1553        odin: "`Spec.row_count`, a `Maybe(u64)`, which calls `kui.row_count` on the node",
1554        doc: "How many `index`ed rows this node's virtual list has, built or not. `uniformList` / `uniform_list` / `widgets::uniform_list` and `widgets::list` declare it on their container; a list composed by hand says it beside `scrollY`. What it buys: Select All (Cmd/Ctrl-A, the menu's row) inside a `selectable` virtual list selects the *data*, rows `0..rowCount`, rather than the rows the frame built, and the copy is a `selectionrange` ask whose `to.byte` is past the last row's length when that row is not built — cut it to the row. Without it Select All is the built rows, which is all the core can see.",
1555    },
1556    CustomProp {
1557        name: "title",
1558        id: P_TITLE,
1559        jsx_names: &["title"],
1560        lua_names: &["window_title"],
1561        jsx: "`title` (root box only)",
1562        lua: "`window_title` (root table)",
1563        c: "`kui_window_title`",
1564        odin: "`kui.window_title`",
1565        doc: "Declares the window title for this frame; the driver diffs and applies.",
1566    },
1567    CustomProp {
1568        name: "alwaysOnTop",
1569        id: P_ALWAYS_ON_TOP,
1570        jsx_names: &["alwaysOnTop"],
1571        lua_names: &["always_on_top"],
1572        jsx: "`alwaysOnTop` (root box only)",
1573        lua: "`always_on_top = true` (root table)",
1574        c: "`kui_set_always_on_top`",
1575        odin: "`kui.set_always_on_top`",
1576        doc: "Declares that this frame wants the window kept above every other app's — a floating palette, a picture-in-picture player, a timer (backlog C30). Frame state the way `title` is, applied by the driver on change and free on the frames it does not change, but with a default of false rather than \"leave as-is\": a frame that stops declaring it lowers the window again, so a pin button is a toggle on the app's own state and nothing has to remember to undo it. Whether the platform has a level to set is `env.window.always_on_top`, which is what the pin button should draw its state from — Wayland has no call for it at all, so there the window never moves and the reading says so; it is the driver's record of what it set, not a query, so a level the OS dropped afterwards (a fullscreen space, a tiling manager) is not reported. A popup keeps its own level whatever its owner declares.",
1577    },
1578    CustomProp {
1579        name: "secureInput",
1580        id: P_SECURE_INPUT,
1581        jsx_names: &["secureInput"],
1582        lua_names: &["secure_input"],
1583        jsx: "`secureInput` (root box only)",
1584        lua: "`secure_input = true` (root table)",
1585        c: "`kui_set_secure_input`",
1586        odin: "`kui.set_secure_input`",
1587        doc: "Declares that this frame wants the keyboard to this window kept from every other process while the window has it — macOS's Secure Keyboard Entry, what a terminal turns on at a password prompt (backlog F85). Frame state the way `alwaysOnTop` is, default false: declare it on every frame the prompt is up, and the frame that stops is what turns it off, so nothing has to remember to undo it. The runner owns the platform call and its balance: `EnableSecureEventInput` is process-wide and counted, and the runner holds one count while a window whose frame asked has the keyboard, giving it back when that window loses the keyboard, closes or stops asking, and at exit — Apple's rule, since while it is on no other process can read the keyboard at all (a launcher's hotkey, a text expander, an accessibility tool). Nothing on Windows or Linux, which have no such switch. A focused `secure` editor asks for it too, on any frame it has the keyboard (backlog F167), and the read-back says so. A C host with its own loop reads the ask with `kui_secure_input_get` and makes the call itself.",
1588    },
1589    CustomProp {
1590        name: "optionAsAlt",
1591        id: P_OPTION_AS_ALT,
1592        jsx_names: &["optionAsAlt"],
1593        lua_names: &["option_as_alt"],
1594        jsx: "`optionAsAlt=\"left\"` — `\"none\"`, `\"left\"`, `\"right\"`, `\"both\"` (root box only)",
1595        lua: "`option_as_alt = \"left\"` (root table)",
1596        c: "`kui_set_option_as_alt` (`KUI_OPTION_AS_ALT_*`)",
1597        odin: "`kui.set_option_as_alt` (an `Option_As_Alt`)",
1598        doc: "Declares which Option keys act as Alt in this window on macOS (backlog F113). On a Mac, Option composes: ⌥m types `µ`, and ⌥u, ⌥e, ⌥i, ⌥n and ⌥` are dead keys that start an accent and wait for the next key, so the press never reaches the app as a key and a keymap binding `<A-u>` never hears it. An Option key named here is Alt instead: it composes nothing and types nothing, and a key under it arrives as a chord of the key the layout prints unmodified — a terminal's \"Option as Meta\", an editor's Alt bindings. `\"left\"` or `\"right\"` leaves the other side composing, so a user keeps `ü` on one Option; `\"both\"` takes both; `\"none\"`, the default, is the Mac's own behaviour. Frame state the way `alwaysOnTop` is: declare it on every frame, and the frame that stops gives the Option keys back to the layout; the runner applies it to the window on change, never per frame. A popup's keys arrive through its owner, so the owner's declaration is the one they are read under. Nothing on Windows or Linux, whose Alt composes nothing. A C host with its own loop reads the ask with `kui_option_as_alt_get` and applies it itself.",
1599    },
1600    CustomProp {
1601        name: "imeOff",
1602        id: P_IME_OFF,
1603        jsx_names: &["imeOff"],
1604        lua_names: &["ime_off"],
1605        jsx: "`imeOff` (root box only)",
1606        lua: "`ime_off = true` (root table)",
1607        c: "`kui_set_ime_off`",
1608        odin: "`kui.set_ime_off`",
1609        doc: "Declares that this window takes the keyboard as keys, with the platform's input method off (backlog F125): no composition and no candidate window, and on a Mac no dead key waiting for the next and no press-and-hold — an input method too, so a held letter repeats instead of opening the accent picker, whatever the user's `ApplePressAndHoldEnabled` says. A `key` event's `text` is still the layout's character; what goes is everything the OS would have composed from it. What a modal editor's normal mode wants — `jjjj` is how one moves, and an IME left on eats the keymap — while its insert mode stops declaring it and gets accents, dead keys and the IME back. Frame state the way `alwaysOnTop` is, default false: declare it on every frame the mode wants it, and the frame that stops gives the input method back; the runner applies it to the window on change, never per frame, and a composition in progress when it turns off ends without a commit, as an empty `preedit`. The window's, not a node's: a stock editor focused under it composes nothing either. A popup's keys arrive through its owner, so the owner's declaration is the one they are read under. On Windows and Linux the window's IME is disabled the same way, and only that: their dead keys are the layout's, and still compose. A focused `secure` editor asks for it too, as a password field takes keys and not compositions (backlog F167), and the read-back says so. A C host with its own loop reads the ask with `kui_ime_off_get` and applies it itself.",
1610    },
1611    CustomProp {
1612        name: "windows",
1613        id: P_WINDOWS,
1614        jsx_names: &["windows"],
1615        lua_names: &["windows"],
1616        jsx: "`windows={[{ name, kind?, anchor?, width?, height?, activates? }]}` (root box only; `windows: (model) => [...]` in the loop config)",
1617        lua: "`windows = { { name=, kind=, anchor=, width=, height=, activates= } }` (root table)",
1618        c: "`kui_window_declare`",
1619        odin: "`kui.window_declare`",
1620        doc: "Declares which windows exist this frame, by stable name (`docs/adr/0004-multi-window.md`). A window opens on the first frame any window's frame declares it — its config is read then and never again, since the user owns its geometry once it exists — and closes on the first frame none does. The driver drains the `Open` / `Close` that result, and the app sees `{kind:\"window\", phase, name, id}`. A window the user closed does not reopen while it is still declared: stop declaring it, then declare it again. `kind: \"popup\"` makes it a menu surface instead: borderless, off the taskbar, owned by the window that declared it and closed with it, placed in screen coordinates against `anchor` — the `{x, y, w, h}` an `onLayout` node reported — and non-activating unless `activates` says otherwise, so the field that opened it keeps the focus ring while the arrows walk the list. A press outside it or Escape raises `{kind:\"dismiss\", reason, name, id}` and closes nothing, exactly as a `modal` node's does: stop declaring the window. Reach for a popup only for the placements a float cannot make — a list taller than the window, a menu with nowhere in-window to go, a panel beside the app; everything else stays `fit` plus a `modal` float, which costs one tree instead of an OS surface.",
1621    },
1622    CustomProp {
1623        name: "tooltip",
1624        id: P_TOOLTIP,
1625        jsx_names: &["tooltip"],
1626        lua_names: &["tooltip"],
1627        jsx: "`tooltip=\"hint\"`",
1628        lua: "`tooltip = \"hint\"`",
1629        c: "`KuiSpec.tooltip` (`kui_tooltip` / `kui_tooltip_with` draw a hint that is not hover-gated)",
1630        odin: "`Spec.tooltip` (`kui.tooltip` / `kui.tooltip_with` draw a hint that is not hover-gated)",
1631        doc: "Floats a hint below the node while hovered. All three effects — hover tracking, the accessible description, and the float itself — come from `PropsOut::apply_tooltip`, so no frontend can implement two of them; a Rust view has all three in `NodeSpec::tooltip` (`NodeSpec::apply_tooltip` is the spec half, for a caller that floats the hint itself). The `description` row is that middle effect on its own, for a hint that is spoken and never drawn. On a box or a `fragment` the float is the node's last child; a leaf holds no children — a `line`, `polygon`, `path`, `cells` grid, `image` or `edit` — and its hint floats beside it instead, anchored to it, and lands below its box the same way, out of every clip and flipping above near the window's bottom (backlog RG113; `PropsOut::for_leaf`). A leaf draws its description, which is the hint unless a `description` applied after it overwrote the slot. A `line`, `polygon` or `path` is hovered by its shape, so its hint shows while the pointer is on the stroke or inside the outline, not anywhere in its box.",
1632    },
1633];
1634
1635/// Where a schema row lands in C when it is not simply the `KuiSpec` /
1636/// `KuiTextStyle` field of the row's snake_case name (`c_field`).
1637pub const C_FIELDS: &[(&str, &str)] = &[
1638    ("width", "`width` (KuiSizing)"),
1639    ("height", "`height` (KuiSizing)"),
1640    ("center", "`main_align` + `cross_align` = `KUI_CENTER`"),
1641    ("window", "`window_role` (`KUI_WINDOW_*`)"),
1642    ("transition", "`transition_ms`"),
1643    ("easing", "`easing` (`KUI_EASE_*`)"),
1644    (
1645        "keyframes",
1646        "`keyframes` + `keyframes_len` (`KuiKeyframe[]`)",
1647    ),
1648    ("gradient", "`gradient` (`const KuiGradient *`)"),
1649    ("repeat", "`repeat` (`KUI_REPEAT_*`)"),
1650    ("delay", "`delay_ms`"),
1651    ("iterations", "`iterations` (0 is for ever)"),
1652    ("enter", "`enter` (`KuiEnter`, with `set` bits)"),
1653    ("exit", "`exit` (`KuiEnter`, with `set` bits)"),
1654    ("opacity", "`opacity` with `opacity_set`"),
1655    ("scale", "`scale` (0 is 1)"),
1656    ("pivotX", "`pivot_x` with `pivot_set`"),
1657    ("pivotY", "`pivot_y` with `pivot_set`"),
1658    ("radiusTL", "`radius_tl` with `per_corner`"),
1659    ("radiusTR", "`radius_tr` with `per_corner`"),
1660    ("radiusBR", "`radius_br` with `per_corner`"),
1661    ("radiusBL", "`radius_bl` with `per_corner`"),
1662    ("hoverGroup", "`hover_group` (KuiStr)"),
1663    ("role", "`role` (`KUI_ROLE_*`)"),
1664    ("expanded", "`expanded` (`KUI_EXPANDED_*`)"),
1665    ("live", "`live` (`KUI_LIVE_*`)"),
1666    ("cursor", "`cursor` (`KUI_CURSOR_*`)"),
1667    ("label", "`label` (KuiStr)"),
1668    (
1669        "valueNow",
1670        "`value_now` with `KUI_VALUE_NOW` in `value_set`",
1671    ),
1672    (
1673        "valueMin",
1674        "`value_min` with `KUI_VALUE_MIN` in `value_set`",
1675    ),
1676    (
1677        "valueMax",
1678        "`value_max` with `KUI_VALUE_MAX` in `value_set`",
1679    ),
1680    ("valueText", "`value_text` (KuiStr)"),
1681    ("caret", "`caret` with `KUI_VALUE_CARET` in `value_set`"),
1682    (
1683        "selectionAnchor",
1684        "`selection_anchor` with `KUI_VALUE_ANCHOR` in `value_set`",
1685    ),
1686    (
1687        "onClick",
1688        "`on_click` argument of `kui_open` / `kui_open_with`",
1689    ),
1690    (
1691        "onDrag",
1692        "`on_drag` argument of `kui_open_draggable` / `kui_open_with`",
1693    ),
1694    ("onKey", "`on_key` argument of `kui_open_with`"),
1695    (
1696        "onContextMenu",
1697        "`on_context_menu` (a borrowed `KuiValue*`, cloned while the node opens)",
1698    ),
1699    (
1700        "onButton",
1701        "`on_button` (a borrowed `KuiValue*`, cloned while the node opens)",
1702    ),
1703    (
1704        "buttons",
1705        "`buttons` (`KUI_BUTTONS_*` bits; zeroed, all three)",
1706    ),
1707    (
1708        "modal",
1709        "`modal` (a borrowed `KuiValue*`, cloned while the node opens)",
1710    ),
1711    (
1712        "onScroll",
1713        "`on_scroll` (a borrowed `KuiValue*`, cloned while the node opens)",
1714    ),
1715    ("onHover", "`on_hover` argument of `kui_open_with`"),
1716    (
1717        "onFocus",
1718        "`on_focus` (a borrowed `KuiValue*`, cloned while the node opens)",
1719    ),
1720    ("ruleWidth", "`rule_w`"),
1721    (
1722        "overscroll",
1723        "`overscroll` (`KUI_OVERSCROLL_*`; zeroed, auto)",
1724    ),
1725    (
1726        "scrollAxes",
1727        "`scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both)",
1728    ),
1729    (
1730        "scrollMods",
1731        "`scroll_mods` (`KUI_KMOD_*` bits; zeroed, none)",
1732    ),
1733    (
1734        "onDrop",
1735        "`on_drop` (a borrowed `KuiValue*`, cloned while the node opens)",
1736    ),
1737    (
1738        "onLayout",
1739        "`on_layout` (a borrowed `KuiValue*`, cloned while the node opens)",
1740    ),
1741    (
1742        "family",
1743        "`KuiTextStyle.family` (`KUI_FONT_*`); `KuiSpan.family` (with `KUI_SPAN_FAMILY`)",
1744    ),
1745    (
1746        "font",
1747        "`KuiTextStyle.font` (from `kui_font_add*`); `KuiSpan.font`",
1748    ),
1749    ("lineHeight", "`KuiTextStyle.line_height`"),
1750    ("wrap", "`KuiTextStyle.wrap` (`KUI_WRAP_*`)"),
1751    ("maxLines", "`KuiTextStyle.max_lines`"),
1752    ("initialLetter", "`KuiTextStyle.initial_letter`"),
1753    ("ellipsis", "`KuiTextStyle.ellipsis`"),
1754    (
1755        "features",
1756        "`KuiTextStyle.features` (a `KuiStr`, the same spelling)",
1757    ),
1758    (
1759        "underline",
1760        "`KuiTextStyle.decoration` (`KUI_DECO_UNDERLINE`); `KuiSpan.flags` (`KUI_SPAN_UNDERLINE`)",
1761    ),
1762    (
1763        "strikethrough",
1764        "`KuiTextStyle.decoration` (`KUI_DECO_STRIKETHROUGH`); `KuiSpan.flags` (`KUI_SPAN_STRIKETHROUGH`)",
1765    ),
1766    (
1767        "bold",
1768        "`KuiTextStyle.bold`; `KuiSpan.flags` (`KUI_SPAN_BOLD`)",
1769    ),
1770    (
1771        "underlineColor",
1772        "`KuiTextStyle.underline_color`; `KuiSpan.underline_color`",
1773    ),
1774    (
1775        "underlineStyle",
1776        "`KuiTextStyle.underline_style` (`KUI_UNDERLINE_*`); `KuiSpan.underline_style`",
1777    ),
1778    ("color", "`KuiTextStyle.color`"),
1779];
1780
1781/// The C spelling of a schema row, for docs.
1782pub fn c_field(def: &PropDef) -> String {
1783    C_FIELDS
1784        .iter()
1785        .find(|(n, _)| *n == def.name)
1786        .map(|(_, c)| (*c).to_string())
1787        .unwrap_or_else(|| format!("`{}`", def.snake_name()))
1788}
1789
1790/// The Odin spelling of a schema row: the `Spec` or `Text_Style` field of
1791/// its snake_case name, which is what the Odin generator names every field.
1792pub fn odin_field(def: &PropDef) -> String {
1793    let record = match def.target() {
1794        Target::Spec => "Spec",
1795        Target::Style => "Text_Style",
1796    };
1797    format!("`{record}.{}`", def.snake_name())
1798}
1799
1800/// A C cell in Odin's words, for the rows where Odin's door is C's: the
1801/// binding's procedures are kui.h's functions under `kui.` without their
1802/// `kui_`, and its node and text fields are `Spec.` / `Text_Style.`.
1803/// `RESOURCES` and `ENV_FIELDS` take their Odin column from here, and the
1804/// Odin generator checks each name it produces.
1805pub fn odin_from_c(c: &str) -> String {
1806    c.replace("kui_", "kui.")
1807        .replace("KuiSpec.", "Spec.")
1808        .replace("KuiTextStyle.", "Text_Style.")
1809}
1810
1811/// An element (node type) and its spelling in each binding. Elements are
1812/// hand-lowered per binding (their shapes differ: JSX children, Lua
1813/// tables, C calls with body callbacks), so this table is documentation
1814/// and a checklist, not a code generator's input.
1815pub struct ElementDef {
1816    pub name: &'static str,
1817    /// The props this element lowers itself, which are therefore in neither
1818    /// `PROPS` nor `CUSTOM`: `<edit initial multiline>`, `<image src>`. They
1819    /// ride in the same prop list as the node's, so a binding needs them to
1820    /// tell a legitimate element prop from a misspelling (see
1821    /// [`known_prop`]) — JSX's spellings here, Lua's below.
1822    pub jsx_own: &'static [&'static str],
1823    /// The same for a Lua node table.
1824    pub lua_own: &'static [&'static str],
1825    /// The schema rows this element reads, or `None` for every one. A
1826    /// composite whose look *is* its spec — the stock button, whose
1827    /// padding, colours and radius are `widgets::button_spec` — cannot take
1828    /// the whole prop list: `dir` alone rebuilds the spec from nothing. It
1829    /// names the rows it reads instead, and a binding drops the rest with a
1830    /// warning that says which rows it does take ([`known_prop`],
1831    /// `diag::unknown_prop`). JSX spellings here, Lua's below.
1832    pub jsx_rows: Option<&'static [&'static str]>,
1833    pub lua_rows: Option<&'static [&'static str]>,
1834    pub jsx: &'static str,
1835    pub lua: &'static str,
1836    pub c: &'static str,
1837    /// The Odin procedures (`kui.` and the name); checked by the Odin
1838    /// generator like the composites' column.
1839    pub odin: &'static str,
1840    pub doc: &'static str,
1841}
1842
1843/// The rows a `text` reads (`ElementDef::jsx_rows` / `lua_rows`): the
1844/// `TextStyle` rows and `size`, the composite the style is built from —
1845/// and nothing else, because every door lowers a text as content plus a
1846/// style and no spec (`Core::text_node`), so a container or access row
1847/// on it reaches no tree. Before AR13 the element admitted every shared
1848/// row, and `<text live="polite">`, `<text role="heading">`, `<text
1849/// label>` and `<text onClick>` were dropped silently by all four
1850/// bindings — no `unknown-prop`, and `live-region-without-name` could
1851/// never fire for them. Pinned equal to the `Target::Style` rows by a
1852/// test, so a style row added to `PROPS` is a row here or a red test.
1853pub const TEXT_ROWS_JSX: &[&str] = &[
1854    "size",
1855    "lineHeight",
1856    "color",
1857    "family",
1858    "font",
1859    "wrap",
1860    "maxLines",
1861    "ellipsis",
1862    "initialLetter",
1863    "underline",
1864    "underlineColor",
1865    "underlineStyle",
1866    "strikethrough",
1867    "bold",
1868    "features",
1869];
1870pub const TEXT_ROWS_LUA: &[&str] = &[
1871    "size",
1872    "line_height",
1873    "color",
1874    "family",
1875    "font",
1876    "wrap",
1877    "max_lines",
1878    "ellipsis",
1879    "initial_letter",
1880    "underline",
1881    "underline_color",
1882    "underline_style",
1883    "strikethrough",
1884    "bold",
1885    "features",
1886];
1887
1888/// The rows the stock button reads (`ElementDef::jsx_rows` / `lua_rows`):
1889/// the click, the identity (`key`, or `index` in a virtual list — declared
1890/// beside `key` the index wins, as on a box), the access
1891/// rows — what a button *is* and what a reader says of it — and the one
1892/// paint row it takes, `accent`, which is not a colour but a question put
1893/// to the OS. The two lists are the same rows in each spelling, index for
1894/// index.
1895pub const BUTTON_ROWS_JSX: &[&str] = &[
1896    "onClick",
1897    "key",
1898    "index",
1899    "label",
1900    "description",
1901    "tooltip",
1902    "disabled",
1903    "accent",
1904];
1905pub const BUTTON_ROWS_LUA: &[&str] = &[
1906    "on_click",
1907    "key",
1908    "index",
1909    "label",
1910    "description",
1911    "tooltip",
1912    "disabled",
1913    "accent",
1914];
1915
1916/// The rows a stock toggle — `checkbox`, `radio`, `switch` — reads
1917/// (`widgets::toggle_with`): the button's access rows, its state
1918/// and no layout or paint row, since its look is its spec. `mixed` means
1919/// something on a checkbox alone.
1920pub const TOGGLE_ROWS_JSX: &[&str] = &[
1921    "onClick",
1922    "key",
1923    "label",
1924    "description",
1925    "tooltip",
1926    "disabled",
1927    "checked",
1928    "mixed",
1929];
1930pub const TOGGLE_ROWS_LUA: &[&str] = &[
1931    "on_click",
1932    "key",
1933    "label",
1934    "description",
1935    "tooltip",
1936    "disabled",
1937    "checked",
1938    "mixed",
1939];
1940/// The rows the stock slider reads (`widgets::slider_with`): the
1941/// value rows, its change tag, the access rows, and its width — the one
1942/// piece of its look an app sizes.
1943pub const SLIDER_ROWS_JSX: &[&str] = &[
1944    "key",
1945    "label",
1946    "description",
1947    "tooltip",
1948    "disabled",
1949    "valueNow",
1950    "valueMin",
1951    "valueMax",
1952    "valueStep",
1953    "valueText",
1954    "onChange",
1955    "width",
1956    "minWidth",
1957    "maxWidth",
1958];
1959pub const SLIDER_ROWS_LUA: &[&str] = &[
1960    "key",
1961    "label",
1962    "description",
1963    "tooltip",
1964    "disabled",
1965    "value_now",
1966    "value_min",
1967    "value_max",
1968    "value_step",
1969    "value_text",
1970    "on_change",
1971    "width",
1972    "min_width",
1973    "max_width",
1974];
1975
1976pub const ELEMENTS: &[ElementDef] = &[
1977    ElementDef {
1978        name: "box",
1979        jsx_own: &[],
1980        lua_own: &[],
1981        jsx_rows: None,
1982        lua_rows: None,
1983        jsx: "`<box>`",
1984        lua: "`row { }`, `column { }`",
1985        c: "`kui_open*` … `kui_close`",
1986        odin: "`kui.box` / `kui.row` / `kui.column` in an `if`, closed at its end; `kui.open` … `kui.close` unscoped",
1987        doc: "A container: every container prop applies.",
1988    },
1989    ElementDef {
1990        name: "table",
1991        jsx_own: &[],
1992        lua_own: &[],
1993        jsx_rows: None,
1994        lua_rows: None,
1995        jsx: "`<box dir=\"table\">`",
1996        lua: "`grid { }`",
1997        c: "`kui_open*` with `dir = KUI_TABLE`",
1998        odin: "`kui.box` with `dir = .Table`",
1999        doc: "A column whose rows' children line up in columns (`docs/adr/0033-a-table-is-a-column-whose-cells-align.md`): its children are the rows, each row's in-flow children its cells, the nth cell of every row column n, and a column as wide as its widest cell — so a label column sits at its longest label with nothing measured and no width picked by hand, in every binding, since the alignment is the layout's and not a widget's. A cell's `width` says how its column sizes: `fit` (the default) and a fixed number are content the column's fit width is the max of; `grow` makes the whole column grow with the table, `grow` factors splitting the room the fit columns leave; a percent takes its cut of the row; and a column's `minWidth` / `maxWidth` are the strictest its cells declared. Fit columns that overflow the row are compressed toward their floors largest first, as a row's children are, unless the table scrolls x; a fixed column never is. A bare text is a cell too, held at its column's width, so a text straight inside a row is a column; an image straight in a row is a cell the same way, its box the column wide and its own aspect tall, the pixels meeting the box by its `fit` row (wrap an icon in a box to keep its own width). The rows are the table's `row` children, ordinary rows — give them `width=\"grow\"` for the columns to grow into (a `fit` row sits at the columns' width) — with their own `gap` between cells, their own padding, background, click, hover and access rows; a row of a table never wraps (`wrap-ignored`). Anything else straight under the table — a text, a `column` section, another table — is a child with its own width and no cells. The table's own `fit` width is its columns', whatever its rows' sizing, so a table with no width is the aligned list; a `scrollX` table's rows are at least as wide as its columns, and it scrolls to them. Everything else is a column's: `gap` is the space between rows, `scrollY` scrolls them, a float in a row is not a cell. Spelled `grid { }` in Lua, since `table` is Lua's own.",
2000    },
2001    ElementDef {
2002        name: "text",
2003        jsx_own: &["bold", "italic", "bg", "bgRadius"],
2004        lua_own: &["value", "spans"],
2005        jsx_rows: Some(TEXT_ROWS_JSX),
2006        lua_rows: Some(TEXT_ROWS_LUA),
2007        jsx: "`<text>` with `<span bold italic underline strikethrough bg bgRadius color family font size>` children",
2008        lua: "`text(\"s\", {…})`, `text({ \"a\", { \"b\", bold = true, underline = true, bg = 0x.., bg_radius = 4 }, { \"code\", family = \"mono\", size = 13 } })`",
2009        c: "`kui_text`, `kui_rich_text`",
2010        odin: "`kui.text`, `kui.rich_text`",
2011        doc: "Plain or rich text; spans shape as one paragraph, so wrapping crosses style boundaries. A text is content plus a style and no box of its own, so the rows it reads are the style rows (`size`, `lineHeight`, `color`, `family`, `font`, `wrap`, `maxLines`, `ellipsis`, `initialLetter`, `underline`, `strikethrough`, `features`) and nothing else: a container row, an access row (`label`, `role`, `live`), `key` or `onClick` on a text is dropped with an `unknown-prop` warning naming the rows it does take — put them on the box around it. `wrap`, `maxLines` and `ellipsis` control line breaking. A span's `bg` is a background behind its glyphs alone, one rect per line it spans, so it follows the span across a wrap the way a box around a run cannot. With `bgRadius` (`bg_radius` in Lua, `KuiSpan.bg_radius` in C, `Span::bg_radius` in Rust; logical px) the background is rounded and joined into one shape with every rounded background of the same colour and radius it meets: a piece whose edge touches it exactly on the line above or below and overlaps it sideways, or that meets it end to end on its own line, in this text or another. Its corners are then convex where a line reaches past its neighbour, a fillet where it falls short, and round where nothing meets it — a selection over many rows, or over the wrapped lines of a paragraph, is one rounded outline, joined after every text of the frame is laid out and painted, so it is never a frame behind. Nothing names the shape: two that touch are one; `underline` and `strikethrough` on a span or on the whole text are lines where the face puts them. A span takes a face and a size of its own: `family` (a stock name or an installed family's, as the text's) or `font` (a handle, which wins) — inline code in `mono` inside a sans paragraph — and `size` in logical px, its line height scaled at the paragraph's ratio (`Span::family` / `Span::mono` / `Span::size` in Rust, `KuiSpan.family` with `KUI_SPAN_FAMILY`, `.font` and `.size` in C). Its glyphs are shaped in that face, so a caret, a hit, a selection and the measurement read the same glyphs and byte positions stay exact across the change; a line is as tall as its tallest span, and a span's background is its own height around its glyphs. A paragraph with a sized span is shaped whole, never in chunks. A text with no line breaks that is 4096 bytes or longer (and no `maxLines` or `ellipsis`), plain or spans alike, is shaped in ~1 KB chunks as they come on screen, so a minified bundle or a log line with a blob in it costs the screenful it shows and a keystroke into it — or a span moving along it, an editor's caret — costs the chunk it lands in; wrapped, the rows are broken from the chunks' positions, so a 100k-character paragraph costs the rows it shows. Its size is estimated from the first chunk until the rest shape (exact under monospace), and the access tree carries its value without its runs.",
2012    },
2013    ElementDef {
2014        name: "button",
2015        jsx_own: &[],
2016        lua_own: &["text"],
2017        jsx_rows: Some(BUTTON_ROWS_JSX),
2018        lua_rows: Some(BUTTON_ROWS_LUA),
2019        jsx: "`<button onClick key|index label description tooltip disabled accent>`",
2020        lua: "`button { label=, on_click=, key= | index=, text=, description=, tooltip=, disabled=, accent= }`",
2021        c: "`kui_button`, `kui_button_with`",
2022        odin: "`kui.button`",
2023        doc: "The stock button: `widgets::button_spec(&theme, &metrics)` — the theme's accent trio as its three backgrounds, declared on the node and resolved by the core — keyed by its text (`key` overrides). It paints from the palette like every stock widget (backlog AR41): the OS's accent where the host reports one, the app's where it set or pinned one, kui's blue otherwise; the label goes black or white by the background's luminance. Its look is its spec, so the layout and paint rows are closed — declared, they are dropped with an `unknown-prop` warning naming the rows it does read — and those are the access rows: `label` when the text is not the name, `description`, `tooltip`, and `disabled` (inert, and dimmed to half). The one paint row it takes is `accent`, which on a button changes nothing (it is the accent already) and is kept for the box's sake. In Lua `label` is the name and the text both unless `text` says otherwise; in C the rows ride a `KuiSpec` whose other fields `kui_button_with` ignores. A button that needs any other row is a box with `role=\"button\"` and the same rows spelled out.",
2024    },
2025    ElementDef {
2026        name: "edit",
2027        jsx_own: &[
2028            "id",
2029            "initial",
2030            "multiline",
2031            "autofocus",
2032            "keepTab",
2033            "placeholder",
2034            "secure",
2035        ],
2036        lua_own: &[
2037            "initial",
2038            "multiline",
2039            "autofocus",
2040            "keep_tab",
2041            "placeholder",
2042            "secure",
2043        ],
2044        jsx_rows: None,
2045        lua_rows: None,
2046        jsx: "`<edit key initial multiline autofocus keepTab placeholder secure>`, `<input label initial>`",
2047        lua: "`edit { key=, initial=, keep_tab=, placeholder=, secure=, … }`, `input { label=, initial= }`",
2048        c: "`kui_text_edit`, `kui_text_edit_placeholder`, `kui_text_input`",
2049        odin: "`kui.text_edit`, `kui.text_input`",
2050        doc: "Retained editor state by key; read it back with `editText(key)` after a `changed` event. `initial` seeds a new editor only — a key declared again keeps the draft the user typed, and `setEditText(name, text)` is what resets one (it leaves the caret at the end). Name it by the label its `key` prop declares — `setEditText(\'note\', text)` — or by the hex key an event carried. It reaches an editor that does not exist yet: the text is held for the frame that declares that name and seeds it there, over `initial`, so the `update` that opens a rename field can fill it in the same turn, which is what the label spelling is for — the hex key comes from an event an editor being opened has not fired. A name nothing declares on that frame drops its text with an `edit-text-without-editor` warning. A single-line editor is a field and a `multiline` one a document, which decides how each is laid out as well as how it reads: a field takes one line whatever its box, sizes to the text it holds when its width is `fit`, and scrolls that line under the caret when it is not, while a document wraps to its box. The one exception is a field with `wrap` declared (`wrap=\"word\"` or `\"glyph\"`): it folds to its width the way a document does and keeps a field\'s keyboard — Enter still submits, a newline is still never admitted, the caret still opens at the end — so a rename field breaks where the label it renames breaks, and with `width=\"fit\"` plus `maxWidth` it sizes to its wrapped draft on the keystroke frame. A single-line editor opens with the caret after its seeded text, as a native field does; a multiline one is a document and opens at its top — a held `setEditText` is the call, not a seed, so it opens at the end either way. State is kept while the key is declared; an undeclared one is kept until the budget needs the room (256 undeclared editors, longest-undeclared evicted first). `autofocus` asks once: the editor takes focus on the frame the flag starts being declared — a new editor, or one whose flag just turned on — and only while nothing holds focus, so a blur afterwards stands and a focused control is never robbed (`docs/adr/0022-focus-regions.md`, decision 9); `focus(key)` is the call for taking it at any other time. A focused editor keeps the keys it acts on — the editing keys, what it types, the clipboard and undo chords — and a chord it does not (⌘N, Ctrl+K) goes to the nearest `onKey` sink above it, as a chord bubbles from a control (`docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`, amended 2026-10-09). `keepTab` makes a field's Tab the app's: Tab and Shift-Tab do not walk the focus ring from it, and the press goes to the sink above it the same way, so a list or an outline built from fields indents on Tab; a `multiline` editor keeps Tab for indentation either way. An arrow, Backspace or Delete that meets the edge of the text emits `boundary` (see the events). `placeholder` is what an empty editor shows where its text would be, in the theme's `faint` — never part of the value, gone with the first character typed or composed, and read as the field's accessible description when it declares none. `secure` makes it a password field (backlog F167): the text is drawn as a bullet (•) per grapheme and measured, hit and selected as the bullets; a copy or a cut from it is refused and a paste taken; no input method composes into it, and it keeps no undo history; the access tree reads it as a `passwordInput` whose value is the bullets; and while it has the keyboard the window asks for secure keyboard entry and the input method off, as `secureInput` and `imeOff` would. `editText` still reads the text. Declare it off on a later frame — an app's show-password toggle — and the same editor draws its text again, the caret where it was; on again, it masks it. A secure editor is a field: `multiline` is ignored, and the word keys and a double click take the whole text, as there are no words to show. In C it is `KUI_EDIT_SECURE`, in Odin `.Secure`.",
2051    },
2052    ElementDef {
2053        name: "select",
2054        jsx_own: &["label", "options", "current"],
2055        lua_own: &["label", "options", "current"],
2056        // Its look is its own, like the button's, and it has no rows of
2057        // the box's to read: the three it takes are all its own.
2058        jsx_rows: Some(&[]),
2059        lua_rows: Some(&[]),
2060        jsx: "`<select label options={[…]} current>`",
2061        lua: "`dropdown { label=, options={…}, current= }`",
2062        c: "`kui_select`",
2063        odin: "`kui.select`",
2064        doc: "The stock select (`widgets::select_items`, backlog F72): a field showing the choice in force that, clicked, opens the core's own menu of the options under it with the current one checked — the menu a right-click opens, drawn in the frame or the platform's where the host shows menus itself, dismissed by Escape or a press outside, its rows walked by the arrows and read as a menu. `label` is the key and the accessible name both; `options` is a list whose entries are strings (an option by its label, posting it) or menu-item objects `{ label, id, enabled }` (posting `id`), and a `{ role: \"separator\" }` is a separator; `current` is the index in force, counted from 0 in JSX and C and from 1 in Lua, or none — one past the options or on a separator is none, with a `select-current-ignored` warning on the field; an empty `options` is refused, and a key of an option object no row reads (`disabled`, where the key is `enabled`) is an `unknown-prop` warning. The app holds no open state: the choice arrives as the `menu` event a menu row posts, on the field's key — `{kind: \"menu\", role: \"custom\", item: <the option>}` — and drawing the field again with the new `current` is the whole loop. A reader hears a button named by the field, described by its choice, expanded while the menu is open. Its look is its spec, so it reads no other row: a layout, paint or access row on it is dropped with an `unknown-prop` warning. Lua spells it `dropdown`, since `select` is Lua's own.",
2065    },
2066    ElementDef {
2067        name: "checkbox",
2068        jsx_own: &[],
2069        lua_own: &["text"],
2070        jsx_rows: Some(TOGGLE_ROWS_JSX),
2071        lua_rows: Some(TOGGLE_ROWS_LUA),
2072        jsx: "`<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>`",
2073        lua: "`checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2074        c: "`kui_checkbox`",
2075        odin: "`kui.checkbox`",
2076        doc: "The stock checkbox (`widgets::toggle_with`, ADR 0034): a box drawn from the state the view declares — `checked`, or `mixed` for the select-all box over a list some of whose rows are selected, drawn as a dash and read as mixed — and its label beside it, keyed by its text (`key` overrides). The state is the app's: a press by the pointer, Space, Enter or assistive technology posts `onClick`, and the view flips its model and draws it again. Its look is its spec, so the layout and paint rows are closed and dropped with an `unknown-prop` warning; the rows it reads are its state and the access rows. In Lua `label` is the name and the text both unless `text` says otherwise. The box is the metrics' control text plus one (16 px comfortable), so `compact` and `scaled` move it with the stock button.",
2077    },
2078    ElementDef {
2079        name: "radio",
2080        jsx_own: &[],
2081        lua_own: &["text"],
2082        jsx_rows: Some(TOGGLE_ROWS_JSX),
2083        lua_rows: Some(TOGGLE_ROWS_LUA),
2084        jsx: "`<radio checked onClick key label description tooltip disabled>text</radio>`",
2085        lua: "`radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2086        c: "`kui_radio`",
2087        odin: "`kui.radio`",
2088        doc: "The stock radio (`widgets::toggle_with`, ADR 0034): a circle drawn from `checked`, and its label, keyed by its text. Put radios in a `radioGroup`, which makes them one Tab stop whose arrows, Home and End move the choice and press the radio they land on (ADR 0007), so radios whose `onClick` each set the choice answer the keyboard with no more code. The state is the app's, as a checkbox's is; the rows are the checkbox's, `mixed` aside.",
2089    },
2090    ElementDef {
2091        name: "radioGroup",
2092        jsx_own: &[],
2093        lua_own: &[],
2094        jsx_rows: None,
2095        lua_rows: None,
2096        jsx: "`<radioGroup label>…radios…</radioGroup>`",
2097        lua: "`radio_group { label=, … }`",
2098        c: "`kui_radio_group_open` … `kui_close`",
2099        odin: "`kui.radio_group` in an `if`, closed at its end",
2100        doc: "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.",
2101    },
2102    ElementDef {
2103        name: "switch",
2104        jsx_own: &[],
2105        lua_own: &["text"],
2106        jsx_rows: Some(TOGGLE_ROWS_JSX),
2107        lua_rows: Some(TOGGLE_ROWS_LUA),
2108        jsx: "`<switch checked onClick key label description tooltip disabled>text</switch>`",
2109        lua: "`switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2110        c: "`kui_switch`",
2111        odin: "`kui.toggle`",
2112        doc: "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.",
2113    },
2114    ElementDef {
2115        name: "slider",
2116        jsx_own: &[],
2117        lua_own: &[],
2118        jsx_rows: Some(SLIDER_ROWS_JSX),
2119        lua_rows: Some(SLIDER_ROWS_LUA),
2120        jsx: "`<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>`",
2121        lua: "`slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }`",
2122        c: "`kui_slider`",
2123        odin: "`kui.slider`",
2124        doc: "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.",
2125    },
2126    ElementDef {
2127        name: "image",
2128        jsx_own: &["src", "sampling", "fit"],
2129        lua_own: &["id", "sampling", "fit"],
2130        jsx_rows: None,
2131        lua_rows: None,
2132        jsx: "`<image src={id} sampling fit>`",
2133        lua: "`image { id=, sampling=, fit= }`",
2134        c: "`kui_image`, `kui_image_with`",
2135        odin: "`kui.image`, `kui.image_with`",
2136        doc: "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.",
2137    },
2138    ElementDef {
2139        name: "polygon",
2140        // `bg` is a schema row already; on a polygon it is the fill.
2141        jsx_own: &["points"],
2142        lua_own: &["points"],
2143        jsx_rows: None,
2144        lua_rows: None,
2145        jsx: "`<polygon points={[[x,y],…]} bg/>`",
2146        lua: "`polygon { points={{x,y},…}, bg= }`",
2147        c: "`kui_polygon`",
2148        odin: "`kui.polygon`, its points a `[][2]f32`",
2149        doc: "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.",
2150    },
2151    ElementDef {
2152        name: "path",
2153        // `bg` is a schema row already; on a path it is the fill. `width`
2154        // and `color` are rows too; on a path they are the stroke's, as on
2155        // a line.
2156        jsx_own: &["d", "fillRule", "rotate", "pivot", "dash", "dashOffset"],
2157        lua_own: &[
2158            "d",
2159            "ops",
2160            "fill_rule",
2161            "rotate",
2162            "pivot",
2163            "dash",
2164            "dash_offset",
2165        ],
2166        jsx_rows: None,
2167        lua_rows: None,
2168        jsx: "`<path d=\"M … Z\" bg width color fillRule rotate pivot dash dashOffset/>`",
2169        lua: "`path { d = \"M … Z\", bg=, width=, color=, fill_rule=, rotate=, pivot=, dash=, dash_offset= }`",
2170        c: "`kui_path`",
2171        odin: "`kui.path`, `kui.path_d`",
2172        doc: "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.",
2173    },
2174    ElementDef {
2175        name: "fragment",
2176        jsx_own: &["src", "image", "params", "animate"],
2177        lua_own: &["id", "image", "params", "animate"],
2178        jsx_rows: None,
2179        lua_rows: None,
2180        jsx: "`<fragment src={id} image={id} params={[…]} animate>`",
2181        lua: "`fragment { id=, image=, params={…}, animate= }`",
2182        c: "`kui_fragment`, `kui_fragment_with`",
2183        odin: "`kui.fragment`, `kui.fragment_with`; `kui.fragment_open` in an `if` holds children",
2184        doc: "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.",
2185    },
2186    ElementDef {
2187        name: "cells",
2188        jsx_own: &[
2189            "rows",
2190            "cols",
2191            "cells",
2192            "cursorAt",
2193            "cursorShape",
2194            "cursorColor",
2195            "originLine",
2196        ],
2197        lua_own: &[
2198            "rows",
2199            "cols",
2200            "lines",
2201            "runs",
2202            "cursor_at",
2203            "cursor_shape",
2204            "cursor_color",
2205            "origin_line",
2206        ],
2207        jsx_rows: None,
2208        lua_rows: None,
2209        jsx: "`<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>`",
2210        lua: "`cells { rows=, cols=, lines={\"row text\", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }`",
2211        c: "`kui_cells`",
2212        odin: "`kui.cells`",
2213        doc: "A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. A character the family has no glyph for is asked of a monospaced face before the platform's fallback list, shaped smaller where it is still wider than its cells (two under wide), and drawn in their middle (backlog F120); the private use area's icons are left as they fall. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value.",
2214    },
2215    ElementDef {
2216        name: "line",
2217        // `width` and `color` are schema rows already (a sizing and the text
2218        // colour); on a line they are the stroke's width and colour.
2219        jsx_own: &["from", "to", "points", "curve", "dash", "dashOffset"],
2220        lua_own: &["from", "to", "points", "curve", "dash", "dash_offset"],
2221        jsx_rows: None,
2222        lua_rows: None,
2223        jsx: "`<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve dash={[6, 4]} dashOffset/>`",
2224        lua: "`line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true, dash={6, 4}, dash_offset= }`",
2225        c: "`kui_line`, `kui_polyline`",
2226        odin: "`kui.line`, `kui.polyline` (points, `[][2]f32`)",
2227        doc: "A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float=\"viewport\"` for viewport space), sized to its own bounding box, so it takes no room in a row or column — but a float for the room alone: in its parent's box space it paints in the parent's layer at its place in the tree, over the siblings declared before it and under those after, as a child does, and opens no layer of its own (backlog F123; a connector meant to sit under two cards is declared before them). A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` stroke escapes, and is a layer of its own (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it. `dash` cuts the stroke into marks and gaps (backlog V2): one length (marks and gaps alike), a mark and a gap, or four lengths for a dash-dot, in px **as seen** — every mark is a short stroke with the stroke's round caps, so `dash` 6, 4 is 6 px of ink and 4 px of nothing at any width up to 6 (SVG's `stroke-dasharray` measures the centre line instead, so with round caps its `4 4` at a width of 4 is solid; this pattern is SVG's `mark − width, gap + width`). A mark no longer than the stroke is wide is a dot as wide as the stroke, in the same period, so its gap is that much shorter; where a mark and its gap together come to no more than the width the dots meet and the gap closes — the marks either side of it are one, and a pattern with no gap left, `dash` 2, 2 at a width of 8, draws solid (backlog RG118). The pattern runs along the stroke's whole length, so it keeps its phase round the corners of a polyline and the pieces of a curve, and `dashOffset` starts that far into it — growing it moves the marks towards the first point, a marquee's marching ants; neither tweens. A pattern with no gap, a mark and gap under a physical pixel together, or more than 16384 marks draws solid. A dashed stroke is hit along its whole length, gaps included, and costs a quad per mark per piece the mark lies on.",
2228    },
2229    ElementDef {
2230        name: "titlebar",
2231        jsx_own: &[],
2232        // The window's own title is `window_title` on the root table; this
2233        // is the string the titlebar draws.
2234        lua_own: &["title"],
2235        jsx_rows: None,
2236        lua_rows: None,
2237        jsx: "`<titlebar title>` or `<titlebar>…</titlebar>`",
2238        lua: "`titlebar { title= }` / `titlebar { … }`",
2239        c: "`kui_titlebar`, `kui_titlebar_with`",
2240        odin: "`kui.titlebar`, `kui.titlebar_with`",
2241        doc: "Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons.",
2242    },
2243    ElementDef {
2244        name: "menuBar",
2245        jsx_own: &["menu"],
2246        lua_own: &["menu"],
2247        jsx_rows: None,
2248        lua_rows: None,
2249        jsx: "`<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked?, items? }] }]}/>`",
2250        lua: "`menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked=, items= } } } } }`",
2251        c: "`kui_menu_bar`",
2252        odin: "`kui.menu_bar`",
2253        doc: "The application menu (`docs/adr/0018-a-menu-bar-the-app-declares.md`): `menu` is what it *is*, and where this element sits is where its titles go when they have to be drawn in the window. One call and not two, because declaring the menu and placing the strip are one decision. It draws **nothing** where the platform owns the bar — macOS, where the driver hands the same declaration to the OS — so the frame has still said what the app's menu is and the strip simply is not there; that is the contract `windowButtons` has under native decorations, and it is what makes one view portable. Its rows are the rows a context menu has: the same `role`s the core performs itself (`copy`, `paste`, `selectAll`, `cut`, `lookUp`), the same `id` payload, the same `accel` text, plus `checked` for a setting — and choosing one posts the same `{kind:\"menu\", role, item}` event, so an app handles one thing whichever menu it came from. Declared every frame and diffed: an unchanged menu costs a comparison, and an empty list takes it away. An accelerator kui can parse is rewritten into the platform's spelling, so `\"mod+s\"` reads as `⌘S` on macOS and `Ctrl+S` elsewhere and binds that key in the platform's own bar. On macOS the first menu is the application menu, which the OS titles with the app's own name whatever the label says. While a menu is open the bar is the frame's modal scope, so hovering across the titles moves the open menu, a press on the open title closes it, and Escape or a press in the app below closes it and reaches nothing else.",
2254    },
2255    ElementDef {
2256        name: "windowButtons",
2257        jsx_own: &[],
2258        lua_own: &[],
2259        jsx_rows: None,
2260        lua_rows: None,
2261        jsx: "`<windowButtons/>`",
2262        lua: "`window_buttons()`",
2263        c: "`kui_window_buttons`",
2264        odin: "`kui.window_buttons`",
2265        doc: "Just the min/max/close buttons, for fully custom titlebars.",
2266    },
2267    ElementDef {
2268        name: "tooltip",
2269        jsx_own: &["value"],
2270        lua_own: &["value"],
2271        jsx_rows: None,
2272        lua_rows: None,
2273        jsx: "`<tooltip value=\"hint\"/>` / `<tooltip>…</tooltip>` nodes, or the `tooltip=\"hint\"` prop (see composites)",
2274        lua: "`tooltip(\"hint\")` / `tooltip { … }` nodes, or the prop",
2275        c: "`kui_tooltip`, `kui_tooltip_with`",
2276        odin: "`kui.tooltip`, `kui.tooltip_with`",
2277        doc: "A float hanging below the parent; the node form always draws, the prop form is hover-gated.",
2278    },
2279    ElementDef {
2280        name: "latencyGraph",
2281        jsx_own: &["at"],
2282        lua_own: &["at"],
2283        jsx_rows: None,
2284        lua_rows: None,
2285        jsx: "`<latencyGraph/>`, `<latencyHud at/>`",
2286        lua: "`latency_graph()`, `latency_hud { at= }`",
2287        c: "`kui_latency_graph`, `kui_latency_hud`",
2288        odin: "`kui.latency_graph`, `kui.latency_hud`",
2289        doc: "Per-phase frame timing (windowed drivers fill it; headless shows the chrome empty).",
2290    },
2291    ElementDef {
2292        name: "audio",
2293        jsx_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2294        lua_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2295        jsx_rows: None,
2296        lua_rows: None,
2297        jsx: "`<audio src={id} loop volume paused finish tag/>`",
2298        lua: "`audio { src=, loop=, volume=, paused=, finish=, tag= }`",
2299        c: "`kui_audio`",
2300        odin: "`kui.audio`",
2301        doc: "A playback retained by node key: present = playing (once, or looped), gone = stopped; `volume` / `paused` apply live, a changed `src` restarts; a `tag` brings back `{kind:\"sound\", phase:\"ended\", tag}`. Draws nothing. `finish` changes what *gone* means: the node's removal releases the playback rather than stopping it, so a one-shot plays to its end and the view need not know the asset's length to declare the node for it (a loop still stops on removal — there is no end to reach — and a paused playback released has nothing to finish). Without it, the way to play a sound whole is to hold the node declared until the `tag`'s `ended` message arrives. A released playback is not free: it holds one of the device's 128 voices until its file ends, and the 129th play is refused — reported as a `playback-refused` warning and, for a `tag`, `phase: \"refused\"` rather than a wait that never returns. In the app's units, voices held = sound length × release rate: a 1.4 s chime released four times a second holds 6 of the 128 at any moment, a 10 s ambience released once a second holds 10, and every playback still declared (a loop included) counts beside them — a `refused` `sound` event is what arriving at 128 sounds like.",
2302    },
2303];
2304
2305/// An event kind hosts receive, with its payload shape.
2306pub struct EventDef {
2307    pub kind: &'static str,
2308    pub payload: &'static str,
2309    pub doc: &'static str,
2310}
2311
2312pub const EVENTS: &[EventDef] = &[
2313    EventDef {
2314        kind: "click",
2315        payload: "the `onClick` payload as-is",
2316        doc: "A press and release on the node (suppressed when a drag moved past the slop). A map payload gains fields where the node can say more: `cell: {row, col}` on a `cells` grid, and inside an `onKey` sink that draws `role=\"line\"` rows, `line`, `byte`, `inside` and `clicks` as a `drag` inside one carries them.",
2317    },
2318    EventDef {
2319        kind: "drag",
2320        payload: "`{ kind: \"drag\", phase: \"start\" | \"move\" | \"end\", x, y, dx, dy, parent: { x, y, w, h }, tag }`",
2321        doc: "A pointer-captured drag on an `onDrag` node. `x`/`y` are where the pointer is; `dx`/`dy` are its displacement **from the press point**, in every phase — `start` carries zero, a `move` how far the pointer is from where it pressed, `end` the whole distance — so a handler sets `value = start + dx` rather than summing deltas, and can commit from `end` alone. Nothing is dropped under the click slop (3 px, measured from the press): the first `move` already carries the whole distance. `parent` is the container rect, so fractions need no geometry query. On a `cells` grid every phase also carries `cell: {row, col}`. Inside an `onKey` sink that draws `role=\"line\"` rows — an editor the app owns — every phase carries `line` (the ordinal among the sink's lines, the numbering its `access` events use), `byte` (where the point falls in that line's text, what `textHit` would answer) and `clicks` (the press's count), so click-to-caret, drag-select and double-click-word are arithmetic on the event with no query and no frame of lag; a point above the first line is the first, below the last the last, and one in a `role=\"none\"` gutter is the line beside it, and `inside` says whether the point is within that line's own box at all — false above, below and in the margin beside a row, so a margin gesture (block selection) is told from a press on the text without comparing `x` to a rect. Nothing is added where the sink draws no lines.",
2322    },
2323    EventDef {
2324        kind: "key",
2325        payload: "`{ kind: \"key\", phase: \"down\" | \"up\", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }`",
2326        doc: "A key press or release on the focused `onKey` sink; `code` is a character or a name (`\"left\"`, `\"f5\"`) and is what a keymap binds against. `physical` is the US-QWERTY key at that *position*, spelled the same way — bind it instead when you want the finger rather than the label (WASD stays a square on every layout). `code` follows the layout while the layout speaks ASCII, so a chord lands on the key the user can see (Dvorak's `⌥v` on the key printed V); on a layout that does not (Cyrillic, Greek, Hebrew, Arabic) the position's US key stands in, as Shift prints it (`J`, `:`; unshifted under Alt, as a chord reads it), so a Latin keymap keeps matching instead of matching nothing. A shifted letter arrives as the upper-case letter — `Z` with `shift` set for ⇧⌘Z, `physical` staying `z` — so a keymap that binds letters folds a one-character `code` to lower case under a chord; a headless press is spelled the same way, since no door re-spells it (`\"z\"` with `shift` is a chord no keyboard produces). `repeat` marks a press the OS auto-repeated; `text` is what the press would insert — always the layout's own character — and is null on every release. 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 a held-key binding (WASD, press-and-hold) cannot be left stuck down. `location` says which of a key's twins it was (backlog F108): `\"left\"` or `\"right\"` for a modifier, `\"numpad\"` for the keypad's digits, operators, Enter and (Num Lock off) arrows — `code` still `\"1\"`, `\"enter\"` — else `\"standard\"`; `caps_lock` and `num_lock` what the lock keys held. The keys F13–F35, `printscreen`, `pause`, `menu`, `clear` and the media keys (`mediaplaypause`, `volumeup`, …) are keys like any other; the modifier and lock keys themselves (`shift`, `ctrl`, `alt`, `super`, `capslock`, `numlock`, `scrolllock`) reach only a sink that says `modifierKeys`.",
2327    },
2328    EventDef {
2329        kind: "text",
2330        payload: "`{ kind: \"text\", text, pasted?: true, concealed?: true, transient?: true, tag }`",
2331        doc: "Text an IME committed at the end of a composition — or the clipboard's text, when the app asked for a paste with `requestPaste` / `request_paste` / `kui_request_paste` — on the focused `onKey` sink (or the nearest one above the focused control, or the root sink with nothing focused) — the one committed text the platform never reports as a key press carrying `text`, so a custom editor inserts it as it would a key's `text`. Plain typing does not arrive this way: the `key` event already carries what the press would insert, and a sink hearing both would type every character twice. A focused `<edit>` takes the commit itself and reports `changed`. A paste's answer carries `pasted: true` (backlog DX14) — an answer being the host's paste reply, or any commit while a paste is outstanding — so a sink that asked tells the clipboard's text from an IME's commit without keeping its own flag; an IME commit has no `pasted`. It also says what the pasteboard marked it (backlog F84): `concealed: true` for a secret — a password manager's copy, which a view should not show, log or keep — and `transient: true` for text not to keep in a history, after the nspasteboard.org convention (on Windows the clipboard's exclusion formats); a marker that is not set is absent, never false. The runner reads them on macOS and Windows; on Linux, and from a host that answers with a bare commit, a paste arrives unmarked.",
2332    },
2333    EventDef {
2334        kind: "preedit",
2335        payload: "`{ kind: \"preedit\", text, cursor: [start, end] | null, tag }`",
2336        doc: "An in-progress IME composition on the focused `onKey` sink: `text` is the uncommitted string to show inline at the caret, `cursor` the byte range inside it the IME's own caret covers (null when it does not say), and an empty `text` means the composition ended without a commit, so what was shown goes away. The OS candidate window is anchored for you: the `line` carrying `caret` says where. A focused `<edit>` draws the composition itself.",
2337    },
2338    EventDef {
2339        kind: "selectionrange",
2340        payload: "`{ kind: \"selectionrange\", from: { index, byte }, to: { index, byte } }` on the scope",
2341        doc: "A copy reached rows of a `selectable` virtual list that no frame built (`docs/adr/0017-selection-as-a-scope.md`, tier 3): the core cannot read text it never laid out, so it asks the app for the range — `index` is a row's data index (its `index` prop), `byte` an offset into that row's text, past the row's length for \"the whole row\" (a Select All over a list that declared `rowCount` ends that way, on its last row) — and the app answers with `answerSelectionRange(text)` / `answer_selection_range` / `kui_answer_selection_range`, which is what reaches the clipboard as a `setClipboard` action. Raised only while a copy is outstanding (`requestCopy()` answered `asked`, or the runner's Cmd/Ctrl-C did); a late answer changes nothing. `examples/rust/features/clipboard.rs` and its Node twin show the round trip.",
2342    },
2343    EventDef {
2344        kind: "contextmenu",
2345        payload: "`{ kind: \"contextmenu\", x, y, tag }`",
2346        doc: "A secondary-button press on an `onContextMenu` node, on the press rather than the release; `x`/`y` are logical viewport coordinates — where the menu goes. The core opens nothing: the app declares the menu (a `modal` float) and stops declaring it on `dismiss`.",
2347    },
2348    EventDef {
2349        kind: "menu",
2350        payload: "`{ kind: \"menu\", role, item }`",
2351        doc: "A row of the core's own context menu was chosen (`openMenu` / `open_menu` / `kui_open_menu`), on the node the menu was about. `item` is the row's `id`, or its label when it declared none; `role` is the row's standard role or `custom`. Every chosen row posts, the standard ones included: a `cut` or `paste` role is carried out by the core (its clipboard work queued for the host) *and* reported, so an app can hear its editor being cut from and is free to ignore it (`docs/adr/0017-selection-as-a-scope.md`, decision 5).",
2352    },
2353    EventDef {
2354        kind: "forceclick",
2355        payload: "`{ kind: \"forceclick\", x, y, tag }`",
2356        doc: "A press that deepened past the second stage of a Force Touch trackpad, on an `onForceClick` node, at the logical viewport point it happened at. Routed as a secondary press is — no focus moved, no caret placed, no click — but asked of the topmost node only, and the ordinary click the press is still producing arrives afterwards. Text needs none of this: over an `edit` or a `selectable` scope the core selects the word under it and asks the host for its Look Up panel instead. macOS-only in practice.",
2357    },
2358    EventDef {
2359        kind: "button",
2360        payload: "`{ kind: \"button\", phase: \"press\" | \"move\" | \"release\", button: \"secondary\" | \"middle\" | number, x, y, clicks, cell?: { row, col }, line?, byte?, inside?, tag }`",
2361        doc: "A non-primary button on an `onButton` node that claims it (`buttons`), backlog F105: `press` where it went down — with the driver's click count, `clicks`, which the native runner keeps for the primary button alone and so always reports as 1 here — then `move` for every pointer move while it is held and `release` where it came up, both on the same node wherever the pointer went, since the press captured the button. `button` is the button's name, or for one past the middle button its number (`3 + n`, as `kui_input_mouse_button` takes it; Node's `ctx.mouse` takes the three names only); `x`/`y` are logical viewport coordinates. On a `cells` grid each carries `cell: {row, col}`, clamped to the grid, and inside an `onKey` sink that draws `role=\"line\"` rows `line`, `byte` and `inside` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar.",
2362    },
2363    EventDef {
2364        kind: "scroll",
2365        payload: "`{ kind: \"scroll\", x, y, dx, dy, lines, mods?: { shift, ctrl, alt, super }, tag }`",
2366        doc: "The wheel over an `onScroll` node, or a drag-select held past a `cells` grid's top or bottom edge: `dx`/`dy` the delta in logical px as the driver reported it (positive `dy` is the wheel rolling up, toward earlier content), `x`/`y` the pointer in logical viewport coordinates, `lines` the whole lines a `cells` grid's `dy` covers — positive is later history, the sign `originLine` grows in, the fraction carried to the next notch — and null on any other node. The core scrolls nothing for it: the app re-declares the grid's `originLine`, or zooms its canvas. From the edge drag it comes once a frame while the pointer is held past the edge, with the lines that frame's step covers, and the selection's absolute lines survive the scroll the app answers with (`docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md`).",
2367    },
2368    EventDef {
2369        kind: "focus",
2370        payload: "`{ kind: \"focus\", phase: \"in\" | \"out\", by: \"pointer\" | \"keyboard\" | \"assistive\" | \"program\", tag }`",
2371        doc: "Keyboard focus entered or left an `onFocus` node's subtree (backlog DX18). `by` is what moved it — a press, a key, a screen reader's request, or the view and the app — so a pane that follows a click into its sink tells that apart from a move the app made itself.",
2372    },
2373    EventDef {
2374        kind: "hover",
2375        payload: "`{ kind: \"hover\", phase: \"enter\" | \"leave\", by: \"pointer\" | \"content\", tag }`",
2376        doc: "The pointer entered or left an `onHover` node — also when a new frame moved it under a still cursor. `by` says which (backlog DX20): `pointer` when the pointer moved or left the window, `content` when it stayed and what is under it changed — a list scrolled by the wheel or the keys, a row that grew, a float that opened. A picker whose selection follows the pointer ignores `content`, or the rows sliding under a still pointer as the keys scroll the list drag the selection with them.",
2377    },
2378    EventDef {
2379        kind: "drop",
2380        payload: "`{ kind: \"drop\", phase: \"enter\" | \"move\" | \"leave\" | \"drop\", paths: string[], x, y, tag }`",
2381        doc: "Files dragged in from the OS over an `onDrop` node (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`): `enter` when they come over the zone, `move` while they move over it (never twice for one point), `leave` when they go to another zone, to no zone or out of the window, `drop` when they land — and no `leave` after a `drop`. `paths` are the OS paths as strings; `x`/`y` the pointer in logical viewport coordinates, absent on `leave`. The zone is the topmost one under the pointer by paint order; a node inside it is its, and a node that is no zone is looked past (an overlay shown on `enter` does not end the hover). Nothing is re-resolved when a frame lands: only the driver's next report moves the files, so a zone the view stops declaring hears its `leave` then.",
2382    },
2383    EventDef {
2384        kind: "files",
2385        payload: "`{ kind: \"files\", paths: string[], tag }`",
2386        doc: "A file dialog's answer (backlog C51): what an Open, Save or folder dialog asked for with `requestFiles` / `request_files` / `kui_request_files` picked — the OS paths, as a `drop` carries them — or no paths when the user cancelled. `tag` is the dialog's own. It reaches whoever asked: the host, or the extension whose fill asked; one dialog is out at a time.",
2387    },
2388    EventDef {
2389        kind: "open",
2390        payload: "`{ kind: \"open\", paths: string[] }` on the root",
2391        doc: "The OS asked the app to open documents (backlog F124): the Finder's Open With, a file dropped on the Dock icon, `open -a App file`, a double-click on a document of a type the app's `Info.plist` declares under `CFBundleDocumentTypes`. Delivered to the host on the root whether or not anything asked — unlike `files`, which answers an ask. `paths` are file-system paths as strings; a URL of a scheme the app registers is not one, and is not carried (room is left for a `urls` beside `paths`). The macOS runner sends it at launch — held until the main window has opened, since AppKit hands the documents over before it exists and puts none in the arguments — and while the app runs; a host driving its own window sends it as input (`openDocuments`, `kui_input_open`, `InputEvent::Open`). Windows and Linux pass documents in the process's arguments instead, and nothing sends it there.",
2392    },
2393    EventDef {
2394        kind: "layout",
2395        payload: "`{ kind: \"layout\", x, y, w, h, parent: { x, y, w, h }, scale, tag }`",
2396        doc: "The rect layout gave an `onLayout` node (logical px, viewport coords, after scrolling and easing): on its first frame and whenever it changes, never on a frame that left it alone. `scale` is physical px per logical px at the node — `w × scale` by `h × scale` is how many pixels to render for it before `updateImage` (the frame's scale today; where a zoom would compose in).",
2397    },
2398    EventDef {
2399        kind: "resize",
2400        payload: "`{ kind: \"resize\", width, height, scale }`",
2401        doc: "The viewport changed size or DPI (logical px, delivered to the host on the root): the window less the devtools' dock while the panel is docked (`docs/adr/0024`), so a dock coming, going or being dragged is a resize too. `KuiWindow.size()` queries the same numbers, before the first frame as well (backlog F43). The first frame establishes the viewport rather than reporting it, so a dock present at launch posts none.",
2402    },
2403    EventDef {
2404        kind: "window",
2405        payload: "`{ kind: \"window\", phase: \"opened\" | \"closed\" | \"focused\" | \"blurred\", name, id }`",
2406        doc: "A declared window opened (the diff queued its `Open`) or closed — because nothing declares it any more, or because the user closed it, in which case it stays closed while still declared: stop declaring `name`, then declare it again to reopen. `id` is what its events carry; the event itself is on the root of whichever window's frame noticed. `focused` and `blurred` are this window gaining and losing the keyboard (backlog DX18) — `env.focused` changing, as the driver reports it — so an app that saves on blur or re-reads the clipboard on return hears it once instead of diffing `env.focused` every frame.",
2407    },
2408    EventDef {
2409        kind: "system",
2410        payload: "`{ kind: \"system\", appearance, accent, motion, locale, assistive }`",
2411        doc: "An OS setting the user changed while the app was open — the light/dark appearance, the accent colour, reduced motion, or the UI language — or assistive technology starting to listen (delivered to the host on the root, one per window that noticed). The payload is `env.system` as it now reads, in the same spellings and with the same nulls, so a handler can keep the whole reading or take the one field it branches on. The first frame establishes the reading rather than reporting it, the way the viewport does; a host whose view is a function the runner calls every frame can equally re-read `env` and ignore this, but a host that retains the tree it was handed (Node, C, Lua) only re-runs its view for a message, so this is how a palette follows the OS.",
2412    },
2413    EventDef {
2414        kind: "fonts",
2415        payload: "`{ kind: \"fonts\" }`",
2416        doc: "The system's installed fonts changed: a rescan found a face installed or removed since the last one (delivered to the host on the root, one per window that noticed). The winit runner rescans itself when macOS or Windows says the set changed; elsewhere it is the app calling `reloadSystemFonts`. Re-read `systemFonts()` / `systemFontFamilies()` here if the model holds them; a view naming a family by `family` needs nothing, it resolves on the same frame. A font the app loads itself raises none, and the first frame establishes the set rather than reporting it.",
2417    },
2418    EventDef {
2419        kind: "modifiers",
2420        payload: "`{ kind: \"modifiers\", shift, ctrl, alt, super }`",
2421        doc: "The physical modifier state changed (delivered to the host on the root).",
2422    },
2423    EventDef {
2424        kind: "changed",
2425        payload: "`{ kind: \"changed\" }`, with the editor's key on the event",
2426        doc: "An editor's text changed.",
2427    },
2428    EventDef {
2429        kind: "submit",
2430        payload: "`{ kind: \"submit\" }`, with the editor's key on the event",
2431        doc: "Enter in a single-line editor.",
2432    },
2433    EventDef {
2434        kind: "cancel",
2435        payload: "`{ kind: \"cancel\" }`, with the editor's key on the event",
2436        doc: "Escape let go of a focused editor: the keyboard leaves it, as it does for a click elsewhere, and this says it was Escape — a draft abandoned, where `submit` is one confirmed and a plain blur is neither. A field that commits on blur cancels here instead.",
2437    },
2438    EventDef {
2439        kind: "boundary",
2440        payload: "`{ kind: \"boundary\", key: \"up\" | \"down\" | \"left\" | \"right\" | \"backspace\" | \"delete\", edge: \"start\" | \"end\", word, doc }`, with the editor's key on the event",
2441        doc: "An editing key that met the edge of an editor's text and did nothing: ↑ on the first line, ← or Backspace at the start (`edge: \"start\"`), ↓ on the last line, → or Delete at the end (`\"end\"`) — from a caret with no selection and without Shift. What a block editor joins blocks and moves between fields by. A field has one line, so its ↑ and ↓ always report. `word` and `doc` are the editing modifiers the key carried (Option or Ctrl, ⌘ or Ctrl — `docs/adr/0002`).",
2442    },
2443    EventDef {
2444        kind: "sound",
2445        payload: "`{ kind: \"sound\", phase: \"ended\" | \"refused\", playback, tag }`",
2446        doc: "A tagged playback (`play(id, { tag })` or `<audio tag>`) finished on its own — never when something stopped it. `phase: \"refused\"` instead when the device would not take the play at all (its 128 voices are all held, or the sound did not decode): that playback never starts and so never ends, so this is what arrives in place of the `ended` a view would otherwise wait forever for.",
2447    },
2448    EventDef {
2449        kind: "dismiss",
2450        payload: "`{ kind: \"dismiss\", reason: \"escape\" | \"outside\", tag }` on a node; `{ kind: \"dismiss\", reason, name, id }` on the root for a popup window",
2451        doc: "The user asked for a surface to go away — Escape, or a press that landed outside it. The core closes nothing: the app stops declaring the surface (or asks first). A `modal` node gets one on the node, carrying its tag, and only the modal in effect does; a `kind: \"popup\"` window gets one on the root, carrying the window's `name` and `id`, reported by the driver because a press outside a window and a key sent to a non-activating one are both facts only the OS has. The two are the same event, so a dropdown that graduates from a modal float to a popup window changes its declaration and not its handler.",
2452    },
2453    EventDef {
2454        kind: "access",
2455        payload: "`{ kind: \"access\", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }`",
2456        doc: "Assistive technology — or the keyboard — asked for what only the app can do: `increment` / `decrement` on a `slider` role that declared no `onChange` (a reader's nudge, or the arrow keys on the focused slider), and `setValue` there with the number asked for as `value` (Windows' UI Automation sets a slider rather than nudging it); `setValue` / `replaceSelectedText` (with `text`) / `setTextSelection` (with `anchor` and `focus` as line ordinals and byte offsets) on a custom editor. `tag` is the node's `onClick` payload (or its `onDrag` / `onKey` tag). Every other request resolves in the core and arrives as the events a pointer would have produced.",
2457    },
2458    EventDef {
2459        kind: "change",
2460        payload: "`{ kind: \"change\", value, phase: \"move\" | \"end\", tag }`",
2461        doc: "A `slider` that declared `onChange` (the stock `<slider>`, ADR 0034): the core turned a press into the value under the pointer, a drag into each new step, an arrow or assistive technology's increment / decrement into one `valueStep` and its set value into that value snapped, PageUp / PageDown into ten, Home / End into the ends — clamped to `valueMin`..`valueMax` and snapped to the step, the decimal the step names. `phase` is `move` while the pointer holds the slider and `end` when it lets go or a key moved it; a key that lands where the slider is proposes nothing. The value is proposed: declare it as `valueNow`. `tag` is the `onChange` payload.",
2462    },
2463];
2464
2465/// A host-registered resource and how each binding registers it.
2466pub struct ResourceDef {
2467    pub what: &'static str,
2468    pub node: &'static str,
2469    pub lua: &'static str,
2470    pub c: &'static str,
2471}
2472
2473pub const RESOURCES: &[ResourceDef] = &[
2474    ResourceDef {
2475        what: "image",
2476        node: "`ctx.addImage(w, h, rgba)` → id for `<image src>`",
2477        lua: "the host registers; `image { id }`",
2478        c: "`kui_image_add` → `kui_image`",
2479    },
2480    ResourceDef {
2481        what: "fragment (WGSL)",
2482        node: "`ctx.addFragment(src)` → id for `<fragment src>`",
2483        lua: "the host registers; `fragment { id }`",
2484        c: "`kui_fragment_add` → `kui_fragment`",
2485    },
2486    ResourceDef {
2487        what: "font from bytes",
2488        node: "`ctx.addFont(buffer)` → id for `font`",
2489        lua: "the host registers; `font = id`",
2490        c: "`kui_font_add` → `KuiTextStyle.font`",
2491    },
2492    ResourceDef {
2493        what: "font file by path",
2494        node: "`ctx.loadFontFile(\"fonts/Antonio.ttf\")` → id for `font`",
2495        lua: "the host registers; `font = id`",
2496        c: "`kui_font_load_file`",
2497    },
2498    ResourceDef {
2499        what: "a folder of fonts",
2500        node: "`ctx.loadFontsDir(\"fonts\")`, then pick by name",
2501        lua: "the host loads",
2502        c: "`kui_font_load_dir`",
2503    },
2504    ResourceDef {
2505        what: "font by family name (installed or loaded)",
2506        node: "`ctx.addSystemFont(\"Antonio\")` (see `systemFontFamilies()`)",
2507        lua: "the host registers; `font = id`",
2508        c: "`kui_font_add_system` (see `kui_font_families`)",
2509    },
2510    ResourceDef {
2511        what: "sound (wav / ogg / mp3 / flac bytes)",
2512        node: "`ctx.addSound(buffer)` → id for `<audio src>`, `clickSound`, `play(id)`",
2513        lua: "the host registers; `audio { src = id }`, `click_sound = id`",
2514        c: "`kui_sound_add` → `kui_audio`, `KuiSpec.click_sound`, `kui_play`",
2515    },
2516];
2517
2518/// One value in the host-environment reading a view gets — `ui.env()` in
2519/// Rust, `view(env)` in Lua, `ctx.env()` / `win.env()` in Node — and the
2520/// key each binding puts it under. C has no reading: a C host is the frame
2521/// driver, so it is the *writer* (`kui_env_set`, `kui_env_set_window`), and
2522/// its column names the argument that carries the fact in.
2523///
2524/// `Env` and `WindowEnv` are the shape; this table is the cross-binding
2525/// restatement of it, and every restatement is pinned to it rather than
2526/// trusted: `schema`'s tests check the rows against the two structs, the
2527/// Lua and Node key-set tests check each binding's reading against the
2528/// `lua` / `node` columns, and kui-ffi checks the header's two prototypes
2529/// against the `c` column. The frame facts a view wants at the same moment
2530/// (the viewport, the focused node) ride in the same reading and have rows
2531/// here too, marked as the frame's rather than `Env`'s.
2532///
2533/// Two divergences are deliberate and named in their rows: `native_controls`
2534/// is the `Rect` the core holds in Node and a width/height at the window
2535/// origin in Lua and C, and `frame_budget_ms` is derived from `refresh_hz`
2536/// but part of the reading everywhere.
2537pub struct EnvField {
2538    /// The canonical name: the Rust field path (`window.custom_chrome`).
2539    pub name: &'static str,
2540    /// Where it comes from — the Rust struct and field for a stored fact,
2541    /// the call for a derived or frame fact.
2542    pub from: &'static str,
2543    /// The key path(s) it occupies in Node's `ctx.env()`; empty when Node
2544    /// carries the fact as a call on the context instead (the `doc` says
2545    /// which) or not at all.
2546    pub node: &'static [&'static str],
2547    /// The same for Lua's `view(env)` table. Two entries where Lua flattens
2548    /// one fact into two keys.
2549    pub lua: &'static [&'static str],
2550    /// The C spelling: `function(argument)` for the setter argument that
2551    /// writes it — the first thing in the cell, so kui-ffi can parse it —
2552    /// or the call that reads it.
2553    pub c: &'static str,
2554    pub doc: &'static str,
2555    /// The fact's reading, as plain data: `Null` for "the host cannot
2556    /// tell", an enum as its wire name, a key as its integer, a rect as
2557    /// `{x, y, w, h}`. What makes the table a reader and not only a pin —
2558    /// a binding iterates the rows and asks each for its value, then
2559    /// spells the key its own way, so a fact added here reaches every
2560    /// reading with no reader restated beside it (the way
2561    /// [`ThemeRole::get`] does for the palette).
2562    pub get: fn(&EnvFacts) -> Value,
2563}
2564
2565/// Everything an env reading is taken from: the stored [`Env`](crate::env::Env) and the
2566/// frame's own facts beside it (`Core::env_facts`).
2567#[derive(Clone, Copy, Debug)]
2568pub struct EnvFacts {
2569    pub env: crate::env::Env,
2570    pub viewport: crate::geom::Size,
2571    pub scale: f32,
2572    pub focus: Option<crate::key::Key>,
2573    pub focus_visible: bool,
2574    pub region: Option<crate::key::Key>,
2575    pub caret_visible: bool,
2576    /// The frame clock in seconds (`Core::now`, backlog F134).
2577    pub now: f64,
2578}
2579
2580fn key_value(k: Option<crate::key::Key>) -> Value {
2581    k.map_or(Value::Null, |k| Value::Int(k.0 as i64))
2582}
2583
2584fn rect_value(r: crate::geom::Rect) -> Value {
2585    Value::map([
2586        ("x", Value::Float(r.x as f64)),
2587        ("y", Value::Float(r.y as f64)),
2588        ("w", Value::Float(r.w as f64)),
2589        ("h", Value::Float(r.h as f64)),
2590    ])
2591}
2592
2593pub const ENV_FIELDS: &[EnvField] = &[
2594    EnvField {
2595        name: "refresh_hz",
2596        get: |f| {
2597            f.env
2598                .refresh_hz
2599                .map_or(Value::Null, |hz| Value::Float(hz as f64))
2600        },
2601        from: "`Env::refresh_hz`",
2602        node: &["refreshHz"],
2603        lua: &["refresh_hz"],
2604        c: "`kui_env_set(refresh_hz)`",
2605        doc: "Display refresh rate in Hz. \"The host cannot tell\" is `null` in Node (a stable shape to destructure, typed `number | null`), an absent key in Lua, and a rate at or below zero in C.",
2606    },
2607    EnvField {
2608        name: "frame_budget_ms",
2609        get: |f| Value::Float(f.env.frame_budget_ms() as f64),
2610        from: "`Env::frame_budget_ms()`, derived",
2611        node: &["frameBudgetMs"],
2612        lua: &["frame_budget_ms"],
2613        c: "—",
2614        doc: "One vsync interval at `refresh_hz`, or at 120 Hz when the host cannot tell: the per-frame time budget, and what the latency HUD draws its line at. Derived, and in the reading anyway, so no view restates the fallback. A C host is the one that knows the rate and computes its own.",
2615    },
2616    EnvField {
2617        name: "focused",
2618        get: |f| Value::Bool(f.env.focused),
2619        from: "`Env::focused`",
2620        node: &["focused"],
2621        lua: &["focused"],
2622        c: "`kui_env_set(focused)`",
2623        doc: "Whether the *window* has the keyboard at all. Not the focused node — that is the `focus` row.",
2624    },
2625    EnvField {
2626        name: "system.appearance",
2627        get: |f| Value::str(f.env.system.appearance.name()),
2628        from: "`SystemEnv::appearance`",
2629        node: &["system.appearance"],
2630        lua: &["system.appearance"],
2631        c: "`kui_env_set_system(appearance)`",
2632        doc: "The OS light/dark setting: `\"light\"`, `\"dark\"`, or `\"unknown\"` when the host has no way to ask (`KUI_APPEARANCE_*` in C, where unknown is 0). Unknown is a real answer and the default — a view picks its own palette for it rather than being handed a guess. The core acts on it in one way: the theme is derived from it (ADR 0019), so the stock widgets and a `<text>` with no colour follow the setting — and nothing of the app's own repaints, because only the view knows which of its colours is the background.",
2633    },
2634    EnvField {
2635        name: "system.accent",
2636        get: |f| {
2637            f.env
2638                .system
2639                .accent
2640                .map_or(Value::Null, |c| Value::Int(c.to_hex() as i64))
2641        },
2642        from: "`SystemEnv::accent`",
2643        node: &["system.accent"],
2644        lua: &["system.accent"],
2645        c: "`kui_env_set_system(accent)`",
2646        doc: "The OS accent/highlight colour as `0xRRGGBBAA`, ready to pass straight back as a `bg` or `color`. \"The host cannot tell\" is `null` in Node, an absent key in Lua, and 0 in C — a fully transparent accent is not a colour anyone was given, the way a refresh rate of zero is not a rate. Node's `setEnv` also takes the `\"#rrggbb\"` spelling a prop takes.",
2647    },
2648    EnvField {
2649        name: "system.motion",
2650        get: |f| Value::str(f.env.system.motion.name()),
2651        from: "`SystemEnv::motion`",
2652        node: &["system.motion"],
2653        lua: &["system.motion"],
2654        c: "`kui_env_set_system(motion)`",
2655        doc: "The OS reduce-motion setting: `\"reduced\"` when the user asked for less animation, `\"full\"` when they did not, `\"unknown\"` when nobody asked the OS (`KUI_MOTION_*` in C, unknown 0). Spelled as what the user wants rather than as a `reduceMotion` boolean, because the third reading has no place in a boolean. Nothing in the core shortens an animation for it — a view that honours it does so where it declares one.",
2656    },
2657    EnvField {
2658        name: "system.locale",
2659        get: |f| {
2660            f.env
2661                .system
2662                .locale
2663                .map_or(Value::Null, |l| Value::str(l.as_str()))
2664        },
2665        from: "`SystemEnv::locale`",
2666        node: &["system.locale"],
2667        lua: &["system.locale"],
2668        c: "`kui_env_set_system(locale)`",
2669        doc: "The UI language as a BCP-47 tag (`\"en\"`, `\"en-US\"`, `\"zh-Hant-HK\"`), for whatever the view formats dates and numbers with; kui does not parse it. Carried inline (31 ASCII bytes, `Locale`) so the reading stays `Copy`, and anything that does not fit reads back as unknown: `null` in Node, an absent key in Lua, an empty `KuiStr` in C.",
2670    },
2671    EnvField {
2672        name: "system.assistive",
2673        get: |f| Value::str(f.env.system.assistive.name()),
2674        from: "`SystemEnv::assistive`",
2675        node: &["system.assistive"],
2676        lua: &["system.assistive"],
2677        c: "`kui_env_set_assistive(assistive)`",
2678        doc: "Whether assistive technology is listening: `\"listening\"` once an accessibility client has asked this window for its tree, `\"none\"` while the bridge is up and nobody has, `\"unknown\"` where there is no bridge — a headless `Ctx`, a runner built without `accesskit`, a C host that never called the setter (`KUI_ASSISTIVE_*`, unknown 0). The reading that changes what a view *says* rather than what it draws: an alert that announces when something is listening and blinks when nothing is. Reported through the `system` event when it changes, like the other four. Two limits are the platform's, not kui's. *Any* client counts — a probe, an accessibility inspector, a test driving the AX API and VoiceOver alike all ask for the tree, and nothing tells them apart — so it says something is listening, not that a person is. And it falls back to `\"none\"` only where the adapter reports deactivation, which in the pinned AccessKit is AT-SPI alone (the session's accessibility bus going away); on macOS and Windows nothing reports a client leaving, so once it has risen it stays `\"listening\"` for the window's life.",
2679    },
2680    EnvField {
2681        name: "window.id",
2682        get: |f| Value::Int(f.env.window.id.0 as i64),
2683        from: "`WindowEnv::id`",
2684        node: &["window.id"],
2685        lua: &["window.id"],
2686        c: "`kui_env_set_window(window)`, read back by `kui_ctx_window`",
2687        doc: "Which window this frame draws, assigned by the driver: 0 for the window the app starts in. Every event from it carries the same number.",
2688    },
2689    EnvField {
2690        name: "window.custom_chrome",
2691        get: |f| Value::Bool(f.env.window.custom_chrome),
2692        from: "`WindowEnv::custom_chrome`",
2693        node: &["window.customChrome"],
2694        lua: &["window.custom_chrome"],
2695        c: "`kui_env_set_window(custom_chrome)`",
2696        doc: "The host asked the app to draw its own chrome, so there is no native titlebar to sit under. `<titlebar>` and `<windowButtons>` build nothing when this is false.",
2697    },
2698    EnvField {
2699        name: "window.maximized",
2700        get: |f| Value::Bool(f.env.window.maximized),
2701        from: "`WindowEnv::maximized`",
2702        node: &["window.maximized"],
2703        lua: &["window.maximized"],
2704        c: "`kui_env_set_window(maximized)`",
2705        doc: "The window is maximized — what picks the restore glyph over the maximize one.",
2706    },
2707    EnvField {
2708        name: "window.fullscreen",
2709        get: |f| Value::Bool(f.env.window.fullscreen),
2710        from: "`WindowEnv::fullscreen`",
2711        node: &["window.fullscreen"],
2712        lua: &["window.fullscreen"],
2713        c: "`kui_env_set_window(fullscreen)`",
2714        doc: "The window is fullscreen.",
2715    },
2716    EnvField {
2717        name: "window.always_on_top",
2718        get: |f| Value::Bool(f.env.window.always_on_top),
2719        from: "`WindowEnv::always_on_top`",
2720        node: &["window.alwaysOnTop"],
2721        lua: &["window.always_on_top"],
2722        c: "`kui_env_set_always_on_top(always_on_top)`",
2723        doc: "The window is above every other app's: the level the driver set after the frame asked for it (`alwaysOnTop` / `always_on_top` / `kui_set_always_on_top`, backlog C30), on a platform that has one. On Wayland winit has no call for it, so a driver there reports false however often the app asks — which is why a pin button draws its state from this and not from the app's own flag. It is the driver's record of what it set and not a query (winit has no level getter), so a level the OS dropped afterwards — a fullscreen space, a tiling manager — is not seen here. A C host reports it through its own setter rather than an argument on `kui_env_set_window`, the way `kui_env_set_assistive` is, so an older host that never applies a level has nothing to recompile.",
2724    },
2725    EnvField {
2726        name: "window.native_controls",
2727        get: |f| f.env.window.native_controls.map_or(Value::Null, rect_value),
2728        from: "`WindowEnv::native_controls`",
2729        node: &["window.nativeControls"],
2730        lua: &["window.controls_w", "window.controls_h"],
2731        c: "`kui_env_set_window(controls_w, controls_h)`",
2732        doc: "Area (logical px, window coordinates) covered by controls the OS still draws over our content — the macOS traffic lights under custom chrome. Keep out of it. Node hands back the `Rect` the core holds (`{x, y, w, h}`, or `null` for none); Lua and C flatten it to a width and height anchored at the window origin (absent in Lua, `0` in C, for none), which is the shape C's two numbers can express and where the one real instance sits.",
2733    },
2734    EnvField {
2735        name: "window.backdrop",
2736        get: |f| Value::str(f.env.window.backdrop.name()),
2737        from: "`WindowEnv::backdrop`",
2738        node: &["window.backdrop"],
2739        lua: &["window.backdrop"],
2740        c: "`kui_env_set_backdrop(backdrop)`; read back with `kui_ctx_backdrop()`",
2741        doc: "What is behind the window's transparent pixels, as the driver got it (backlog F126): `\"opaque\"` (the default, and every headless core's), `\"transparent\"` (the desktop as it is), `\"blur\"` (a live blur of what is behind the window) or `\"tinted\"` (the desktop's colour, not live) — `KUI_BACKDROP_*` in C, opaque 0. The app asks with `Launcher::backdrop` and decides which regions show it by painting them with alpha; this is the answer, which is less where the platform has less — a blur asked of GNOME reads `\"tinted\"`, the wallpaper kui draws itself, or `\"opaque\"` where none could be read — so a view paints its translucent regions opaque when this says so. A C host reports it through its own setter, as `always_on_top` is; a C app asks with `KuiRunConfig.backdrop` and its view reads the answer with `kui_ctx_backdrop`.",
2742    },
2743    EnvField {
2744        name: "audio.device",
2745        get: |f| Value::str(f.env.audio.device.name()),
2746        from: "`AudioEnv::device`",
2747        node: &["audio.device"],
2748        lua: &["audio.device"],
2749        c: "`kui_env_set_audio(device)`",
2750        doc: "What the driver's output device is doing: `\"closed\"` (the default, and a headless driver's answer), `\"opening\"` (the ~90 ms open, on its own thread), `\"open\"`, or `\"failed\"` (it refused, and commands are dropped) — `KUI_AUDIO_DEVICE_*` in C, closed 0. A fact and not a verb: nothing lets a view close it, the driver does that itself once it has been idle a while. Worth reading because an open stream is a real-time thread whether or not anything plays, which is the whole of an idle app's CPU once a session has held a sound.",
2751    },
2752    EnvField {
2753        name: "audio.live",
2754        get: |f| Value::Int(f.env.audio.live as i64),
2755        from: "`AudioEnv::live`",
2756        node: &["audio.live"],
2757        lua: &["audio.live"],
2758        c: "`kui_env_set_audio(live)`",
2759        doc: "Playbacks started and not yet ended, plus any waiting on the device to open. Zero with the device still `\"open\"` is the idle stream the row above is about. A play that arrives while the device is `\"opening\"` counts here from the frame it was asked, until the open answers: if the device refuses, the play is refused on the next apply — `{kind:\"sound\", phase:\"refused\"}` for a tagged one — and leaves the count with it, so what a machine with no output device shows is `opening`/1 then `failed`/0 with the refusal between (backlog F63).",
2760    },
2761    EnvField {
2762        name: "viewport.w",
2763        get: |f| Value::Float(f.viewport.w as f64),
2764        from: "`Core::viewport()`, the frame's",
2765        node: &["viewport.width"],
2766        lua: &["viewport_w"],
2767        c: "`kui_frame_begin(w)`",
2768        doc: "The logical width of the current (or last) frame's viewport — the window less the devtools' dock while the panel is docked (`docs/adr/0024`), the same number a `resize` reports and Node's `KuiWindow.size()` answers — the other host fact a view wants at the same moment, so it rides in the same reading. Zero before the first frame, since the frame establishes it (backlog F43: this row once read the window instead, so an app under `KUI_DEVTOOLS` sized itself to a viewport it did not have). Node's `viewport` is the `WindowSize` shape `runWindowed` already uses.",
2769    },
2770    EnvField {
2771        name: "viewport.h",
2772        get: |f| Value::Float(f.viewport.h as f64),
2773        from: "`Core::viewport()`, the frame's",
2774        node: &["viewport.height"],
2775        lua: &["viewport_h"],
2776        c: "`kui_frame_begin(h)`",
2777        doc: "Its logical height.",
2778    },
2779    EnvField {
2780        name: "scale",
2781        get: |f| Value::Float(f.scale as f64),
2782        from: "`Core::scale()`, the frame's",
2783        node: &["viewport.scale"],
2784        lua: &[],
2785        c: "`kui_frame_begin(scale)`",
2786        doc: "Device pixels per logical px. Lua has no reading: a script sees logical px only.",
2787    },
2788    EnvField {
2789        name: "focus",
2790        get: |f| key_value(f.focus),
2791        from: "`Core::focus()`, the frame's",
2792        node: &[],
2793        lua: &["focus"],
2794        c: "`kui_focused()`",
2795        doc: "The focused *node*'s key, as events carry it (absent for none). A value the host wrote before the view ran, so it lags a same-frame verb by one frame; `env.is_focused(key)` is the live query. Node spells it as the call `focused()` on the context, and C as `kui_focused`, rather than a key on `env`.",
2796    },
2797    EnvField {
2798        name: "focus_visible",
2799        get: |f| Value::Bool(f.focus_visible),
2800        from: "`Core::focus_visible()`, the frame's",
2801        node: &[],
2802        lua: &["focus_visible"],
2803        c: "`kui_focus_visible()`",
2804        doc: "Whether focus shows — the keyboard or assistive technology put it where it is, or acted on it there; a click alone does not. Node: `focusVisible()` on the context.",
2805    },
2806    EnvField {
2807        name: "caret_visible",
2808        get: |f| Value::Bool(f.caret_visible),
2809        from: "`Core::caret_visible()`, the frame's",
2810        node: &[],
2811        lua: &["caret_visible"],
2812        c: "`kui_caret_visible()`",
2813        doc: "The caret's blink phase — `true` draws it (backlog C35). The driver's clock sets it while there is a caret to blink: a focused `edit`'s, or the `caret` a `line` under a focused `onKey` sink declares; a custom editor draws its caret node on the on phase and skips it on the off, keeping the `caret` row on its `line` either way, so it blinks in step with the stock editor and, in a window without the keyboard, not at all. Always `true` headless. Node: `caretVisible()` on the context (`setCaretVisible` is the driver's half, for a test that drives the phase).",
2814    },
2815    EnvField {
2816        name: "now",
2817        get: |f| Value::Float(f.now),
2818        from: "`Core::now()`, the frame's",
2819        node: &[],
2820        lua: &["now"],
2821        c: "`kui_now()`",
2822        doc: "The frame clock in seconds (backlog F134): the driver's monotonic clock, any origin, the one `transition` and `keyframes` read this frame — 0 before a driver sets one. Read a deadline off it (a toast's expiry, a sequence's beats) rather than off a clock of the app's own, so a test that moves the frame clock moves both. Node: `now()` on the context.",
2823    },
2824    EnvField {
2825        name: "region",
2826        get: |f| key_value(f.region),
2827        from: "`Core::region()`, the frame's",
2828        node: &[],
2829        lua: &["region"],
2830        c: "`kui_region()`",
2831        doc: "The focus region in effect — the key of the `focusRegion` node whose ring Tab walks (absent for the main ring; `docs/adr/0022-focus-regions.md`). What a chord that toggles between a dock and the app reads to know which way it is going. Node spells it as the call `region()` on the context, and C as `kui_region`.",
2832    },
2833];
2834
2835/// One colour role in a [`crate::theme::Theme`], with the spelling each
2836/// binding reads it under and the reading itself.
2837///
2838/// The same pin `ENV_FIELDS` is: the palette is one contract, so the roles
2839/// are written down once and every binding's reading is generated from
2840/// this table rather than restated beside it. `get` is what makes that
2841/// possible — a binding iterates the rows and asks each one for its
2842/// colour, so adding a token is one row here and nothing anywhere else.
2843/// The test below destructures [`crate::theme::Theme`] exhaustively, so a
2844/// field added to it stops the crate compiling until it has a row.
2845pub struct ThemeRole {
2846    /// The Rust field, and the name used everywhere but Node.
2847    pub name: &'static str,
2848    /// What Node calls it (camelCase).
2849    pub node: &'static str,
2850    pub doc: &'static str,
2851    /// This role's colour out of a theme.
2852    pub get: fn(&crate::theme::Theme) -> crate::color::Color,
2853    /// The same colour written: what a binding's theme setter uses, so
2854    /// writing a palette is the table's business the way reading one is.
2855    pub set: fn(&mut crate::theme::Theme, crate::color::Color),
2856}
2857
2858pub const THEME_ROLES: &[ThemeRole] = &[
2859    ThemeRole {
2860        name: "bg",
2861        node: "bg",
2862        get: |t| t.bg,
2863        set: |t, c| t.bg = c,
2864        doc: "The window behind everything.",
2865    },
2866    ThemeRole {
2867        name: "surface",
2868        node: "surface",
2869        get: |t| t.surface,
2870        set: |t, c| t.surface = c,
2871        doc: "A card, panel or list sitting on `bg`.",
2872    },
2873    ThemeRole {
2874        name: "raised",
2875        node: "raised",
2876        get: |t| t.raised,
2877        set: |t, c| t.raised = c,
2878        doc: "A surface floating above content: a menu, a tooltip, a popover. Under a light theme it is no lighter than `surface` — a float on a white page separates by its border.",
2879    },
2880    ThemeRole {
2881        name: "sunken",
2882        node: "sunken",
2883        get: |t| t.sunken,
2884        set: |t, c| t.sunken = c,
2885        doc: "A well cut into a surface: a text field, a code block, a track.",
2886    },
2887    ThemeRole {
2888        name: "border",
2889        node: "border",
2890        get: |t| t.border,
2891        set: |t, c| t.border = c,
2892        doc: "The hairline between two surfaces.",
2893    },
2894    ThemeRole {
2895        name: "border_strong",
2896        node: "borderStrong",
2897        get: |t| t.border_strong,
2898        set: |t, c| t.border_strong = c,
2899        doc: "A border that has to be seen — a float's edge, a focused field.",
2900    },
2901    ThemeRole {
2902        name: "fg",
2903        node: "fg",
2904        get: |t| t.fg,
2905        set: |t, c| t.fg = c,
2906        doc: "Body text, and what a `color`-less text run resolves to.",
2907    },
2908    ThemeRole {
2909        name: "muted",
2910        node: "muted",
2911        get: |t| t.muted,
2912        set: |t, c| t.muted = c,
2913        doc: "Secondary text: captions, hints, an accelerator beside a label.",
2914    },
2915    ThemeRole {
2916        name: "faint",
2917        node: "faint",
2918        get: |t| t.faint,
2919        set: |t, c| t.faint = c,
2920        doc: "Text that is barely there: a placeholder, a gutter number.",
2921    },
2922    ThemeRole {
2923        name: "accent",
2924        node: "accent",
2925        get: |t| t.accent,
2926        set: |t, c| t.accent = c,
2927        doc: "The one saturated colour: the OS accent where the host reports one, the app's where it pinned one, kui's blue otherwise. The `accent` prop paints from this.",
2928    },
2929    ThemeRole {
2930        name: "accent_hover",
2931        node: "accentHover",
2932        get: |t| t.accent_hover,
2933        set: |t, c| t.accent_hover = c,
2934        doc: "`accent` under a pointer.",
2935    },
2936    ThemeRole {
2937        name: "accent_pressed",
2938        node: "accentPressed",
2939        get: |t| t.accent_pressed,
2940        set: |t, c| t.accent_pressed = c,
2941        doc: "`accent` under a press.",
2942    },
2943    ThemeRole {
2944        name: "on_accent",
2945        node: "onAccent",
2946        get: |t| t.on_accent,
2947        set: |t, c| t.on_accent = c,
2948        doc: "Black or white — whichever a reader can see on `accent`. What a button's label is.",
2949    },
2950    ThemeRole {
2951        name: "accent_soft",
2952        node: "accentSoft",
2953        get: |t| t.accent_soft,
2954        set: |t, c| t.accent_soft = c,
2955        doc: "The accent as a translucent wash rather than a fill: a selected menu row, a chosen tab, a highlighted list item. Keeps `fg` readable over it on both bases, which a fill does not.",
2956    },
2957    ThemeRole {
2958        name: "selection",
2959        node: "selection",
2960        get: |t| t.selection,
2961        set: |t, c| t.selection = c,
2962        doc: "What a text selection is painted under, in an editor and over a `selectable` scope alike.",
2963    },
2964    ThemeRole {
2965        name: "focus_ring",
2966        node: "focusRing",
2967        get: |t| t.focus_ring,
2968        set: |t, c| t.focus_ring = c,
2969        doc: "The default keyboard focus ring (ADR 0002).",
2970    },
2971    ThemeRole {
2972        name: "hover",
2973        node: "hover",
2974        get: |t| t.hover,
2975        set: |t, c| t.hover = c,
2976        doc: "A translucent wash over a hovered neutral control. An overlay, not a fill, so one value works on every surface.",
2977    },
2978    ThemeRole {
2979        name: "pressed",
2980        node: "pressed",
2981        get: |t| t.pressed,
2982        set: |t, c| t.pressed = c,
2983        doc: "The same over a pressed one, and the firmer of the two on both bases.",
2984    },
2985    ThemeRole {
2986        name: "success",
2987        node: "success",
2988        get: |t| t.success,
2989        set: |t, c| t.success = c,
2990        doc: "A good outcome. Readable on `surface` on both bases, which is why it is not one colour for both.",
2991    },
2992    ThemeRole {
2993        name: "warning",
2994        node: "warning",
2995        get: |t| t.warning,
2996        set: |t, c| t.warning = c,
2997        doc: "Something that wants attention.",
2998    },
2999    ThemeRole {
3000        name: "danger",
3001        node: "danger",
3002        get: |t| t.danger,
3003        set: |t, c| t.danger = c,
3004        doc: "A destructive action or a failure. The close button's hover, too.",
3005    },
3006    ThemeRole {
3007        name: "scrollbar",
3008        node: "scrollbar",
3009        get: |t| t.scrollbar,
3010        set: |t, c| t.scrollbar = c,
3011        doc: "The scrollbar thumb at rest.",
3012    },
3013    ThemeRole {
3014        name: "scrollbar_active",
3015        node: "scrollbarActive",
3016        get: |t| t.scrollbar_active,
3017        set: |t, c| t.scrollbar_active = c,
3018        doc: "The thumb while hovered or dragged.",
3019    },
3020];
3021
3022/// One size the stock widgets are built from (`crate::metrics::Metrics`), pinned the way [`ThemeRole`] pins a colour: a binding
3023/// iterates the rows to read or write a set, so a field added to
3024/// `Metrics` is one row here and nothing anywhere else. The test below
3025/// destructures the struct exhaustively.
3026pub struct MetricRole {
3027    /// The Rust field, and the name used everywhere but Node.
3028    pub name: &'static str,
3029    /// What Node calls it (camelCase).
3030    pub node: &'static str,
3031    pub doc: &'static str,
3032    pub get: fn(&crate::metrics::Metrics) -> f32,
3033    pub set: fn(&mut crate::metrics::Metrics, f32),
3034    /// `Some` for a row whose stock value is the platform's own rather
3035    /// than a density's: the value on Windows, then everywhere else.
3036    /// `get` answers for the running platform; a generator prints the
3037    /// pair, so `docs/props.md` reads the same whichever machine wrote it.
3038    /// Stock and compact share it — the test below pins
3039    /// that `compact()` leaves such a row alone.
3040    pub platform: Option<PlatformValue>,
3041}
3042
3043/// A metric's value per platform (see [`MetricRole::platform`]).
3044#[derive(Clone, Copy, Debug, PartialEq)]
3045pub struct PlatformValue {
3046    pub windows: f32,
3047    pub elsewhere: f32,
3048}
3049
3050impl PlatformValue {
3051    /// The member in force on the platform this was compiled for.
3052    pub const fn here(self) -> f32 {
3053        if cfg!(target_os = "windows") {
3054            self.windows
3055        } else {
3056            self.elsewhere
3057        }
3058    }
3059}
3060
3061macro_rules! metric_role {
3062    ($field:ident, $node:literal, $doc:literal) => {
3063        MetricRole {
3064            name: stringify!($field),
3065            node: $node,
3066            get: |m| m.$field,
3067            set: |m, v| m.$field = v,
3068            doc: $doc,
3069            platform: None,
3070        }
3071    };
3072    ($field:ident, $node:literal, $doc:literal, windows $w:expr, elsewhere $e:expr) => {
3073        MetricRole {
3074            name: stringify!($field),
3075            node: $node,
3076            get: |m| m.$field,
3077            set: |m, v| m.$field = v,
3078            doc: $doc,
3079            platform: Some(PlatformValue {
3080                windows: $w,
3081                elsewhere: $e,
3082            }),
3083        }
3084    };
3085}
3086
3087pub const METRIC_ROLES: &[MetricRole] = &[
3088    metric_role!(
3089        control_text,
3090        "controlText",
3091        "A stock control's label: the button's text size."
3092    ),
3093    metric_role!(
3094        chrome_text,
3095        "chromeText",
3096        "The chrome's text: a menu row, a menu-bar title, the titlebar's title."
3097    ),
3098    metric_role!(hint_text, "hintText", "A tooltip's text."),
3099    metric_role!(
3100        radius,
3101        "radius",
3102        "The corner of every stock surface: a button, a field, a menu, a tooltip."
3103    ),
3104    metric_role!(
3105        radius_inner,
3106        "radiusInner",
3107        "The corner of a row inside one: a menu row, a menu-bar title."
3108    ),
3109    metric_role!(
3110        control_pad_x,
3111        "controlPadX",
3112        "A button's horizontal padding."
3113    ),
3114    metric_role!(control_pad_y, "controlPadY", "A button's vertical padding."),
3115    metric_role!(
3116        field_pad_x,
3117        "fieldPadX",
3118        "A text field's horizontal padding."
3119    ),
3120    metric_role!(field_pad_y, "fieldPadY", "A text field's vertical padding."),
3121    metric_role!(hint_pad_x, "hintPadX", "A tooltip's horizontal padding."),
3122    metric_role!(hint_pad_y, "hintPadY", "A tooltip's vertical padding."),
3123    metric_role!(
3124        menu_pad_x,
3125        "menuPadX",
3126        "A menu row's horizontal padding, and a menu-bar title's."
3127    ),
3128    metric_role!(
3129        menu_pad_y,
3130        "menuPadY",
3131        "A menu row's vertical padding; a menu-bar title's is two px less."
3132    ),
3133    metric_role!(menu_width, "menuWidth", "A menu panel's width."),
3134    metric_role!(menu_bar_h, "menuBarH", "The drawn menu bar's height."),
3135    metric_role!(
3136        titlebar_h,
3137        "titlebarH",
3138        "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`).",
3139        windows crate::metrics::TITLEBAR_H_WINDOWS,
3140        elsewhere crate::metrics::TITLEBAR_H_ELSEWHERE
3141    ),
3142];
3143
3144static SNAKE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
3145    PROPS
3146        .iter()
3147        .map(|d| Box::leak(snake_case(d.name).into_boxed_str()) as &'static str)
3148        .collect()
3149});
3150
3151/// `minWidth` → `min_width`, `radiusTL` → `radius_tl` (a run of capitals
3152/// is one word); names without capitals pass through.
3153pub fn snake_case(name: &str) -> String {
3154    let mut out = String::with_capacity(name.len() + 2);
3155    let mut prev_upper = false;
3156    for c in name.chars() {
3157        if c.is_ascii_uppercase() {
3158            if !prev_upper {
3159                out.push('_');
3160            }
3161            out.push(c.to_ascii_lowercase());
3162            prev_upper = true;
3163        } else {
3164            out.push(c);
3165            prev_upper = false;
3166        }
3167    }
3168    out
3169}
3170
3171pub fn by_name(name: &str) -> Option<&'static PropDef> {
3172    PROPS.iter().find(|d| d.name == name)
3173}
3174
3175/// The rows by their snake_case name, for a binding that looks one up per
3176/// prop per node every frame (kui-lua's walk, backlog F143): a scan of the
3177/// ~200 names was 8% of a Lua view's lowering.
3178static BY_SNAKE: LazyLock<FxHashMap<&'static [u8], usize>> = LazyLock::new(|| {
3179    SNAKE_NAMES
3180        .iter()
3181        .enumerate()
3182        .map(|(i, n)| (n.as_bytes(), i))
3183        .collect()
3184});
3185
3186pub fn by_snake_name(name: &str) -> Option<&'static PropDef> {
3187    by_snake_bytes(name.as_bytes())
3188}
3189
3190/// [`by_snake_name`] by the name's bytes: a key read off a Lua table is
3191/// bytes, and is looked up without being checked as UTF-8 first.
3192pub fn by_snake_bytes(name: &[u8]) -> Option<&'static PropDef> {
3193    BY_SNAKE.get(name).map(|&i| &PROPS[i])
3194}
3195
3196pub fn by_id(id: u32) -> Option<&'static PropDef> {
3197    PROPS.iter().find(|d| d.id == id)
3198}
3199
3200/// The spelling a binding writes prop names in: JSX's camelCase rows, or the
3201/// snake_case ones a Lua table takes. The allow-list below is per spelling,
3202/// so `hover_bg` in JSX and `hoverBg` in Lua are each as unknown as a typo —
3203/// which is what they are: neither binding reads the other's spelling.
3204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3205pub enum Spelling {
3206    Camel,
3207    Snake,
3208}
3209
3210/// A name one binding takes for a row it cannot spell the usual way, with
3211/// the row's own snake_case name: `repeat` is a Lua keyword, so that row
3212/// also answers to CSS's own name for it. The Lua binding remaps through
3213/// this table, and the check below accepts both sides of it.
3214pub const LUA_ALIASES: &[(&str, &str)] = &[("direction", "repeat")];
3215
3216/// `direction` → `repeat`: the schema name a Lua table key stands for, when
3217/// it is not the name itself.
3218pub fn lua_alias(name: &str) -> Option<&'static str> {
3219    LUA_ALIASES
3220        .iter()
3221        .find(|(alias, _)| *alias == name)
3222        .map(|(_, real)| *real)
3223}
3224
3225/// Every prop name that is not tied to one element, per spelling: the schema
3226/// rows, every spelling of every composite, and the aliases.
3227static SHARED_NAMES: LazyLock<[FxHashSet<&'static str>; 2]> = LazyLock::new(|| {
3228    let mut camel: FxHashSet<&'static str> = PROPS.iter().map(|d| d.name).collect();
3229    let mut snake: FxHashSet<&'static str> = PROPS.iter().map(|d| d.snake_name()).collect();
3230    for c in CUSTOM {
3231        camel.extend(c.jsx_names);
3232        snake.extend(c.lua_names);
3233    }
3234    snake.extend(LUA_ALIASES.iter().map(|(alias, _)| *alias));
3235    [camel, snake]
3236});
3237
3238fn shared_names(spelling: Spelling) -> &'static FxHashSet<&'static str> {
3239    &SHARED_NAMES[(spelling == Spelling::Snake) as usize]
3240}
3241
3242/// The props only this element takes, in `spelling`. An element the table
3243/// does not know has none.
3244pub fn element_own(element: &str, spelling: Spelling) -> &'static [&'static str] {
3245    match ELEMENTS.iter().find(|e| e.name == element) {
3246        Some(e) if spelling == Spelling::Camel => e.jsx_own,
3247        Some(e) => e.lua_own,
3248        None => &[],
3249    }
3250}
3251
3252/// The schema rows `element` reads, in `spelling`, when it does not read
3253/// them all (`ElementDef::jsx_rows`); `None` for an element that takes
3254/// every row, and for one the table does not know.
3255pub fn element_rows(element: &str, spelling: Spelling) -> Option<&'static [&'static str]> {
3256    let e = ELEMENTS.iter().find(|e| e.name == element)?;
3257    if spelling == Spelling::Camel {
3258        e.jsx_rows
3259    } else {
3260        e.lua_rows
3261    }
3262}
3263
3264/// Is `name` a row every element reads — a schema row, a composite, an
3265/// alias — as opposed to a misspelling? What [`known_prop`] answers for an
3266/// element that admits only some rows still depends on this: a row the
3267/// element does not read is dropped like a misspelling, but the warning
3268/// can say so instead of hunting for a nearer spelling.
3269pub fn shared_prop(name: &str, spelling: Spelling) -> bool {
3270    shared_names(spelling).contains(name)
3271}
3272
3273/// Is `name` a prop `element` reads — a schema row, a composite, an alias,
3274/// or one of the element's own? A binding drops everything else on the
3275/// floor, so everything else is a `diag::UNKNOWN_PROP` warning. An element
3276/// that names its rows reads those and its own, and nothing else.
3277pub fn known_prop(element: &str, name: &str, spelling: Spelling) -> bool {
3278    if element_own(element, spelling).contains(&name) {
3279        return true;
3280    }
3281    match element_rows(element, spelling) {
3282        Some(rows) => rows.contains(&name),
3283        None => shared_names(spelling).contains(name),
3284    }
3285}
3286
3287/// The name an unknown one was probably meant to be: the same word in the
3288/// other convention (`hoverBg` for `hover_bg`, `onClick` for `onclick`),
3289/// which is what a wrong spelling almost always is. Nothing fuzzier — a
3290/// confident suggestion or none.
3291pub fn suggest(element: &str, name: &str, spelling: Spelling) -> Option<&'static str> {
3292    let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
3293    let want = squash(name);
3294    let near = |c: &&&str| squash(c) == want;
3295    let own = element_own(element, spelling).iter();
3296    // An element that names its rows is not sent to a row it would drop.
3297    match element_rows(element, spelling) {
3298        Some(rows) => rows.iter().chain(own).find(near).copied(),
3299        None => shared_names(spelling).iter().chain(own).find(near).copied(),
3300    }
3301}
3302
3303/// A parsed prop value, transport-independent.
3304pub enum Parsed {
3305    F32(f32),
3306    Color(Color),
3307    Flag,
3308    Enum(usize),
3309    Sizing(Sizing),
3310    /// A `Min` or a `Max` row's clamp.
3311    Bound(Bound),
3312    Msg(Value),
3313    Str(String),
3314    Resource(u64),
3315    Keyframes(Vec<Keyframe>),
3316    Enter(Enter),
3317    Gradient(crate::gradient::Gradient),
3318    /// A family, already resolved by the parser (`NameRefs::family`).
3319    Family(FontFamily),
3320}
3321
3322/// Everything a prop list can carry; elements pick the parts they use.
3323#[derive(Clone, Debug, PartialEq)]
3324pub struct PropsOut {
3325    pub spec: NodeSpec,
3326    pub style: TextStyle,
3327    pub key: Option<String>,
3328    /// `index`: the data index this node is opened under, which beats `key`
3329    /// when a binding is handed both.
3330    pub index: Option<u64>,
3331    /// `rowCount`: how many indexed rows the node's virtual list has
3332    /// (`Core::row_count`).
3333    pub row_count: Option<u64>,
3334    pub title: Option<String>,
3335    /// `alwaysOnTop`: the root asked for the window above every other
3336    /// app's this frame (`Core::set_always_on_top`).
3337    pub always_on_top: bool,
3338    /// `secureInput`: the root asked for secure keyboard entry while the
3339    /// window has the keyboard (`Core::set_secure_input`).
3340    pub secure_input: bool,
3341    /// `optionAsAlt`: which Option keys the root asked to act as Alt this
3342    /// frame (`Core::set_option_as_alt`).
3343    pub option_as_alt: crate::OptionAsAlt,
3344    /// `imeOff`: the root asked for the platform's input method off in
3345    /// its window this frame (`Core::set_ime_off`).
3346    pub ime_off: bool,
3347    pub key_focus: bool,
3348    /// Hover hint: the element lowering floats `widgets::tooltip` below the
3349    /// node while it is hovered (the parser also marks the spec hoverable).
3350    pub tooltip: Option<String>,
3351    /// The windows the root declared (`Core::declare_window`, in order).
3352    pub windows: Vec<(String, WindowConfig)>,
3353    /// Whether the `wrap` row was declared: the mode is in `style.wrap`,
3354    /// whose default is `Word`, so the style alone cannot say. A
3355    /// single-line editor folds to its width when it was
3356    /// (`EditOptions::wrap`); nothing else reads it.
3357    pub wrap: bool,
3358}
3359
3360/// Which key a prop list opens its node under: the next auto key, the
3361/// `key` label, or — beating the label when a binding is handed both — the
3362/// `index` a virtual list opens its rows by.
3363#[derive(Clone, Copy, Debug, PartialEq)]
3364pub enum Identity<'a> {
3365    Auto,
3366    Label(&'a str),
3367    Index(u64),
3368}
3369
3370impl PropsOut {
3371    /// The identity the list gave the node (see [`Identity`]).
3372    pub fn identity(&self) -> Identity<'_> {
3373        match (self.index, &self.key) {
3374            (Some(i), _) => Identity::Index(i),
3375            (None, Some(label)) => Identity::Label(label),
3376            (None, None) => Identity::Auto,
3377        }
3378    }
3379
3380    pub fn new() -> Self {
3381        PropsOut {
3382            spec: NodeSpec::column(),
3383            style: TextStyle::new(16.0),
3384            key: None,
3385            index: None,
3386            row_count: None,
3387            title: None,
3388            always_on_top: false,
3389            secure_input: false,
3390            option_as_alt: crate::OptionAsAlt::None,
3391            ime_off: false,
3392            key_focus: false,
3393            tooltip: None,
3394            windows: Vec::new(),
3395            wrap: false,
3396        }
3397    }
3398
3399    /// Applies a builder step to the spec in place.
3400    pub fn with_spec(&mut self, f: impl FnOnce(NodeSpec) -> NodeSpec) {
3401        self.spec = f(std::mem::take(&mut self.spec));
3402    }
3403
3404    /// All three effects of the `tooltip` prop at once: hover tracking and
3405    /// the accessible description (both [`NodeSpec::apply_tooltip`]), plus
3406    /// the hint the element lowering floats. A parser that only extracted
3407    /// the string cannot end up implementing two of the three.
3408    pub fn apply_tooltip(&mut self, hint: impl Into<String>) {
3409        let hint = hint.into();
3410        self.with_spec(|s| s.apply_tooltip(&hint));
3411        self.tooltip = Some(hint);
3412    }
3413
3414    /// The list for a leaf — a `line`, `polygon`, `path`, `cells` grid,
3415    /// `image` or `edit` — whose `tooltip` the core floats beside it rather
3416    /// than as its last child, since a leaf holds none (backlog RG113): the
3417    /// spec asks for the hint drawn ([`crate::spec::AccessSpec::tooltip`]), and
3418    /// the leaf's door floats it while the leaf is hovered. `Core::open_from`
3419    /// calls it for the leaves it opens; a binding that lowers an `image`
3420    /// or an `edit` through its own door calls it before handing the spec
3421    /// over. A list with no tooltip comes back as it went in.
3422    pub fn for_leaf(mut self) -> Self {
3423        if self.tooltip.is_some() {
3424            self.spec.access_mut().tooltip = true;
3425        }
3426        self
3427    }
3428
3429    /// Resolves the `pad` shorthand family and applies it — a no-op when the
3430    /// frontend saw none of the seven names, so a spec built from another
3431    /// source keeps its padding.
3432    pub fn apply_pad(&mut self, pad: PadShorthand) {
3433        if pad.declared() {
3434            let edges = pad.resolve();
3435            self.with_spec(|s| s.padding(edges));
3436        }
3437    }
3438}
3439
3440impl Default for PropsOut {
3441    fn default() -> Self {
3442        Self::new()
3443    }
3444}
3445
3446/// Applies one parsed value through its row. Errors on a kind mismatch,
3447/// which can only come from a transport bug (each transport parses by the
3448/// same `Kind`).
3449pub fn apply(def: &PropDef, value: Parsed, out: &mut PropsOut) -> Result<(), String> {
3450    let spec = std::mem::take(&mut out.spec);
3451    let style = out.style;
3452    // Declared at all is a fact of its own for one element (see
3453    // `PropsOut::wrap`); the mode still lands in the style below.
3454    out.wrap |= def.id == P_WRAP;
3455    match (&def.apply, value) {
3456        (Apply::SpecF32(f), Parsed::F32(v)) => out.spec = f(spec, v),
3457        (Apply::SpecColor(f), Parsed::Color(v)) => out.spec = f(spec, v),
3458        (Apply::SpecFlag(f), Parsed::Flag) => out.spec = f(spec),
3459        (Apply::SpecEnum(f), Parsed::Enum(v)) => out.spec = f(spec, v),
3460        (Apply::SpecSizing(f), Parsed::Sizing(v)) => out.spec = f(spec, v),
3461        (Apply::SpecBound(f), Parsed::Bound(v)) => out.spec = f(spec, v),
3462        (Apply::SpecMsg(f), Parsed::Msg(v)) => out.spec = f(spec, v),
3463        (Apply::SpecStr(f), Parsed::Str(v)) => out.spec = f(spec, &v),
3464        (Apply::SpecKeyframes(f), Parsed::Keyframes(v)) => out.spec = f(spec, v),
3465        (Apply::SpecEnter(f), Parsed::Enter(v)) => out.spec = f(spec, v),
3466        (Apply::SpecGradient(f), Parsed::Gradient(v)) => out.spec = f(spec, v),
3467        (Apply::SpecResource(f), Parsed::Resource(v)) => out.spec = f(spec, v),
3468        (Apply::StyleF32(f), Parsed::F32(v)) => {
3469            out.spec = spec;
3470            out.style = f(style, v);
3471        }
3472        (Apply::StyleColor(f), Parsed::Color(v)) => {
3473            out.spec = spec;
3474            out.style = f(style, v);
3475        }
3476        (Apply::StyleEnum(f), Parsed::Enum(v)) => {
3477            out.spec = spec;
3478            out.style = f(style, v);
3479        }
3480        (Apply::StyleFlag(f), Parsed::Flag) => {
3481            out.spec = spec;
3482            out.style = f(style);
3483        }
3484        (Apply::StyleResource(f), Parsed::Resource(v)) => {
3485            out.spec = spec;
3486            out.style = f(style, v);
3487        }
3488        (Apply::StyleStr(f), Parsed::Str(v)) => {
3489            out.spec = spec;
3490            out.style = f(style, &v);
3491        }
3492        (Apply::StyleFamily(f), Parsed::Family(v)) => {
3493            out.spec = spec;
3494            out.style = f(style, v);
3495        }
3496        _ => {
3497            out.spec = spec;
3498            return Err(format!("prop {} value/kind mismatch", def.name));
3499        }
3500    }
3501    Ok(())
3502}
3503
3504/// Looks an enum name up in its row's list.
3505pub fn enum_index(names: &[&str], s: &str) -> Result<usize, String> {
3506    names
3507        .iter()
3508        .position(|n| *n == s)
3509        .ok_or_else(|| format!("bad value {s:?} (one of {names:?})"))
3510}
3511
3512// ---------------------------------------------------------------------------
3513// Value parsing shared by transports
3514
3515/// 0 = transparent, anything else 0xRRGGBBAA.
3516pub fn color_num(hex: u32) -> Color {
3517    if hex == 0 {
3518        Color::TRANSPARENT
3519    } else {
3520        Color::hex(hex)
3521    }
3522}
3523
3524/// `#rgb` / `#rrggbb` / `#rrggbbaa`.
3525pub fn color_hex_str(s: &str) -> Result<Color, String> {
3526    let bad = || format!("bad color {s:?}");
3527    let hex = s.strip_prefix('#').ok_or_else(bad)?;
3528    let expanded = match hex.len() {
3529        3 => hex
3530            .chars()
3531            .flat_map(|c| [c, c])
3532            .chain("ff".chars())
3533            .collect::<String>(),
3534        6 => format!("{hex}ff"),
3535        8 => hex.to_string(),
3536        _ => return Err(bad()),
3537    };
3538    let n = u32::from_str_radix(&expanded, 16).map_err(|_| bad())?;
3539    Ok(Color::hex(n))
3540}
3541
3542/// The mode a sizing's, a min's or a max's first binary slot holds for a
3543/// size expression, whose spelling follows as a strref (v19).
3544pub const SIZE_MODE_CALC: u32 = 4;
3545
3546/// The mode for a size expression as data: a count of slots follows,
3547/// then the expression in prefix code ([`crate::calc::from_code`]) —
3548/// what the Node encoder sends for `{ clamp: [...] }`, so the addon
3549/// reads numbers and parses no text (v19).
3550pub const SIZE_MODE_TREE: u32 = 5;
3551
3552/// The string forms of a min: `"fit"`, or a size expression
3553/// ([`crate::calc`]). A number arrives as a number.
3554pub fn min_str(s: &str) -> Result<Bound, String> {
3555    match s {
3556        "fit" => Ok(Bound::Fit),
3557        _ => crate::calc::bound(s)
3558            .map_err(|e| format!("bad min {s:?} (number | \"fit\" | a size expression): {e}")),
3559    }
3560}
3561
3562/// The string forms of a max: a size expression.
3563pub fn max_str(s: &str) -> Result<Bound, String> {
3564    crate::calc::bound(s).map_err(|e| format!("bad max {s:?} (number | a size expression): {e}"))
3565}
3566
3567/// The string forms of a sizing: "fit" | "grow" | "N%" | a size
3568/// expression ([`crate::calc`]).
3569pub fn sizing_str(s: &str) -> Result<Sizing, String> {
3570    match s {
3571        "fit" => Ok(Sizing::Fit),
3572        "grow" => Ok(Sizing::Grow(1.0)),
3573        _ => crate::calc::sizing(s).map_err(|e| {
3574            format!("bad sizing {s:?} (fit | grow | number | \"N%\" | a size expression): {e}")
3575        }),
3576    }
3577}
3578
3579#[cfg(test)]
3580mod tests {
3581    use super::*;
3582
3583    /// An enum row's name list is the wire order — a binding sends the
3584    /// index — so each list is its enum's `ALL` by `name`, and the index
3585    /// map is a lookup in `ALL` and not a second table (`Easing`, `Repeat`, `Live` and `FontFamily` were hand maps that a
3586    /// variant appended or a list reordered put one off, with nothing to
3587    /// say so). `CURSORS` has the same pin in `cursor.rs`, `ROLES` its
3588    /// own below.
3589    #[test]
3590    fn every_enum_list_is_its_enum_s_all_by_name() {
3591        fn names(it: &[&'static str]) -> Vec<&'static str> {
3592            it.to_vec()
3593        }
3594        assert_eq!(
3595            Easing::ALL.iter().map(|e| e.name()).collect::<Vec<_>>(),
3596            names(EASINGS)
3597        );
3598        assert_eq!(
3599            Repeat::ALL.iter().map(|r| r.name()).collect::<Vec<_>>(),
3600            names(REPEATS)
3601        );
3602        assert_eq!(
3603            crate::access::Live::ALL
3604                .iter()
3605                .map(|l| l.name())
3606                .collect::<Vec<_>>(),
3607            names(LIVE)
3608        );
3609        assert_eq!(
3610            FontFamily::ALL
3611                .iter()
3612                .map(|f| f.name().expect("a stock family has a name"))
3613                .collect::<Vec<_>>(),
3614            names(FAMILIES)
3615        );
3616        // And the index map round-trips through the list, every index.
3617        for (i, e) in Easing::ALL.iter().enumerate() {
3618            assert_eq!(easing_idx(i), *e);
3619        }
3620        for (i, r) in Repeat::ALL.iter().enumerate() {
3621            assert_eq!(repeat_idx(i), *r);
3622        }
3623        for (i, l) in crate::access::Live::ALL.iter().enumerate() {
3624            assert_eq!(crate::access::Live::from_index(i), *l);
3625        }
3626        for (i, f) in FontFamily::ALL.iter().enumerate() {
3627            assert_eq!(FontFamily::from_index(i), *f);
3628        }
3629        // An index this build lacks is the default, not a panic.
3630        assert_eq!(easing_idx(99), Easing::default());
3631        assert_eq!(repeat_idx(99), Repeat::default());
3632        assert_eq!(FontFamily::from_index(99), FontFamily::Sans);
3633    }
3634
3635    #[test]
3636    fn ids_and_names_are_unique() {
3637        let all: Vec<(&str, u32)> = PROPS
3638            .iter()
3639            .map(|d| (d.name, d.id))
3640            .chain(CUSTOM.iter().map(|c| (c.name, c.id)))
3641            .collect();
3642        for (i, (name, id)) in all.iter().enumerate() {
3643            for (other_name, other_id) in &all[i + 1..] {
3644                assert_ne!(name, other_name, "duplicate prop name");
3645                assert_ne!(id, other_id, "duplicate wire id for {name} / {other_name}");
3646            }
3647        }
3648    }
3649
3650    /// `menu_bar_h` → `menuBarH`: what a role's Node spelling is.
3651    fn snake_to_camel(name: &str) -> String {
3652        let mut out = String::new();
3653        let mut up = false;
3654        for ch in name.chars() {
3655            if ch == '_' {
3656                up = true;
3657            } else if up {
3658                out.extend(ch.to_uppercase());
3659                up = false;
3660            } else {
3661                out.push(ch);
3662            }
3663        }
3664        out
3665    }
3666
3667    /// `METRIC_ROLES` restates `Metrics` the way `THEME_ROLES` restates
3668    /// `Theme`, and this is the same pin: an exhaustive pattern on the
3669    /// struct, then the names both ways.
3670    #[test]
3671    fn metric_roles_restate_the_metrics_exactly() {
3672        use crate::metrics::Metrics;
3673        let m = Metrics::compact();
3674        let Metrics {
3675            control_text,
3676            chrome_text,
3677            hint_text,
3678            radius,
3679            radius_inner,
3680            control_pad_x,
3681            control_pad_y,
3682            field_pad_x,
3683            field_pad_y,
3684            hint_pad_x,
3685            hint_pad_y,
3686            menu_pad_x,
3687            menu_pad_y,
3688            menu_width,
3689            menu_bar_h,
3690            titlebar_h,
3691        } = m;
3692        let fields: &[(&str, f32)] = &[
3693            ("control_text", control_text),
3694            ("chrome_text", chrome_text),
3695            ("hint_text", hint_text),
3696            ("radius", radius),
3697            ("radius_inner", radius_inner),
3698            ("control_pad_x", control_pad_x),
3699            ("control_pad_y", control_pad_y),
3700            ("field_pad_x", field_pad_x),
3701            ("field_pad_y", field_pad_y),
3702            ("hint_pad_x", hint_pad_x),
3703            ("hint_pad_y", hint_pad_y),
3704            ("menu_pad_x", menu_pad_x),
3705            ("menu_pad_y", menu_pad_y),
3706            ("menu_width", menu_width),
3707            ("menu_bar_h", menu_bar_h),
3708            ("titlebar_h", titlebar_h),
3709        ];
3710        assert_eq!(METRIC_ROLES.len(), fields.len(), "a metric has no row");
3711        for (name, value) in fields {
3712            let row = METRIC_ROLES
3713                .iter()
3714                .find(|r| r.name == *name)
3715                .unwrap_or_else(|| panic!("no METRIC_ROLES row for {name}"));
3716            assert_eq!((row.get)(&m), *value, "{name}'s row reads another field");
3717            let mut w = m;
3718            (row.set)(&mut w, 1.0);
3719            assert_eq!((row.get)(&w), 1.0, "{name}'s row writes another field");
3720        }
3721        for row in METRIC_ROLES {
3722            assert!(
3723                fields.iter().any(|(n, _)| *n == row.name),
3724                "METRIC_ROLES names a field Metrics does not have: {}",
3725                row.name
3726            );
3727            let camel = snake_to_camel(row.name);
3728            assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3729            // A platform row's pair is what the struct reads on this
3730            // platform, in both sets: the generator prints the pair for
3731            // both columns on the strength of this.
3732            if let Some(p) = row.platform {
3733                assert_eq!(
3734                    (row.get)(&Metrics::default()),
3735                    p.here(),
3736                    "{}'s stock value",
3737                    row.name
3738                );
3739                assert_eq!(
3740                    (row.get)(&m),
3741                    p.here(),
3742                    "{}: compact leaves a platform row alone",
3743                    row.name
3744                );
3745            }
3746        }
3747        assert!(
3748            METRIC_ROLES.iter().any(|r| r.platform.is_some()),
3749            "titlebar_h is the platform's; its row says so"
3750        );
3751    }
3752
3753    /// `THEME_ROLES` restates `Theme`; this pins the two together the way
3754    /// `ENV_FIELDS` pins `Env`. The exhaustive pattern is the pin on the
3755    /// struct — a role added to `Theme` stops this compiling until it has
3756    /// a row — and the names are then checked in both directions, so a
3757    /// row cannot be forgotten and a row cannot name a field that is not
3758    /// there. `appearance` and `disabled_opacity` are not colours and are
3759    /// carried beside the roles rather than among them.
3760    #[test]
3761    fn theme_roles_restate_the_theme_exactly() {
3762        use crate::theme::Theme;
3763        let t = Theme::dark();
3764        let Theme {
3765            appearance: _,
3766            disabled_opacity: _,
3767            bg,
3768            surface,
3769            raised,
3770            sunken,
3771            border,
3772            border_strong,
3773            fg,
3774            muted,
3775            faint,
3776            accent,
3777            accent_hover,
3778            accent_pressed,
3779            on_accent,
3780            accent_soft,
3781            selection,
3782            focus_ring,
3783            hover,
3784            pressed,
3785            success,
3786            warning,
3787            danger,
3788            scrollbar,
3789            scrollbar_active,
3790        } = t;
3791        let fields: &[(&str, crate::color::Color)] = &[
3792            ("bg", bg),
3793            ("surface", surface),
3794            ("raised", raised),
3795            ("sunken", sunken),
3796            ("border", border),
3797            ("border_strong", border_strong),
3798            ("fg", fg),
3799            ("muted", muted),
3800            ("faint", faint),
3801            ("accent", accent),
3802            ("accent_hover", accent_hover),
3803            ("accent_pressed", accent_pressed),
3804            ("on_accent", on_accent),
3805            ("accent_soft", accent_soft),
3806            ("selection", selection),
3807            ("focus_ring", focus_ring),
3808            ("hover", hover),
3809            ("pressed", pressed),
3810            ("success", success),
3811            ("warning", warning),
3812            ("danger", danger),
3813            ("scrollbar", scrollbar),
3814            ("scrollbar_active", scrollbar_active),
3815        ];
3816        assert_eq!(THEME_ROLES.len(), fields.len(), "a role has no row");
3817        for (name, value) in fields {
3818            let row = THEME_ROLES
3819                .iter()
3820                .find(|r| r.name == *name)
3821                .unwrap_or_else(|| panic!("no THEME_ROLES row for {name}"));
3822            assert_eq!((row.get)(&t), *value, "{name}'s row reads another field");
3823        }
3824        for row in THEME_ROLES {
3825            assert!(
3826                fields.iter().any(|(n, _)| *n == row.name),
3827                "{} names no field",
3828                row.name
3829            );
3830            // Node's spelling is this one in camelCase, always.
3831            let camel = {
3832                let mut out = String::new();
3833                let mut up = false;
3834                for ch in row.name.chars() {
3835                    if ch == '_' {
3836                        up = true;
3837                    } else if up {
3838                        out.extend(ch.to_uppercase());
3839                        up = false;
3840                    } else {
3841                        out.push(ch);
3842                    }
3843                }
3844                out
3845            };
3846            assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3847        }
3848    }
3849
3850    /// `ENV_FIELDS` restates `Env` and `WindowEnv`; this pins the three
3851    /// together. The exhaustive patterns are the pin on the structs — a
3852    /// field added to either stops this compiling until it is named here
3853    /// — and the list beside them is then checked against the table in
3854    /// both directions, so a row cannot be forgotten and a row cannot
3855    /// claim a field that does not exist.
3856    #[test]
3857    fn env_fields_restate_env_and_window_env_exactly() {
3858        use crate::env::{AudioEnv, Env, SystemEnv};
3859        use crate::window::WindowEnv;
3860        let Env {
3861            refresh_hz: _,
3862            focused: _,
3863            system,
3864            window,
3865            audio,
3866        } = Env::default();
3867        let AudioEnv { device: _, live: _ } = audio;
3868        let SystemEnv {
3869            appearance: _,
3870            accent: _,
3871            motion: _,
3872            locale: _,
3873            assistive: _,
3874        } = system;
3875        let WindowEnv {
3876            id: _,
3877            custom_chrome: _,
3878            maximized: _,
3879            fullscreen: _,
3880            always_on_top: _,
3881            native_controls: _,
3882            backdrop: _,
3883        } = window;
3884        let stored = [
3885            "refresh_hz",
3886            "focused",
3887            "system.appearance",
3888            "system.accent",
3889            "system.motion",
3890            "system.locale",
3891            "system.assistive",
3892            "window.id",
3893            "window.custom_chrome",
3894            "window.maximized",
3895            "window.fullscreen",
3896            "window.always_on_top",
3897            "window.native_controls",
3898            "window.backdrop",
3899            "audio.device",
3900            "audio.live",
3901        ];
3902        // A stored fact's `from` is the struct and the field, spelled the
3903        // one way; everything else in the column is a call.
3904        let from_structs: Vec<&str> = ENV_FIELDS
3905            .iter()
3906            .filter(|f| !f.from.contains('('))
3907            .map(|f| {
3908                let (strukt, field) = match f.name.split_once('.') {
3909                    Some(("window", field)) => ("WindowEnv", field),
3910                    Some(("system", field)) => ("SystemEnv", field),
3911                    Some(("audio", field)) => ("AudioEnv", field),
3912                    Some((group, _)) => panic!("{}: no struct holds a {group} fact", f.name),
3913                    None => ("Env", f.name),
3914                };
3915                assert_eq!(f.from, format!("`{strukt}::{field}`"), "{}", f.name);
3916                f.name
3917            })
3918            .collect();
3919        assert_eq!(from_structs, stored);
3920        for f in ENV_FIELDS.iter().filter(|f| f.from.contains('(')) {
3921            assert!(
3922                f.from.contains("derived") || f.from.contains("the frame's"),
3923                "{}: a call in `from` is derived or the frame's, and says which",
3924                f.name
3925            );
3926        }
3927    }
3928
3929    /// Every spelling in `ENV_FIELDS` is unique per binding — two rows
3930    /// cannot land on one key — and every row says something in every
3931    /// column, so the generated table has no blank cells to wonder about.
3932    #[test]
3933    fn env_field_spellings_are_unique_and_complete() {
3934        let mut names: Vec<&str> = ENV_FIELDS.iter().map(|f| f.name).collect();
3935        let mut node: Vec<&str> = ENV_FIELDS
3936            .iter()
3937            .flat_map(|f| f.node.iter().copied())
3938            .collect();
3939        let mut lua: Vec<&str> = ENV_FIELDS
3940            .iter()
3941            .flat_map(|f| f.lua.iter().copied())
3942            .collect();
3943        for list in [&mut names, &mut node, &mut lua] {
3944            let before = list.len();
3945            list.sort_unstable();
3946            list.dedup();
3947            assert_eq!(list.len(), before, "a spelling is used twice");
3948        }
3949        for f in ENV_FIELDS {
3950            assert!(!f.c.is_empty() && !f.doc.is_empty(), "{}", f.name);
3951            // A binding that has no key for a fact says where it went
3952            // instead, so the empty cell is explained by the row itself.
3953            if f.node.is_empty() {
3954                assert!(
3955                    f.doc.contains("Node"),
3956                    "{}: where does Node carry it?",
3957                    f.name
3958                );
3959            }
3960            if f.lua.is_empty() {
3961                assert!(
3962                    f.doc.contains("Lua"),
3963                    "{}: where does Lua carry it?",
3964                    f.name
3965                );
3966            }
3967        }
3968    }
3969
3970    /// An element's admitted rows are real rows in each spelling and the
3971    /// same rows in both, so the JSX check and the Lua check admit the
3972    /// same button.
3973    #[test]
3974    fn admitted_rows_are_shared_rows_in_both_spellings() {
3975        for e in ELEMENTS {
3976            let (Some(jsx), Some(lua)) = (e.jsx_rows, e.lua_rows) else {
3977                assert!(
3978                    e.jsx_rows.is_none() && e.lua_rows.is_none(),
3979                    "{}: one spelling only",
3980                    e.name
3981                );
3982                continue;
3983            };
3984            assert_eq!(
3985                jsx.len(),
3986                lua.len(),
3987                "{}: the lists differ in length",
3988                e.name
3989            );
3990            for (j, l) in jsx.iter().zip(lua) {
3991                assert!(
3992                    shared_prop(j, Spelling::Camel),
3993                    "{}: `{j}` is not a row",
3994                    e.name
3995                );
3996                assert!(
3997                    shared_prop(l, Spelling::Snake),
3998                    "{}: `{l}` is not a row",
3999                    e.name
4000                );
4001                assert_eq!(
4002                    snake_case(j),
4003                    *l,
4004                    "{}: `{j}` and `{l}` are not one row",
4005                    e.name
4006                );
4007            }
4008            // The row a reader hears first: a stock button without a name
4009            // is the warning `control-without-name`, so `label` is never
4010            // the row a closed *control* leaves out. A text is its own
4011            // name.
4012            if jsx.contains(&"onClick") {
4013                assert!(jsx.contains(&"label"), "{}: `label` missing", e.name);
4014            }
4015        }
4016        assert!(known_prop("button", "description", Spelling::Camel));
4017        assert!(known_prop("button", "on_click", Spelling::Snake));
4018        assert!(known_prop("button", "text", Spelling::Snake));
4019        assert!(!known_prop("button", "radius", Spelling::Camel));
4020        assert!(!known_prop("button", "text", Spelling::Camel));
4021        assert!(known_prop("box", "radius", Spelling::Camel));
4022        // The other spelling is looked for among the rows it reads: a row
4023        // it would drop is not offered as the fix.
4024        assert_eq!(
4025            suggest("button", "on_click", Spelling::Camel),
4026            Some("onClick")
4027        );
4028        assert_eq!(suggest("button", "hover_bg", Spelling::Camel), None);
4029        assert_eq!(suggest("box", "hover_bg", Spelling::Camel), Some("hoverBg"));
4030    }
4031
4032    /// AR13: a text is content plus a style, so the rows it admits are
4033    /// exactly the rows that land on a `TextStyle`, plus `size`, the
4034    /// composite the style is built from. Every other row — a container's,
4035    /// an access row, `key` — is the `unknown-prop` warning in every
4036    /// binding rather than a silent drop.
4037    #[test]
4038    fn text_admits_exactly_the_style_rows() {
4039        let style: Vec<&str> = PROPS
4040            .iter()
4041            .filter(|d| d.target() == Target::Style)
4042            .map(|d| d.name)
4043            .collect();
4044        let mut expect = vec!["size"];
4045        expect.extend(style);
4046        let mut got = TEXT_ROWS_JSX.to_vec();
4047        expect.sort_unstable();
4048        got.sort_unstable();
4049        assert_eq!(got, expect, "TEXT_ROWS_JSX is not the style rows");
4050        for name in ["lineHeight", "color", "wrap", "size"] {
4051            assert!(known_prop("text", name, Spelling::Camel), "{name}");
4052        }
4053        for name in ["live", "role", "label", "onClick", "key", "pad", "bg"] {
4054            assert_eq!(
4055                known_prop("text", name, Spelling::Camel),
4056                name == "bg",
4057                "{name}: `bg` is the element's own (a span's), the rest are not rows it reads"
4058            );
4059        }
4060        assert!(known_prop("text", "line_height", Spelling::Snake));
4061        assert!(known_prop("text", "value", Spelling::Snake));
4062        assert!(!known_prop("text", "live", Spelling::Snake));
4063        assert!(!known_prop("text", "on_click", Spelling::Snake));
4064        // A misspelling is still steered to a row it reads, and to nothing
4065        // it would drop.
4066        assert_eq!(
4067            suggest("text", "max_lines", Spelling::Camel),
4068            Some("maxLines")
4069        );
4070        assert_eq!(suggest("text", "hover_bg", Spelling::Camel), None);
4071    }
4072
4073    #[test]
4074    fn snake_names_round_trip() {
4075        assert_eq!(by_snake_name("min_width").unwrap().name, "minWidth");
4076        assert_eq!(by_snake_name("on_click").unwrap().name, "onClick");
4077        assert_eq!(by_snake_name("bg").unwrap().name, "bg");
4078        assert!(by_snake_name("minWidth").is_none());
4079        assert_eq!(by_name("lineHeight").unwrap().snake_name(), "line_height");
4080        assert_eq!(by_name("radiusTL").unwrap().snake_name(), "radius_tl");
4081        assert_eq!(by_snake_name("radius_bl").unwrap().name, "radiusBL");
4082    }
4083
4084    /// The allow-list is per spelling, so each binding's own names pass and
4085    /// the other's do not — which is the point: neither binding reads the
4086    /// other's, so `hover_bg` in JSX is as dropped as `hoverBgg` would be.
4087    #[test]
4088    fn the_allow_list_is_per_spelling() {
4089        use Spelling::{Camel, Snake};
4090        assert!(known_prop("box", "hoverBg", Camel));
4091        assert!(known_prop("box", "hover_bg", Snake));
4092        assert!(!known_prop("box", "hover_bg", Camel));
4093        assert!(!known_prop("box", "hoverBg", Snake));
4094        // Composites, each in the spelling its binding takes.
4095        assert!(known_prop("box", "padX", Camel) && !known_prop("box", "padX", Snake));
4096        assert!(known_prop("box", "scroll", Snake) && !known_prop("box", "scroll", Camel));
4097        // `repeat` is a Lua keyword; the alias stands in for the row.
4098        assert!(known_prop("box", "direction", Snake));
4099        assert_eq!(lua_alias("direction"), Some("repeat"));
4100        // An element's own props are its own: `initial` is an editor's.
4101        assert!(known_prop("edit", "initial", Camel));
4102        assert!(!known_prop("box", "initial", Camel));
4103        // A shared text row on an editor is the editor's too: `wrap` is
4104        // what folds a single-line one, and it must not warn.
4105        assert!(known_prop("edit", "wrap", Camel) && known_prop("edit", "wrap", Snake));
4106        assert!(known_prop("image", "src", Camel) && known_prop("image", "id", Snake));
4107        // And nothing claims a typo.
4108        assert!(!known_prop("box", "colour", Camel));
4109    }
4110
4111    #[test]
4112    fn a_suggestion_is_the_same_word_in_the_right_convention() {
4113        use Spelling::{Camel, Snake};
4114        assert_eq!(suggest("box", "hoverBg", Snake), Some("hover_bg"));
4115        assert_eq!(suggest("box", "onclick", Camel), Some("onClick"));
4116        assert_eq!(suggest("box", "SCROLL_X", Camel), Some("scrollX"));
4117        assert_eq!(suggest("edit", "Initial", Camel), Some("initial"));
4118        // Nothing fuzzy: a guess or nothing.
4119        assert_eq!(suggest("box", "colour", Camel), None);
4120    }
4121
4122    /// Every name a binding hand-lowers has to be in one of the tables, or
4123    /// the binding warns about a prop it reads perfectly well.
4124    #[test]
4125    fn every_composite_and_element_prop_is_in_the_allow_list() {
4126        for c in CUSTOM {
4127            for n in c.jsx_names {
4128                assert!(known_prop("box", n, Spelling::Camel), "jsx `{n}`");
4129            }
4130            for n in c.lua_names {
4131                assert!(known_prop("box", n, Spelling::Snake), "lua `{n}`");
4132            }
4133        }
4134        for e in ELEMENTS {
4135            for n in e.jsx_own {
4136                assert!(known_prop(e.name, n, Spelling::Camel), "{}.{n}", e.name);
4137            }
4138            for n in e.lua_own {
4139                assert!(known_prop(e.name, n, Spelling::Snake), "{}.{n}", e.name);
4140            }
4141        }
4142    }
4143
4144    #[test]
4145    fn every_row_applies_a_sample_of_its_kind() {
4146        for def in PROPS {
4147            // `opacity` is a 0..=1 slot whose default is the top of the
4148            // range, so the shared sample would clamp back to it.
4149            let f = if def.name == "opacity" { 0.5 } else { 7.0 };
4150            let sample = match def.kind {
4151                Kind::F32 => Parsed::F32(f),
4152                Kind::Color => Parsed::Color(Color::hex(0x11223344)),
4153                Kind::Flag => Parsed::Flag,
4154                Kind::Enum(_) => Parsed::Enum(1),
4155                Kind::Sizing => Parsed::Sizing(Sizing::Percent(0.5)),
4156                Kind::Min => Parsed::Bound(Bound::Fit),
4157                Kind::Max => Parsed::Bound(Bound::Px(10.0)),
4158                Kind::Msg | Kind::Tag => Parsed::Msg(Value::Int(1)),
4159                // A list of four names, and a string of none of them is
4160                // the default: none.
4161                Kind::Str if def.name == "scrollMods" => Parsed::Str("ctrl".into()),
4162                Kind::Str => Parsed::Str("name".into()),
4163                Kind::Family => Parsed::Family(FontFamily::Mono),
4164                Kind::Resource => Parsed::Resource(7),
4165                Kind::Keyframes => Parsed::Keyframes(vec![Keyframe::default().radius(7.0)]),
4166                Kind::Enter => Parsed::Enter(Enter::from(-7.0, 0.0)),
4167                Kind::Gradient => Parsed::Gradient(crate::gradient::Gradient::to(
4168                    crate::gradient::Side::Right,
4169                    [Color::hex(0x11223344), Color::WHITE],
4170                )),
4171            };
4172            let mut out = PropsOut::new();
4173            apply(def, sample, &mut out).unwrap();
4174            let changed = match def.target() {
4175                Target::Spec => out.spec != NodeSpec::column(),
4176                Target::Style => out.style != TextStyle::new(16.0),
4177            };
4178            assert!(changed, "{} applied a sample but nothing changed", def.name);
4179        }
4180    }
4181
4182    /// A null tag keeps the behaviour and drops the `tag` field; the
4183    /// contract every transport relies on to accept `null` for `Tag` rows.
4184    #[test]
4185    fn a_null_tag_still_declares_the_behaviour() {
4186        let mut out = PropsOut::new();
4187        apply(
4188            by_name("onKey").unwrap(),
4189            Parsed::Msg(Value::Null),
4190            &mut out,
4191        )
4192        .unwrap();
4193        assert_eq!(out.spec.events().on_key, Some(Value::Null));
4194        assert!(out.spec.hover_tracked());
4195        let mut out = PropsOut::new();
4196        apply(
4197            by_name("onLayout").unwrap(),
4198            Parsed::Msg(Value::Null),
4199            &mut out,
4200        )
4201        .unwrap();
4202        assert_eq!(out.spec.events().on_layout, Some(Value::Null));
4203    }
4204
4205    /// `ROLES` is the wire order for the `role` enum — a binding sends the
4206    /// index — so a typo, or a spelling that drifted from `access.rs`, does
4207    /// not fail anywhere: the name simply stops parsing, and every view
4208    /// declaring that role silently gets [`role_idx`]'s fallback instead.
4209    /// Until this test that fallback was `Role::None`, which takes the node
4210    /// *and its whole subtree* out of the access tree — a drifted spelling
4211    /// would have removed part of the app from every screen reader, with
4212    /// nothing failing and nothing warning. Both halves are pinned here:
4213    /// each name is a real role, and index `i` still means `ROLES[i]`.
4214    #[test]
4215    fn every_declarable_role_name_is_a_real_role() {
4216        for (i, name) in ROLES.iter().enumerate() {
4217            let role = Role::parse(name)
4218                .unwrap_or_else(|| panic!("ROLES[{i}] = {name:?} is not a Role::ALL name"));
4219            assert_eq!(role_idx(i), role, "role index {i} lowers to the wrong role");
4220            assert_eq!(role_idx(i).name(), *name);
4221        }
4222    }
4223
4224    /// The C header pins its role enum to `Role::ALL` by construction
4225    /// (`kui-ffi`'s `abi_enum!`), so a new role fails the C build until the
4226    /// header names it. Lua, Node and JSX have no such pin — they read
4227    /// `ROLES` — and a role added to `Role::ALL` and forgotten there is
4228    /// simply undeclarable from all three, silently. This is that pin: a
4229    /// role is declarable or derived, never neither and never both, and an
4230    /// exemption has to be one a frame really does derive, so `DERIVED_ONLY`
4231    /// cannot become somewhere to put a forgotten row.
4232    #[test]
4233    fn every_role_is_declarable_or_derived() {
4234        for role in Role::ALL {
4235            let declarable = ROLES.contains(&role.name());
4236            let derived = DERIVED_ONLY.iter().any(|(n, _)| *n == role.name());
4237            assert!(
4238                declarable || derived,
4239                "Role::{role:?} is in Role::ALL but no view can declare it and \
4240                 DERIVED_ONLY does not say the core derives it — add {:?} to \
4241                 ROLES, or to DERIVED_ONLY with the reason",
4242                role.name()
4243            );
4244            assert!(
4245                !(declarable && derived),
4246                "Role::{role:?} is in ROLES, so it is declarable — drop it from \
4247                 DERIVED_ONLY"
4248            );
4249        }
4250        for (name, why) in DERIVED_ONLY {
4251            assert!(!why.is_empty(), "{name:?} is exempted without a reason");
4252            assert!(
4253                Role::parse(name).is_some(),
4254                "DERIVED_ONLY names {name:?}, which is not a Role::ALL name"
4255            );
4256        }
4257        let derived = roles_one_frame_derives();
4258        for (name, _) in DERIVED_ONLY {
4259            assert!(
4260                derived.contains(&Role::parse(name).unwrap()),
4261                "DERIVED_ONLY keeps {name:?} out of ROLES because the core \
4262                 derives it, but no frame does any more — either it belongs in \
4263                 ROLES now, or the exemption is stale"
4264            );
4265        }
4266    }
4267
4268    /// One frame that derives each of `DERIVED_ONLY`'s roles: the root is
4269    /// the window, a `window_drag` row the title bar, a scrolling box the
4270    /// scroll view, the text inside it static text, and a `secure` editor
4271    /// a password input.
4272    fn roles_one_frame_derives() -> Vec<Role> {
4273        let mut core = crate::Core::new();
4274        let mut ui = core.frame(crate::Size::new(200.0, 200.0), 1.0);
4275        ui.window_title("Demo");
4276        ui.configure_root(NodeSpec::column().fill());
4277        ui.leaf_keyed("titlebar", NodeSpec::row().window_drag());
4278        ui.text_in_keyed(
4279            "scroll",
4280            NodeSpec::column().height(40.0).scroll_y(),
4281            "hello",
4282            TextStyle::new(12.0),
4283        );
4284        ui.text_edit(
4285            "password",
4286            "",
4287            &crate::EditOptions {
4288                secure: true,
4289                ..Default::default()
4290            },
4291            NodeSpec::row().width(100.0).label("Password"),
4292        );
4293        ui.finish();
4294        core.access_tree().nodes.iter().map(|n| n.role).collect()
4295    }
4296
4297    #[test]
4298    fn colors_and_sizings_parse() {
4299        assert_eq!(color_hex_str("#fff").unwrap(), Color::hex(0xffffffff));
4300        assert_eq!(color_hex_str("#11223344").unwrap(), Color::hex(0x11223344));
4301        assert!(color_hex_str("fff").is_err());
4302        assert_eq!(sizing_str("50%").unwrap(), Sizing::Percent(0.5));
4303        assert_eq!(sizing_str("grow").unwrap(), Sizing::Grow(1.0));
4304        assert!(sizing_str("wide").is_err());
4305    }
4306}