Skip to main content

kui_core/
schema.rs

1//! The prop schema: the single source of truth for the per-node surface
2//! every frontend lowers into. One `PROPS` row declares a prop's name, wire
3//! id, value kind, apply function, and doc — and every binding interprets
4//! that row instead of restating it:
5//!
6//! - `kui-node` parses JSON and its binary stream by kind, exports the rows
7//!   as `protocol()` so the JS encoder writes by kind, and generates the TS
8//!   prop types from them (`npm run gen`);
9//! - `kui-lua` walks a node table and looks each key up by snake_case name
10//!   (`minWidth` is `min_width` in Lua);
11//! - `kui-ffi` mirrors the rows in a `repr(C)` struct — that layout has to be
12//!   static, so it stays hand-written and pins itself to this table with a
13//!   parity test that fails when a row has no C counterpart.
14//!
15//! Adding a simple prop = one row here (+ `npm run gen` for TS, + a field in
16//! `KuiSpec` when the parity test says so). Composite props with real logic
17//! (the pad shorthand family, border, overflow bits, float configs) and the
18//! constructor-ordering specials (`dir`, `size`, `key`, `title`, `keyFocus`)
19//! need per-binding *extraction* — a Lua table, a serde_json map, a binary
20//! stream and a C struct are genuinely different to read — but not
21//! per-binding *decisions*: what "below" attaches to, what `padX` falls
22//! back to, what a scroll bit implies and what a tooltip means all live in
23//! one place ([`crate::spec::FloatConfig::build`],
24//! [`crate::spec::PadShorthand`], [`crate::spec::NodeSpec::overflow_bits`],
25//! [`PropsOut::apply_tooltip`]), and a binding pulls typed scalars out of
26//! its own value type and calls them. `CUSTOM` lists them by name and wire
27//! id so transports agree on identity, and `crate::conformance` makes them
28//! agree on behaviour — every `CUSTOM` and `ELEMENTS` row has to appear in
29//! a scene that all four bindings reproduce byte for byte, or the build
30//! fails.
31//!
32//! Those rows are also the allow-list: a dynamic binding drops a name it
33//! cannot place, so the names it may legitimately drop have to be written
34//! down somewhere both bindings read. `CUSTOM` carries every spelling of
35//! each composite and `ELEMENTS` the props an element lowers itself
36//! (`<edit initial>`, `<image src>`), each in both conventions;
37//! [`known_prop`] answers from them and everything else is a
38//! `diag::UNKNOWN_PROP` warning.
39//!
40//! The verbs — what an app or a host *calls* rather than declares — are
41//! the one surface this table did not cover; [`DOORS`] does (backlog B1a),
42//! one row per verb with its C, Node and Lua spelling or the reason there
43//! is none, pinned by each binding's own test.
44
45use std::sync::LazyLock;
46
47use rustc_hash::FxHashSet;
48
49use crate::access::Role;
50use crate::anim::{Easing, Repeat};
51use crate::color::Color;
52use crate::cursor::CursorShape;
53use crate::enter::Enter;
54use crate::keyframes::Keyframe;
55use crate::spec::{
56    Align, Bound, FontFamily, NodeSpec, PadShorthand, Sizing, TextStyle, TextWrap, UnderlineStyle,
57};
58use crate::value::Value;
59use crate::window::{WindowButton, WindowConfig};
60
61mod doors;
62pub use doors::{Cell, DOORS, Door, GUEST};
63
64// Wire ids, stable within a binary protocol version (see kui-node).
65pub const P_DIR: u32 = 1;
66pub const P_WIDTH: u32 = 2;
67pub const P_HEIGHT: u32 = 3;
68pub const P_MIN_W: u32 = 4;
69pub const P_MAX_W: u32 = 5;
70pub const P_MIN_H: u32 = 6;
71pub const P_MAX_H: u32 = 7;
72pub const P_PAD: u32 = 8;
73pub const P_GAP: u32 = 9;
74pub const P_MAIN_ALIGN: u32 = 10;
75pub const P_CROSS_ALIGN: u32 = 11;
76pub const P_BG: u32 = 12;
77pub const P_BORDER: u32 = 13;
78pub const P_RADIUS: u32 = 14;
79pub const P_OVERFLOW: u32 = 15;
80pub const P_FLOAT: u32 = 16;
81pub const P_HOVERABLE: u32 = 17;
82pub const P_ON_CLICK: u32 = 18;
83pub const P_ON_DRAG: u32 = 19;
84pub const P_ON_KEY: u32 = 20;
85pub const P_WINDOW: u32 = 21;
86pub const P_KEY_FOCUS: u32 = 22;
87pub const P_CENTER: u32 = 23;
88pub const P_SIZE: u32 = 24;
89pub const P_LINE_HEIGHT: u32 = 25;
90pub const P_COLOR: u32 = 26;
91pub const P_FAMILY: u32 = 27;
92pub const P_KEY: u32 = 28;
93pub const P_TITLE: u32 = 29;
94pub const P_TOOLTIP: u32 = 30;
95pub const P_TRANSITION: u32 = 31;
96pub const P_EASING: u32 = 32;
97pub const P_SLIDE: u32 = 33;
98pub const P_HOVER_BG: u32 = 34;
99pub const P_PRESSED_BG: u32 = 35;
100pub const P_HOVER_GROUP: u32 = 36;
101pub const P_ON_HOVER: u32 = 37;
102pub const P_RADIUS_TL: u32 = 38;
103pub const P_RADIUS_TR: u32 = 39;
104pub const P_RADIUS_BR: u32 = 40;
105pub const P_RADIUS_BL: u32 = 41;
106pub const P_FONT: u32 = 42;
107pub const P_KEYFRAMES: u32 = 43;
108pub const P_REPEAT: u32 = 44;
109pub const P_DELAY: u32 = 45;
110pub const P_WRAP: u32 = 46;
111pub const P_MAX_LINES: u32 = 47;
112pub const P_ELLIPSIS: u32 = 48;
113pub const P_ENTER: u32 = 49;
114pub const P_CLICK_SOUND: u32 = 50;
115pub const P_HOVER_SOUND: u32 = 51;
116pub const P_ON_LAYOUT: u32 = 52;
117pub const P_ROLE: u32 = 53;
118pub const P_LABEL: u32 = 54;
119pub const P_CHECKED: u32 = 55;
120pub const P_VALUE_NOW: u32 = 56;
121pub const P_VALUE_MIN: u32 = 57;
122pub const P_VALUE_MAX: u32 = 58;
123pub const P_CARET: u32 = 59;
124pub const P_SELECTION_ANCHOR: u32 = 60;
125pub const P_FOCUSABLE: u32 = 61;
126pub const P_DISABLED: u32 = 62;
127pub const P_FOCUS_BG: u32 = 63;
128pub const P_MODAL: u32 = 64;
129pub const P_ON_CONTEXT_MENU: u32 = 65;
130pub const P_CURSOR: u32 = 66;
131pub const P_SELECTED: u32 = 67;
132pub const P_EXPANDED: u32 = 68;
133pub const P_OPACITY: u32 = 69;
134pub const P_SHADOW_COLOR: u32 = 70;
135pub const P_SHADOW_BLUR: u32 = 71;
136pub const P_SHADOW_X: u32 = 72;
137pub const P_SHADOW_Y: u32 = 73;
138pub const P_SHADOW_SPREAD: u32 = 74;
139pub const P_WRAP_CHILDREN: u32 = 75;
140pub const P_CROSS_GAP: u32 = 76;
141pub const P_INITIAL_FOCUS: u32 = 77;
142pub const P_EXIT: u32 = 78;
143pub const P_WINDOWS: u32 = 79;
144pub const P_LIVE: u32 = 80;
145pub const P_KEY_UP: u32 = 81;
146pub const P_VALUE_TEXT: u32 = 82;
147pub const P_DESCRIPTION: u32 = 83;
148pub const P_FEATURES: u32 = 84;
149pub const P_UNDERLINE: u32 = 85;
150pub const P_STRIKETHROUGH: u32 = 86;
151pub const P_ANIMATE: u32 = 87;
152pub const P_ACCENT: u32 = 88;
153pub const P_INDEX: u32 = 89;
154pub const P_SELECTABLE: u32 = 90;
155pub const P_ON_FORCE_CLICK: u32 = 91;
156pub const P_FOCUS_REGION: u32 = 92;
157pub const P_SCROLLBAR: u32 = 93;
158pub const P_SCROLLBAR_WIDTH: u32 = 94;
159pub const P_SCROLLBAR_COLOR: u32 = 95;
160pub const P_SCROLLBAR_ACTIVE_COLOR: u32 = 96;
161pub const P_ANCHOR: u32 = 97;
162pub const P_ALWAYS_ON_TOP: u32 = 98;
163pub const P_ON_SCROLL: u32 = 99;
164pub const P_ROW_COUNT: u32 = 100;
165pub const P_UNDERLINE_COLOR: u32 = 101;
166pub const P_UNDERLINE_STYLE: u32 = 102;
167pub const P_ON_DROP: u32 = 103;
168pub const P_DROP_BG: u32 = 104;
169pub const P_CARET_SOLID: u32 = 105;
170pub const P_SECURE_INPUT: u32 = 106;
171pub const P_ASPECT_RATIO: u32 = 107;
172pub const P_MIXED: u32 = 108;
173pub const P_VALUE_STEP: u32 = 109;
174pub const P_ON_CHANGE: u32 = 110;
175pub const P_PIXEL_SNAP: u32 = 111;
176pub const P_KEEP_FOCUS: u32 = 112;
177pub const P_ON_FOCUS: u32 = 113;
178pub const P_RULES: u32 = 114;
179pub const P_RULE_WIDTH: u32 = 115;
180pub const P_ON_BUTTON: u32 = 116;
181pub const P_BUTTONS: u32 = 117;
182pub const P_OVERSCROLL: u32 = 118;
183pub const P_SCROLL_AXES: u32 = 119;
184pub const P_MODIFIER_KEYS: u32 = 120;
185pub const P_OPTION_AS_ALT: u32 = 121;
186pub const P_BOUNCE: u32 = 122;
187
188/// The `mainAlign` / `crossAlign` rows and a float's attach points, in
189/// `Align`'s order. Append-only: the Lua and Node wires carry the index,
190/// and C's `KUI_ALIGN_*` is it. The spreads mean something on `mainAlign`
191/// and `baseline` on a row's `crossAlign` only (backlog C13).
192pub const ALIGNS: &[&str] = &[
193    "start",
194    "center",
195    "end",
196    "spaceBetween",
197    "spaceAround",
198    "spaceEvenly",
199    "baseline",
200];
201pub const WINDOW_ROLES: &[&str] = &["drag", "close", "minimize", "maximize"];
202/// The `scrollbar` row, in `ScrollbarMode::ALL`'s order: the stock
203/// overlay bar, none, or one that fades out when the scroll state has not
204/// changed. C spells it as the index plus one (`KUI_SCROLLBAR_*`), so a
205/// zeroed field is "unset".
206pub const SCROLLBARS: &[&str] = &["visible", "hidden", "auto"];
207/// The `overscroll` row, in `Overscroll::ALL`'s order (backlog F107): a
208/// scroll gesture starting over a scroller at its limit goes on to the one
209/// around it, or stays. C spells it as the index plus one
210/// (`KUI_OVERSCROLL_*`), a zeroed field being `auto`.
211pub const OVERSCROLLS: &[&str] = &["auto", "contain"];
212/// The `scrollAxes` row, in `ScrollAxes::ALL`'s order (backlog F107): the
213/// axes an `onScroll` node takes. C spells it as the index plus one
214/// (`KUI_SCROLL_AXES_*`), a zeroed field being `both`.
215pub const SCROLL_AXES: &[&str] = &["both", "x", "y"];
216/// The stock families (`FontFamily::name` spellings, in `FontFamily::ALL`
217/// order); a registered font travels as the `font` row's handle instead.
218pub const FAMILIES: &[&str] = &["sans", "serif", "mono"];
219/// The pointer shapes a view can declare (`CursorShape::name` spellings, in
220/// `CursorShape::ALL` order — a `cursor.rs` test pins the two together).
221pub const CURSORS: &[&str] = &[
222    "default",
223    "text",
224    "pointer",
225    "grab",
226    "grabbing",
227    "notAllowed",
228    "ewResize",
229    "nsResize",
230    "nwseResize",
231    "neswResize",
232];
233
234pub fn cursor_idx(i: usize) -> CursorShape {
235    CURSORS
236        .get(i)
237        .and_then(|n| CursorShape::parse(n))
238        .unwrap_or(CursorShape::Default)
239}
240pub const WRAPS: &[&str] = &["word", "glyph", "none", "break-spaces"];
241/// `underlineStyle` / `underline_style` (backlog K4); `UnderlineStyle::NAMES`.
242pub const UNDERLINE_STYLES: &[&str] = UnderlineStyle::NAMES;
243/// `expanded` names its state rather than being a flag: a disclosure that
244/// is shut has to say "collapsed", and an absent flag cannot — absent has
245/// to keep meaning "this node does not expand" (AccessKit's `expanded`,
246/// ARIA's `aria-expanded`, are three-state for the same reason).
247pub const EXPANDED: &[&str] = &["collapsed", "expanded"];
248/// How urgently a reader should read a change it was not asked to read
249/// (`crate::access::Live::name` spellings, in wire order — a binding
250/// sends the index). `off` is the default and means "not a live region".
251pub const LIVE: &[&str] = &["off", "polite", "assertive"];
252/// The roles a view can declare (`crate::access::Role::name` spellings),
253/// in wire order — a binding sends the index. The purely derived roles are
254/// the ones [`DERIVED_ONLY`] names, and every other `Role::ALL` variant is
255/// here; `textInput`, `multilineTextInput` and `line` are, because an app
256/// that draws its own text declares them.
257pub const ROLES: &[&str] = &[
258    "none",
259    "button",
260    "checkbox",
261    "radio",
262    "switch",
263    "slider",
264    "tab",
265    "tabList",
266    "link",
267    "heading",
268    "list",
269    "listItem",
270    "image",
271    "dialog",
272    "group",
273    "textInput",
274    "multilineTextInput",
275    "line",
276    // Appended by ADR 0007. The tail is the only free position (ADR 0006),
277    // which is what makes "the first fifteen can be declared" a list rather
278    // than a range.
279    "radioGroup",
280    "menu",
281    "menuItem",
282    // Appended by backlog C20: a cell grid's derived role.
283    "terminal",
284];
285
286/// How a composite container arranges its items
287/// (`crate::access::Orientation::name` spellings), in wire order. Derived
288/// from the container's `dir` and reported on its access node, never
289/// declared — so unlike [`ROLES`] this is not a prop's enum, only a list
290/// the C header restates.
291pub const ORIENTATIONS: &[&str] = &["horizontal", "vertical"];
292
293/// The OS light/dark setting (`crate::env::Appearance::name` spellings, in
294/// `Appearance::ALL` order — an `env.rs` test pins the two together). Like
295/// [`ORIENTATIONS`] this is not a prop's enum: it is a fact a host pushes
296/// and every binding spells the same way. Index 0 is `unknown`, so a
297/// zeroed C call reports what it actually knows.
298pub const APPEARANCES: &[&str] = &["unknown", "light", "dark"];
299
300/// The OS reduce-motion setting (`crate::env::MotionPref::name` spellings, in
301/// `MotionPref::ALL` order), `unknown` first for the same reason.
302pub const MOTIONS: &[&str] = &["unknown", "full", "reduced"];
303
304/// Whether assistive technology is listening (`crate::env::Assistive::name`
305/// spellings, in `Assistive::ALL` order), `unknown` first for the same
306/// reason: a host with no bridge reports that it cannot tell.
307pub const ASSISTIVE: &[&str] = &["unknown", "none", "listening"];
308
309/// The audio output device's state (`crate::env::AudioDevice::name`
310/// spellings, in `AudioDevice::ALL` order), `closed` first so a zeroed C
311/// call reports the default.
312pub const AUDIO_DEVICES: &[&str] = &["closed", "opening", "open", "failed"];
313
314/// The roles no view can declare, because the core derives them itself
315/// ([`crate::access::derived_role`]), with what derives each one. Every
316/// [`Role::ALL`] variant is on this list or in [`ROLES`], and
317/// `every_role_is_declarable_or_derived` keeps both halves honest: a role
318/// exempted here has to be one a frame really does derive, so the list
319/// cannot absorb a variant that was simply forgotten from `ROLES`.
320pub const DERIVED_ONLY: &[(&str, &str)] = &[
321    (
322        "window",
323        "the root node of a frame, named by the window title",
324    ),
325    ("titleBar", "a `windowDrag` node"),
326    ("staticText", "a text node"),
327    ("scrollView", "a `scrollX` / `scrollY` node"),
328];
329
330/// The role at wire index `i` — `ROLES`' order is the protocol, so this
331/// and [`ROLES`] are pinned to each other by
332/// `every_declarable_role_name_is_a_real_role`.
333///
334/// Neither way of missing can happen today. A name in `ROLES` that no
335/// longer parses is what that test catches, by construction rather than
336/// at run time. Out of range cannot arrive through a transport: Node's
337/// binary reader rejects `i >= names.len()` before it builds a
338/// `Parsed::Enum`, Node's JSON and Lua resolve a *name* through
339/// [`enum_index`], and C carries `Role::ALL` positions that
340/// [`crate::access::Role`] itself bounds.
341///
342/// So the fallback is reachable only from a future transport that forgets
343/// its check, and it is `Role::Group` rather than `Role::None` for that
344/// reader: `None` takes the node *and its whole subtree* out of the access
345/// tree, which is a destructive answer to "an index I do not have", while
346/// a group is what the core already derives for a box that is merely
347/// somewhere focus can land — the node keeps its children, and a wrong
348/// role is recoverable where a missing subtree is not. It stays silent
349/// because this is a pure schema function with no warning sink, and the
350/// transports' own errors name the prop and the index, which is a better
351/// report than a warning here.
352pub fn role_idx(i: usize) -> Role {
353    ROLES
354        .get(i)
355        .and_then(|n| Role::parse(n))
356        .unwrap_or(Role::Group)
357}
358/// The easing curves (`Easing::name` spellings, in `Easing::ALL` order —
359/// the test below pins the two together, as `CURSORS` is pinned).
360pub const EASINGS: &[&str] = &[
361    "easeOut",
362    "linear",
363    "easeIn",
364    "easeInOut",
365    "spring",
366    "bouncy",
367    "smooth",
368    "snappy",
369];
370
371/// CSS's `animation-direction` values, in `Repeat::ALL`'s order.
372pub const REPEATS: &[&str] = &["normal", "reverse", "alternate", "alternateReverse"];
373
374pub fn repeat_idx(i: usize) -> Repeat {
375    Repeat::from_index(i)
376}
377
378pub fn easing_idx(i: usize) -> Easing {
379    Easing::from_index(i)
380}
381
382/// How a prop's value is parsed (per transport) and encoded (binary slots).
383pub enum Kind {
384    /// One number, or a `"$length"` token (ADR 0027). Binary: 1 slot.
385    F32,
386    /// One color: `0xRRGGBBAA` number or `#hex` string, or a `"$color"`
387    /// token. Binary: 1 slot (u32).
388    Color,
389    /// Marker, present-or-absent (`false` = absent). Binary: 0 slots.
390    Flag,
391    /// One of a closed name list. Binary: 1 slot (index).
392    Enum(&'static [&'static str]),
393    /// A sizing: number | "fit" | "grow" | "N%" | a size expression
394    /// (`"clamp(400px, 80%, 1000px)"`, [`crate::calc`]) | {grow} |
395    /// {percent}, or a `"$length"` token (a fixed length). Binary: 2 slots
396    /// (mode, value), or with mode [`SIZE_MODE_CALC`] the mode and a
397    /// strref, the expression's spelling (v19); tagged, 1 slot (the index).
398    Sizing,
399    /// A lower clamp: number | "fit" (the node's own fit size on that axis)
400    /// | a size expression, or a `"$length"` token. Binary: 2 slots (mode,
401    /// value), a sizing's first two modes, or [`SIZE_MODE_CALC`] and a
402    /// strref; tagged, 1 slot (the index).
403    Min,
404    /// An upper clamp: number | a size expression, or a `"$length"` token.
405    /// Binary as a `Min` (v19; a plain number before): mode 0 and px, or
406    /// [`SIZE_MODE_CALC`] and a strref; tagged, 1 slot (the index).
407    Max,
408    /// An arbitrary message payload (a `Value`). Binary: strref to JSON.
409    Msg,
410    /// A message merged into the core's own event under `tag` (`onDrag`,
411    /// `onKey`, `onHover`, `onLayout`). Parsed and carried exactly like a
412    /// `Msg`, but null is a legal value: the node still gets the behaviour
413    /// (a key sink, a drag source) and its events simply carry no `tag` —
414    /// so an app whose messages are a typed union needs no inert member
415    /// just to name a sink.
416    Tag,
417    /// A plain string (a name, not a message). Binary: strref.
418    Str,
419    /// A font family: one of the stock names (`FAMILIES`) or an installed
420    /// or loaded family's name, which the parser registers and turns into
421    /// its handle (ADR 0037). Binary: strref (v18).
422    Family,
423    /// A registered resource handle (a font or sound id): the integer form
424    /// of the slotmap key. JSON/binary carry it as the 16-hex string the
425    /// addon hands out; Lua as an integer; C as a `uint64_t`.
426    Resource,
427    /// A keyframe stop list (`crate::keyframes::parse` reads the plain-data
428    /// form). JSON/binary/Lua carry it like a `Msg` and parse it in the
429    /// core; C passes a `KuiKeyframe` array.
430    Keyframes,
431    /// An entrance (`crate::enter::parse` reads the plain-data form).
432    /// Carried like a `Msg` and parsed in the core; C fills a `KuiEnter`.
433    Enter,
434}
435
436/// Where a parsed value lands. `PropDef::target` derives from this.
437pub enum Apply {
438    SpecF32(fn(NodeSpec, f32) -> NodeSpec),
439    SpecColor(fn(NodeSpec, Color) -> NodeSpec),
440    SpecFlag(fn(NodeSpec) -> NodeSpec),
441    SpecEnum(fn(NodeSpec, usize) -> NodeSpec),
442    SpecSizing(fn(NodeSpec, Sizing) -> NodeSpec),
443    SpecBound(fn(NodeSpec, Bound) -> NodeSpec),
444    SpecMsg(fn(NodeSpec, Value) -> NodeSpec),
445    SpecStr(fn(NodeSpec, &str) -> NodeSpec),
446    SpecKeyframes(fn(NodeSpec, Vec<Keyframe>) -> NodeSpec),
447    SpecEnter(fn(NodeSpec, Enter) -> NodeSpec),
448    SpecResource(fn(NodeSpec, u64) -> NodeSpec),
449    StyleF32(fn(TextStyle, f32) -> TextStyle),
450    StyleColor(fn(TextStyle, Color) -> TextStyle),
451    StyleEnum(fn(TextStyle, usize) -> TextStyle),
452    StyleFlag(fn(TextStyle) -> TextStyle),
453    StyleResource(fn(TextStyle, u64) -> TextStyle),
454    StyleStr(fn(TextStyle, &str) -> TextStyle),
455    StyleFamily(fn(TextStyle, FontFamily) -> TextStyle),
456}
457
458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum Target {
460    /// Lands on the node's `NodeSpec` (containers, edits, images).
461    Spec,
462    /// Lands on a `TextStyle` (text, rich text, edits).
463    Style,
464}
465
466pub struct PropDef {
467    /// Canonical camelCase name (the JSX prop). Lua uses `snake_name()`.
468    pub name: &'static str,
469    pub id: u32,
470    pub kind: Kind,
471    pub apply: Apply,
472    pub doc: &'static str,
473}
474
475impl PropDef {
476    pub fn target(&self) -> Target {
477        match self.apply {
478            Apply::StyleF32(_)
479            | Apply::StyleColor(_)
480            | Apply::StyleEnum(_)
481            | Apply::StyleFlag(_)
482            | Apply::StyleResource(_)
483            | Apply::StyleStr(_)
484            | Apply::StyleFamily(_) => Target::Style,
485            _ => Target::Spec,
486        }
487    }
488
489    /// The Lua-side name: `minWidth` → `min_width`.
490    pub fn snake_name(&self) -> &'static str {
491        SNAKE_NAMES[self.index()]
492    }
493
494    fn index(&self) -> usize {
495        // By name, not by address: `PROPS` is a const, so another crate
496        // iterating it sees its own copy of the rows.
497        PROPS
498            .iter()
499            .position(|d| d.name == self.name)
500            .expect("PropDef not from PROPS")
501    }
502}
503
504pub fn align_idx(i: usize) -> Align {
505    match i {
506        1 => Align::Center,
507        2 => Align::End,
508        3 => Align::SpaceBetween,
509        4 => Align::SpaceAround,
510        5 => Align::SpaceEvenly,
511        6 => Align::Baseline,
512        _ => Align::Start,
513    }
514}
515
516/// Binary min or max decode: (mode, value) → a clamp, the first two
517/// sizing modes ([`SIZE_MODE_CALC`] is read by the transport, which
518/// holds the strref).
519pub fn min_num(mode: u32, value: f64) -> Bound {
520    match mode {
521        1 => Bound::Fit,
522        _ => Bound::Px(value as f32),
523    }
524}
525
526/// Binary sizing decode: (mode, value) → Sizing.
527pub fn sizing_num(mode: u32, value: f64) -> Sizing {
528    match mode {
529        1 => Sizing::Fit,
530        2 => Sizing::Grow(value as f32),
531        3 => Sizing::Percent(value as f32),
532        _ => Sizing::Fixed(value as f32),
533    }
534}
535
536pub const PROPS: &[PropDef] = &[
537    PropDef {
538        name: "width",
539        id: P_WIDTH,
540        kind: Kind::Sizing,
541        apply: Apply::SpecSizing(|s, v| s.width(v)),
542        doc: "Horizontal size: px | \"fit\" | \"grow\" | \"N%\" | a size expression — \
543              `\"clamp(400px, 80%, 1000px)\"`, `\"min(720px, 100%)\"`, `\"max(50%, 300)\"`, \
544              nested — which layout resolves against the parent's content box, the box a \
545              percentage takes its cut of (backlog F109). An expression with no percentage \
546              in it is a length; a calc does not ease under `transition`. A percentage \
547              or an expression gives, with the fit children, when its parent overflows \
548              — two `\"50%\"` children and a gap fit their row (backlog F110) — where a \
549              px size keeps its own. A row holding one gives as CSS's flex items do: \
550              every child that can give gives in proportion to its size, and stops at \
551              its content — the widest thing in it that cannot wrap, a label's longest \
552              word — unless `minWidth` says otherwise (backlog RG92). The process keeps \
553              65 536 distinct expressions and never lets one go: past that a new one leaves \
554              its prop at its default, with a `size-expressions-full` warning, so declare \
555              one per layout — a px size for the part that moves each frame, a splitter's \
556              drag — not one per frame (backlog RG93).",
557    },
558    PropDef {
559        name: "height",
560        id: P_HEIGHT,
561        kind: Kind::Sizing,
562        apply: Apply::SpecSizing(|s, v| s.height(v)),
563        doc: "Vertical size: px | \"fit\" | \"grow\" | \"N%\" | a size expression (see `width`).",
564    },
565    PropDef {
566        name: "minWidth",
567        id: P_MIN_W,
568        kind: Kind::Min,
569        apply: Apply::SpecBound(|s, v| s.min_width(v)),
570        doc: "Lower width clamp: logical px, a size expression (see `width`; a percentage \
571              clamp is none until the parent's width is known, as in CSS), or \"fit\" for \
572              the node's own fit width. \
573              \"fit\" under `width=\"grow\"` is a content floor — CSS's `flex: 1 0 auto` — \
574              which is what an i3-style tab bar is: tabs that split the bar evenly \
575              while they fit and sit at their label's width, scrolling, once they do \
576              not. Opt-in, because a fit width is the unwrapped one: a paragraph in a \
577              grow column would stop wrapping under it. Left out, a child giving in an \
578              overflowing row that holds a percentage or a size expression stops at its \
579              content, CSS's `min-width: auto`; `0` lets it go below, CSS's \
580              `min-width: 0` (backlog RG92). A fit node across a column is no wider \
581              than the column's box — CSS's `fit-content` — down to this floor, 0 left \
582              out, unless the column scrolls x, so a text one wrapper deep in a capped \
583              card wraps there; \"fit\" keeps its content's width and runs past \
584              (backlog F116).",
585    },
586    PropDef {
587        name: "maxWidth",
588        id: P_MAX_W,
589        kind: Kind::Max,
590        apply: Apply::SpecBound(|s, v| s.max_width(v)),
591        doc: "Upper width clamp: logical px or a size expression (see `width`); \
592              grow+maxWidth is the responsive-width pattern.",
593    },
594    PropDef {
595        name: "minHeight",
596        id: P_MIN_H,
597        kind: Kind::Min,
598        apply: Apply::SpecBound(|s, v| s.min_height(v)),
599        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`).",
600    },
601    PropDef {
602        name: "maxHeight",
603        id: P_MAX_H,
604        kind: Kind::Max,
605        apply: Apply::SpecBound(|s, v| s.max_height(v)),
606        doc: "Upper height clamp: logical px or a size expression (see `width`).",
607    },
608    PropDef {
609        name: "gap",
610        id: P_GAP,
611        kind: Kind::F32,
612        apply: Apply::SpecF32(|s, v| s.gap(v)),
613        doc: "Space between children along the main axis.",
614    },
615    // Not `wrap`: that name is taken, by the text prop that picks where a
616    // line breaks inside one paragraph. One name cannot mean both, and the
617    // two meet on `<edit>`, which takes container and text props at once.
618    PropDef {
619        name: "wrapChildren",
620        id: P_WRAP_CHILDREN,
621        kind: Kind::Flag,
622        apply: Apply::SpecFlag(NodeSpec::wrap),
623        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.",
624    },
625    PropDef {
626        name: "crossGap",
627        id: P_CROSS_GAP,
628        kind: Kind::F32,
629        apply: Apply::SpecF32(|s, v| s.cross_gap(v)),
630        doc: "Space between wrap lines, across the main axis (`gap` stays the space along it).",
631    },
632    PropDef {
633        name: "mainAlign",
634        id: P_MAIN_ALIGN,
635        kind: Kind::Enum(ALIGNS),
636        apply: Apply::SpecEnum(|s, i| s.main_align(align_idx(i))),
637        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.",
638    },
639    PropDef {
640        name: "crossAlign",
641        id: P_CROSS_ALIGN,
642        kind: Kind::Enum(ALIGNS),
643        apply: Apply::SpecEnum(|s, i| s.cross_align(align_idx(i))),
644        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.",
645    },
646    PropDef {
647        name: "aspectRatio",
648        id: P_ASPECT_RATIO,
649        kind: Kind::F32,
650        apply: Apply::SpecF32(|s, v| s.aspect_ratio(v)),
651        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.",
652    },
653    PropDef {
654        name: "bg",
655        id: P_BG,
656        kind: Kind::Color,
657        apply: Apply::SpecColor(|s, c| s.bg(c)),
658        doc: "Background fill.",
659    },
660    PropDef {
661        name: "radius",
662        id: P_RADIUS,
663        kind: Kind::F32,
664        apply: Apply::SpecF32(|s, v| s.radius(v)),
665        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.",
666    },
667    PropDef {
668        name: "opacity",
669        id: P_OPACITY,
670        kind: Kind::F32,
671        apply: Apply::SpecF32(|s, v| s.opacity(v)),
672        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.",
673    },
674    PropDef {
675        name: "pixelSnap",
676        id: P_PIXEL_SNAP,
677        kind: Kind::Flag,
678        apply: Apply::SpecFlag(|s| s.pixel_snap()),
679        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.",
680    },
681    PropDef {
682        name: "shadowColor",
683        id: P_SHADOW_COLOR,
684        kind: Kind::Color,
685        apply: Apply::SpecColor(|s, c| s.shadow_color(c)),
686        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.",
687    },
688    PropDef {
689        name: "shadowBlur",
690        id: P_SHADOW_BLUR,
691        kind: Kind::F32,
692        apply: Apply::SpecF32(|s, v| s.shadow_blur(v)),
693        doc: "Drop-shadow blur radius (logical px): the edge ramps over this distance and reaches this far past the shape. 0 = a hard edge.",
694    },
695    PropDef {
696        name: "shadowX",
697        id: P_SHADOW_X,
698        kind: Kind::F32,
699        apply: Apply::SpecF32(|s, v| s.shadow_x(v)),
700        doc: "Drop-shadow horizontal offset (logical px).",
701    },
702    PropDef {
703        name: "shadowY",
704        id: P_SHADOW_Y,
705        kind: Kind::F32,
706        apply: Apply::SpecF32(|s, v| s.shadow_y(v)),
707        doc: "Drop-shadow vertical offset (logical px); positive casts downward.",
708    },
709    PropDef {
710        name: "shadowSpread",
711        id: P_SHADOW_SPREAD,
712        kind: Kind::F32,
713        apply: Apply::SpecF32(|s, v| s.shadow_spread(v)),
714        doc: "Grows (or, negative, shrinks) the drop shadow's shape before blurring (logical px).",
715    },
716    PropDef {
717        name: "radiusTL",
718        id: P_RADIUS_TL,
719        kind: Kind::F32,
720        apply: Apply::SpecF32(|s, v| s.radius_tl(v)),
721        doc: "Top-left corner radius (logical px).",
722    },
723    PropDef {
724        name: "radiusTR",
725        id: P_RADIUS_TR,
726        kind: Kind::F32,
727        apply: Apply::SpecF32(|s, v| s.radius_tr(v)),
728        doc: "Top-right corner radius (logical px).",
729    },
730    PropDef {
731        name: "radiusBR",
732        id: P_RADIUS_BR,
733        kind: Kind::F32,
734        apply: Apply::SpecF32(|s, v| s.radius_br(v)),
735        doc: "Bottom-right corner radius (logical px).",
736    },
737    PropDef {
738        name: "radiusBL",
739        id: P_RADIUS_BL,
740        kind: Kind::F32,
741        apply: Apply::SpecF32(|s, v| s.radius_bl(v)),
742        doc: "Bottom-left corner radius (logical px).",
743    },
744    PropDef {
745        name: "center",
746        id: P_CENTER,
747        kind: Kind::Flag,
748        apply: Apply::SpecFlag(|s| s.center()),
749        doc: "Center children on both axes.",
750    },
751    PropDef {
752        name: "hoverable",
753        id: P_HOVERABLE,
754        kind: Kind::Flag,
755        apply: Apply::SpecFlag(|s| s.hoverable()),
756        doc: "Hover-track without a click payload (for isHovered-driven styling).",
757    },
758    PropDef {
759        name: "animate",
760        id: P_ANIMATE,
761        kind: Kind::Flag,
762        apply: Apply::SpecFlag(|s| s.animate()),
763        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.",
764    },
765    PropDef {
766        name: "accent",
767        id: P_ACCENT,
768        kind: Kind::Flag,
769        apply: Apply::SpecFlag(|s| s.accent()),
770        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.",
771    },
772    PropDef {
773        name: "selectable",
774        id: P_SELECTABLE,
775        kind: Kind::Flag,
776        apply: Apply::SpecFlag(|s| s.selectable()),
777        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.",
778    },
779    PropDef {
780        name: "focusable",
781        id: P_FOCUSABLE,
782        kind: Kind::Flag,
783        apply: Apply::SpecFlag(|s| s.focusable()),
784        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.",
785    },
786    PropDef {
787        name: "keepFocus",
788        id: P_KEEP_FOCUS,
789        kind: Kind::Flag,
790        apply: Apply::SpecFlag(|s| s.keep_focus()),
791        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.",
792    },
793    PropDef {
794        name: "focusRegion",
795        id: P_FOCUS_REGION,
796        kind: Kind::Flag,
797        apply: Apply::SpecFlag(|s| s.focus_region()),
798        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.",
799    },
800    PropDef {
801        name: "scrollbar",
802        id: P_SCROLLBAR,
803        kind: Kind::Enum(SCROLLBARS),
804        apply: Apply::SpecEnum(|s, i| s.scrollbar(crate::spec::ScrollbarMode::ALL[i])),
805        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.",
806    },
807    PropDef {
808        name: "anchor",
809        id: P_ANCHOR,
810        kind: Kind::Flag,
811        apply: Apply::SpecFlag(|s| s.anchor()),
812        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.",
813    },
814    PropDef {
815        name: "scrollbarWidth",
816        id: P_SCROLLBAR_WIDTH,
817        kind: Kind::F32,
818        apply: Apply::SpecF32(|s, v| s.scrollbar_width(v)),
819        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.",
820    },
821    PropDef {
822        name: "scrollbarColor",
823        id: P_SCROLLBAR_COLOR,
824        kind: Kind::Color,
825        apply: Apply::SpecColor(|s, c| s.scrollbar_color(c)),
826        doc: "The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on.",
827    },
828    PropDef {
829        name: "scrollbarActiveColor",
830        id: P_SCROLLBAR_ACTIVE_COLOR,
831        kind: Kind::Color,
832        apply: Apply::SpecColor(|s, c| s.scrollbar_active_color(c)),
833        doc: "The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role.",
834    },
835    PropDef {
836        name: "overscroll",
837        id: P_OVERSCROLL,
838        kind: Kind::Enum(OVERSCROLLS),
839        apply: Apply::SpecEnum(|s, i| s.overscroll(crate::spec::Overscroll::ALL[i])),
840        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.",
841    },
842    PropDef {
843        name: "disabled",
844        id: P_DISABLED,
845        kind: Kind::Flag,
846        apply: Apply::SpecFlag(|s| s.disabled(true)),
847        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.",
848    },
849    PropDef {
850        name: "hoverBg",
851        id: P_HOVER_BG,
852        kind: Kind::Color,
853        apply: Apply::SpecColor(|s, c| s.hover_bg(c)),
854        doc: "Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`.",
855    },
856    PropDef {
857        name: "rules",
858        id: P_RULES,
859        kind: Kind::Color,
860        apply: Apply::SpecColor(|s, c| s.rules(c)),
861        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.",
862    },
863    PropDef {
864        name: "ruleWidth",
865        id: P_RULE_WIDTH,
866        kind: Kind::F32,
867        apply: Apply::SpecF32(|s, v| s.rule_width(v)),
868        doc: "The width of a table's `rules` in logical px; 1 when unset.",
869    },
870    PropDef {
871        name: "dropBg",
872        id: P_DROP_BG,
873        kind: Kind::Color,
874        apply: Apply::SpecColor(|s, c| s.drop_bg(c)),
875        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`.",
876    },
877    PropDef {
878        name: "pressedBg",
879        id: P_PRESSED_BG,
880        kind: Kind::Color,
881        apply: Apply::SpecColor(|s, c| s.pressed_bg(c)),
882        doc: "Background while pressed (or while its hoverGroup is); implies hover tracking.",
883    },
884    PropDef {
885        name: "focusBg",
886        id: P_FOCUS_BG,
887        kind: Kind::Color,
888        apply: Apply::SpecColor(|s, c| s.focus_bg(c)),
889        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`.",
890    },
891    PropDef {
892        name: "modal",
893        id: P_MODAL,
894        kind: Kind::Tag,
895        apply: Apply::SpecMsg(|s, v| s.modal(v)),
896        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.",
897    },
898    PropDef {
899        name: "initialFocus",
900        id: P_INITIAL_FOCUS,
901        kind: Kind::Flag,
902        apply: Apply::SpecFlag(|s| s.initial_focus()),
903        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.",
904    },
905    PropDef {
906        name: "hoverGroup",
907        id: P_HOVER_GROUP,
908        kind: Kind::Str,
909        apply: Apply::SpecStr(|s, name| s.hover_group(name)),
910        doc: "Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape).",
911    },
912    PropDef {
913        name: "onHover",
914        id: P_ON_HOVER,
915        kind: Kind::Tag,
916        apply: Apply::SpecMsg(|s, v| s.on_hover(v)),
917        doc: "Hover tag: the pointer entering/leaving emits {kind:\"hover\", phase:\"enter\"|\"leave\", tag} events.",
918    },
919    PropDef {
920        name: "onDrop",
921        id: P_ON_DROP,
922        kind: Kind::Tag,
923        apply: Apply::SpecMsg(|s, v| s.on_drop(v)),
924        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.",
925    },
926    PropDef {
927        name: "onLayout",
928        id: P_ON_LAYOUT,
929        kind: Kind::Tag,
930        apply: Apply::SpecMsg(|s, v| s.on_layout(v)),
931        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).",
932    },
933    PropDef {
934        name: "onClick",
935        id: P_ON_CLICK,
936        kind: Kind::Msg,
937        apply: Apply::SpecMsg(|s, v| s.on_click(v)),
938        doc: "Message emitted when clicked (data, not a callback).",
939    },
940    PropDef {
941        name: "onDrag",
942        id: P_ON_DRAG,
943        kind: Kind::Tag,
944        apply: Apply::SpecMsg(|s, v| s.on_drag(v)),
945        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.",
946    },
947    PropDef {
948        name: "onKey",
949        id: P_ON_KEY,
950        kind: Kind::Tag,
951        apply: Apply::SpecMsg(|s, v| s.on_key(v)),
952        doc: "Key-sink tag: with key focus held, presses arrive as {kind:\"key\", phase:\"down\", code, ...} events. Releases only with `keyUp` beside it.",
953    },
954    PropDef {
955        name: "keyUp",
956        id: P_KEY_UP,
957        kind: Kind::Flag,
958        apply: Apply::SpecFlag(|s| s.key_up()),
959        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.",
960    },
961    PropDef {
962        name: "modifierKeys",
963        id: P_MODIFIER_KEYS,
964        kind: Kind::Flag,
965        apply: Apply::SpecFlag(|s| s.modifier_keys()),
966        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.",
967    },
968    PropDef {
969        name: "onContextMenu",
970        id: P_ON_CONTEXT_MENU,
971        kind: Kind::Tag,
972        apply: Apply::SpecMsg(|s, v| s.on_context_menu(v)),
973        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.",
974    },
975    PropDef {
976        name: "onForceClick",
977        id: P_ON_FORCE_CLICK,
978        kind: Kind::Tag,
979        apply: Apply::SpecMsg(|s, v| s.on_force_click(v)),
980        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.",
981    },
982    PropDef {
983        name: "onButton",
984        id: P_ON_BUTTON,
985        kind: Kind::Tag,
986        apply: Apply::SpecMsg(|s, v| s.on_button(v)),
987        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.",
988    },
989    PropDef {
990        name: "buttons",
991        id: P_BUTTONS,
992        kind: Kind::Str,
993        apply: Apply::SpecStr(|s, v| s.buttons(crate::input::Buttons::parse(v))),
994        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`.",
995    },
996    PropDef {
997        name: "onScroll",
998        id: P_ON_SCROLL,
999        kind: Kind::Tag,
1000        apply: Apply::SpecMsg(|s, v| s.on_scroll(v)),
1001        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`).",
1002    },
1003    PropDef {
1004        name: "scrollAxes",
1005        id: P_SCROLL_AXES,
1006        kind: Kind::Enum(SCROLL_AXES),
1007        apply: Apply::SpecEnum(|s, i| s.scroll_axes(crate::spec::ScrollAxes::ALL[i])),
1008        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`.",
1009    },
1010    PropDef {
1011        name: "window",
1012        id: P_WINDOW,
1013        kind: Kind::Enum(WINDOW_ROLES),
1014        apply: Apply::SpecEnum(|s, i| match i {
1015            0 => s.window_drag(),
1016            1 => s.window_button(WindowButton::Close),
1017            2 => s.window_button(WindowButton::Minimize),
1018            3 => s.window_button(WindowButton::Maximize),
1019            _ => s,
1020        }),
1021        doc: "Window-chrome role: interactions become window commands, not events.",
1022    },
1023    PropDef {
1024        name: "cursor",
1025        id: P_CURSOR,
1026        kind: Kind::Enum(CURSORS),
1027        apply: Apply::SpecEnum(|s, i| s.cursor(cursor_idx(i))),
1028        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.",
1029    },
1030    PropDef {
1031        name: "transition",
1032        id: P_TRANSITION,
1033        kind: Kind::F32,
1034        apply: Apply::SpecF32(|s, v| s.transition(v)),
1035        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).",
1036    },
1037    PropDef {
1038        name: "easing",
1039        id: P_EASING,
1040        kind: Kind::Enum(EASINGS),
1041        apply: Apply::SpecEnum(|s, i| s.easing(easing_idx(i))),
1042        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.",
1043    },
1044    PropDef {
1045        name: "bounce",
1046        id: P_BOUNCE,
1047        kind: Kind::F32,
1048        apply: Apply::SpecF32(|s, v| s.bounce(v)),
1049        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.",
1050    },
1051    PropDef {
1052        name: "slide",
1053        id: P_SLIDE,
1054        kind: Kind::Flag,
1055        apply: Apply::SpecFlag(|s| s.slide()),
1056        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.",
1057    },
1058    PropDef {
1059        name: "keyframes",
1060        id: P_KEYFRAMES,
1061        kind: Kind::Keyframes,
1062        apply: Apply::SpecKeyframes(|s, k| s.keyframes(k)),
1063        doc: "CSS-style stops `[{ at?, width?, height?, bg?, radius?, opacity? }, …]`: the slots they name cycle through them over `transition` ms, forever, without the view redrawing; `at` is 0..1 and spreads evenly when omitted.",
1064    },
1065    PropDef {
1066        name: "enter",
1067        id: P_ENTER,
1068        kind: Kind::Enter,
1069        apply: Apply::SpecEnter(|s, e| s.enter(e)),
1070        doc: "Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }`: 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).",
1071    },
1072    PropDef {
1073        name: "exit",
1074        id: P_EXIT,
1075        kind: Kind::Enter,
1076        apply: Apply::SpecEnter(|s, e| s.exit(e)),
1077        doc: "Where the node ends the frame after the view stops declaring it `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }` — 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. Needs a stable key across frames.",
1078    },
1079    PropDef {
1080        name: "repeat",
1081        id: P_REPEAT,
1082        kind: Kind::Enum(REPEATS),
1083        apply: Apply::SpecEnum(|s, i| s.repeat(repeat_idx(i))),
1084        doc: "How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword.",
1085    },
1086    PropDef {
1087        name: "delay",
1088        id: P_DELAY,
1089        kind: Kind::F32,
1090        apply: Apply::SpecF32(|s, v| s.delay(v)),
1091        doc: "Holds the `keyframes` cycle back by this many ms (CSS `animation-delay`); siblings with different delays run out of phase.",
1092    },
1093    PropDef {
1094        name: "clickSound",
1095        id: P_CLICK_SOUND,
1096        kind: Kind::Resource,
1097        apply: Apply::SpecResource(|s, id| s.click_sound(crate::resources::SoundId::from_ffi(id))),
1098        doc: "A registered sound (addSound) played when the node is clicked; implies hover tracking.",
1099    },
1100    PropDef {
1101        name: "hoverSound",
1102        id: P_HOVER_SOUND,
1103        kind: Kind::Resource,
1104        apply: Apply::SpecResource(|s, id| s.hover_sound(crate::resources::SoundId::from_ffi(id))),
1105        doc: "A registered sound (addSound) played when the pointer enters the node; implies hover tracking.",
1106    },
1107    PropDef {
1108        name: "lineHeight",
1109        id: P_LINE_HEIGHT,
1110        kind: Kind::F32,
1111        apply: Apply::StyleF32(|t, v| t.line_height(v)),
1112        doc: "Line height (logical px); default size * 1.35.",
1113    },
1114    PropDef {
1115        name: "color",
1116        id: P_COLOR,
1117        kind: Kind::Color,
1118        apply: Apply::StyleColor(|t, c| t.color(c)),
1119        doc: "Text color; default foreground when omitted.",
1120    },
1121    PropDef {
1122        name: "family",
1123        id: P_FAMILY,
1124        kind: Kind::Family,
1125        apply: Apply::StyleFamily(|t, f| t.family(f)),
1126        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.",
1127    },
1128    PropDef {
1129        name: "font",
1130        id: P_FONT,
1131        kind: Kind::Resource,
1132        apply: Apply::StyleResource(|t, id| t.font(crate::resources::FontId::from_ffi(id))),
1133        doc: "A registered font handle (addFont / addSystemFont); overrides `family`.",
1134    },
1135    PropDef {
1136        name: "wrap",
1137        id: P_WRAP,
1138        kind: Kind::Enum(WRAPS),
1139        apply: Apply::StyleEnum(|t, i| match i {
1140            1 => t.wrap(TextWrap::Glyph),
1141            2 => t.wrap(TextWrap::None),
1142            3 => t.wrap(TextWrap::BreakSpaces),
1143            _ => t.wrap(TextWrap::Word),
1144        }),
1145        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`).",
1146    },
1147    PropDef {
1148        name: "maxLines",
1149        id: P_MAX_LINES,
1150        kind: Kind::F32,
1151        apply: Apply::StyleF32(|t, v| t.max_lines(v.max(0.0) as u32)),
1152        doc: "Lay out at most this many lines (0 = unlimited); with `ellipsis`, a line clamp.",
1153    },
1154    PropDef {
1155        name: "ellipsis",
1156        id: P_ELLIPSIS,
1157        kind: Kind::Flag,
1158        apply: Apply::StyleFlag(|t| t.ellipsis()),
1159        doc: "End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise.",
1160    },
1161    PropDef {
1162        name: "underline",
1163        id: P_UNDERLINE,
1164        kind: Kind::Flag,
1165        apply: Apply::StyleFlag(|t| t.underline()),
1166        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.",
1167    },
1168    PropDef {
1169        name: "underlineColor",
1170        id: P_UNDERLINE_COLOR,
1171        kind: Kind::Color,
1172        apply: Apply::StyleColor(|t, c| t.underline_color(c)),
1173        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.",
1174    },
1175    PropDef {
1176        name: "underlineStyle",
1177        id: P_UNDERLINE_STYLE,
1178        kind: Kind::Enum(UNDERLINE_STYLES),
1179        apply: Apply::StyleEnum(|t, i| t.underline_style(UnderlineStyle::from_index(i as u32))),
1180        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.",
1181    },
1182    PropDef {
1183        name: "strikethrough",
1184        id: P_STRIKETHROUGH,
1185        kind: Kind::Flag,
1186        apply: Apply::StyleFlag(|t| t.strikethrough()),
1187        doc: "A line through the text, where the face puts its strikeout. Paint only; on a `<span>` the span alone, per line.",
1188    },
1189    PropDef {
1190        name: "features",
1191        id: P_FEATURES,
1192        kind: Kind::Str,
1193        apply: Apply::StyleStr(|t, s| t.features(crate::spec::FontFeatures::parse(s))),
1194        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.",
1195    },
1196    PropDef {
1197        name: "role",
1198        id: P_ROLE,
1199        kind: Kind::Enum(ROLES),
1200        apply: Apply::SpecEnum(|s, i| s.role(role_idx(i))),
1201        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. 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.",
1202    },
1203    PropDef {
1204        name: "label",
1205        id: P_LABEL,
1206        kind: Kind::Str,
1207        apply: Apply::SpecStr(|s, v| s.label(v)),
1208        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`).",
1209    },
1210    PropDef {
1211        name: "description",
1212        id: P_DESCRIPTION,
1213        kind: Kind::Str,
1214        apply: Apply::SpecStr(|s, v| s.description(v)),
1215        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.",
1216    },
1217    PropDef {
1218        name: "checked",
1219        id: P_CHECKED,
1220        kind: Kind::Flag,
1221        apply: Apply::SpecFlag(|s| s.checked(true)),
1222        doc: "The on state of a `checkbox` / `radio` / `switch` role.",
1223    },
1224    PropDef {
1225        name: "mixed",
1226        id: P_MIXED,
1227        kind: Kind::Flag,
1228        apply: Apply::SpecFlag(|s| s.mixed(true)),
1229        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.",
1230    },
1231    PropDef {
1232        name: "selected",
1233        id: P_SELECTED,
1234        kind: Kind::Flag,
1235        apply: Apply::SpecFlag(|s| s.selected(true)),
1236        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.",
1237    },
1238    PropDef {
1239        name: "expanded",
1240        id: P_EXPANDED,
1241        kind: Kind::Enum(EXPANDED),
1242        apply: Apply::SpecEnum(|s, i| s.expanded(i == 1)),
1243        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.",
1244    },
1245    PropDef {
1246        name: "live",
1247        id: P_LIVE,
1248        kind: Kind::Enum(LIVE),
1249        apply: Apply::SpecEnum(|s, i| s.live(crate::access::Live::from_index(i))),
1250        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.",
1251    },
1252    PropDef {
1253        name: "valueNow",
1254        id: P_VALUE_NOW,
1255        kind: Kind::F32,
1256        apply: Apply::SpecF32(|s, v| s.value_now(v)),
1257        doc: "A `slider` role's current value (the drawing stays yours; this is what assistive technology reads).",
1258    },
1259    PropDef {
1260        name: "valueMin",
1261        id: P_VALUE_MIN,
1262        kind: Kind::F32,
1263        apply: Apply::SpecF32(|s, v| s.value_min(v)),
1264        doc: "A `slider` role's minimum.",
1265    },
1266    PropDef {
1267        name: "valueMax",
1268        id: P_VALUE_MAX,
1269        kind: Kind::F32,
1270        apply: Apply::SpecF32(|s, v| s.value_max(v)),
1271        doc: "A `slider` role's maximum.",
1272    },
1273    PropDef {
1274        name: "valueText",
1275        id: P_VALUE_TEXT,
1276        kind: Kind::Str,
1277        apply: Apply::SpecStr(|s, v| s.value_text(v)),
1278        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.",
1279    },
1280    PropDef {
1281        name: "valueStep",
1282        id: P_VALUE_STEP,
1283        kind: Kind::F32,
1284        apply: Apply::SpecF32(|s, v| s.value_step(v)),
1285        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.",
1286    },
1287    PropDef {
1288        name: "onFocus",
1289        id: P_ON_FOCUS,
1290        kind: Kind::Tag,
1291        apply: Apply::SpecMsg(|s, v| s.on_focus(v)),
1292        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.",
1293    },
1294    PropDef {
1295        name: "onChange",
1296        id: P_ON_CHANGE,
1297        kind: Kind::Tag,
1298        apply: Apply::SpecMsg(|s, v| s.on_change(v)),
1299        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.",
1300    },
1301    PropDef {
1302        name: "caret",
1303        id: P_CARET,
1304        kind: Kind::F32,
1305        apply: Apply::SpecF32(|s, v| s.caret(v.max(0.0) as u32)),
1306        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.",
1307    },
1308    PropDef {
1309        name: "selectionAnchor",
1310        id: P_SELECTION_ANCHOR,
1311        kind: Kind::F32,
1312        apply: Apply::SpecF32(|s, v| s.selection_anchor(v.max(0.0) as u32)),
1313        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).",
1314    },
1315    PropDef {
1316        name: "caretSolid",
1317        id: P_CARET_SOLID,
1318        kind: Kind::Flag,
1319        apply: Apply::SpecFlag(|s| s.caret_solid()),
1320        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.",
1321    },
1322];
1323
1324/// A prop every binding handles by hand (a composite with real logic, or a
1325/// constructor-order special), with its wire id so transports agree on
1326/// identity and its per-binding spelling so the docs can say so.
1327pub struct CustomProp {
1328    pub name: &'static str,
1329    pub id: u32,
1330    /// Every prop name a JSX view may write for it — the machine-readable
1331    /// half of `jsx`, which is prose for the docs. A composite is spelled
1332    /// differently in each binding (`borderW` here, `border = {…}` there),
1333    /// so the two lists are separate; together with `PROPS` and an element's
1334    /// own props they are the whole allow-list a binding checks a view
1335    /// against (see [`known_prop`]).
1336    pub jsx_names: &'static [&'static str],
1337    /// The same for a Lua node table.
1338    pub lua_names: &'static [&'static str],
1339    /// How JSX spells it.
1340    pub jsx: &'static str,
1341    /// How a Lua table spells it.
1342    pub lua: &'static str,
1343    /// Where it lands in C.
1344    pub c: &'static str,
1345    pub doc: &'static str,
1346}
1347
1348pub const CUSTOM: &[CustomProp] = &[
1349    CustomProp {
1350        name: "dir",
1351        id: P_DIR,
1352        jsx_names: &["dir"],
1353        lua_names: &[],
1354        jsx: "`dir=\"row\" | \"column\" | \"table\"`",
1355        lua: "`row { }` / `column { }` / `grid { }`",
1356        c: "`dir` (`KUI_ROW` / `KUI_COLUMN` / `KUI_TABLE`)",
1357        doc: "Main axis; column is the default. `table` is a column whose rows' children line up in columns (the `table` element).",
1358    },
1359    CustomProp {
1360        name: "size",
1361        id: P_SIZE,
1362        jsx_names: &["size"],
1363        lua_names: &["size"],
1364        jsx: "`size` (text)",
1365        lua: "`size`",
1366        c: "`KuiTextStyle.size`",
1367        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.",
1368    },
1369    CustomProp {
1370        name: "pad",
1371        id: P_PAD,
1372        jsx_names: &["pad", "padX", "padY", "padL", "padR", "padT", "padB"],
1373        lua_names: &["pad"],
1374        jsx: "`pad`, `padX`, `padY`, `padL`, `padR`, `padT`, `padB`",
1375        lua: "`pad = n` or `pad = { all=, x=, y=, l=, r=, t=, b= }`",
1376        c: "`pad_l`, `pad_r`, `pad_t`, `pad_b`",
1377        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.",
1378    },
1379    CustomProp {
1380        name: "border",
1381        id: P_BORDER,
1382        jsx_names: &["borderW", "borderColor"],
1383        lua_names: &["border"],
1384        jsx: "`borderW`, `borderColor`",
1385        lua: "`border = { w=, color= }`",
1386        c: "`border_w`, `border_color`",
1387        doc: "Border width and color (drawn inside the rect).",
1388    },
1389    CustomProp {
1390        name: "overflow",
1391        id: P_OVERFLOW,
1392        jsx_names: &["clip", "scrollX", "scrollY"],
1393        lua_names: &["clip", "scroll", "scroll_x", "scroll_y"],
1394        jsx: "`clip`, `scrollX`, `scrollY`",
1395        lua: "`clip`, `scroll_x`, `scroll_y` (`scroll` = `scroll_y`)",
1396        c: "`overflow` bits `KUI_CLIP` | `KUI_SCROLL_X` | `KUI_SCROLL_Y`",
1397        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).",
1398    },
1399    CustomProp {
1400        name: "float",
1401        id: P_FLOAT,
1402        jsx_names: &["float"],
1403        lua_names: &["float"],
1404        jsx: "`float=\"below\" | \"above\" | \"parent\" | \"viewport\"` or `{ anchor, at, self, dx, dy, fit, clip }`",
1405        lua: "`float = \"below\"` or `float = { anchor=, at=, self=, dx=, dy=, fit=, clip= }`",
1406        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",
1407        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` or `polygon` 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`).",
1408    },
1409    CustomProp {
1410        name: "keyFocus",
1411        id: P_KEY_FOCUS,
1412        jsx_names: &["keyFocus"],
1413        lua_names: &["key_focus"],
1414        jsx: "`keyFocus`",
1415        lua: "`key_focus`",
1416        c: "`kui_set_key_focus`",
1417        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`).",
1418    },
1419    CustomProp {
1420        name: "key",
1421        id: P_KEY,
1422        jsx_names: &["key"],
1423        lua_names: &["key"],
1424        jsx: "`key`",
1425        lua: "`key`",
1426        c: "`kui_open_keyed` label",
1427        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).",
1428    },
1429    CustomProp {
1430        name: "index",
1431        id: P_INDEX,
1432        jsx_names: &["index"],
1433        lua_names: &["index"],
1434        jsx: "`index`",
1435        lua: "`index`",
1436        c: "`kui_open_indexed`",
1437        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.",
1438    },
1439    CustomProp {
1440        name: "rowCount",
1441        id: P_ROW_COUNT,
1442        jsx_names: &["rowCount"],
1443        lua_names: &["row_count"],
1444        jsx: "`rowCount`",
1445        lua: "`row_count`",
1446        c: "`kui_row_count`",
1447        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.",
1448    },
1449    CustomProp {
1450        name: "title",
1451        id: P_TITLE,
1452        jsx_names: &["title"],
1453        lua_names: &["window_title"],
1454        jsx: "`title` (root box only)",
1455        lua: "`window_title` (root table)",
1456        c: "`kui_window_title`",
1457        doc: "Declares the window title for this frame; the driver diffs and applies.",
1458    },
1459    CustomProp {
1460        name: "alwaysOnTop",
1461        id: P_ALWAYS_ON_TOP,
1462        jsx_names: &["alwaysOnTop"],
1463        lua_names: &["always_on_top"],
1464        jsx: "`alwaysOnTop` (root box only)",
1465        lua: "`always_on_top = true` (root table)",
1466        c: "`kui_set_always_on_top`",
1467        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.",
1468    },
1469    CustomProp {
1470        name: "secureInput",
1471        id: P_SECURE_INPUT,
1472        jsx_names: &["secureInput"],
1473        lua_names: &["secure_input"],
1474        jsx: "`secureInput` (root box only)",
1475        lua: "`secure_input = true` (root table)",
1476        c: "`kui_set_secure_input`",
1477        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 C host with its own loop reads the ask with `kui_secure_input_get` and makes the call itself.",
1478    },
1479    CustomProp {
1480        name: "optionAsAlt",
1481        id: P_OPTION_AS_ALT,
1482        jsx_names: &["optionAsAlt"],
1483        lua_names: &["option_as_alt"],
1484        jsx: "`optionAsAlt=\"left\"` — `\"none\"`, `\"left\"`, `\"right\"`, `\"both\"` (root box only)",
1485        lua: "`option_as_alt = \"left\"` (root table)",
1486        c: "`kui_set_option_as_alt` (`KUI_OPTION_AS_ALT_*`)",
1487        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.",
1488    },
1489    CustomProp {
1490        name: "windows",
1491        id: P_WINDOWS,
1492        jsx_names: &["windows"],
1493        lua_names: &["windows"],
1494        jsx: "`windows={[{ name, kind?, anchor?, width?, height?, activates? }]}` (root box only; `windows: (model) => [...]` in the loop config)",
1495        lua: "`windows = { { name=, kind=, anchor=, width=, height=, activates= } }` (root table)",
1496        c: "`kui_window_declare`",
1497        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.",
1498    },
1499    CustomProp {
1500        name: "tooltip",
1501        id: P_TOOLTIP,
1502        jsx_names: &["tooltip"],
1503        lua_names: &["tooltip"],
1504        jsx: "`tooltip=\"hint\"`",
1505        lua: "`tooltip = \"hint\"`",
1506        c: "`KuiSpec.tooltip` (`kui_tooltip` / `kui_tooltip_with` draw a hint that is not hover-gated)",
1507        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. The float is the node's last child, so it is drawn for a box or a `fragment`; on a leaf that holds no children — an `image`, an `edit`, a `cells` grid — the hint is tracked and spoken but not drawn, so put the tooltip on a box around it (backlog RG75).",
1508    },
1509];
1510
1511/// Where a schema row lands in C when it is not simply the `KuiSpec` /
1512/// `KuiTextStyle` field of the row's snake_case name (`c_field`).
1513pub const C_FIELDS: &[(&str, &str)] = &[
1514    ("width", "`width` (KuiSizing)"),
1515    ("height", "`height` (KuiSizing)"),
1516    ("center", "`main_align` + `cross_align` = `KUI_CENTER`"),
1517    ("window", "`window_role` (`KUI_WINDOW_*`)"),
1518    ("transition", "`transition_ms`"),
1519    ("easing", "`easing` (`KUI_EASE_*`)"),
1520    (
1521        "keyframes",
1522        "`keyframes` + `keyframes_len` (`KuiKeyframe[]`)",
1523    ),
1524    ("repeat", "`repeat` (`KUI_REPEAT_*`)"),
1525    ("delay", "`delay_ms`"),
1526    ("enter", "`enter` (`KuiEnter`, with `set` bits)"),
1527    ("exit", "`exit` (`KuiEnter`, with `set` bits)"),
1528    ("opacity", "`opacity` with `opacity_set`"),
1529    ("radiusTL", "`radius_tl` with `per_corner`"),
1530    ("radiusTR", "`radius_tr` with `per_corner`"),
1531    ("radiusBR", "`radius_br` with `per_corner`"),
1532    ("radiusBL", "`radius_bl` with `per_corner`"),
1533    ("hoverGroup", "`hover_group` (KuiStr)"),
1534    ("role", "`role` (`KUI_ROLE_*`)"),
1535    ("expanded", "`expanded` (`KUI_EXPANDED_*`)"),
1536    ("live", "`live` (`KUI_LIVE_*`)"),
1537    ("cursor", "`cursor` (`KUI_CURSOR_*`)"),
1538    ("label", "`label` (KuiStr)"),
1539    (
1540        "valueNow",
1541        "`value_now` with `KUI_VALUE_NOW` in `value_set`",
1542    ),
1543    (
1544        "valueMin",
1545        "`value_min` with `KUI_VALUE_MIN` in `value_set`",
1546    ),
1547    (
1548        "valueMax",
1549        "`value_max` with `KUI_VALUE_MAX` in `value_set`",
1550    ),
1551    ("valueText", "`value_text` (KuiStr)"),
1552    ("caret", "`caret` with `KUI_VALUE_CARET` in `value_set`"),
1553    (
1554        "selectionAnchor",
1555        "`selection_anchor` with `KUI_VALUE_ANCHOR` in `value_set`",
1556    ),
1557    (
1558        "onClick",
1559        "`on_click` argument of `kui_open` / `kui_open_with`",
1560    ),
1561    (
1562        "onDrag",
1563        "`on_drag` argument of `kui_open_draggable` / `kui_open_with`",
1564    ),
1565    ("onKey", "`on_key` argument of `kui_open_with`"),
1566    (
1567        "onContextMenu",
1568        "`on_context_menu` (a borrowed `KuiValue*`, cloned while the node opens)",
1569    ),
1570    (
1571        "onButton",
1572        "`on_button` (a borrowed `KuiValue*`, cloned while the node opens)",
1573    ),
1574    (
1575        "buttons",
1576        "`buttons` (`KUI_BUTTONS_*` bits; zeroed, all three)",
1577    ),
1578    (
1579        "modal",
1580        "`modal` (a borrowed `KuiValue*`, cloned while the node opens)",
1581    ),
1582    (
1583        "onScroll",
1584        "`on_scroll` (a borrowed `KuiValue*`, cloned while the node opens)",
1585    ),
1586    ("onHover", "`on_hover` argument of `kui_open_with`"),
1587    (
1588        "onFocus",
1589        "`on_focus` (a borrowed `KuiValue*`, cloned while the node opens)",
1590    ),
1591    ("ruleWidth", "`rule_w`"),
1592    (
1593        "overscroll",
1594        "`overscroll` (`KUI_OVERSCROLL_*`; zeroed, auto)",
1595    ),
1596    (
1597        "scrollAxes",
1598        "`scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both)",
1599    ),
1600    (
1601        "onDrop",
1602        "`on_drop` (a borrowed `KuiValue*`, cloned while the node opens)",
1603    ),
1604    (
1605        "onLayout",
1606        "`on_layout` (a borrowed `KuiValue*`, cloned while the node opens)",
1607    ),
1608    ("family", "`KuiTextStyle.family` (`KUI_FONT_*`)"),
1609    ("font", "`KuiTextStyle.font` (from `kui_font_add*`)"),
1610    ("lineHeight", "`KuiTextStyle.line_height`"),
1611    ("wrap", "`KuiTextStyle.wrap` (`KUI_WRAP_*`)"),
1612    ("maxLines", "`KuiTextStyle.max_lines`"),
1613    ("ellipsis", "`KuiTextStyle.ellipsis`"),
1614    (
1615        "features",
1616        "`KuiTextStyle.features` (a `KuiStr`, the same spelling)",
1617    ),
1618    (
1619        "underline",
1620        "`KuiTextStyle.decoration` (`KUI_DECO_UNDERLINE`); `KuiSpan.flags` (`KUI_SPAN_UNDERLINE`)",
1621    ),
1622    (
1623        "strikethrough",
1624        "`KuiTextStyle.decoration` (`KUI_DECO_STRIKETHROUGH`); `KuiSpan.flags` (`KUI_SPAN_STRIKETHROUGH`)",
1625    ),
1626    (
1627        "underlineColor",
1628        "`KuiTextStyle.underline_color`; `KuiSpan.underline_color`",
1629    ),
1630    (
1631        "underlineStyle",
1632        "`KuiTextStyle.underline_style` (`KUI_UNDERLINE_*`); `KuiSpan.underline_style`",
1633    ),
1634    ("color", "`KuiTextStyle.color`"),
1635];
1636
1637/// The C spelling of a schema row, for docs.
1638pub fn c_field(def: &PropDef) -> String {
1639    C_FIELDS
1640        .iter()
1641        .find(|(n, _)| *n == def.name)
1642        .map(|(_, c)| (*c).to_string())
1643        .unwrap_or_else(|| format!("`{}`", def.snake_name()))
1644}
1645
1646/// An element (node type) and its spelling in each binding. Elements are
1647/// hand-lowered per binding (their shapes differ: JSX children, Lua
1648/// tables, C calls with body callbacks), so this table is documentation
1649/// and a checklist, not a code generator's input.
1650pub struct ElementDef {
1651    pub name: &'static str,
1652    /// The props this element lowers itself, which are therefore in neither
1653    /// `PROPS` nor `CUSTOM`: `<edit initial multiline>`, `<image src>`. They
1654    /// ride in the same prop list as the node's, so a binding needs them to
1655    /// tell a legitimate element prop from a misspelling (see
1656    /// [`known_prop`]) — JSX's spellings here, Lua's below.
1657    pub jsx_own: &'static [&'static str],
1658    /// The same for a Lua node table.
1659    pub lua_own: &'static [&'static str],
1660    /// The schema rows this element reads, or `None` for every one. A
1661    /// composite whose look *is* its spec — the stock button, whose
1662    /// padding, colours and radius are `widgets::button_spec` — cannot take
1663    /// the whole prop list: `dir` alone rebuilds the spec from nothing. It
1664    /// names the rows it reads instead, and a binding drops the rest with a
1665    /// warning that says which rows it does take ([`known_prop`],
1666    /// `diag::unknown_prop`). JSX spellings here, Lua's below.
1667    pub jsx_rows: Option<&'static [&'static str]>,
1668    pub lua_rows: Option<&'static [&'static str]>,
1669    pub jsx: &'static str,
1670    pub lua: &'static str,
1671    pub c: &'static str,
1672    pub doc: &'static str,
1673}
1674
1675/// The rows a `text` reads (`ElementDef::jsx_rows` / `lua_rows`): the
1676/// `TextStyle` rows and `size`, the composite the style is built from —
1677/// and nothing else, because every door lowers a text as content plus a
1678/// style and no spec (`Core::text_node`), so a container or access row
1679/// on it reaches no tree. Before AR13 the element admitted every shared
1680/// row, and `<text live="polite">`, `<text role="heading">`, `<text
1681/// label>` and `<text onClick>` were dropped silently by all four
1682/// bindings — no `unknown-prop`, and `live-region-without-name` could
1683/// never fire for them. Pinned equal to the `Target::Style` rows by a
1684/// test, so a style row added to `PROPS` is a row here or a red test.
1685pub const TEXT_ROWS_JSX: &[&str] = &[
1686    "size",
1687    "lineHeight",
1688    "color",
1689    "family",
1690    "font",
1691    "wrap",
1692    "maxLines",
1693    "ellipsis",
1694    "underline",
1695    "underlineColor",
1696    "underlineStyle",
1697    "strikethrough",
1698    "features",
1699];
1700pub const TEXT_ROWS_LUA: &[&str] = &[
1701    "size",
1702    "line_height",
1703    "color",
1704    "family",
1705    "font",
1706    "wrap",
1707    "max_lines",
1708    "ellipsis",
1709    "underline",
1710    "underline_color",
1711    "underline_style",
1712    "strikethrough",
1713    "features",
1714];
1715
1716/// The rows the stock button reads (`ElementDef::jsx_rows` / `lua_rows`):
1717/// the click, the identity (`key`, or `index` in a virtual list — declared
1718/// beside `key` the index wins, as on a box, backlog AR40), the access
1719/// rows — what a button *is* and what a reader says of it — and the one
1720/// paint row it takes, `accent`, which is not a colour but a question put
1721/// to the OS. The two lists are the same rows in each spelling, index for
1722/// index.
1723pub const BUTTON_ROWS_JSX: &[&str] = &[
1724    "onClick",
1725    "key",
1726    "index",
1727    "label",
1728    "description",
1729    "tooltip",
1730    "disabled",
1731    "accent",
1732];
1733pub const BUTTON_ROWS_LUA: &[&str] = &[
1734    "on_click",
1735    "key",
1736    "index",
1737    "label",
1738    "description",
1739    "tooltip",
1740    "disabled",
1741    "accent",
1742];
1743
1744/// The rows a stock toggle — `checkbox`, `radio`, `switch` — reads
1745/// (`widgets::toggle_with`, ADR 0034): the button's access rows, its state
1746/// and no layout or paint row, since its look is its spec. `mixed` means
1747/// something on a checkbox alone.
1748pub const TOGGLE_ROWS_JSX: &[&str] = &[
1749    "onClick",
1750    "key",
1751    "label",
1752    "description",
1753    "tooltip",
1754    "disabled",
1755    "checked",
1756    "mixed",
1757];
1758pub const TOGGLE_ROWS_LUA: &[&str] = &[
1759    "on_click",
1760    "key",
1761    "label",
1762    "description",
1763    "tooltip",
1764    "disabled",
1765    "checked",
1766    "mixed",
1767];
1768/// The rows the stock slider reads (`widgets::slider_with`, ADR 0034): the
1769/// value rows, its change tag, the access rows, and its width — the one
1770/// piece of its look an app sizes.
1771pub const SLIDER_ROWS_JSX: &[&str] = &[
1772    "key",
1773    "label",
1774    "description",
1775    "tooltip",
1776    "disabled",
1777    "valueNow",
1778    "valueMin",
1779    "valueMax",
1780    "valueStep",
1781    "valueText",
1782    "onChange",
1783    "width",
1784    "minWidth",
1785    "maxWidth",
1786];
1787pub const SLIDER_ROWS_LUA: &[&str] = &[
1788    "key",
1789    "label",
1790    "description",
1791    "tooltip",
1792    "disabled",
1793    "value_now",
1794    "value_min",
1795    "value_max",
1796    "value_step",
1797    "value_text",
1798    "on_change",
1799    "width",
1800    "min_width",
1801    "max_width",
1802];
1803
1804pub const ELEMENTS: &[ElementDef] = &[
1805    ElementDef {
1806        name: "box",
1807        jsx_own: &[],
1808        lua_own: &[],
1809        jsx_rows: None,
1810        lua_rows: None,
1811        jsx: "`<box>`",
1812        lua: "`row { }`, `column { }`",
1813        c: "`kui_open*` … `kui_close`",
1814        doc: "A container: every container prop applies.",
1815    },
1816    ElementDef {
1817        name: "table",
1818        jsx_own: &[],
1819        lua_own: &[],
1820        jsx_rows: None,
1821        lua_rows: None,
1822        jsx: "`<box dir=\"table\">`",
1823        lua: "`grid { }`",
1824        c: "`kui_open*` with `dir = KUI_TABLE`",
1825        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.",
1826    },
1827    ElementDef {
1828        name: "text",
1829        jsx_own: &["bold", "italic", "bg", "bgRadius"],
1830        lua_own: &["value", "spans"],
1831        jsx_rows: Some(TEXT_ROWS_JSX),
1832        lua_rows: Some(TEXT_ROWS_LUA),
1833        jsx: "`<text>` with `<span bold italic underline strikethrough bg bgRadius color>` children",
1834        lua: "`text(\"s\", {…})`, `text({ \"a\", { \"b\", bold = true, underline = true, bg = 0x.., bg_radius = 4 } })`",
1835        c: "`kui_text`, `kui_rich_text`",
1836        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`, `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 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.",
1837    },
1838    ElementDef {
1839        name: "button",
1840        jsx_own: &[],
1841        lua_own: &["text"],
1842        jsx_rows: Some(BUTTON_ROWS_JSX),
1843        lua_rows: Some(BUTTON_ROWS_LUA),
1844        jsx: "`<button onClick key|index label description tooltip disabled accent>`",
1845        lua: "`button { label=, on_click=, key= | index=, text=, description=, tooltip=, disabled=, accent= }`",
1846        c: "`kui_button`, `kui_button_with`",
1847        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.",
1848    },
1849    ElementDef {
1850        name: "edit",
1851        jsx_own: &["id", "initial", "multiline", "autofocus"],
1852        lua_own: &["initial", "multiline", "autofocus"],
1853        jsx_rows: None,
1854        lua_rows: None,
1855        jsx: "`<edit key initial multiline autofocus>`, `<input label initial>`",
1856        lua: "`edit { key=, initial=, … }`, `input { label=, initial= }`",
1857        c: "`kui_text_edit`, `kui_text_input`",
1858        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.",
1859    },
1860    ElementDef {
1861        name: "select",
1862        jsx_own: &["label", "options", "current"],
1863        lua_own: &["label", "options", "current"],
1864        // Its look is its own, like the button's, and it has no rows of
1865        // the box's to read: the three it takes are all its own.
1866        jsx_rows: Some(&[]),
1867        lua_rows: Some(&[]),
1868        jsx: "`<select label options={[…]} current>`",
1869        lua: "`dropdown { label=, options={…}, current= }`",
1870        c: "`kui_select`",
1871        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.",
1872    },
1873    ElementDef {
1874        name: "checkbox",
1875        jsx_own: &[],
1876        lua_own: &["text"],
1877        jsx_rows: Some(TOGGLE_ROWS_JSX),
1878        lua_rows: Some(TOGGLE_ROWS_LUA),
1879        jsx: "`<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>`",
1880        lua: "`checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1881        c: "`kui_checkbox`",
1882        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.",
1883    },
1884    ElementDef {
1885        name: "radio",
1886        jsx_own: &[],
1887        lua_own: &["text"],
1888        jsx_rows: Some(TOGGLE_ROWS_JSX),
1889        lua_rows: Some(TOGGLE_ROWS_LUA),
1890        jsx: "`<radio checked onClick key label description tooltip disabled>text</radio>`",
1891        lua: "`radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1892        c: "`kui_radio`",
1893        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.",
1894    },
1895    ElementDef {
1896        name: "radioGroup",
1897        jsx_own: &[],
1898        lua_own: &[],
1899        jsx_rows: None,
1900        lua_rows: None,
1901        jsx: "`<radioGroup label>…radios…</radioGroup>`",
1902        lua: "`radio_group { label=, … }`",
1903        c: "`kui_radio_group_open` … `kui_close`",
1904        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.",
1905    },
1906    ElementDef {
1907        name: "switch",
1908        jsx_own: &[],
1909        lua_own: &["text"],
1910        jsx_rows: Some(TOGGLE_ROWS_JSX),
1911        lua_rows: Some(TOGGLE_ROWS_LUA),
1912        jsx: "`<switch checked onClick key label description tooltip disabled>text</switch>`",
1913        lua: "`switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1914        c: "`kui_switch`",
1915        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.",
1916    },
1917    ElementDef {
1918        name: "slider",
1919        jsx_own: &[],
1920        lua_own: &[],
1921        jsx_rows: Some(SLIDER_ROWS_JSX),
1922        lua_rows: Some(SLIDER_ROWS_LUA),
1923        jsx: "`<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>`",
1924        lua: "`slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }`",
1925        c: "`kui_slider`",
1926        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.",
1927    },
1928    ElementDef {
1929        name: "image",
1930        jsx_own: &["src", "sampling", "fit"],
1931        lua_own: &["id", "sampling", "fit"],
1932        jsx_rows: None,
1933        lua_rows: None,
1934        jsx: "`<image src={id} sampling fit>`",
1935        lua: "`image { id=, sampling=, fit= }`",
1936        c: "`kui_image`, `kui_image_with`",
1937        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.",
1938    },
1939    ElementDef {
1940        name: "polygon",
1941        // `bg` is a schema row already; on a polygon it is the fill.
1942        jsx_own: &["points"],
1943        lua_own: &["points"],
1944        jsx_rows: None,
1945        lua_rows: None,
1946        jsx: "`<polygon points={[[x,y],…]} bg/>`",
1947        lua: "`polygon { points={{x,y},…}, bg= }`",
1948        c: "`kui_polygon`",
1949        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. 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.",
1950    },
1951    ElementDef {
1952        name: "fragment",
1953        jsx_own: &["src", "image", "params", "animate"],
1954        lua_own: &["id", "image", "params", "animate"],
1955        jsx_rows: None,
1956        lua_rows: None,
1957        jsx: "`<fragment src={id} image={id} params={[…]} animate>`",
1958        lua: "`fragment { id=, image=, params={…}, animate= }`",
1959        c: "`kui_fragment`, `kui_fragment_with`",
1960        doc: "A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): gradients, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare.",
1961    },
1962    ElementDef {
1963        name: "cells",
1964        jsx_own: &[
1965            "rows",
1966            "cols",
1967            "cells",
1968            "cursorAt",
1969            "cursorShape",
1970            "cursorColor",
1971            "originLine",
1972        ],
1973        lua_own: &[
1974            "rows",
1975            "cols",
1976            "lines",
1977            "runs",
1978            "cursor_at",
1979            "cursor_shape",
1980            "cursor_color",
1981            "origin_line",
1982        ],
1983        jsx_rows: None,
1984        lua_rows: None,
1985        jsx: "`<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>`",
1986        lua: "`cells { rows=, cols=, lines={\"row text\", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }`",
1987        c: "`kui_cells`",
1988        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. 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.",
1989    },
1990    ElementDef {
1991        name: "line",
1992        // `width` and `color` are schema rows already (a sizing and the text
1993        // colour); on a line they are the stroke's width and colour.
1994        jsx_own: &["from", "to", "points", "curve"],
1995        lua_own: &["from", "to", "points", "curve"],
1996        jsx_rows: None,
1997        lua_rows: None,
1998        jsx: "`<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve/>`",
1999        lua: "`line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true }`",
2000        c: "`kui_line`, `kui_polyline`",
2001        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. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it.",
2002    },
2003    ElementDef {
2004        name: "titlebar",
2005        jsx_own: &[],
2006        // The window's own title is `window_title` on the root table; this
2007        // is the string the titlebar draws.
2008        lua_own: &["title"],
2009        jsx_rows: None,
2010        lua_rows: None,
2011        jsx: "`<titlebar title>` or `<titlebar>…</titlebar>`",
2012        lua: "`titlebar { title= }` / `titlebar { … }`",
2013        c: "`kui_titlebar`, `kui_titlebar_with`",
2014        doc: "Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons.",
2015    },
2016    ElementDef {
2017        name: "menuBar",
2018        jsx_own: &["menu"],
2019        lua_own: &["menu"],
2020        jsx_rows: None,
2021        lua_rows: None,
2022        jsx: "`<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked? }] }]}/>`",
2023        lua: "`menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked= } } } } }`",
2024        c: "`kui_menu_bar`",
2025        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.",
2026    },
2027    ElementDef {
2028        name: "windowButtons",
2029        jsx_own: &[],
2030        lua_own: &[],
2031        jsx_rows: None,
2032        lua_rows: None,
2033        jsx: "`<windowButtons/>`",
2034        lua: "`window_buttons()`",
2035        c: "`kui_window_buttons`",
2036        doc: "Just the min/max/close buttons, for fully custom titlebars.",
2037    },
2038    ElementDef {
2039        name: "tooltip",
2040        jsx_own: &["value"],
2041        lua_own: &["value"],
2042        jsx_rows: None,
2043        lua_rows: None,
2044        jsx: "`<tooltip value=\"hint\"/>` / `<tooltip>…</tooltip>` nodes, or the `tooltip=\"hint\"` prop (see composites)",
2045        lua: "`tooltip(\"hint\")` / `tooltip { … }` nodes, or the prop",
2046        c: "`kui_tooltip`, `kui_tooltip_with`",
2047        doc: "A float hanging below the parent; the node form always draws, the prop form is hover-gated.",
2048    },
2049    ElementDef {
2050        name: "latencyGraph",
2051        jsx_own: &["at"],
2052        lua_own: &["at"],
2053        jsx_rows: None,
2054        lua_rows: None,
2055        jsx: "`<latencyGraph/>`, `<latencyHud at/>`",
2056        lua: "`latency_graph()`, `latency_hud { at= }`",
2057        c: "`kui_latency_graph`, `kui_latency_hud`",
2058        doc: "Per-phase frame timing (windowed drivers fill it; headless shows the chrome empty).",
2059    },
2060    ElementDef {
2061        name: "audio",
2062        jsx_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2063        lua_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2064        jsx_rows: None,
2065        lua_rows: None,
2066        jsx: "`<audio src={id} loop volume paused finish tag/>`",
2067        lua: "`audio { src=, loop=, volume=, paused=, finish=, tag= }`",
2068        c: "`kui_audio`",
2069        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.",
2070    },
2071];
2072
2073/// An event kind hosts receive, with its payload shape.
2074pub struct EventDef {
2075    pub kind: &'static str,
2076    pub payload: &'static str,
2077    pub doc: &'static str,
2078}
2079
2080pub const EVENTS: &[EventDef] = &[
2081    EventDef {
2082        kind: "click",
2083        payload: "the `onClick` payload as-is",
2084        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` and `clicks` as a `drag` inside one carries them.",
2085    },
2086    EventDef {
2087        kind: "drag",
2088        payload: "`{ kind: \"drag\", phase: \"start\" | \"move\" | \"end\", x, y, dx, dy, parent: { x, y, w, h }, tag }`",
2089        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. Nothing is added where the sink draws no lines.",
2090    },
2091    EventDef {
2092        kind: "key",
2093        payload: "`{ kind: \"key\", phase: \"down\" | \"up\", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }`",
2094        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`.",
2095    },
2096    EventDef {
2097        kind: "text",
2098        payload: "`{ kind: \"text\", text, pasted?: true, concealed?: true, transient?: true, tag }`",
2099        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.",
2100    },
2101    EventDef {
2102        kind: "preedit",
2103        payload: "`{ kind: \"preedit\", text, cursor: [start, end] | null, tag }`",
2104        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.",
2105    },
2106    EventDef {
2107        kind: "selectionrange",
2108        payload: "`{ kind: \"selectionrange\", from: { index, byte }, to: { index, byte } }` on the scope",
2109        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.",
2110    },
2111    EventDef {
2112        kind: "contextmenu",
2113        payload: "`{ kind: \"contextmenu\", x, y, tag }`",
2114        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`.",
2115    },
2116    EventDef {
2117        kind: "menu",
2118        payload: "`{ kind: \"menu\", role, item }`",
2119        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).",
2120    },
2121    EventDef {
2122        kind: "forceclick",
2123        payload: "`{ kind: \"forceclick\", x, y, tag }`",
2124        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.",
2125    },
2126    EventDef {
2127        kind: "button",
2128        payload: "`{ kind: \"button\", phase: \"press\" | \"move\" | \"release\", button: \"secondary\" | \"middle\" | number, x, y, clicks, cell?: { row, col }, line?, byte?, tag }`",
2129        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` and `byte` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar.",
2130    },
2131    EventDef {
2132        kind: "scroll",
2133        payload: "`{ kind: \"scroll\", x, y, dx, dy, lines, tag }`",
2134        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`).",
2135    },
2136    EventDef {
2137        kind: "focus",
2138        payload: "`{ kind: \"focus\", phase: \"in\" | \"out\", by: \"pointer\" | \"keyboard\" | \"assistive\" | \"program\", tag }`",
2139        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.",
2140    },
2141    EventDef {
2142        kind: "hover",
2143        payload: "`{ kind: \"hover\", phase: \"enter\" | \"leave\", by: \"pointer\" | \"content\", tag }`",
2144        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.",
2145    },
2146    EventDef {
2147        kind: "drop",
2148        payload: "`{ kind: \"drop\", phase: \"enter\" | \"move\" | \"leave\" | \"drop\", paths: string[], x, y, tag }`",
2149        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.",
2150    },
2151    EventDef {
2152        kind: "files",
2153        payload: "`{ kind: \"files\", paths: string[], tag }`",
2154        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.",
2155    },
2156    EventDef {
2157        kind: "layout",
2158        payload: "`{ kind: \"layout\", x, y, w, h, parent: { x, y, w, h }, scale, tag }`",
2159        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).",
2160    },
2161    EventDef {
2162        kind: "resize",
2163        payload: "`{ kind: \"resize\", width, height, scale }`",
2164        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.",
2165    },
2166    EventDef {
2167        kind: "window",
2168        payload: "`{ kind: \"window\", phase: \"opened\" | \"closed\" | \"focused\" | \"blurred\", name, id }`",
2169        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.",
2170    },
2171    EventDef {
2172        kind: "system",
2173        payload: "`{ kind: \"system\", appearance, accent, motion, locale, assistive }`",
2174        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.",
2175    },
2176    EventDef {
2177        kind: "fonts",
2178        payload: "`{ kind: \"fonts\" }`",
2179        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.",
2180    },
2181    EventDef {
2182        kind: "modifiers",
2183        payload: "`{ kind: \"modifiers\", shift, ctrl, alt, super }`",
2184        doc: "The physical modifier state changed (delivered to the host on the root).",
2185    },
2186    EventDef {
2187        kind: "changed",
2188        payload: "`{ kind: \"changed\" }`, with the editor's key on the event",
2189        doc: "An editor's text changed.",
2190    },
2191    EventDef {
2192        kind: "submit",
2193        payload: "`{ kind: \"submit\" }`, with the editor's key on the event",
2194        doc: "Enter in a single-line editor.",
2195    },
2196    EventDef {
2197        kind: "sound",
2198        payload: "`{ kind: \"sound\", phase: \"ended\" | \"refused\", playback, tag }`",
2199        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.",
2200    },
2201    EventDef {
2202        kind: "dismiss",
2203        payload: "`{ kind: \"dismiss\", reason: \"escape\" | \"outside\", tag }` on a node; `{ kind: \"dismiss\", reason, name, id }` on the root for a popup window",
2204        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.",
2205    },
2206    EventDef {
2207        kind: "access",
2208        payload: "`{ kind: \"access\", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }`",
2209        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.",
2210    },
2211    EventDef {
2212        kind: "change",
2213        payload: "`{ kind: \"change\", value, phase: \"move\" | \"end\", tag }`",
2214        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.",
2215    },
2216];
2217
2218/// A host-registered resource and how each binding registers it.
2219pub struct ResourceDef {
2220    pub what: &'static str,
2221    pub node: &'static str,
2222    pub lua: &'static str,
2223    pub c: &'static str,
2224}
2225
2226pub const RESOURCES: &[ResourceDef] = &[
2227    ResourceDef {
2228        what: "image",
2229        node: "`ctx.addImage(w, h, rgba)` → id for `<image src>`",
2230        lua: "the host registers; `image { id }`",
2231        c: "`kui_image_add` → `kui_image`",
2232    },
2233    ResourceDef {
2234        what: "fragment (WGSL)",
2235        node: "`ctx.addFragment(src)` → id for `<fragment src>`",
2236        lua: "the host registers; `fragment { id }`",
2237        c: "`kui_fragment_add` → `kui_fragment`",
2238    },
2239    ResourceDef {
2240        what: "font from bytes",
2241        node: "`ctx.addFont(buffer)` → id for `font`",
2242        lua: "the host registers; `font = id`",
2243        c: "`kui_font_add` → `KuiTextStyle.font`",
2244    },
2245    ResourceDef {
2246        what: "font file by path",
2247        node: "`ctx.loadFontFile(\"fonts/Antonio.ttf\")` → id for `font`",
2248        lua: "the host registers; `font = id`",
2249        c: "`kui_font_load_file`",
2250    },
2251    ResourceDef {
2252        what: "a folder of fonts",
2253        node: "`ctx.loadFontsDir(\"fonts\")`, then pick by name",
2254        lua: "the host loads",
2255        c: "`kui_font_load_dir`",
2256    },
2257    ResourceDef {
2258        what: "font by family name (installed or loaded)",
2259        node: "`ctx.addSystemFont(\"Antonio\")` (see `systemFontFamilies()`)",
2260        lua: "the host registers; `font = id`",
2261        c: "`kui_font_add_system` (see `kui_font_families`)",
2262    },
2263    ResourceDef {
2264        what: "sound (wav / ogg / mp3 / flac bytes)",
2265        node: "`ctx.addSound(buffer)` → id for `<audio src>`, `clickSound`, `play(id)`",
2266        lua: "the host registers; `audio { src = id }`, `click_sound = id`",
2267        c: "`kui_sound_add` → `kui_audio`, `KuiSpec.click_sound`, `kui_play`",
2268    },
2269];
2270
2271/// One value in the host-environment reading a view gets — `ui.env()` in
2272/// Rust, `view(env)` in Lua, `ctx.env()` / `win.env()` in Node — and the
2273/// key each binding puts it under. C has no reading: a C host is the frame
2274/// driver, so it is the *writer* (`kui_env_set`, `kui_env_set_window`), and
2275/// its column names the argument that carries the fact in.
2276///
2277/// `Env` and `WindowEnv` are the shape; this table is the cross-binding
2278/// restatement of it, and every restatement is pinned to it rather than
2279/// trusted: `schema`'s tests check the rows against the two structs, the
2280/// Lua and Node key-set tests check each binding's reading against the
2281/// `lua` / `node` columns, and kui-ffi checks the header's two prototypes
2282/// against the `c` column. The frame facts a view wants at the same moment
2283/// (the viewport, the focused node) ride in the same reading and have rows
2284/// here too, marked as the frame's rather than `Env`'s.
2285///
2286/// Two divergences are deliberate and named in their rows: `native_controls`
2287/// is the `Rect` the core holds in Node and a width/height at the window
2288/// origin in Lua and C, and `frame_budget_ms` is derived from `refresh_hz`
2289/// but part of the reading everywhere.
2290pub struct EnvField {
2291    /// The canonical name: the Rust field path (`window.custom_chrome`).
2292    pub name: &'static str,
2293    /// Where it comes from — the Rust struct and field for a stored fact,
2294    /// the call for a derived or frame fact.
2295    pub from: &'static str,
2296    /// The key path(s) it occupies in Node's `ctx.env()`; empty when Node
2297    /// carries the fact as a call on the context instead (the `doc` says
2298    /// which) or not at all.
2299    pub node: &'static [&'static str],
2300    /// The same for Lua's `view(env)` table. Two entries where Lua flattens
2301    /// one fact into two keys.
2302    pub lua: &'static [&'static str],
2303    /// The C spelling: `function(argument)` for the setter argument that
2304    /// writes it — the first thing in the cell, so kui-ffi can parse it —
2305    /// or the call that reads it.
2306    pub c: &'static str,
2307    pub doc: &'static str,
2308    /// The fact's reading, as plain data: `Null` for "the host cannot
2309    /// tell", an enum as its wire name, a key as its integer, a rect as
2310    /// `{x, y, w, h}`. What makes the table a reader and not only a pin —
2311    /// a binding iterates the rows and asks each for its value, then
2312    /// spells the key its own way, so a fact added here reaches every
2313    /// reading with no reader restated beside it (the way
2314    /// [`ThemeRole::get`] does for the palette).
2315    pub get: fn(&EnvFacts) -> Value,
2316}
2317
2318/// Everything an env reading is taken from: the stored [`Env`] and the
2319/// frame's own facts beside it (`Core::env_facts`).
2320#[derive(Clone, Copy, Debug)]
2321pub struct EnvFacts {
2322    pub env: crate::env::Env,
2323    pub viewport: crate::geom::Size,
2324    pub scale: f32,
2325    pub focus: Option<crate::key::Key>,
2326    pub focus_visible: bool,
2327    pub region: Option<crate::key::Key>,
2328    pub caret_visible: bool,
2329}
2330
2331fn key_value(k: Option<crate::key::Key>) -> Value {
2332    k.map_or(Value::Null, |k| Value::Int(k.0 as i64))
2333}
2334
2335fn rect_value(r: crate::geom::Rect) -> Value {
2336    Value::map([
2337        ("x", Value::Float(r.x as f64)),
2338        ("y", Value::Float(r.y as f64)),
2339        ("w", Value::Float(r.w as f64)),
2340        ("h", Value::Float(r.h as f64)),
2341    ])
2342}
2343
2344pub const ENV_FIELDS: &[EnvField] = &[
2345    EnvField {
2346        name: "refresh_hz",
2347        get: |f| {
2348            f.env
2349                .refresh_hz
2350                .map_or(Value::Null, |hz| Value::Float(hz as f64))
2351        },
2352        from: "`Env::refresh_hz`",
2353        node: &["refreshHz"],
2354        lua: &["refresh_hz"],
2355        c: "`kui_env_set(refresh_hz)`",
2356        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.",
2357    },
2358    EnvField {
2359        name: "frame_budget_ms",
2360        get: |f| Value::Float(f.env.frame_budget_ms() as f64),
2361        from: "`Env::frame_budget_ms()`, derived",
2362        node: &["frameBudgetMs"],
2363        lua: &["frame_budget_ms"],
2364        c: "—",
2365        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.",
2366    },
2367    EnvField {
2368        name: "focused",
2369        get: |f| Value::Bool(f.env.focused),
2370        from: "`Env::focused`",
2371        node: &["focused"],
2372        lua: &["focused"],
2373        c: "`kui_env_set(focused)`",
2374        doc: "Whether the *window* has the keyboard at all. Not the focused node — that is the `focus` row.",
2375    },
2376    EnvField {
2377        name: "system.appearance",
2378        get: |f| Value::str(f.env.system.appearance.name()),
2379        from: "`SystemEnv::appearance`",
2380        node: &["system.appearance"],
2381        lua: &["system.appearance"],
2382        c: "`kui_env_set_system(appearance)`",
2383        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.",
2384    },
2385    EnvField {
2386        name: "system.accent",
2387        get: |f| {
2388            f.env
2389                .system
2390                .accent
2391                .map_or(Value::Null, |c| Value::Int(c.to_hex() as i64))
2392        },
2393        from: "`SystemEnv::accent`",
2394        node: &["system.accent"],
2395        lua: &["system.accent"],
2396        c: "`kui_env_set_system(accent)`",
2397        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.",
2398    },
2399    EnvField {
2400        name: "system.motion",
2401        get: |f| Value::str(f.env.system.motion.name()),
2402        from: "`SystemEnv::motion`",
2403        node: &["system.motion"],
2404        lua: &["system.motion"],
2405        c: "`kui_env_set_system(motion)`",
2406        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.",
2407    },
2408    EnvField {
2409        name: "system.locale",
2410        get: |f| {
2411            f.env
2412                .system
2413                .locale
2414                .map_or(Value::Null, |l| Value::str(l.as_str()))
2415        },
2416        from: "`SystemEnv::locale`",
2417        node: &["system.locale"],
2418        lua: &["system.locale"],
2419        c: "`kui_env_set_system(locale)`",
2420        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.",
2421    },
2422    EnvField {
2423        name: "system.assistive",
2424        get: |f| Value::str(f.env.system.assistive.name()),
2425        from: "`SystemEnv::assistive`",
2426        node: &["system.assistive"],
2427        lua: &["system.assistive"],
2428        c: "`kui_env_set_assistive(assistive)`",
2429        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.",
2430    },
2431    EnvField {
2432        name: "window.id",
2433        get: |f| Value::Int(f.env.window.id.0 as i64),
2434        from: "`WindowEnv::id`",
2435        node: &["window.id"],
2436        lua: &["window.id"],
2437        c: "`kui_env_set_window(window)`, read back by `kui_ctx_window`",
2438        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.",
2439    },
2440    EnvField {
2441        name: "window.custom_chrome",
2442        get: |f| Value::Bool(f.env.window.custom_chrome),
2443        from: "`WindowEnv::custom_chrome`",
2444        node: &["window.customChrome"],
2445        lua: &["window.custom_chrome"],
2446        c: "`kui_env_set_window(custom_chrome)`",
2447        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.",
2448    },
2449    EnvField {
2450        name: "window.maximized",
2451        get: |f| Value::Bool(f.env.window.maximized),
2452        from: "`WindowEnv::maximized`",
2453        node: &["window.maximized"],
2454        lua: &["window.maximized"],
2455        c: "`kui_env_set_window(maximized)`",
2456        doc: "The window is maximized — what picks the restore glyph over the maximize one.",
2457    },
2458    EnvField {
2459        name: "window.fullscreen",
2460        get: |f| Value::Bool(f.env.window.fullscreen),
2461        from: "`WindowEnv::fullscreen`",
2462        node: &["window.fullscreen"],
2463        lua: &["window.fullscreen"],
2464        c: "`kui_env_set_window(fullscreen)`",
2465        doc: "The window is fullscreen.",
2466    },
2467    EnvField {
2468        name: "window.always_on_top",
2469        get: |f| Value::Bool(f.env.window.always_on_top),
2470        from: "`WindowEnv::always_on_top`",
2471        node: &["window.alwaysOnTop"],
2472        lua: &["window.always_on_top"],
2473        c: "`kui_env_set_always_on_top(always_on_top)`",
2474        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.",
2475    },
2476    EnvField {
2477        name: "window.native_controls",
2478        get: |f| f.env.window.native_controls.map_or(Value::Null, rect_value),
2479        from: "`WindowEnv::native_controls`",
2480        node: &["window.nativeControls"],
2481        lua: &["window.controls_w", "window.controls_h"],
2482        c: "`kui_env_set_window(controls_w, controls_h)`",
2483        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.",
2484    },
2485    EnvField {
2486        name: "audio.device",
2487        get: |f| Value::str(f.env.audio.device.name()),
2488        from: "`AudioEnv::device`",
2489        node: &["audio.device"],
2490        lua: &["audio.device"],
2491        c: "`kui_env_set_audio(device)`",
2492        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.",
2493    },
2494    EnvField {
2495        name: "audio.live",
2496        get: |f| Value::Int(f.env.audio.live as i64),
2497        from: "`AudioEnv::live`",
2498        node: &["audio.live"],
2499        lua: &["audio.live"],
2500        c: "`kui_env_set_audio(live)`",
2501        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).",
2502    },
2503    EnvField {
2504        name: "viewport.w",
2505        get: |f| Value::Float(f.viewport.w as f64),
2506        from: "`Core::viewport()`, the frame's",
2507        node: &["viewport.width"],
2508        lua: &["viewport_w"],
2509        c: "`kui_frame_begin(w)`",
2510        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.",
2511    },
2512    EnvField {
2513        name: "viewport.h",
2514        get: |f| Value::Float(f.viewport.h as f64),
2515        from: "`Core::viewport()`, the frame's",
2516        node: &["viewport.height"],
2517        lua: &["viewport_h"],
2518        c: "`kui_frame_begin(h)`",
2519        doc: "Its logical height.",
2520    },
2521    EnvField {
2522        name: "scale",
2523        get: |f| Value::Float(f.scale as f64),
2524        from: "`Core::scale()`, the frame's",
2525        node: &["viewport.scale"],
2526        lua: &[],
2527        c: "`kui_frame_begin(scale)`",
2528        doc: "Device pixels per logical px. Lua has no reading: a script sees logical px only.",
2529    },
2530    EnvField {
2531        name: "focus",
2532        get: |f| key_value(f.focus),
2533        from: "`Core::focus()`, the frame's",
2534        node: &[],
2535        lua: &["focus"],
2536        c: "`kui_focused()`",
2537        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`.",
2538    },
2539    EnvField {
2540        name: "focus_visible",
2541        get: |f| Value::Bool(f.focus_visible),
2542        from: "`Core::focus_visible()`, the frame's",
2543        node: &[],
2544        lua: &["focus_visible"],
2545        c: "`kui_focus_visible()`",
2546        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.",
2547    },
2548    EnvField {
2549        name: "caret_visible",
2550        get: |f| Value::Bool(f.caret_visible),
2551        from: "`Core::caret_visible()`, the frame's",
2552        node: &[],
2553        lua: &["caret_visible"],
2554        c: "`kui_caret_visible()`",
2555        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).",
2556    },
2557    EnvField {
2558        name: "region",
2559        get: |f| key_value(f.region),
2560        from: "`Core::region()`, the frame's",
2561        node: &[],
2562        lua: &["region"],
2563        c: "`kui_region()`",
2564        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`.",
2565    },
2566];
2567
2568/// One colour role in a [`crate::theme::Theme`], with the spelling each
2569/// binding reads it under and the reading itself.
2570///
2571/// The same pin `ENV_FIELDS` is: the palette is one contract, so the roles
2572/// are written down once and every binding's reading is generated from
2573/// this table rather than restated beside it. `get` is what makes that
2574/// possible — a binding iterates the rows and asks each one for its
2575/// colour, so adding a token is one row here and nothing anywhere else.
2576/// The test below destructures [`crate::theme::Theme`] exhaustively, so a
2577/// field added to it stops the crate compiling until it has a row.
2578pub struct ThemeRole {
2579    /// The Rust field, and the name used everywhere but Node.
2580    pub name: &'static str,
2581    /// What Node calls it (camelCase).
2582    pub node: &'static str,
2583    pub doc: &'static str,
2584    /// This role's colour out of a theme.
2585    pub get: fn(&crate::theme::Theme) -> crate::color::Color,
2586    /// The same colour written: what a binding's theme setter uses, so
2587    /// writing a palette is the table's business the way reading one is.
2588    pub set: fn(&mut crate::theme::Theme, crate::color::Color),
2589}
2590
2591pub const THEME_ROLES: &[ThemeRole] = &[
2592    ThemeRole {
2593        name: "bg",
2594        node: "bg",
2595        get: |t| t.bg,
2596        set: |t, c| t.bg = c,
2597        doc: "The window behind everything.",
2598    },
2599    ThemeRole {
2600        name: "surface",
2601        node: "surface",
2602        get: |t| t.surface,
2603        set: |t, c| t.surface = c,
2604        doc: "A card, panel or list sitting on `bg`.",
2605    },
2606    ThemeRole {
2607        name: "raised",
2608        node: "raised",
2609        get: |t| t.raised,
2610        set: |t, c| t.raised = c,
2611        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.",
2612    },
2613    ThemeRole {
2614        name: "sunken",
2615        node: "sunken",
2616        get: |t| t.sunken,
2617        set: |t, c| t.sunken = c,
2618        doc: "A well cut into a surface: a text field, a code block, a track.",
2619    },
2620    ThemeRole {
2621        name: "border",
2622        node: "border",
2623        get: |t| t.border,
2624        set: |t, c| t.border = c,
2625        doc: "The hairline between two surfaces.",
2626    },
2627    ThemeRole {
2628        name: "border_strong",
2629        node: "borderStrong",
2630        get: |t| t.border_strong,
2631        set: |t, c| t.border_strong = c,
2632        doc: "A border that has to be seen — a float's edge, a focused field.",
2633    },
2634    ThemeRole {
2635        name: "fg",
2636        node: "fg",
2637        get: |t| t.fg,
2638        set: |t, c| t.fg = c,
2639        doc: "Body text, and what a `color`-less text run resolves to.",
2640    },
2641    ThemeRole {
2642        name: "muted",
2643        node: "muted",
2644        get: |t| t.muted,
2645        set: |t, c| t.muted = c,
2646        doc: "Secondary text: captions, hints, an accelerator beside a label.",
2647    },
2648    ThemeRole {
2649        name: "faint",
2650        node: "faint",
2651        get: |t| t.faint,
2652        set: |t, c| t.faint = c,
2653        doc: "Text that is barely there: a placeholder, a gutter number.",
2654    },
2655    ThemeRole {
2656        name: "accent",
2657        node: "accent",
2658        get: |t| t.accent,
2659        set: |t, c| t.accent = c,
2660        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.",
2661    },
2662    ThemeRole {
2663        name: "accent_hover",
2664        node: "accentHover",
2665        get: |t| t.accent_hover,
2666        set: |t, c| t.accent_hover = c,
2667        doc: "`accent` under a pointer.",
2668    },
2669    ThemeRole {
2670        name: "accent_pressed",
2671        node: "accentPressed",
2672        get: |t| t.accent_pressed,
2673        set: |t, c| t.accent_pressed = c,
2674        doc: "`accent` under a press.",
2675    },
2676    ThemeRole {
2677        name: "on_accent",
2678        node: "onAccent",
2679        get: |t| t.on_accent,
2680        set: |t, c| t.on_accent = c,
2681        doc: "Black or white — whichever a reader can see on `accent`. What a button's label is.",
2682    },
2683    ThemeRole {
2684        name: "accent_soft",
2685        node: "accentSoft",
2686        get: |t| t.accent_soft,
2687        set: |t, c| t.accent_soft = c,
2688        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.",
2689    },
2690    ThemeRole {
2691        name: "selection",
2692        node: "selection",
2693        get: |t| t.selection,
2694        set: |t, c| t.selection = c,
2695        doc: "What a text selection is painted under, in an editor and over a `selectable` scope alike.",
2696    },
2697    ThemeRole {
2698        name: "focus_ring",
2699        node: "focusRing",
2700        get: |t| t.focus_ring,
2701        set: |t, c| t.focus_ring = c,
2702        doc: "The default keyboard focus ring (ADR 0002).",
2703    },
2704    ThemeRole {
2705        name: "hover",
2706        node: "hover",
2707        get: |t| t.hover,
2708        set: |t, c| t.hover = c,
2709        doc: "A translucent wash over a hovered neutral control. An overlay, not a fill, so one value works on every surface.",
2710    },
2711    ThemeRole {
2712        name: "pressed",
2713        node: "pressed",
2714        get: |t| t.pressed,
2715        set: |t, c| t.pressed = c,
2716        doc: "The same over a pressed one, and the firmer of the two on both bases.",
2717    },
2718    ThemeRole {
2719        name: "success",
2720        node: "success",
2721        get: |t| t.success,
2722        set: |t, c| t.success = c,
2723        doc: "A good outcome. Readable on `surface` on both bases, which is why it is not one colour for both.",
2724    },
2725    ThemeRole {
2726        name: "warning",
2727        node: "warning",
2728        get: |t| t.warning,
2729        set: |t, c| t.warning = c,
2730        doc: "Something that wants attention.",
2731    },
2732    ThemeRole {
2733        name: "danger",
2734        node: "danger",
2735        get: |t| t.danger,
2736        set: |t, c| t.danger = c,
2737        doc: "A destructive action or a failure. The close button's hover, too.",
2738    },
2739    ThemeRole {
2740        name: "scrollbar",
2741        node: "scrollbar",
2742        get: |t| t.scrollbar,
2743        set: |t, c| t.scrollbar = c,
2744        doc: "The scrollbar thumb at rest.",
2745    },
2746    ThemeRole {
2747        name: "scrollbar_active",
2748        node: "scrollbarActive",
2749        get: |t| t.scrollbar_active,
2750        set: |t, c| t.scrollbar_active = c,
2751        doc: "The thumb while hovered or dragged.",
2752    },
2753];
2754
2755/// One size the stock widgets are built from (`crate::metrics::Metrics`,
2756/// backlog T2), pinned the way [`ThemeRole`] pins a colour: a binding
2757/// iterates the rows to read or write a set, so a field added to
2758/// `Metrics` is one row here and nothing anywhere else. The test below
2759/// destructures the struct exhaustively.
2760pub struct MetricRole {
2761    /// The Rust field, and the name used everywhere but Node.
2762    pub name: &'static str,
2763    /// What Node calls it (camelCase).
2764    pub node: &'static str,
2765    pub doc: &'static str,
2766    pub get: fn(&crate::metrics::Metrics) -> f32,
2767    pub set: fn(&mut crate::metrics::Metrics, f32),
2768    /// `Some` for a row whose stock value is the platform's own rather
2769    /// than a density's: the value on Windows, then everywhere else.
2770    /// `get` answers for the running platform; a generator prints the
2771    /// pair, so `docs/props.md` reads the same whichever machine wrote it
2772    /// (backlog W13). Stock and compact share it — the test below pins
2773    /// that `compact()` leaves such a row alone.
2774    pub platform: Option<PlatformValue>,
2775}
2776
2777/// A metric's value per platform (see [`MetricRole::platform`]).
2778#[derive(Clone, Copy, Debug, PartialEq)]
2779pub struct PlatformValue {
2780    pub windows: f32,
2781    pub elsewhere: f32,
2782}
2783
2784impl PlatformValue {
2785    /// The member in force on the platform this was compiled for.
2786    pub const fn here(self) -> f32 {
2787        if cfg!(target_os = "windows") {
2788            self.windows
2789        } else {
2790            self.elsewhere
2791        }
2792    }
2793}
2794
2795macro_rules! metric_role {
2796    ($field:ident, $node:literal, $doc:literal) => {
2797        MetricRole {
2798            name: stringify!($field),
2799            node: $node,
2800            get: |m| m.$field,
2801            set: |m, v| m.$field = v,
2802            doc: $doc,
2803            platform: None,
2804        }
2805    };
2806    ($field:ident, $node:literal, $doc:literal, windows $w:expr, elsewhere $e:expr) => {
2807        MetricRole {
2808            name: stringify!($field),
2809            node: $node,
2810            get: |m| m.$field,
2811            set: |m, v| m.$field = v,
2812            doc: $doc,
2813            platform: Some(PlatformValue {
2814                windows: $w,
2815                elsewhere: $e,
2816            }),
2817        }
2818    };
2819}
2820
2821pub const METRIC_ROLES: &[MetricRole] = &[
2822    metric_role!(
2823        control_text,
2824        "controlText",
2825        "A stock control's label: the button's text size."
2826    ),
2827    metric_role!(
2828        chrome_text,
2829        "chromeText",
2830        "The chrome's text: a menu row, a menu-bar title, the titlebar's title."
2831    ),
2832    metric_role!(hint_text, "hintText", "A tooltip's text."),
2833    metric_role!(
2834        radius,
2835        "radius",
2836        "The corner of every stock surface: a button, a field, a menu, a tooltip."
2837    ),
2838    metric_role!(
2839        radius_inner,
2840        "radiusInner",
2841        "The corner of a row inside one: a menu row, a menu-bar title."
2842    ),
2843    metric_role!(
2844        control_pad_x,
2845        "controlPadX",
2846        "A button's horizontal padding."
2847    ),
2848    metric_role!(control_pad_y, "controlPadY", "A button's vertical padding."),
2849    metric_role!(
2850        field_pad_x,
2851        "fieldPadX",
2852        "A text field's horizontal padding."
2853    ),
2854    metric_role!(field_pad_y, "fieldPadY", "A text field's vertical padding."),
2855    metric_role!(hint_pad_x, "hintPadX", "A tooltip's horizontal padding."),
2856    metric_role!(hint_pad_y, "hintPadY", "A tooltip's vertical padding."),
2857    metric_role!(
2858        menu_pad_x,
2859        "menuPadX",
2860        "A menu row's horizontal padding, and a menu-bar title's."
2861    ),
2862    metric_role!(
2863        menu_pad_y,
2864        "menuPadY",
2865        "A menu row's vertical padding; a menu-bar title's is two px less."
2866    ),
2867    metric_role!(menu_width, "menuWidth", "A menu panel's width."),
2868    metric_role!(menu_bar_h, "menuBarH", "The drawn menu bar's height."),
2869    metric_role!(
2870        titlebar_h,
2871        "titlebarH",
2872        "The titlebar's height where the strip is the app's alone: the platform's caption height, 32 on Windows and 34 elsewhere. Under macOS custom chrome the strip is the OS's own titlebar, as tall as `window.native_controls` measures it (32 on macOS 27, 28 before), and this row is not read (`widgets::titlebar_height`).",
2873        windows crate::metrics::TITLEBAR_H_WINDOWS,
2874        elsewhere crate::metrics::TITLEBAR_H_ELSEWHERE
2875    ),
2876];
2877
2878static SNAKE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
2879    PROPS
2880        .iter()
2881        .map(|d| Box::leak(snake_case(d.name).into_boxed_str()) as &'static str)
2882        .collect()
2883});
2884
2885/// `minWidth` → `min_width`, `radiusTL` → `radius_tl` (a run of capitals
2886/// is one word); names without capitals pass through.
2887pub fn snake_case(name: &str) -> String {
2888    let mut out = String::with_capacity(name.len() + 2);
2889    let mut prev_upper = false;
2890    for c in name.chars() {
2891        if c.is_ascii_uppercase() {
2892            if !prev_upper {
2893                out.push('_');
2894            }
2895            out.push(c.to_ascii_lowercase());
2896            prev_upper = true;
2897        } else {
2898            out.push(c);
2899            prev_upper = false;
2900        }
2901    }
2902    out
2903}
2904
2905pub fn by_name(name: &str) -> Option<&'static PropDef> {
2906    PROPS.iter().find(|d| d.name == name)
2907}
2908
2909pub fn by_snake_name(name: &str) -> Option<&'static PropDef> {
2910    SNAKE_NAMES
2911        .iter()
2912        .position(|n| *n == name)
2913        .map(|i| &PROPS[i])
2914}
2915
2916pub fn by_id(id: u32) -> Option<&'static PropDef> {
2917    PROPS.iter().find(|d| d.id == id)
2918}
2919
2920/// The spelling a binding writes prop names in: JSX's camelCase rows, or the
2921/// snake_case ones a Lua table takes. The allow-list below is per spelling,
2922/// so `hover_bg` in JSX and `hoverBg` in Lua are each as unknown as a typo —
2923/// which is what they are: neither binding reads the other's spelling.
2924#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2925pub enum Spelling {
2926    Camel,
2927    Snake,
2928}
2929
2930/// A name one binding takes for a row it cannot spell the usual way, with
2931/// the row's own snake_case name: `repeat` is a Lua keyword, so that row
2932/// also answers to CSS's own name for it. The Lua binding remaps through
2933/// this table, and the check below accepts both sides of it.
2934pub const LUA_ALIASES: &[(&str, &str)] = &[("direction", "repeat")];
2935
2936/// `direction` → `repeat`: the schema name a Lua table key stands for, when
2937/// it is not the name itself.
2938pub fn lua_alias(name: &str) -> Option<&'static str> {
2939    LUA_ALIASES
2940        .iter()
2941        .find(|(alias, _)| *alias == name)
2942        .map(|(_, real)| *real)
2943}
2944
2945/// Every prop name that is not tied to one element, per spelling: the schema
2946/// rows, every spelling of every composite, and the aliases.
2947static SHARED_NAMES: LazyLock<[FxHashSet<&'static str>; 2]> = LazyLock::new(|| {
2948    let mut camel: FxHashSet<&'static str> = PROPS.iter().map(|d| d.name).collect();
2949    let mut snake: FxHashSet<&'static str> = PROPS.iter().map(|d| d.snake_name()).collect();
2950    for c in CUSTOM {
2951        camel.extend(c.jsx_names);
2952        snake.extend(c.lua_names);
2953    }
2954    snake.extend(LUA_ALIASES.iter().map(|(alias, _)| *alias));
2955    [camel, snake]
2956});
2957
2958fn shared_names(spelling: Spelling) -> &'static FxHashSet<&'static str> {
2959    &SHARED_NAMES[(spelling == Spelling::Snake) as usize]
2960}
2961
2962/// The props only this element takes, in `spelling`. An element the table
2963/// does not know has none.
2964pub fn element_own(element: &str, spelling: Spelling) -> &'static [&'static str] {
2965    match ELEMENTS.iter().find(|e| e.name == element) {
2966        Some(e) if spelling == Spelling::Camel => e.jsx_own,
2967        Some(e) => e.lua_own,
2968        None => &[],
2969    }
2970}
2971
2972/// The schema rows `element` reads, in `spelling`, when it does not read
2973/// them all (`ElementDef::jsx_rows`); `None` for an element that takes
2974/// every row, and for one the table does not know.
2975pub fn element_rows(element: &str, spelling: Spelling) -> Option<&'static [&'static str]> {
2976    let e = ELEMENTS.iter().find(|e| e.name == element)?;
2977    if spelling == Spelling::Camel {
2978        e.jsx_rows
2979    } else {
2980        e.lua_rows
2981    }
2982}
2983
2984/// Is `name` a row every element reads — a schema row, a composite, an
2985/// alias — as opposed to a misspelling? What [`known_prop`] answers for an
2986/// element that admits only some rows still depends on this: a row the
2987/// element does not read is dropped like a misspelling, but the warning
2988/// can say so instead of hunting for a nearer spelling.
2989pub fn shared_prop(name: &str, spelling: Spelling) -> bool {
2990    shared_names(spelling).contains(name)
2991}
2992
2993/// Is `name` a prop `element` reads — a schema row, a composite, an alias,
2994/// or one of the element's own? A binding drops everything else on the
2995/// floor, so everything else is a `diag::UNKNOWN_PROP` warning. An element
2996/// that names its rows reads those and its own, and nothing else.
2997pub fn known_prop(element: &str, name: &str, spelling: Spelling) -> bool {
2998    if element_own(element, spelling).contains(&name) {
2999        return true;
3000    }
3001    match element_rows(element, spelling) {
3002        Some(rows) => rows.contains(&name),
3003        None => shared_names(spelling).contains(name),
3004    }
3005}
3006
3007/// The name an unknown one was probably meant to be: the same word in the
3008/// other convention (`hoverBg` for `hover_bg`, `onClick` for `onclick`),
3009/// which is what a wrong spelling almost always is. Nothing fuzzier — a
3010/// confident suggestion or none.
3011pub fn suggest(element: &str, name: &str, spelling: Spelling) -> Option<&'static str> {
3012    let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
3013    let want = squash(name);
3014    let near = |c: &&&str| squash(c) == want;
3015    let own = element_own(element, spelling).iter();
3016    // An element that names its rows is not sent to a row it would drop.
3017    match element_rows(element, spelling) {
3018        Some(rows) => rows.iter().chain(own).find(near).copied(),
3019        None => shared_names(spelling).iter().chain(own).find(near).copied(),
3020    }
3021}
3022
3023/// A parsed prop value, transport-independent.
3024pub enum Parsed {
3025    F32(f32),
3026    Color(Color),
3027    Flag,
3028    Enum(usize),
3029    Sizing(Sizing),
3030    /// A `Min` or a `Max` row's clamp.
3031    Bound(Bound),
3032    Msg(Value),
3033    Str(String),
3034    Resource(u64),
3035    Keyframes(Vec<Keyframe>),
3036    Enter(Enter),
3037    /// A family, already resolved by the parser (`NameRefs::family`).
3038    Family(FontFamily),
3039}
3040
3041/// Everything a prop list can carry; elements pick the parts they use.
3042#[derive(Clone, Debug, PartialEq)]
3043pub struct PropsOut {
3044    pub spec: NodeSpec,
3045    pub style: TextStyle,
3046    pub key: Option<String>,
3047    /// `index`: the data index this node is opened under, which beats `key`
3048    /// when a binding is handed both.
3049    pub index: Option<u64>,
3050    /// `rowCount`: how many indexed rows the node's virtual list has
3051    /// (`Core::row_count`).
3052    pub row_count: Option<u64>,
3053    pub title: Option<String>,
3054    /// `alwaysOnTop`: the root asked for the window above every other
3055    /// app's this frame (`Core::set_always_on_top`, backlog C30).
3056    pub always_on_top: bool,
3057    /// `secureInput`: the root asked for secure keyboard entry while the
3058    /// window has the keyboard (`Core::set_secure_input`, backlog F85).
3059    pub secure_input: bool,
3060    /// `optionAsAlt`: which Option keys the root asked to act as Alt this
3061    /// frame (`Core::set_option_as_alt`, backlog F113).
3062    pub option_as_alt: crate::OptionAsAlt,
3063    pub key_focus: bool,
3064    /// Hover hint: the element lowering floats `widgets::tooltip` below the
3065    /// node while it is hovered (the parser also marks the spec hoverable).
3066    pub tooltip: Option<String>,
3067    /// The windows the root declared (`Core::declare_window`, in order).
3068    pub windows: Vec<(String, WindowConfig)>,
3069    /// Whether the `wrap` row was declared: the mode is in `style.wrap`,
3070    /// whose default is `Word`, so the style alone cannot say. A
3071    /// single-line editor folds to its width when it was
3072    /// (`EditOptions::wrap`, backlog F44); nothing else reads it.
3073    pub wrap: bool,
3074}
3075
3076/// Which key a prop list opens its node under: the next auto key, the
3077/// `key` label, or — beating the label when a binding is handed both — the
3078/// `index` a virtual list opens its rows by.
3079#[derive(Clone, Copy, Debug, PartialEq)]
3080pub enum Identity<'a> {
3081    Auto,
3082    Label(&'a str),
3083    Index(u64),
3084}
3085
3086impl PropsOut {
3087    /// The identity the list gave the node (see [`Identity`]).
3088    pub fn identity(&self) -> Identity<'_> {
3089        match (self.index, &self.key) {
3090            (Some(i), _) => Identity::Index(i),
3091            (None, Some(label)) => Identity::Label(label),
3092            (None, None) => Identity::Auto,
3093        }
3094    }
3095
3096    pub fn new() -> Self {
3097        PropsOut {
3098            spec: NodeSpec::column(),
3099            style: TextStyle::new(16.0),
3100            key: None,
3101            index: None,
3102            row_count: None,
3103            title: None,
3104            always_on_top: false,
3105            secure_input: false,
3106            option_as_alt: crate::OptionAsAlt::None,
3107            key_focus: false,
3108            tooltip: None,
3109            windows: Vec::new(),
3110            wrap: false,
3111        }
3112    }
3113
3114    /// Applies a builder step to the spec in place.
3115    pub fn with_spec(&mut self, f: impl FnOnce(NodeSpec) -> NodeSpec) {
3116        self.spec = f(std::mem::take(&mut self.spec));
3117    }
3118
3119    /// All three effects of the `tooltip` prop at once: hover tracking and
3120    /// the accessible description (both [`NodeSpec::apply_tooltip`]), plus
3121    /// the hint the element lowering floats. A parser that only extracted
3122    /// the string cannot end up implementing two of the three.
3123    pub fn apply_tooltip(&mut self, hint: impl Into<String>) {
3124        let hint = hint.into();
3125        self.with_spec(|s| s.apply_tooltip(&hint));
3126        self.tooltip = Some(hint);
3127    }
3128
3129    /// Resolves the `pad` shorthand family and applies it — a no-op when the
3130    /// frontend saw none of the seven names, so a spec built from another
3131    /// source keeps its padding.
3132    pub fn apply_pad(&mut self, pad: PadShorthand) {
3133        if pad.declared() {
3134            let edges = pad.resolve();
3135            self.with_spec(|s| s.padding(edges));
3136        }
3137    }
3138}
3139
3140impl Default for PropsOut {
3141    fn default() -> Self {
3142        Self::new()
3143    }
3144}
3145
3146/// Applies one parsed value through its row. Errors on a kind mismatch,
3147/// which can only come from a transport bug (each transport parses by the
3148/// same `Kind`).
3149pub fn apply(def: &PropDef, value: Parsed, out: &mut PropsOut) -> Result<(), String> {
3150    let spec = std::mem::take(&mut out.spec);
3151    let style = out.style;
3152    // Declared at all is a fact of its own for one element (see
3153    // `PropsOut::wrap`); the mode still lands in the style below.
3154    out.wrap |= def.id == P_WRAP;
3155    match (&def.apply, value) {
3156        (Apply::SpecF32(f), Parsed::F32(v)) => out.spec = f(spec, v),
3157        (Apply::SpecColor(f), Parsed::Color(v)) => out.spec = f(spec, v),
3158        (Apply::SpecFlag(f), Parsed::Flag) => out.spec = f(spec),
3159        (Apply::SpecEnum(f), Parsed::Enum(v)) => out.spec = f(spec, v),
3160        (Apply::SpecSizing(f), Parsed::Sizing(v)) => out.spec = f(spec, v),
3161        (Apply::SpecBound(f), Parsed::Bound(v)) => out.spec = f(spec, v),
3162        (Apply::SpecMsg(f), Parsed::Msg(v)) => out.spec = f(spec, v),
3163        (Apply::SpecStr(f), Parsed::Str(v)) => out.spec = f(spec, &v),
3164        (Apply::SpecKeyframes(f), Parsed::Keyframes(v)) => out.spec = f(spec, v),
3165        (Apply::SpecEnter(f), Parsed::Enter(v)) => out.spec = f(spec, v),
3166        (Apply::SpecResource(f), Parsed::Resource(v)) => out.spec = f(spec, v),
3167        (Apply::StyleF32(f), Parsed::F32(v)) => {
3168            out.spec = spec;
3169            out.style = f(style, v);
3170        }
3171        (Apply::StyleColor(f), Parsed::Color(v)) => {
3172            out.spec = spec;
3173            out.style = f(style, v);
3174        }
3175        (Apply::StyleEnum(f), Parsed::Enum(v)) => {
3176            out.spec = spec;
3177            out.style = f(style, v);
3178        }
3179        (Apply::StyleFlag(f), Parsed::Flag) => {
3180            out.spec = spec;
3181            out.style = f(style);
3182        }
3183        (Apply::StyleResource(f), Parsed::Resource(v)) => {
3184            out.spec = spec;
3185            out.style = f(style, v);
3186        }
3187        (Apply::StyleStr(f), Parsed::Str(v)) => {
3188            out.spec = spec;
3189            out.style = f(style, &v);
3190        }
3191        (Apply::StyleFamily(f), Parsed::Family(v)) => {
3192            out.spec = spec;
3193            out.style = f(style, v);
3194        }
3195        _ => {
3196            out.spec = spec;
3197            return Err(format!("prop {} value/kind mismatch", def.name));
3198        }
3199    }
3200    Ok(())
3201}
3202
3203/// Looks an enum name up in its row's list.
3204pub fn enum_index(names: &[&str], s: &str) -> Result<usize, String> {
3205    names
3206        .iter()
3207        .position(|n| *n == s)
3208        .ok_or_else(|| format!("bad value {s:?} (one of {names:?})"))
3209}
3210
3211// ---------------------------------------------------------------------------
3212// Value parsing shared by transports
3213
3214/// 0 = transparent, anything else 0xRRGGBBAA.
3215pub fn color_num(hex: u32) -> Color {
3216    if hex == 0 {
3217        Color::TRANSPARENT
3218    } else {
3219        Color::hex(hex)
3220    }
3221}
3222
3223/// `#rgb` / `#rrggbb` / `#rrggbbaa`.
3224pub fn color_hex_str(s: &str) -> Result<Color, String> {
3225    let bad = || format!("bad color {s:?}");
3226    let hex = s.strip_prefix('#').ok_or_else(bad)?;
3227    let expanded = match hex.len() {
3228        3 => hex
3229            .chars()
3230            .flat_map(|c| [c, c])
3231            .chain("ff".chars())
3232            .collect::<String>(),
3233        6 => format!("{hex}ff"),
3234        8 => hex.to_string(),
3235        _ => return Err(bad()),
3236    };
3237    let n = u32::from_str_radix(&expanded, 16).map_err(|_| bad())?;
3238    Ok(Color::hex(n))
3239}
3240
3241/// The mode a sizing's, a min's or a max's first binary slot holds for a
3242/// size expression, whose spelling follows as a strref (v19).
3243pub const SIZE_MODE_CALC: u32 = 4;
3244
3245/// The mode for a size expression as data: a count of slots follows,
3246/// then the expression in prefix code ([`crate::calc::from_code`]) —
3247/// what the Node encoder sends for `{ clamp: [...] }`, so the addon
3248/// reads numbers and parses no text (v19).
3249pub const SIZE_MODE_TREE: u32 = 5;
3250
3251/// The string forms of a min: `"fit"`, or a size expression
3252/// ([`crate::calc`]). A number arrives as a number.
3253pub fn min_str(s: &str) -> Result<Bound, String> {
3254    match s {
3255        "fit" => Ok(Bound::Fit),
3256        _ => crate::calc::bound(s)
3257            .map_err(|e| format!("bad min {s:?} (number | \"fit\" | a size expression): {e}")),
3258    }
3259}
3260
3261/// The string forms of a max: a size expression.
3262pub fn max_str(s: &str) -> Result<Bound, String> {
3263    crate::calc::bound(s).map_err(|e| format!("bad max {s:?} (number | a size expression): {e}"))
3264}
3265
3266/// The string forms of a sizing: "fit" | "grow" | "N%" | a size
3267/// expression ([`crate::calc`]).
3268pub fn sizing_str(s: &str) -> Result<Sizing, String> {
3269    match s {
3270        "fit" => Ok(Sizing::Fit),
3271        "grow" => Ok(Sizing::Grow(1.0)),
3272        _ => crate::calc::sizing(s).map_err(|e| {
3273            format!("bad sizing {s:?} (fit | grow | number | \"N%\" | a size expression): {e}")
3274        }),
3275    }
3276}
3277
3278#[cfg(test)]
3279mod tests {
3280    use super::*;
3281
3282    /// An enum row's name list is the wire order — a binding sends the
3283    /// index — so each list is its enum's `ALL` by `name`, and the index
3284    /// map is a lookup in `ALL` and not a second table (backlog AR42:
3285    /// `Easing`, `Repeat`, `Live` and `FontFamily` were hand maps that a
3286    /// variant appended or a list reordered put one off, with nothing to
3287    /// say so). `CURSORS` has the same pin in `cursor.rs`, `ROLES` its
3288    /// own below.
3289    #[test]
3290    fn every_enum_list_is_its_enum_s_all_by_name() {
3291        fn names(it: &[&'static str]) -> Vec<&'static str> {
3292            it.to_vec()
3293        }
3294        assert_eq!(
3295            Easing::ALL.iter().map(|e| e.name()).collect::<Vec<_>>(),
3296            names(EASINGS)
3297        );
3298        assert_eq!(
3299            Repeat::ALL.iter().map(|r| r.name()).collect::<Vec<_>>(),
3300            names(REPEATS)
3301        );
3302        assert_eq!(
3303            crate::access::Live::ALL
3304                .iter()
3305                .map(|l| l.name())
3306                .collect::<Vec<_>>(),
3307            names(LIVE)
3308        );
3309        assert_eq!(
3310            FontFamily::ALL
3311                .iter()
3312                .map(|f| f.name().expect("a stock family has a name"))
3313                .collect::<Vec<_>>(),
3314            names(FAMILIES)
3315        );
3316        // And the index map round-trips through the list, every index.
3317        for (i, e) in Easing::ALL.iter().enumerate() {
3318            assert_eq!(easing_idx(i), *e);
3319        }
3320        for (i, r) in Repeat::ALL.iter().enumerate() {
3321            assert_eq!(repeat_idx(i), *r);
3322        }
3323        for (i, l) in crate::access::Live::ALL.iter().enumerate() {
3324            assert_eq!(crate::access::Live::from_index(i), *l);
3325        }
3326        for (i, f) in FontFamily::ALL.iter().enumerate() {
3327            assert_eq!(FontFamily::from_index(i), *f);
3328        }
3329        // An index this build lacks is the default, not a panic.
3330        assert_eq!(easing_idx(99), Easing::default());
3331        assert_eq!(repeat_idx(99), Repeat::default());
3332        assert_eq!(FontFamily::from_index(99), FontFamily::Sans);
3333    }
3334
3335    #[test]
3336    fn ids_and_names_are_unique() {
3337        let all: Vec<(&str, u32)> = PROPS
3338            .iter()
3339            .map(|d| (d.name, d.id))
3340            .chain(CUSTOM.iter().map(|c| (c.name, c.id)))
3341            .collect();
3342        for (i, (name, id)) in all.iter().enumerate() {
3343            for (other_name, other_id) in &all[i + 1..] {
3344                assert_ne!(name, other_name, "duplicate prop name");
3345                assert_ne!(id, other_id, "duplicate wire id for {name} / {other_name}");
3346            }
3347        }
3348    }
3349
3350    /// `menu_bar_h` → `menuBarH`: what a role's Node spelling is.
3351    fn snake_to_camel(name: &str) -> String {
3352        let mut out = String::new();
3353        let mut up = false;
3354        for ch in name.chars() {
3355            if ch == '_' {
3356                up = true;
3357            } else if up {
3358                out.extend(ch.to_uppercase());
3359                up = false;
3360            } else {
3361                out.push(ch);
3362            }
3363        }
3364        out
3365    }
3366
3367    /// `METRIC_ROLES` restates `Metrics` the way `THEME_ROLES` restates
3368    /// `Theme`, and this is the same pin: an exhaustive pattern on the
3369    /// struct, then the names both ways.
3370    #[test]
3371    fn metric_roles_restate_the_metrics_exactly() {
3372        use crate::metrics::Metrics;
3373        let m = Metrics::compact();
3374        let Metrics {
3375            control_text,
3376            chrome_text,
3377            hint_text,
3378            radius,
3379            radius_inner,
3380            control_pad_x,
3381            control_pad_y,
3382            field_pad_x,
3383            field_pad_y,
3384            hint_pad_x,
3385            hint_pad_y,
3386            menu_pad_x,
3387            menu_pad_y,
3388            menu_width,
3389            menu_bar_h,
3390            titlebar_h,
3391        } = m;
3392        let fields: &[(&str, f32)] = &[
3393            ("control_text", control_text),
3394            ("chrome_text", chrome_text),
3395            ("hint_text", hint_text),
3396            ("radius", radius),
3397            ("radius_inner", radius_inner),
3398            ("control_pad_x", control_pad_x),
3399            ("control_pad_y", control_pad_y),
3400            ("field_pad_x", field_pad_x),
3401            ("field_pad_y", field_pad_y),
3402            ("hint_pad_x", hint_pad_x),
3403            ("hint_pad_y", hint_pad_y),
3404            ("menu_pad_x", menu_pad_x),
3405            ("menu_pad_y", menu_pad_y),
3406            ("menu_width", menu_width),
3407            ("menu_bar_h", menu_bar_h),
3408            ("titlebar_h", titlebar_h),
3409        ];
3410        assert_eq!(METRIC_ROLES.len(), fields.len(), "a metric has no row");
3411        for (name, value) in fields {
3412            let row = METRIC_ROLES
3413                .iter()
3414                .find(|r| r.name == *name)
3415                .unwrap_or_else(|| panic!("no METRIC_ROLES row for {name}"));
3416            assert_eq!((row.get)(&m), *value, "{name}'s row reads another field");
3417            let mut w = m;
3418            (row.set)(&mut w, 1.0);
3419            assert_eq!((row.get)(&w), 1.0, "{name}'s row writes another field");
3420        }
3421        for row in METRIC_ROLES {
3422            assert!(
3423                fields.iter().any(|(n, _)| *n == row.name),
3424                "METRIC_ROLES names a field Metrics does not have: {}",
3425                row.name
3426            );
3427            let camel = snake_to_camel(row.name);
3428            assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3429            // A platform row's pair is what the struct reads on this
3430            // platform, in both sets: the generator prints the pair for
3431            // both columns on the strength of this (W13).
3432            if let Some(p) = row.platform {
3433                assert_eq!(
3434                    (row.get)(&Metrics::default()),
3435                    p.here(),
3436                    "{}'s stock value",
3437                    row.name
3438                );
3439                assert_eq!(
3440                    (row.get)(&m),
3441                    p.here(),
3442                    "{}: compact leaves a platform row alone",
3443                    row.name
3444                );
3445            }
3446        }
3447        assert!(
3448            METRIC_ROLES.iter().any(|r| r.platform.is_some()),
3449            "titlebar_h is the platform's; its row says so"
3450        );
3451    }
3452
3453    /// `THEME_ROLES` restates `Theme`; this pins the two together the way
3454    /// `ENV_FIELDS` pins `Env`. The exhaustive pattern is the pin on the
3455    /// struct — a role added to `Theme` stops this compiling until it has
3456    /// a row — and the names are then checked in both directions, so a
3457    /// row cannot be forgotten and a row cannot name a field that is not
3458    /// there. `appearance` and `disabled_opacity` are not colours and are
3459    /// carried beside the roles rather than among them.
3460    #[test]
3461    fn theme_roles_restate_the_theme_exactly() {
3462        use crate::theme::Theme;
3463        let t = Theme::dark();
3464        let Theme {
3465            appearance: _,
3466            disabled_opacity: _,
3467            bg,
3468            surface,
3469            raised,
3470            sunken,
3471            border,
3472            border_strong,
3473            fg,
3474            muted,
3475            faint,
3476            accent,
3477            accent_hover,
3478            accent_pressed,
3479            on_accent,
3480            accent_soft,
3481            selection,
3482            focus_ring,
3483            hover,
3484            pressed,
3485            success,
3486            warning,
3487            danger,
3488            scrollbar,
3489            scrollbar_active,
3490        } = t;
3491        let fields: &[(&str, crate::color::Color)] = &[
3492            ("bg", bg),
3493            ("surface", surface),
3494            ("raised", raised),
3495            ("sunken", sunken),
3496            ("border", border),
3497            ("border_strong", border_strong),
3498            ("fg", fg),
3499            ("muted", muted),
3500            ("faint", faint),
3501            ("accent", accent),
3502            ("accent_hover", accent_hover),
3503            ("accent_pressed", accent_pressed),
3504            ("on_accent", on_accent),
3505            ("accent_soft", accent_soft),
3506            ("selection", selection),
3507            ("focus_ring", focus_ring),
3508            ("hover", hover),
3509            ("pressed", pressed),
3510            ("success", success),
3511            ("warning", warning),
3512            ("danger", danger),
3513            ("scrollbar", scrollbar),
3514            ("scrollbar_active", scrollbar_active),
3515        ];
3516        assert_eq!(THEME_ROLES.len(), fields.len(), "a role has no row");
3517        for (name, value) in fields {
3518            let row = THEME_ROLES
3519                .iter()
3520                .find(|r| r.name == *name)
3521                .unwrap_or_else(|| panic!("no THEME_ROLES row for {name}"));
3522            assert_eq!((row.get)(&t), *value, "{name}'s row reads another field");
3523        }
3524        for row in THEME_ROLES {
3525            assert!(
3526                fields.iter().any(|(n, _)| *n == row.name),
3527                "{} names no field",
3528                row.name
3529            );
3530            // Node's spelling is this one in camelCase, always.
3531            let camel = {
3532                let mut out = String::new();
3533                let mut up = false;
3534                for ch in row.name.chars() {
3535                    if ch == '_' {
3536                        up = true;
3537                    } else if up {
3538                        out.extend(ch.to_uppercase());
3539                        up = false;
3540                    } else {
3541                        out.push(ch);
3542                    }
3543                }
3544                out
3545            };
3546            assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3547        }
3548    }
3549
3550    /// `ENV_FIELDS` restates `Env` and `WindowEnv`; this pins the three
3551    /// together. The exhaustive patterns are the pin on the structs — a
3552    /// field added to either stops this compiling until it is named here
3553    /// — and the list beside them is then checked against the table in
3554    /// both directions, so a row cannot be forgotten and a row cannot
3555    /// claim a field that does not exist.
3556    #[test]
3557    fn env_fields_restate_env_and_window_env_exactly() {
3558        use crate::env::{AudioEnv, Env, SystemEnv};
3559        use crate::window::WindowEnv;
3560        let Env {
3561            refresh_hz: _,
3562            focused: _,
3563            system,
3564            window,
3565            audio,
3566        } = Env::default();
3567        let AudioEnv { device: _, live: _ } = audio;
3568        let SystemEnv {
3569            appearance: _,
3570            accent: _,
3571            motion: _,
3572            locale: _,
3573            assistive: _,
3574        } = system;
3575        let WindowEnv {
3576            id: _,
3577            custom_chrome: _,
3578            maximized: _,
3579            fullscreen: _,
3580            always_on_top: _,
3581            native_controls: _,
3582        } = window;
3583        let stored = [
3584            "refresh_hz",
3585            "focused",
3586            "system.appearance",
3587            "system.accent",
3588            "system.motion",
3589            "system.locale",
3590            "system.assistive",
3591            "window.id",
3592            "window.custom_chrome",
3593            "window.maximized",
3594            "window.fullscreen",
3595            "window.always_on_top",
3596            "window.native_controls",
3597            "audio.device",
3598            "audio.live",
3599        ];
3600        // A stored fact's `from` is the struct and the field, spelled the
3601        // one way; everything else in the column is a call.
3602        let from_structs: Vec<&str> = ENV_FIELDS
3603            .iter()
3604            .filter(|f| !f.from.contains('('))
3605            .map(|f| {
3606                let (strukt, field) = match f.name.split_once('.') {
3607                    Some(("window", field)) => ("WindowEnv", field),
3608                    Some(("system", field)) => ("SystemEnv", field),
3609                    Some(("audio", field)) => ("AudioEnv", field),
3610                    Some((group, _)) => panic!("{}: no struct holds a {group} fact", f.name),
3611                    None => ("Env", f.name),
3612                };
3613                assert_eq!(f.from, format!("`{strukt}::{field}`"), "{}", f.name);
3614                f.name
3615            })
3616            .collect();
3617        assert_eq!(from_structs, stored);
3618        for f in ENV_FIELDS.iter().filter(|f| f.from.contains('(')) {
3619            assert!(
3620                f.from.contains("derived") || f.from.contains("the frame's"),
3621                "{}: a call in `from` is derived or the frame's, and says which",
3622                f.name
3623            );
3624        }
3625    }
3626
3627    /// Every spelling in `ENV_FIELDS` is unique per binding — two rows
3628    /// cannot land on one key — and every row says something in every
3629    /// column, so the generated table has no blank cells to wonder about.
3630    #[test]
3631    fn env_field_spellings_are_unique_and_complete() {
3632        let mut names: Vec<&str> = ENV_FIELDS.iter().map(|f| f.name).collect();
3633        let mut node: Vec<&str> = ENV_FIELDS
3634            .iter()
3635            .flat_map(|f| f.node.iter().copied())
3636            .collect();
3637        let mut lua: Vec<&str> = ENV_FIELDS
3638            .iter()
3639            .flat_map(|f| f.lua.iter().copied())
3640            .collect();
3641        for list in [&mut names, &mut node, &mut lua] {
3642            let before = list.len();
3643            list.sort_unstable();
3644            list.dedup();
3645            assert_eq!(list.len(), before, "a spelling is used twice");
3646        }
3647        for f in ENV_FIELDS {
3648            assert!(!f.c.is_empty() && !f.doc.is_empty(), "{}", f.name);
3649            // A binding that has no key for a fact says where it went
3650            // instead, so the empty cell is explained by the row itself.
3651            if f.node.is_empty() {
3652                assert!(
3653                    f.doc.contains("Node"),
3654                    "{}: where does Node carry it?",
3655                    f.name
3656                );
3657            }
3658            if f.lua.is_empty() {
3659                assert!(
3660                    f.doc.contains("Lua"),
3661                    "{}: where does Lua carry it?",
3662                    f.name
3663                );
3664            }
3665        }
3666    }
3667
3668    /// An element's admitted rows are real rows in each spelling and the
3669    /// same rows in both, so the JSX check and the Lua check admit the
3670    /// same button.
3671    #[test]
3672    fn admitted_rows_are_shared_rows_in_both_spellings() {
3673        for e in ELEMENTS {
3674            let (Some(jsx), Some(lua)) = (e.jsx_rows, e.lua_rows) else {
3675                assert!(
3676                    e.jsx_rows.is_none() && e.lua_rows.is_none(),
3677                    "{}: one spelling only",
3678                    e.name
3679                );
3680                continue;
3681            };
3682            assert_eq!(
3683                jsx.len(),
3684                lua.len(),
3685                "{}: the lists differ in length",
3686                e.name
3687            );
3688            for (j, l) in jsx.iter().zip(lua) {
3689                assert!(
3690                    shared_prop(j, Spelling::Camel),
3691                    "{}: `{j}` is not a row",
3692                    e.name
3693                );
3694                assert!(
3695                    shared_prop(l, Spelling::Snake),
3696                    "{}: `{l}` is not a row",
3697                    e.name
3698                );
3699                assert_eq!(
3700                    snake_case(j),
3701                    *l,
3702                    "{}: `{j}` and `{l}` are not one row",
3703                    e.name
3704                );
3705            }
3706            // The row a reader hears first: a stock button without a name
3707            // is the warning `control-without-name`, so `label` is never
3708            // the row a closed *control* leaves out. A text is its own
3709            // name.
3710            if jsx.contains(&"onClick") {
3711                assert!(jsx.contains(&"label"), "{}: `label` missing", e.name);
3712            }
3713        }
3714        assert!(known_prop("button", "description", Spelling::Camel));
3715        assert!(known_prop("button", "on_click", Spelling::Snake));
3716        assert!(known_prop("button", "text", Spelling::Snake));
3717        assert!(!known_prop("button", "radius", Spelling::Camel));
3718        assert!(!known_prop("button", "text", Spelling::Camel));
3719        assert!(known_prop("box", "radius", Spelling::Camel));
3720        // The other spelling is looked for among the rows it reads: a row
3721        // it would drop is not offered as the fix.
3722        assert_eq!(
3723            suggest("button", "on_click", Spelling::Camel),
3724            Some("onClick")
3725        );
3726        assert_eq!(suggest("button", "hover_bg", Spelling::Camel), None);
3727        assert_eq!(suggest("box", "hover_bg", Spelling::Camel), Some("hoverBg"));
3728    }
3729
3730    /// AR13: a text is content plus a style, so the rows it admits are
3731    /// exactly the rows that land on a `TextStyle`, plus `size`, the
3732    /// composite the style is built from. Every other row — a container's,
3733    /// an access row, `key` — is the `unknown-prop` warning in every
3734    /// binding rather than a silent drop.
3735    #[test]
3736    fn text_admits_exactly_the_style_rows() {
3737        let style: Vec<&str> = PROPS
3738            .iter()
3739            .filter(|d| d.target() == Target::Style)
3740            .map(|d| d.name)
3741            .collect();
3742        let mut expect = vec!["size"];
3743        expect.extend(style);
3744        let mut got = TEXT_ROWS_JSX.to_vec();
3745        expect.sort_unstable();
3746        got.sort_unstable();
3747        assert_eq!(got, expect, "TEXT_ROWS_JSX is not the style rows");
3748        for name in ["lineHeight", "color", "wrap", "size"] {
3749            assert!(known_prop("text", name, Spelling::Camel), "{name}");
3750        }
3751        for name in ["live", "role", "label", "onClick", "key", "pad", "bg"] {
3752            assert_eq!(
3753                known_prop("text", name, Spelling::Camel),
3754                name == "bg",
3755                "{name}: `bg` is the element's own (a span's), the rest are not rows it reads"
3756            );
3757        }
3758        assert!(known_prop("text", "line_height", Spelling::Snake));
3759        assert!(known_prop("text", "value", Spelling::Snake));
3760        assert!(!known_prop("text", "live", Spelling::Snake));
3761        assert!(!known_prop("text", "on_click", Spelling::Snake));
3762        // A misspelling is still steered to a row it reads, and to nothing
3763        // it would drop.
3764        assert_eq!(
3765            suggest("text", "max_lines", Spelling::Camel),
3766            Some("maxLines")
3767        );
3768        assert_eq!(suggest("text", "hover_bg", Spelling::Camel), None);
3769    }
3770
3771    #[test]
3772    fn snake_names_round_trip() {
3773        assert_eq!(by_snake_name("min_width").unwrap().name, "minWidth");
3774        assert_eq!(by_snake_name("on_click").unwrap().name, "onClick");
3775        assert_eq!(by_snake_name("bg").unwrap().name, "bg");
3776        assert!(by_snake_name("minWidth").is_none());
3777        assert_eq!(by_name("lineHeight").unwrap().snake_name(), "line_height");
3778        assert_eq!(by_name("radiusTL").unwrap().snake_name(), "radius_tl");
3779        assert_eq!(by_snake_name("radius_bl").unwrap().name, "radiusBL");
3780    }
3781
3782    /// The allow-list is per spelling, so each binding's own names pass and
3783    /// the other's do not — which is the point: neither binding reads the
3784    /// other's, so `hover_bg` in JSX is as dropped as `hoverBgg` would be.
3785    #[test]
3786    fn the_allow_list_is_per_spelling() {
3787        use Spelling::{Camel, Snake};
3788        assert!(known_prop("box", "hoverBg", Camel));
3789        assert!(known_prop("box", "hover_bg", Snake));
3790        assert!(!known_prop("box", "hover_bg", Camel));
3791        assert!(!known_prop("box", "hoverBg", Snake));
3792        // Composites, each in the spelling its binding takes.
3793        assert!(known_prop("box", "padX", Camel) && !known_prop("box", "padX", Snake));
3794        assert!(known_prop("box", "scroll", Snake) && !known_prop("box", "scroll", Camel));
3795        // `repeat` is a Lua keyword; the alias stands in for the row.
3796        assert!(known_prop("box", "direction", Snake));
3797        assert_eq!(lua_alias("direction"), Some("repeat"));
3798        // An element's own props are its own: `initial` is an editor's.
3799        assert!(known_prop("edit", "initial", Camel));
3800        assert!(!known_prop("box", "initial", Camel));
3801        // A shared text row on an editor is the editor's too: `wrap` is
3802        // what folds a single-line one (backlog F44), and it must not warn.
3803        assert!(known_prop("edit", "wrap", Camel) && known_prop("edit", "wrap", Snake));
3804        assert!(known_prop("image", "src", Camel) && known_prop("image", "id", Snake));
3805        // And nothing claims a typo.
3806        assert!(!known_prop("box", "colour", Camel));
3807    }
3808
3809    #[test]
3810    fn a_suggestion_is_the_same_word_in_the_right_convention() {
3811        use Spelling::{Camel, Snake};
3812        assert_eq!(suggest("box", "hoverBg", Snake), Some("hover_bg"));
3813        assert_eq!(suggest("box", "onclick", Camel), Some("onClick"));
3814        assert_eq!(suggest("box", "SCROLL_X", Camel), Some("scrollX"));
3815        assert_eq!(suggest("edit", "Initial", Camel), Some("initial"));
3816        // Nothing fuzzy: a guess or nothing.
3817        assert_eq!(suggest("box", "colour", Camel), None);
3818    }
3819
3820    /// Every name a binding hand-lowers has to be in one of the tables, or
3821    /// the binding warns about a prop it reads perfectly well.
3822    #[test]
3823    fn every_composite_and_element_prop_is_in_the_allow_list() {
3824        for c in CUSTOM {
3825            for n in c.jsx_names {
3826                assert!(known_prop("box", n, Spelling::Camel), "jsx `{n}`");
3827            }
3828            for n in c.lua_names {
3829                assert!(known_prop("box", n, Spelling::Snake), "lua `{n}`");
3830            }
3831        }
3832        for e in ELEMENTS {
3833            for n in e.jsx_own {
3834                assert!(known_prop(e.name, n, Spelling::Camel), "{}.{n}", e.name);
3835            }
3836            for n in e.lua_own {
3837                assert!(known_prop(e.name, n, Spelling::Snake), "{}.{n}", e.name);
3838            }
3839        }
3840    }
3841
3842    #[test]
3843    fn every_row_applies_a_sample_of_its_kind() {
3844        for def in PROPS {
3845            // `opacity` is a 0..=1 slot whose default is the top of the
3846            // range, so the shared sample would clamp back to it.
3847            let f = if def.name == "opacity" { 0.5 } else { 7.0 };
3848            let sample = match def.kind {
3849                Kind::F32 => Parsed::F32(f),
3850                Kind::Color => Parsed::Color(Color::hex(0x11223344)),
3851                Kind::Flag => Parsed::Flag,
3852                Kind::Enum(_) => Parsed::Enum(1),
3853                Kind::Sizing => Parsed::Sizing(Sizing::Percent(0.5)),
3854                Kind::Min => Parsed::Bound(Bound::Fit),
3855                Kind::Max => Parsed::Bound(Bound::Px(10.0)),
3856                Kind::Msg | Kind::Tag => Parsed::Msg(Value::Int(1)),
3857                Kind::Str => Parsed::Str("name".into()),
3858                Kind::Family => Parsed::Family(FontFamily::Mono),
3859                Kind::Resource => Parsed::Resource(7),
3860                Kind::Keyframes => Parsed::Keyframes(vec![Keyframe::default().radius(7.0)]),
3861                Kind::Enter => Parsed::Enter(Enter::from(-7.0, 0.0)),
3862            };
3863            let mut out = PropsOut::new();
3864            apply(def, sample, &mut out).unwrap();
3865            let changed = match def.target() {
3866                Target::Spec => out.spec != NodeSpec::column(),
3867                Target::Style => out.style != TextStyle::new(16.0),
3868            };
3869            assert!(changed, "{} applied a sample but nothing changed", def.name);
3870        }
3871    }
3872
3873    /// A null tag keeps the behaviour and drops the `tag` field; the
3874    /// contract every transport relies on to accept `null` for `Tag` rows.
3875    #[test]
3876    fn a_null_tag_still_declares_the_behaviour() {
3877        let mut out = PropsOut::new();
3878        apply(
3879            by_name("onKey").unwrap(),
3880            Parsed::Msg(Value::Null),
3881            &mut out,
3882        )
3883        .unwrap();
3884        assert_eq!(out.spec.events().on_key, Some(Value::Null));
3885        assert!(out.spec.hover_tracked());
3886        let mut out = PropsOut::new();
3887        apply(
3888            by_name("onLayout").unwrap(),
3889            Parsed::Msg(Value::Null),
3890            &mut out,
3891        )
3892        .unwrap();
3893        assert_eq!(out.spec.events().on_layout, Some(Value::Null));
3894    }
3895
3896    /// `ROLES` is the wire order for the `role` enum — a binding sends the
3897    /// index — so a typo, or a spelling that drifted from `access.rs`, does
3898    /// not fail anywhere: the name simply stops parsing, and every view
3899    /// declaring that role silently gets [`role_idx`]'s fallback instead.
3900    /// Until this test that fallback was `Role::None`, which takes the node
3901    /// *and its whole subtree* out of the access tree — a drifted spelling
3902    /// would have removed part of the app from every screen reader, with
3903    /// nothing failing and nothing warning. Both halves are pinned here:
3904    /// each name is a real role, and index `i` still means `ROLES[i]`.
3905    #[test]
3906    fn every_declarable_role_name_is_a_real_role() {
3907        for (i, name) in ROLES.iter().enumerate() {
3908            let role = Role::parse(name)
3909                .unwrap_or_else(|| panic!("ROLES[{i}] = {name:?} is not a Role::ALL name"));
3910            assert_eq!(role_idx(i), role, "role index {i} lowers to the wrong role");
3911            assert_eq!(role_idx(i).name(), *name);
3912        }
3913    }
3914
3915    /// The C header pins its role enum to `Role::ALL` by construction
3916    /// (`kui-ffi`'s `abi_enum!`), so a new role fails the C build until the
3917    /// header names it. Lua, Node and JSX have no such pin — they read
3918    /// `ROLES` — and a role added to `Role::ALL` and forgotten there is
3919    /// simply undeclarable from all three, silently. This is that pin: a
3920    /// role is declarable or derived, never neither and never both, and an
3921    /// exemption has to be one a frame really does derive, so `DERIVED_ONLY`
3922    /// cannot become somewhere to put a forgotten row.
3923    #[test]
3924    fn every_role_is_declarable_or_derived() {
3925        for role in Role::ALL {
3926            let declarable = ROLES.contains(&role.name());
3927            let derived = DERIVED_ONLY.iter().any(|(n, _)| *n == role.name());
3928            assert!(
3929                declarable || derived,
3930                "Role::{role:?} is in Role::ALL but no view can declare it and \
3931                 DERIVED_ONLY does not say the core derives it — add {:?} to \
3932                 ROLES, or to DERIVED_ONLY with the reason",
3933                role.name()
3934            );
3935            assert!(
3936                !(declarable && derived),
3937                "Role::{role:?} is in ROLES, so it is declarable — drop it from \
3938                 DERIVED_ONLY"
3939            );
3940        }
3941        for (name, why) in DERIVED_ONLY {
3942            assert!(!why.is_empty(), "{name:?} is exempted without a reason");
3943            assert!(
3944                Role::parse(name).is_some(),
3945                "DERIVED_ONLY names {name:?}, which is not a Role::ALL name"
3946            );
3947        }
3948        let derived = roles_one_frame_derives();
3949        for (name, _) in DERIVED_ONLY {
3950            assert!(
3951                derived.contains(&Role::parse(name).unwrap()),
3952                "DERIVED_ONLY keeps {name:?} out of ROLES because the core \
3953                 derives it, but no frame does any more — either it belongs in \
3954                 ROLES now, or the exemption is stale"
3955            );
3956        }
3957    }
3958
3959    /// One frame that derives each of `DERIVED_ONLY`'s roles: the root is
3960    /// the window, a `window_drag` row the title bar, a scrolling box the
3961    /// scroll view, and the text inside it static text.
3962    fn roles_one_frame_derives() -> Vec<Role> {
3963        let mut core = crate::Core::new();
3964        let mut ui = core.frame(crate::Size::new(200.0, 200.0), 1.0);
3965        ui.window_title("Demo");
3966        ui.configure_root(NodeSpec::column().fill());
3967        ui.leaf_keyed("titlebar", NodeSpec::row().window_drag());
3968        ui.text_in_keyed(
3969            "scroll",
3970            NodeSpec::column().height(40.0).scroll_y(),
3971            "hello",
3972            TextStyle::new(12.0),
3973        );
3974        ui.finish();
3975        core.access_tree().nodes.iter().map(|n| n.role).collect()
3976    }
3977
3978    #[test]
3979    fn colors_and_sizings_parse() {
3980        assert_eq!(color_hex_str("#fff").unwrap(), Color::hex(0xffffffff));
3981        assert_eq!(color_hex_str("#11223344").unwrap(), Color::hex(0x11223344));
3982        assert!(color_hex_str("fff").is_err());
3983        assert_eq!(sizing_str("50%").unwrap(), Sizing::Percent(0.5));
3984        assert_eq!(sizing_str("grow").unwrap(), Sizing::Grow(1.0));
3985        assert!(sizing_str("wide").is_err());
3986    }
3987}