From 7419f8f4448d745cdfee85adeb6dfb1aa7b6f533 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:07:13 -0700 Subject: [PATCH 1/2] feat(ui_l0): lower a card to role calls on the theme kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.1 says a card names roles and a theme answers them. `makepad::lower` does neither: it emits a backend's own widget dialect with ten hardcoded colours and a font-size ramp, so it decides what a theme decides and reaches one backend of three. `kit::lower` emits role calls and lets the kit answer. l0_panel([ l0_caption("LEAD"), l0_body("Rust 1.95 lands") ]) not RoundedView{ draw_bg.color: #ffffff12 draw_bg.border_radius: 14.0 … } THE CONTRACT IS EXECUTED, NOT ASSERTED. Two earlier attempts at this were reverted for asserting one nobody had run — five of twenty-three roles mapped to tags that did not exist. This crate still cannot depend on `splash-render`, and must not, so the check lives in `Splash-Makepad`: it dev-depends on this crate and builds every reference card through the real evaluator. That dependency is possible only because this crate was extracted from `splash-core` and needs `serde_json` alone — the extraction paying for itself a second time. The check found the same defect the reverted attempt shipped, immediately. `Photo` WITH children is not an image, it is the page: the weather card wraps the whole card in one, and lowering it as a leaf dropped all 62 nodes to 1. `l0_surface_photo` composes image, scrim and content, which is what `makepad::lower` already does and what a leaf cannot. `hero_points` is now shared rather than duplicated. Both lowerings size a hero by what it will DRAW, and two copies of that ramp would have been two ramps. The presentation test asserts the emitted SOURCE, not the built tree: the tree is full of colours by design, because the kit puts them there. That is the whole point of the layer. 842 Splash tests, 32 splash-makepad tests, clippy clean. news 25 nodes, stock 11, weather 62 — through the real renderer. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/examples/lower_kit.rs | 16 ++ crates/splash-ui-l0/src/lib.rs | 224 ++++++++++++++++++++-- 2 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 crates/splash-ui-l0/examples/lower_kit.rs diff --git a/crates/splash-ui-l0/examples/lower_kit.rs b/crates/splash-ui-l0/examples/lower_kit.rs new file mode 100644 index 0000000..c56c11a --- /dev/null +++ b/crates/splash-ui-l0/examples/lower_kit.rs @@ -0,0 +1,16 @@ +//! Lower a card to calls on the L0 theme kit. +//! +//! Usage: lower_kit [event] [payload] +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let card = std::fs::read_to_string(&a[0]).expect("card"); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&a[1]).expect("data")).expect("json"); + let mut store = splash_ui_l0::InstanceStore::default(); + if let Some(event) = a.get(2) { + let payload = a.get(3).map(|v| serde_json::Value::String(v.clone())); + splash_ui_l0::dispatch_with(&card, &mut store, "root", event, payload.as_ref()); + } + let r = splash_ui_l0::realize_with_state(&card, &data, &store, Default::default()); + print!("{}", splash_ui_l0::kit::lower(&r.root.expect("root"))); +} diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 1e5e68f..688ee46 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -4532,7 +4532,7 @@ pub mod makepad { /// no market cap or P/E, so those two lower to the seeded value and always /// will. Emitting a call the VM cannot answer would render an em dash where /// a real number was available. - fn expr_of(node: &UiNode, arg_name: &str) -> String { + pub(super) fn expr_of(node: &UiNode, arg_name: &str) -> String { if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == arg_name) { if let Some(call) = vm_call(binding) { return call; @@ -4654,7 +4654,7 @@ pub mod makepad { /// A value plus its unit suffix, which is a presentation concern the runtime /// owns — the card never wrote "°" next to a number. - fn valued(node: &UiNode) -> String { + pub(super) fn valued(node: &UiNode) -> String { // Prefer a live call, where the backend can answer the source itself and // the decoration survives being concatenated around it. if let Some(live) = live_valued(node) { @@ -4736,6 +4736,23 @@ pub mod makepad { } } + /// How large a hero should be, given what it will DRAW. + /// + /// A hero is defined by dominating the card rather than by a point size: + /// "18°" fits at 62 where "$184.20" clipped off the right edge on device. + /// Shared by both lowerings — two copies of a ramp is two ramps, and they + /// drift. + pub(super) fn hero_points(node: &UiNode, emitted: &str) -> u32 { + let measured = sizing_text(node, emitted); + match measured.trim_matches('"').chars().count() { + 0..=4 => 62, + 5..=6 => 50, + 7..=8 => 40, + 9..=12 => 32, + _ => 24, + } + } + /// What to MEASURE when a layout decision depends on how long a value is. /// /// Emitted text and rendered text are the same thing until a value goes @@ -5108,16 +5125,10 @@ pub mod makepad { // to 24pt and shifted the whole card up. The seeded value is a // sound proxy for the live one's LENGTH, which is all this needs. let size = if role == "TextHero" { - let measured = sizing_text(node, &body); - let glyphs = measured.trim_matches('"').chars().count(); - let pt = match glyphs { - 0..=4 => 62, - 5..=6 => 50, - 7..=8 => 40, - 9..=12 => 32, - _ => 24, - }; - format!(" draw_text.text_style.font_size: {pt}") + format!( + " draw_text.text_style.font_size: {}", + hero_points(node, &body) + ) } else { String::new() }; @@ -6114,3 +6125,192 @@ fn emit_dsl(node: &UiNode, depth: usize, out: &mut String, sink: &mut Vec String { + let mut out = String::from("// REALIZED from an L0 ledger — do not edit.\n"); + out.push_str("let node = "); + element(root, 0, &mut out); + out.push_str("\nnode\n"); + out + } + + /// A card's roles, and the kit function that answers each. + /// + /// Returning `None` means the role has no answer in this kit — it lowers to + /// a visible marker rather than to nothing, because a card that silently + /// loses its temperature bars still looks complete (§1.1). + fn kit_fn(role: &str) -> Option<&'static str> { + Some(match role { + "Surface" => "l0_surface", + "Col" => "l0_col", + "Row" => "l0_row", + "Grid" => "l0_grid", + "Panel" | "Card" => "l0_panel", + "Rule" => "l0_rule", + "Tile" => "l0_tile", + "Chip" => "l0_chip", + "Photo" => "l0_photo", + "WeatherIcon" => "l0_weathericon", + "TextHero" => "l0_hero", + "TextTitle" => "l0_title", + "TextBody" => "l0_body", + "TextRow" => "l0_row_text", + "TextCaption" => "l0_caption", + "TextValue" => "l0_value", + "TextStat" => "l0_stat", + _ => return None, + }) + } + + fn arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a NodeValue> { + node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) + } + + /// A statistic's direction — the SIGN, not the colour. + /// + /// Red-versus-green is presentation and belongs to the kit; "this value + /// fell" is meaning and belongs here. A lowering that dropped `tint` + /// entirely lost both, which is why the profile calls it the instructive + /// case. + fn direction(node: &UiNode) -> i32 { + match arg(node, "tint") { + Some(NodeValue::Number(n)) if *n > 0.0 => 1, + Some(NodeValue::Number(n)) if *n < 0.0 => -1, + Some(NodeValue::Token(t)) if t == "up" => 1, + Some(NodeValue::Token(t)) if t == "down" => -1, + _ => 0, + } + } + + fn children(node: &UiNode, depth: usize, out: &mut String) { + out.push_str("[\n"); + for (i, child) in node.children.iter().enumerate() { + let _ = write!(out, "{}", " ".repeat(depth + 1)); + element(child, depth + 1, out); + if i + 1 < node.children.len() { + out.push(','); + } + out.push('\n'); + } + let _ = write!(out, "{}]", " ".repeat(depth)); + } + + fn element(node: &UiNode, depth: usize, out: &mut String) { + let Some(f) = kit_fn(&node.kind) else { + let _ = write!(out, "l0_unsupported({:?})", node.kind); + return; + }; + match node.kind.as_str() { + "Surface" => { + let _ = write!(out, "{f}("); + children(node, depth, out); + out.push(')'); + } + "Col" | "Row" => { + // `gap` is a declared spacing, so it stays a semantic argument + // rather than becoming a number the kit cannot reinterpret. + match arg(node, "gap") { + Some(NodeValue::Number(g)) => { + let _ = write!(out, "{f}_gap({}, ", makepad::trim_num(*g)); + } + _ => { + let _ = write!(out, "{f}("); + } + } + children(node, depth, out); + out.push(')'); + } + "Panel" | "Card" | "Grid" => { + let _ = write!(out, "{f}("); + children(node, depth, out); + out.push(')'); + } + "Rule" => { + let _ = write!(out, "{f}()"); + } + "Tile" => { + let _ = write!( + out, + "{f}({}, {})", + makepad::expr_of(node, "label"), + makepad::valued(node) + ); + } + "Chip" => { + let on = i32::from(matches!(arg(node, "active"), Some(NodeValue::Bool(true)))); + let _ = write!(out, "{f}({}, {on})", makepad::expr_of(node, "text")); + } + // A `Photo` WITH children is not an image, it is the page: the + // weather card wraps the whole card in one, and a lowering that made + // it a leaf dropped every child. That is the mistake the reverted + // attempt made, and it is why `l0_surface_photo` exists — image, + // scrim, then the content, so text stays legible over whatever the + // image turns out to be. + "Photo" if !node.children.is_empty() => { + let _ = write!(out, "l0_surface_photo({}, ", makepad::expr_of(node, "src")); + children(node, depth, out); + out.push(')'); + } + "Photo" => { + let _ = write!(out, "{f}({})", makepad::expr_of(node, "src")); + } + "WeatherIcon" => { + let cond = match arg(node, "cond") { + Some(NodeValue::Text(c)) => format!("{c:?}"), + Some(NodeValue::Token(c)) => format!("{c:?}"), + _ => "\"\"".into(), + }; + let _ = write!(out, "{f}({cond})"); + } + "TextStat" => { + let _ = write!(out, "{f}({}, {})", makepad::valued(node), direction(node)); + } + // A hero is sized by the caller, because only the lowering knows + // what will be DRAWN — a live value's text is not in the DSL. + "TextHero" => { + let body = makepad::valued(node); + let _ = write!(out, "{f}({body}, {})", makepad::hero_points(node, &body)); + } + // The remaining text roles take one string. + _ => { + let body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { + makepad::valued(node) + } else { + makepad::expr_of(node, "text") + }; + let _ = write!(out, "{f}({body})"); + } + } + } +} From 7566a49de3ec4dbaa01be5679035c4ee4738f8c7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:31 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui=5Fl0):=20carry=20a=20declared=20tap?= =?UTF-8?q?=20through=20the=20kit,=20and=20bring=20=C2=A78=20current?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An L0 event now reaches the renderer as `tapto: "l0:{…}"`. No VM change, no scoped state: this renderer's own `set:` verbs are handled internally and anything else falls through to the host, so an instance key survives intact. The fork I described between "collapse L0 events into set: strings" and "build scoped state in the VM" was not a fork — I had not read far enough. THE WRAPPER IS THE LOAD-BEARING PART. A `card`, `chip` or `image` carrying `tapto` renders and does nothing: the attribute is accepted by the node model and then dropped, so the string never reaches the UI at all. Only a container carries a tap. Measured, not assumed — card, chip and image all produced neither an overlay nor an on_click, while row, column and text produced one. So a tappable node is wrapped in a container, uniformly: deciding per role which kinds carry a tap natively is a second rule that rots the next time this renderer changes. The target is JSON, not a delimited string. Every field is data — an instance key contains colons (`w:list#0`) and slashes, a payload is whatever the card bound — so every separator worth using has a data-dependent failure, and a failure here routes a tap to the wrong row. §8 said the fix "is to retarget it at the component kit", which assumed a kit answering L0's roles existed. None did; writing it was most of the work. It now records what is actually left: `makepad::lower` stays because five of the six data visualisations lower to a marker, not because the kit path is incapable — live backend calls already survive it, since both lowerings share one value formatter. It also separates the two senses of "L1", which that paragraph conflated. 842 Splash tests, 34 splash-makepad, 19 mutation rules held, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 45 ++++++++++++++++++++++++++++++++++ docs/ui-profile-l0.md | 28 ++++++++++++++++++--- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 688ee46..cfc523b 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6226,7 +6226,52 @@ pub mod kit { let _ = write!(out, "{}]", " ".repeat(depth)); } + /// A tap, as the string the backend carries. + /// + /// JSON rather than a delimited form, because every field is data: an + /// instance key contains colons (`w:list#0`) and slashes, and a payload is + /// whatever the card bound. Every separator worth using has a + /// data-dependent failure, and a failure here is a tap routed to the wrong + /// row — which looks like a card bug and is not. + /// + /// The `l0:` prefix is what distinguishes it from this renderer's own + /// `set:` verbs, which it handles internally. + fn tap_target(node: &UiNode) -> Option { + let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { + return None; + }; + let value = match arg(node, "value") { + Some(NodeValue::Text(t)) => t.clone(), + Some(NodeValue::Number(n)) => makepad::trim_num(*n), + Some(NodeValue::Token(t)) => t.clone(), + _ => String::new(), + }; + let json = serde_json::json!({ "e": event, "k": node.key, "v": value }); + Some(format!("l0:{json}")) + } + fn element(node: &UiNode, depth: usize, out: &mut String) { + // A tappable node is WRAPPED. A `card`, `chip` or `image` carrying + // `tapto` renders and does nothing — the attribute is dropped before it + // reaches the UI — so only a container carries a tap. + if let Some(target) = tap_target(node) { + // The wrapper sizes like what it wraps: a Chip is intrinsic and sits + // in a row of chips, and a filling wrapper makes the first one eat + // the row. + let f = if node.kind == "Chip" { + "l0_tap_fit" + } else { + "l0_tap" + }; + let _ = write!(out, "{f}({target:?}, "); + element_untapped(node, depth, out); + out.push(')'); + return; + } + element_untapped(node, depth, out); + } + + fn element_untapped(node: &UiNode, depth: usize, out: &mut String) { let Some(f) = kit_fn(&node.kind) else { let _ = write!(out, "l0_unsupported({:?})", node.kind); return; diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index d674a4b..cb92c31 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -910,10 +910,30 @@ the language. once: it lowers to a *backend dialect* rather than to the theme's component kit, it decides *presentation* (ten hardcoded colours, a font-size ramp) which belongs to a theme, and it therefore reaches one backend of three. It also defines a second `UiNode`, duplicating the one -`splash-render` already produces. The fix is not to move it to a backend crate — it is to -retarget it at the component kit and delete the colour table. L1 and L2 are -specified only as "what L0 excludes" — neither is implemented, and the level classifier can -therefore name them but not check them. +`splash-render` already produces. + +**The replacement now exists, and does not yet replace anything.** `kit::lower` emits role calls +against `Splash-Makepad`'s `components/l0/_kit.splash` — 21 roles, each verified by building it +through `splash_render::build` in the repository where that consumer lives. The three reference +cards build (news 25 nodes, stock 11, weather 62), the emitted source carries no presentation, +and live backend calls survive, because both lowerings share one value formatter. + +An earlier version of this paragraph said the fix was "to retarget it at the component kit", +which assumed a kit answering L0's roles existed. None did: `components/flutter` and +`components/material` are ports of Flutter samples, and about four of L0's roles map loosely. +The kit had to be written, and that was most of the work. + +What still keeps `makepad::lower` in place: **a card lowered through the kit has no +interaction**, and five of the six data visualisations lower to a named marker rather than to a +chart. Neither is a language question. Interaction is a matter of carrying an event through the +`tapto` attribute the renderer already has — its non-`set:` strings fall through to the host, so +an instance key survives without any change to the VM, and `docs/scoped-state.md` is not needed +for it. + +L1 and L2 **as capability levels** — the levels this document's §7 classifies, not the layers +above — remain specified only as "what L0 excludes". Neither is implemented, and the classifier +can name them but not check them. That is unchanged and unrelated to the kit work; the two +senses of "L1" are easy to conflate and this paragraph previously did. An earlier version of this paragraph said `StockPlot` and `AqiContour` "lower without their data arrays". They have no data arrays: both name what to plot and fetch it themselves, and the