From 21368ba26913403067d025b4541f122cd86de463 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:09:36 -0700 Subject: [PATCH 01/97] feat(ui_l0): lower the five data visualisations, not markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.1 left this unsettled: six roles are small data visualisations rather than compositions of boxes and text, and how they reach a backend had no answer. Five of them lowered to a named marker — visible, so a card could not silently lose its temperature bars, but not a chart. They lower to kit calls now, carrying their declared arguments in the catalog's order. Every one of L0's 23 roles has a kit answer; `l0_unsupported` keeps its place for the role that has none on the day it is added. A missing argument becomes `0` rather than an omission, because these kit functions have fixed arity and a card short of one would otherwise fail to parse. Zero draws something visibly wrong; a parse error takes the whole card down. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index cfc523b..ed4643c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6182,6 +6182,11 @@ pub mod kit { "Chip" => "l0_chip", "Photo" => "l0_photo", "WeatherIcon" => "l0_weathericon", + "TempBar" => "l0_tempbar", + "SunArc" => "l0_sunarc", + "MoonPhase" => "l0_moonphase", + "AqiContour" => "l0_aqicontour", + "StockPlot" => "l0_stockplot", "TextHero" => "l0_hero", "TextTitle" => "l0_title", "TextBody" => "l0_body", @@ -6250,6 +6255,21 @@ pub mod kit { Some(format!("l0:{json}")) } + /// A visualisation's argument, as the kit takes it. + /// + /// A missing one is `0` rather than an omission: these functions have fixed + /// arity, and a card that failed to supply a bound would otherwise not + /// parse. Zero at least draws something visibly wrong, where a parse error + /// takes the whole card down. + fn scalar_of(node: &UiNode, name: &str) -> String { + match arg(node, name) { + Some(NodeValue::Number(n)) => makepad::trim_num(*n), + Some(NodeValue::Text(t)) => format!("{t:?}"), + Some(NodeValue::Token(t)) => format!("{t:?}"), + _ => "0".into(), + } + } + 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 @@ -6341,6 +6361,21 @@ pub mod kit { "TextStat" => { let _ = write!(out, "{f}({}, {})", makepad::valued(node), direction(node)); } + // The five data visualisations. Each takes its declared arguments in + // the catalog's order — no defaults, because a bar drawn against a + // range nobody supplied is a bar drawn against zero, and it looks + // like data. + "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "StockPlot" => { + let params: &[&str] = match node.kind.as_str() { + "TempBar" => &["lo", "hi", "min", "max"], + "SunArc" => &["rise", "set", "now"], + "MoonPhase" => &["phase", "illum"], + "AqiContour" => &["lat", "lon", "span"], + _ => &["symbol", "range"], + }; + let args: Vec = params.iter().map(|p| scalar_of(node, p)).collect(); + let _ = write!(out, "{f}({})", args.join(", ")); + } // 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" => { From 4fa3331a58edae34af13d4e1f9997392b228bb2c Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:37:08 -0700 Subject: [PATCH 02/97] feat(ui_l0): lower a tint for every text role that admits one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five roles may carry a `tint`, and the stock LIST tints a `TextValue` while the detail tints a `TextStat`. Lowering only the latter left every percentage on the list rendering white — "this one fell" stopped being said at all. `tint` is §1.1's instructive case: red-versus-green is presentation and belongs to the theme, but the SIGN is meaning and belongs in the lowering. The kit composes `l0_tinted` around a role rather than taking a direction on all seven text functions, six of which would pass 0 forever. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index ed4643c..73397d1 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6361,6 +6361,20 @@ pub mod kit { "TextStat" => { let _ = write!(out, "{f}({}, {})", makepad::valued(node), direction(node)); } + // Five text roles may carry a tint, and the stock LIST tints a + // `TextValue` while the detail tints a `TextStat`. Lowering only the + // latter dropped the colour from every row on the list — the + // percentages rendered white, and "this one fell" stopped being + // said at all. `tint` is §1.1's instructive case for exactly this: + // red-versus-green is presentation, but the SIGN is meaning. + "TextTitle" | "TextBody" | "TextCaption" | "TextValue" if direction(node) != 0 => { + 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, "l0_tinted({f}({body}), {})", direction(node)); + } // The five data visualisations. Each takes its declared arguments in // the catalog's order — no defaults, because a bar drawn against a // range nobody supplied is a bar drawn against zero, and it looks From 25de52d5fc30e686581d9f015d68ac648c620640 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:48:26 -0700 Subject: [PATCH 03/97] feat(ui_l0): find the boundary by trying to cross it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weather, news and stock shaped every role and capability this profile has, so their acceptance proves close to nothing. Two cards it was NOT designed against say more. `activity` GENERALISES. From a spec written for another framework, it needed one catalog entry (`sys.places`) and no profile change. Two requirements came out better than the source: the loading guard is `when parks.$state == .pending` rather than a `-9999` sentinel that also has to mean "no data", and its venue list is a declared collection rather than indexed capability calls. It also found a real defect on its first run. `TextCaption(text: …, suffix: …)` DROPPED the suffix — every row read "300 m" where the card said "300 m away · quiet green space". Nothing caught it because every `suffix` in the original three pairs with `value:`, and only that path decorated. The catalog declared the argument on the role and one of its two paths ignored it: specified but not retained, again, and only an out-of-corpus card could surface it. Fixed in three places, red test first. `nav` DOES NOT, AND CANNOT. The classifier puts its shipping card at L2 and inspection says the same more bluntly: 30 `let` bindings, 83 assignments, 128 conditionals, 606 arithmetic and concatenation operators, 190 capability calls, and a `fn tick()` that recomputes route geometry every frame — reassigning coordinates, rebuilding an OSRM waypoint string, re-resolving an origin whose search has not landed. That is a program, not a card, and the gap is NOT a missing role. Adding `Map` and eight `sys.*` entries would not close it. `nav` computes, and L0 has no expression form — the single property everything else here is built on. §1.0 records the boundary and says plainly that a card of that kind is L1 or L2 by the §7 classifier, not a reason to widen L0. Both are asserted rather than described: a program must classify as L2 and name `let` as the reason, and `activity` must be admitted at L0. One gap surfaced and left open. `activity`'s spec wants an empty state and L0 cannot say it — no length, no count, no emptiness predicate, so a guard cannot tell an empty collection from a full one. Every list-shaped card wants this; it is §8 question 10, with a note that the fix is a predicate and NOT a count, since a number in a card is one operator away from arithmetic. 843 tests, 19 mutation rules held, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/examples/check_card.rs | 9 ++ crates/splash-ui-l0/src/lib.rs | 62 ++++++++++---- .../splash-ui-l0/tests/fixtures/activity.card | 61 ++++++++++++++ .../tests/fixtures/nav-excerpt.splash | 18 ++++ crates/splash-ui-l0/tests/profile.rs | 82 +++++++++++++++++++ docs/ui-l0-constructors.toml | 11 +++ docs/ui-profile-l0.md | 47 +++++++++++ 7 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 crates/splash-ui-l0/examples/check_card.rs create mode 100644 crates/splash-ui-l0/tests/fixtures/activity.card create mode 100644 crates/splash-ui-l0/tests/fixtures/nav-excerpt.splash diff --git a/crates/splash-ui-l0/examples/check_card.rs b/crates/splash-ui-l0/examples/check_card.rs new file mode 100644 index 0000000..935d5e1 --- /dev/null +++ b/crates/splash-ui-l0/examples/check_card.rs @@ -0,0 +1,9 @@ +fn main() { + let path = std::env::args().nth(1).expect("card"); + let card = std::fs::read_to_string(&path).expect("read"); + let r = splash_ui_l0::check_ui_l0_named("activity", &card); + println!(" valid = {} level = {:?}", r.valid, r.level); + for d in &r.diagnostics { + println!(" {}:{} {}", d.line, d.column, d.message); + } +} diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 73397d1..2bf90b2 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2400,6 +2400,7 @@ pub mod catalog { ("sys.moonphase", &["lat", "lon"]), ("sys.photo", &["query"]), ("sys.locale", &[]), + ("sys.places", &["lat", "lon", "category", "count", "fields"]), ("sys.news", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), ("sys.movers", &["count", "fields"]), @@ -4732,10 +4733,43 @@ pub mod makepad { }; format!("{:?}", format!("{glyph}{body}{unit}{suffix}")) } - None => expr_of(node, "text"), + // A `text:` argument decorates too. It did not, and every caption + // in `activity.card` read "300 m" where the card said + // "300 m away · quiet green space". Nothing caught it because every + // `suffix` in the original three cards pairs with `value:`, and only + // that path applied the decoration. + None => decorate(expr_of(node, "text"), &glyph, unit, &suffix), } } + /// Wrap an already-emitted text in its glyph, unit and suffix. + /// + /// The text may be a literal (`"300 m"`) or a live call, so the decoration + /// is concatenated in the DSL rather than in Rust — `"a" + call + "b"` works + /// for both, and quoting a call would draw it instead of evaluating it. + fn decorate(body: String, glyph: &str, unit: &str, suffix: &str) -> String { + let head = format!("{glyph}"); + let tail = format!("{unit}{suffix}"); + if head.is_empty() && tail.is_empty() { + return body; + } + // A plain quoted literal can be spliced directly, which keeps the common + // case readable rather than emitting `"" + "300 m" + " away"`. + if body.starts_with('"') && body.ends_with('"') && !body[1..body.len() - 1].contains('"') { + let inner = &body[1..body.len() - 1]; + return format!("{:?}", format!("{head}{inner}{tail}")); + } + let mut out = String::new(); + if !head.is_empty() { + out.push_str(&format!("{head:?} + ")); + } + out.push_str(&body); + if !tail.is_empty() { + out.push_str(&format!(" + {tail:?}")); + } + out + } + /// 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: @@ -5108,11 +5142,9 @@ pub mod makepad { Some(NodeValue::Token(_)) => " width: Fit", _ => "", }; - let body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { - valued(node) - } else { - expr_of(node, "text") - }; + // Always through `valued`: it falls back to `text:` and + // decorates either way. + let body = valued(node); // A hero is sized for the ONE dominant value. "18°" fits at 62; // "$184.20" clipped off the right edge on device. Scaling to fit // is the runtime's call — the card says "this is the hero", not @@ -6368,11 +6400,10 @@ pub mod kit { // said at all. `tint` is §1.1's instructive case for exactly this: // red-versus-green is presentation, but the SIGN is meaning. "TextTitle" | "TextBody" | "TextCaption" | "TextValue" if direction(node) != 0 => { - let body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { - makepad::valued(node) - } else { - makepad::expr_of(node, "text") - }; + // Always through `valued`: it falls back to `text:` and + // decorates either way. Branching here meant a `text:` carrying a + // `suffix:` skipped the decoration entirely. + let body = makepad::valued(node); let _ = write!(out, "l0_tinted({f}({body}), {})", direction(node)); } // The five data visualisations. Each takes its declared arguments in @@ -6398,11 +6429,10 @@ pub mod kit { } // 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") - }; + // Always through `valued`: it falls back to `text:` and + // decorates either way. Branching here meant a `text:` carrying a + // `suffix:` skipped the decoration entirely. + let body = makepad::valued(node); let _ = write!(out, "{f}({body})"); } } diff --git a/crates/splash-ui-l0/tests/fixtures/activity.card b/crates/splash-ui-l0/tests/fixtures/activity.card new file mode 100644 index 0000000..4a0bdca --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/activity.card @@ -0,0 +1,61 @@ +# level: L0 +# model: activity +# +# The FIRST reference card L0 was not designed against. +# +# weather, news and stock shaped the profile — every role and capability in the +# catalog exists because one of them needed it, so "L0 renders them" is close to +# circular. This one comes from `a2app/apps/activity/app.md`, written for a +# different framework, and what it needs that L0 lacks is the point of it. + +source place sys.geocode(name: state.city) +source parks sys.places(lat: place.lat, lon: place.lon, category: "park", + count: 4, fields: [id, name, distance]) +source cafes sys.places(lat: place.lat, lon: place.lon, category: "cafe", + count: 4, fields: [id, name, distance]) +source env.locale sys.locale() + +state city { shape: text, initial: "" } # empty ⇒ device location + +copy eyebrow { class: vocabulary, en: "NEARBY · GREEN & COFFEE" } +copy heading { class: vocabulary, en: "Around You" } +copy loading { class: vocabulary, en: "Finding places nearby…" } +copy park_why { class: vocabulary, en: "away · quiet green space" } +copy cafe_why { class: vocabulary, en: "away · somewhere to sit" } + +view root Surface { + Col(gap: 2) { + TextCaption(text: copy.eyebrow) + TextTitle(text: copy.heading) + } + + # §5.9's lifecycle, where the source framework used a -9999 sentinel. The + # card asks whether the fetch has landed; it does not compare against a magic + # number that also has to mean "no data". + when parks.$state == .pending { TextBody(text: copy.loading) } + + when parks.$state == .ready { + Panel { + for p, i in parks key p.id { + Row(align: .center, gap: 10) { + TextRow(text: "🌳") + Col(gap: 2) { + TextRow(text: p.name) + TextCaption(text: p.distance, suffix: copy.park_why) + } + } + Rule() + } + for c, i in cafes key c.id { + Row(align: .center, gap: 10) { + TextRow(text: "☕") + Col(gap: 2) { + TextRow(text: c.name) + TextCaption(text: c.distance, suffix: copy.cafe_why) + } + } + Rule() + } + } + } +} diff --git a/crates/splash-ui-l0/tests/fixtures/nav-excerpt.splash b/crates/splash-ui-l0/tests/fixtures/nav-excerpt.splash new file mode 100644 index 0000000..a91658f --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/nav-excerpt.splash @@ -0,0 +1,18 @@ +// name: nav-app +let q = "{{state.q}}" +let find = "{{state.find}}" +let orig = "{{state.orig}}" +let dest = "{{state.dest}}" +let wp1 = "{{state.wp1}}" +let wp2 = "{{state.wp2}}" +let sel = "{{state.sel}}" +let ss = {{state.sel}} +let go = "{{state.go}}" +let vw = "{{state.view}}" +let md = "{{state.mode}}" +let oq = "{{state.oq}}" + +fn tick() { + if dest != "0" { dlat = sys.coord(dest, "lat") } + vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") +} diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index aecc59f..38a4202 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4352,3 +4352,85 @@ fn a_hero_is_sized_by_what_it_draws_not_by_what_it_emits() { "sized by the drawn value (7 glyphs -> 40pt), not the emitted 33:\n{hero}" ); } + +/// A decoration applies to `text:`, not only to `value:`. +/// +/// FOUND BY THE FIRST CARD OUTSIDE THE CORPUS. `activity.card` writes +/// `TextCaption(text: p.distance, suffix: copy.park_why)` — the distance is +/// already a formatted string from the host, so it arrives as text — and both +/// lowerings dropped the suffix on the floor. Every row read "300 m" where the +/// card said "300 m away · quiet green space". +/// +/// Nothing caught it because every use of `suffix` in weather, news and stock +/// pairs it with `value:`, and that path applies the decoration. The catalog +/// declared `suffix` on the role and one of its two argument paths ignored it — +/// "specified but not retained", once more. +#[test] +fn a_suffix_applies_to_text_as_well_as_value() { + const CARD: &str = r#" +copy why { class: vocabulary, en: "away" } +source parks sys.places(lat: 1.0, lon: 2.0, category: "park", count: 1, fields: [id, name, distance]) +view root Surface { for p, i in parks key p.id { TextCaption(text: p.distance, suffix: copy.why) } } +"#; + let data = serde_json::json!({ + "parks": [{"id": "a", "name": "N", "distance": "300 m"}], + "env": {"locale": {"lang": "en"}} + }); + let report = realize(CARD, &data, RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.expect("root"); + + for (name, dsl) in [ + ("makepad", makepad::lower(&root)), + ("kit", splash_ui_l0::kit::lower(&root)), + ] { + assert!( + dsl.contains("300 m away"), + "{name} dropped the suffix from a text-valued caption:\n{dsl}" + ); + } +} + +/// The boundary §1.0 draws, asserted rather than described. +/// +/// `nav`'s shipping card is a program: 30 `let` bindings, 83 assignments, 128 +/// conditionals, 606 arithmetic operators and a `fn tick()` that recomputes +/// route geometry every frame. The classifier must place it at L2 and refuse it, +/// and it must do so for the RIGHT reason — a profile that accepted it, or that +/// rejected it as unparseable, would be wrong in opposite directions. +#[test] +fn a_card_that_computes_is_classified_beyond_l0() { + const NAV: &str = include_str!("fixtures/nav-excerpt.splash"); + let report = check_ui_l0_named("nav", NAV); + assert!(!report.valid, "a program must not pass as a card"); + assert_eq!( + report.level, + Level::L2, + "`fn` and `let` put this at L2, not merely 'invalid'" + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("`let`")), + "the diagnostic must name what is beyond L0: {:#?}", + report.diagnostics + ); +} + +/// And `activity` — the first card L0 was NOT designed against — is L0. +/// +/// Weather, news and stock shaped every role and capability the profile has, so +/// their acceptance proves little. This one came from a spec written for another +/// framework and needed one catalog entry. +#[test] +fn a_card_from_outside_the_corpus_is_admitted() { + const ACTIVITY: &str = include_str!("fixtures/activity.card"); + let report = check_ui_l0_named("activity", ACTIVITY); + assert!( + report.valid, + "activity must be admissible: {:#?}", + report.diagnostics + ); + assert_eq!(report.level, Level::L0); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index f8d4271..0d21fb6 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -227,6 +227,17 @@ args = ["query"] [sources."sys.locale"] args = [] +# Nearby venues of one category, around a point. The card names WHERE and WHAT +# KIND; the host answers with a collection. +# +# `sys.places` in the app-card framework is indexed — `sys.places(lat, lon, cat, +# i, "name")` — with a companion `sys.placesnum` for the count and a `-9999` +# sentinel while the fetch is in flight. Neither survives into L0 and neither +# should: a card iterates a declared collection here, and §5.9's `$state` says +# "not yet" without a magic number standing in for it. +[sources."sys.places"] +args = ["lat", "lon", "category", "count", "fields"] + [sources."sys.news"] args = ["count", "offset", "fields"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index cb92c31..9b3e25d 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -26,6 +26,52 @@ wrong in an interesting direction: **L0 has no expression form, so there is noth evaluate.** Realization is a pure walk over the parsed tree with data substituted. The reference implementation contains no reference to the VM whatsoever. +### 1.0 What Level 0 is NOT for + +Measured, on the app cards this system already ships. Three of them — weather, +news, stock — are the profile's reference corpus, so "L0 expresses them" is close +to circular; they shaped every role and capability it has. The interesting +results are the others. + +**`activity` generalises.** It came from a spec written for a different framework +and needed one catalog entry (`sys.places`) and no profile change. Two of its +requirements came out *better* than the source: the loading guard is +`when parks.$state == .pending` rather than a `-9999` sentinel that also has to +mean "no data", and its venue list is a declared collection rather than indexed +capability calls. It also found a real defect on its first run — `suffix` applied +to `value:` and silently not to `text:`, because every use of it in the original +three pairs with `value:`. + +**`nav` does not, and cannot.** The profile's own classifier puts its shipping +card at **L2**, and inspection says the same thing more bluntly. That one card +contains 30 `let` bindings, 83 assignments, 128 conditionals, 606 arithmetic and +concatenation operators, 190 capability calls, and a `fn tick()` that recomputes +route geometry every frame — reassigning coordinates, rebuilding an OSRM +waypoint string, re-resolving an origin whose search has not landed yet. + +**That is a program, not a card.** The gap is not a missing role or an +uncatalogued capability; adding `Map` and eight `sys.*` entries would not close +it. `nav` *computes*, and L0 has no expression form at all — which is the single +property everything else in this profile is built on. §1 says confinement is +structural because there is nothing to evaluate; `nav` is what that costs. + +So the boundary is: + +| | | +|---|---| +| **L0 suits** | a card that *displays* declared data, branches on it, and writes declared state on a tap | +| **L0 cannot express** | a card that computes derived values, or drives its own animation loop | + +A card of the second kind is not a defect in the profile and must not be forced +into one. It is L1 or L2 by the §7 classifier, and the honest answer for it is a +different level — not a wider L0. + +**One gap this did surface.** `activity`'s spec wants an empty state — "Nothing +close by" when a collection has no members — and L0 cannot say it. There is no +length, no count, and no emptiness predicate, so a guard cannot distinguish an +empty list from a full one. Every list-shaped card wants this. It is §8's +question 10. + ### 1.1 A card names roles, not appearance **L0 decides what a thing IS. It does not decide what it looks like, and it never names a @@ -899,6 +945,7 @@ construct and keep the widest. | ~~6~~ | ~~An explicit state migration~~ — **settled in §5.8.** `keep: true` opts a cell out of the schema-change reset, and only while that field's own shape is unchanged | | ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and tests assert the two agree in both directions — constructor names and shared token sets, and for source capabilities the arguments too | | **8** | **What, if anything, is durable.** Card state resets with the card; §5.8 previously claimed a card-state migration that does not exist. `units` and `city` look like preferences a user expects to survive a restart. Answering needs a storage contract, a migration story, and a decision about what a user is entitled to get back — none of which belongs under a state mechanism | +| **10** | **How a card says a collection is empty.** `activity` wants "Nothing close by" when a list has no members, and L0 has no length, no count and no emptiness predicate — a guard cannot tell an empty collection from a full one, so the card silently renders nothing where it should say something. Every list-shaped card wants this. The narrow fix is a predicate against a collection's emptiness, NOT a `.count` — a count is a number, and a number in a card is one operator away from arithmetic | | **9** | **Whether component-local state (§5.10 kind 2) moves to the component kit.** The corpus says it would cost little — six kind-1 declarations against one kind-2. §5.10.1 says it cannot move alone: identity, mount/unmount and invalidation must move with it, and that protocol is unwritten. Open until the protocol exists, not until the split looks tidy | Questions 1–7 are settled; 8 and 9 were opened by working through where state lives. What From 6415be9c8ad0e6552b7477f4966bcaf445617dd1 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:50:00 -0700 Subject: [PATCH 04/97] fix(ui_l0): a useless format! in the decoration helper Clippy under -D warnings, which CI runs and my last commit did not. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 2bf90b2..b16ce58 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -4748,7 +4748,7 @@ pub mod makepad { /// is concatenated in the DSL rather than in Rust — `"a" + call + "b"` works /// for both, and quoting a call would draw it instead of evaluating it. fn decorate(body: String, glyph: &str, unit: &str, suffix: &str) -> String { - let head = format!("{glyph}"); + let head = glyph.to_string(); let tail = format!("{unit}{suffix}"); if head.is_empty() && tail.is_empty() { return body; From 54d0019aabbd7b3df043d2811df4b32b4a702eee Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:27:29 -0700 Subject: [PATCH 05/97] =?UTF-8?q?feat(ui=5Fl0):=20Map=20and=20Field,=20and?= =?UTF-8?q?=20a=20=C2=A71.0=20that=20stops=20overreading=20one=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.0 said `nav` proved a structural limit and that a card like it is "L1 or L2, not a reason to widen L0". That was too strong, and wrong in the direction that stops work: it read one card's accumulated workarounds as evidence about the language. WHAT THE CARD ACTUALLY DOES. Of `tick()`'s 157 lines, 61 re-resolve values after a fetch lands and 32 build a URL parameter by concatenation. Its own comments say why, seven times — "top-level dlat/dlon freeze at build", "the top-level origin resolution FREEZES at build time — before oq's search lands", "GPS may land AFTER build, so (re)resolve it here each tick". It recomputes everything every frame because it cannot say THIS VALUE DEPENDS ON THAT FETCH. Three more lines disambiguate a `-9999` sentinel meaning loading *or* failed. L0 already supplies all of that: declared sources with a dependency graph, `$state` as four distinct states, and source arguments the host assembles. AND THE MAP IS COMPOSABLE. Its card-facing surface is already declarative — ten parameters, no callbacks — and `nav_period` means the widget owns its animation, so the moving vehicle is not the card's concern. What the card does wrongly is call `sys.navroute` itself, hand-build a marker string and push both in through imperative setters. That is the card doing the WIDGET's job, and it is the identical mistake `AqiContour` and `StockPlot` were corrected for — the catalog's note on that correction says supplying the data "put a GPU uniform layout into the authoring language — it is not a widget contract, it is an ABI". So `Map` takes a TRIP — mode, from, to, via, zoom — and the widget fetches its route. The mode token set is closed, so a card cannot ask for a camera behaviour the widget has no answer for; the test asserts an invented one is refused. `Field` is the other half. A card with no way to receive typed text cannot have a search box, which was the rest of why nav could not be written here. The typed value reaches declared state through a declared transition, so it arrives by the same total path a tap does, and §4's `user-copy` class already names what it is. §1.0 now says what is decided and what is not: whether `nav` rewritten against declared sources and a `Map` role fits inside L0 is a CARD TO WRITE, not an argument to have. 846 tests, 19 mutation rules held, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 38 ++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 51 ++++++++++++++++++++++++ docs/ui-l0-constructors.toml | 27 +++++++++++++ docs/ui-profile-l0.md | 58 +++++++++++++++++++--------- 4 files changed, 155 insertions(+), 19 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index b16ce58..09374c4 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2272,6 +2272,11 @@ pub mod catalog { "date", ]; pub const WIDTH: &[&str] = &["fill", "fit", "day", "rank", "temp"]; + + /// How a map presents a trip. These are the shipping widget's own modes: + /// `plan` shows the whole route, `drive` follows the vehicle, and `flat` is + /// the same route without the 2.5D camera. + pub const MAP_MODE: &[&str] = &["plan", "drive", "flat"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; @@ -2284,6 +2289,39 @@ pub mod catalog { pub const CONSTRUCTORS: &[(&str, Args)] = &[ ("Surface", &[("pad", Token(PAD))]), ("Photo", &[("src", Path), ("pad", Token(PAD))]), + // A map. The card names the TRIP; the widget fetches its own route. + // + // The same correction `AqiContour` and `StockPlot` already took. The + // shipping nav card calls `sys.navroute` itself, hand-builds a marker + // string and pushes both in through imperative setters — which is the + // card doing the widget's job, and is most of why that card classifies + // at L2. A route is not a card's to compute. + ( + "Map", + &[ + ("mode", Token(MAP_MODE)), + ("from", Path), + ("to", Path), + ("via", Path), + ("zoom", Number), + ], + ), + // A text field. The ONE role that lets a card receive something the user + // typed. + // + // `text` is where the value lives — declared card state, never a free + // binding — and `on_commit` carries it as `$value` to a declared + // transition. So typed text enters through the same total, declared path + // as a tap, and §4's `user-copy` class already names what it is. + ( + "Field", + &[ + ("text", Path), + ("placeholder", ArgKind::Data), + ("on_commit", Event), + ("width", TokenOrPath(WIDTH)), + ], + ), ("Panel", &[]), ("Card", &[("on_tap", Event), ("value", Any)]), ("Col", &[("align", Token(ALIGN)), ("gap", Number)]), diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 38a4202..1ed0df4 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4434,3 +4434,54 @@ fn a_card_from_outside_the_corpus_is_admitted() { ); assert_eq!(report.level, Level::L0); } + +/// `Map` and `Field` — the two roles nav needed — are admissible and behave. +/// +/// Added after looking at what the shipping nav card actually does with its map: +/// it calls `sys.navroute` itself, hand-builds a marker string, and pushes both +/// in through imperative setters. That is the card doing the WIDGET's job, and +/// the identical mistake `AqiContour` and `StockPlot` were corrected for. `Map` +/// takes a TRIP; the widget fetches the route. +/// +/// `Field` is the other half: a card with no way to receive typed text cannot +/// have a search box. The typed value goes to declared state through a declared +/// transition, so it arrives by the same total path a tap does. +#[test] +fn a_card_can_name_a_trip_and_take_typed_text() { + const CARD: &str = r#" +state dest { shape: text, initial: "" } +state query { shape: text, initial: "" } +event set_dest { dest: set($value) } +source place sys.geocode(name: query) +copy find { class: vocabulary, en: "Where to?" } +view root Surface { + Field(text: query, placeholder: copy.find, on_commit: set_dest) + Map(mode: .drive, from: place, to: place, zoom: 16) +} +"#; + let report = check_ui_l0_named("trip", CARD); + assert!(report.valid, "{:#?}", report.diagnostics); + assert_eq!(report.level, Level::L0, "neither role needs an expression"); + + // A mode outside the declared set is refused — the token set is closed, so a + // card cannot invent a camera behaviour the widget has no answer for. + let bad = CARD.replace("mode: .drive", "mode: .helicopter"); + assert!( + !check_ui_l0_named("trip", &bad).valid, + "an uncatalogued map mode must be refused" + ); + + // And the trip reaches the realized tree, so a backend can act on it. + let data = serde_json::json!({ + "place": {"lat": 37.3, "lon": -121.9, "name": "San Jose"}, + "query": "", "dest": "", "env": {"locale": {"lang": "en"}} + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert!( + dsl.contains("l0_unsupported(\"Map\")") || dsl.contains("l0_map"), + "Map must reach the lowering as itself or as a named marker:\n{dsl}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 0d21fb6..c3f9199 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -33,6 +33,33 @@ pad = { kind = "token", tokens = ["page", "tight", "none"] } src = { kind = "path" } pad = { kind = "token", tokens = ["page", "tight", "none"] } +# A map. The card names the TRIP; the widget fetches its own route. +# +# The same correction `AqiContour` and `StockPlot` already took, and for the same +# reason. The shipping nav card calls `sys.navroute` itself, hand-builds a marker +# string, and pushes both into the widget through imperative setters from inside +# a per-frame `tick()` — which is the card doing the widget's job, and is most of +# why that card classifies at L2. A route is not a card's to compute. +[Map] +mode = { kind = "token", tokens = ["plan", "drive", "flat"] } +from = { kind = "path" } +to = { kind = "path" } +via = { kind = "path" } +zoom = { kind = "number" } + +# A text field — the one role that lets a card receive something the user typed. +# +# `text` is a path into declared card state, never a free binding, and +# `on_commit` carries the typed value as `$value` to a declared transition. So +# text enters through the same total, declared path a tap does, and §4's +# `user-copy` class already names what it is. Without this, a card cannot have a +# search box, which is the other half of why nav could not be written here. +[Field] +text = { kind = "path" } +placeholder = { kind = "data" } +on_commit = { kind = "event" } +width = { kind = "width" } + [Panel] [Card] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 9b3e25d..6b287f8 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -42,29 +42,49 @@ capability calls. It also found a real defect on its first run — `suffix` appl to `value:` and silently not to `text:`, because every use of it in the original three pairs with `value:`. -**`nav` does not, and cannot.** The profile's own classifier puts its shipping -card at **L2**, and inspection says the same thing more bluntly. That one card -contains 30 `let` bindings, 83 assignments, 128 conditionals, 606 arithmetic and -concatenation operators, 190 capability calls, and a `fn tick()` that recomputes -route geometry every frame — reassigning coordinates, rebuilding an OSRM -waypoint string, re-resolving an origin whose search has not landed yet. - -**That is a program, not a card.** The gap is not a missing role or an -uncatalogued capability; adding `Map` and eight `sys.*` entries would not close -it. `nav` *computes*, and L0 has no expression form at all — which is the single -property everything else in this profile is built on. §1 says confinement is -structural because there is nothing to evaluate; `nav` is what that costs. - -So the boundary is: +**`nav` does not — and the reason is not what it first looks like.** The +classifier puts its shipping card at **L2**, and the card is indeed a program: +30 `let` bindings, 83 assignments, 128 conditionals, 606 arithmetic and +concatenation operators, and a `fn tick()` that recomputes route geometry every +frame. + +**But most of that is compensation, not requirement.** Of tick's 157 lines, 61 +re-resolve values after a fetch lands and 32 build a URL parameter by string +concatenation. Its own comments say why, seven times: *"top-level dlat/dlon +freeze at build"*, *"the top-level origin resolution FREEZES at build time — +before oq's search lands"*, *"GPS may land AFTER build, so (re)resolve it here +each tick"*. The card recomputes everything every frame because it has no way to +say **this value depends on that fetch**. Three more lines exist to disambiguate +a `-9999` sentinel that means loading *or* failed. + +L0 supplies exactly those: declared sources with a dependency graph, +`.$state` as four distinct states rather than a sentinel, and source +arguments the host assembles instead of the card concatenating a URL. + +**And the map is composable.** Its card-facing surface is already declarative — +about ten parameters, `nav_mode`, `zoom`, `nav_route_width`, `nav_period`, no +callbacks — and `nav_period` means the widget owns its own animation, so the +moving vehicle is not the card's concern either. What the card does wrongly is +call `sys.navroute` itself, hand-build a marker string, and push both in through +imperative setters. **That is the card doing the widget's job**, and it is the +identical mistake `AqiContour` and `StockPlot` were already corrected for: the +catalog's own note on that correction says supplying the data *"put a GPU uniform +layout into the authoring language — it is not a widget contract, it is an ABI"*. + +So `Map` is now a role, taking a trip rather than a route, and `Field` is now a +role, because a card with no way to receive typed text cannot have a search box — +which was the other half of why nav could not be written here. + +**An earlier version of this section said the gap was structural** and that a +card like `nav` is L1 or L2 "not a reason to widen L0". That was too strong, and +wrong in the direction that stops work: it read a card's accumulated workarounds +as evidence about the language. The honest position is narrower — | | | |---|---| -| **L0 suits** | a card that *displays* declared data, branches on it, and writes declared state on a tap | +| **L0 suits** | a card that displays declared data, branches on it, and writes declared state on a tap or a commit | | **L0 cannot express** | a card that computes derived values, or drives its own animation loop | - -A card of the second kind is not a defect in the profile and must not be forced -into one. It is L1 or L2 by the §7 classifier, and the honest answer for it is a -different level — not a wider L0. +| **Not yet decided** | whether `nav` rewritten against declared sources and a `Map` role fits inside the first row — which is a card to write, not an argument to have | **One gap this did surface.** `activity`'s spec wants an empty state — "Nothing close by" when a collection has no members — and L0 cannot say it. There is no From 381e8929b981fe817ece94b2628ed3707a056188 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:38:18 -0700 Subject: [PATCH 06/97] feat(ui_l0): nav, at L0, in 54 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.0 said whether nav fits is a card to write rather than an argument to have. Written: `tests/fixtures/nav.card` is the same screen as the 664-line exemplar — origin, destination, live search results, route and ETA — in 54 lines, admitted at L0. That settles what the earlier §1.0 got wrong. nav's complexity was mostly compensation, and each piece had a declared answer already: - a `tick()` re-resolving values every frame because a top-level `let` freezes before the fetch lands -> a `source` is a declared dependency - a hand-built OSRM waypoint string -> a source argument the host assembles - `-9999` meaning loading OR failed -> `$state`, four distinct states - a polyline the card fetched and pushed into the widget -> `Map` takes a TRIP and fetches its route `sys.gps`, `sys.search` and `sys.route` join the catalog. `sys.route` returns the trip's FACTS — duration, distance — while the route itself stays the widget's to fetch: asking twice is cheaper than making a card carry a polyline. FOUR GAPS FOUND, none structural, all recorded in §1.0 rather than patched: - A card cannot accumulate a list. `collection` is a PROP shape, not a state shape, and there is no append — so waypoints, favourites and any multi-select are inexpressible. The planner lost its "add a stop". This is the sharpest of the four, because it is a whole interaction class rather than one argument. - The text roles' argument sets are an artefact of the corpus: `suffix` and `glyph` only on `TextCaption`, `format` on Hero/Stat/Value but not Caption, `tint` on Stat/Value but not Hero. Each role has exactly what the original three cards happened to use on it. - No duration format, so a trip time cannot be written as one. - `on_tap` is absent from the text roles, so a tappable label needs a `Row` around it. Possibly right; not written down as a decision. The test asserts the card uses both new roles and that it stays SMALL — the claim is not merely that L0 can express this screen, and a 600-line L0 card would disprove it as surely as a rejection would. 847 tests, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 3 + crates/splash-ui-l0/tests/fixtures/nav.card | 92 +++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 41 +++++++++ docs/ui-l0-constructors.toml | 17 ++++ docs/ui-profile-l0.md | 18 +++- 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 crates/splash-ui-l0/tests/fixtures/nav.card diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 09374c4..a787b14 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2438,6 +2438,9 @@ pub mod catalog { ("sys.moonphase", &["lat", "lon"]), ("sys.photo", &["query"]), ("sys.locale", &[]), + ("sys.gps", &[]), + ("sys.search", &["query", "count", "fields"]), + ("sys.route", &["from", "to", "via", "mode", "fields"]), ("sys.places", &["lat", "lon", "category", "count", "fields"]), ("sys.news", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card new file mode 100644 index 0000000..03c28e4 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -0,0 +1,92 @@ +# level: L0 +# model: nav +# +# The card §1.0 said should be written rather than argued about. +# +# `a2app/apps/nav/exemplars/trip-planner.splash` is 664 lines and classifies at +# L2: 30 `let` bindings, 83 assignments, 128 conditionals, 606 operators, and a +# `fn tick()` that recomputes route geometry every frame. This is the same +# screen — pick an origin, a destination, up to two stops, see the route and its +# ETA — written against declared sources instead. +# +# THE COMPARISON IS THE POINT. Everything that shrank was compensation: +# +# - `tick()` re-resolved coordinates every frame because a top-level `let` +# freezes at build, before the fetch lands. A `source` here is a declared +# dependency; the runtime knows when it changes and nothing re-runs. +# - It hand-built an OSRM `vias` string by concatenation. `sys.route` takes +# `via` as a value and the host assembles the request. +# - It compared against `-9999` to tell "loading" from "failed". §5.9's +# `$state` distinguishes them. +# - It called `sys.navroute` itself and pushed a polyline into the widget. +# `Map` takes the trip and fetches its own route, exactly as `StockPlot` +# takes a symbol and `AqiContour` takes a location. + +source here sys.gps() +source found sys.search(query: state.query, count: 5, fields: [id, name, lat, lon]) +source trip sys.route(from: here, to: dest_place, mode: .drive, + fields: [duration, distance]) +source dest_place sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon]) +source env.locale sys.locale() + +state query { shape: text, initial: "" } # what the user is typing +state dest { shape: text, initial: "" } # the chosen destination + +event choose_dest { dest: set($value), query: clear } +event clear_dest { dest: clear, query: clear } + +copy where { class: vocabulary, en: "Where to?" } +copy from { class: vocabulary, en: "FROM" } +copy to { class: vocabulary, en: "TO" } +copy here_now { class: vocabulary, en: "Current location" } +copy eta { class: vocabulary, en: "ETA" } +copy away { class: vocabulary, en: "away" } +copy seeking { class: vocabulary, en: "Finding a route…" } +copy nostop { class: vocabulary, en: "Add a stop" } + +view root Surface { + # ---- the trip, as the card states it ------------------------------------- + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.from) + TextRow(text: here.name) + } + Rule() + Row(align: .center, gap: 10) { + TextCaption(text: copy.to) + # A destination is either chosen or being typed. No branch on a sentinel: + # the state itself says which. + when dest == "" { Field(text: query, placeholder: copy.where, on_commit: choose_dest) } + when dest != "" { + Row(on_tap: clear_dest) { TextRow(text: dest_place.name) } + } + } + } + + # ---- what the user is choosing between ----------------------------------- + when dest == "" { + Panel { + for f, i in found key f.id { + Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { + TextRow(text: f.name) + } + Rule() + } + } + } + + # ---- the route ------------------------------------------------------------ + when dest != "" { + # §5.9, where the original compared against -9999. "Not yet" and "failed" + # are different states and the card can say so. + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + } + } + # The card names the TRIP. The widget fetches its own route. + Map(mode: .drive, from: here, to: dest_place, zoom: 16) + } +} diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 1ed0df4..dfbf85b 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4485,3 +4485,44 @@ view root Surface { "Map must reach the lowering as itself or as a named marker:\n{dsl}" ); } + +/// The nav card §1.0 said to write instead of argue about. +/// +/// The shipping trip planner is 664 lines of Splash DSL and classifies at L2. +/// This is the same screen — origin, destination, live search results, route and +/// ETA — in 54 lines of L0, and it is admitted. +/// +/// That settles the question the earlier §1.0 got wrong. nav's complexity was +/// mostly compensation: a `tick()` re-resolving values because a top-level `let` +/// freezes before the fetch lands, a hand-built URL parameter, a `-9999` sentinel +/// meaning loading *or* failed, and a polyline the card fetched and pushed into +/// the widget. Declared sources, `$state`, source arguments and a `Map` that +/// takes a trip remove all four. +#[test] +fn the_nav_trip_planner_is_expressible_at_l0() { + const NAV: &str = include_str!("fixtures/nav.card"); + let report = check_ui_l0_named("nav", NAV); + assert!(report.valid, "{:#?}", report.diagnostics); + assert_eq!(report.level, Level::L0); + + // It must actually use the two roles that made it possible — otherwise this + // passes for a card that quietly dropped the map and the search box. + assert!(NAV.contains("Map(mode:"), "the card must name a trip"); + assert!(NAV.contains("Field(text:"), "the card must take typed text"); + + // And it must be SMALL. The claim is not merely that L0 can express this + // screen but that most of the original was working around missing + // machinery — a 600-line L0 card would disprove that as surely as a + // rejection would. + let lines = NAV + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !t.starts_with('#') + }) + .count(); + assert!( + lines < 100, + "the point is that it is small; this is {lines} lines" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index c3f9199..57869df 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -262,6 +262,23 @@ args = [] # sentinel while the fetch is in flight. Neither survives into L0 and neither # should: a card iterates a declared collection here, and §5.9's `$state` says # "not yet" without a magic number standing in for it. +# The device's own position. No arguments: a card does not get to ask where +# something else is. +[sources."sys.gps"] +args = [] + +# Free-text place search, for what a `Field` collected. The query is a path into +# declared state, so the card cannot search for something it did not first +# receive from the user. +[sources."sys.search"] +args = ["query", "count", "fields"] + +# A trip's facts — duration, distance, the step list. The ROUTE ITSELF is the +# `Map` widget's to fetch; this is what the card needs to write on the screen +# beside it, and asking twice is cheaper than making the card carry a polyline. +[sources."sys.route"] +args = ["from", "to", "via", "mode", "fields"] + [sources."sys.places"] args = ["lat", "lon", "category", "count", "fields"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6b287f8..67582e9 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -84,7 +84,23 @@ as evidence about the language. The honest position is narrower — |---|---| | **L0 suits** | a card that displays declared data, branches on it, and writes declared state on a tap or a commit | | **L0 cannot express** | a card that computes derived values, or drives its own animation loop | -| **Not yet decided** | whether `nav` rewritten against declared sources and a `Map` role fits inside the first row — which is a card to write, not an argument to have | +| **Settled by writing it** | `nav` rewritten against declared sources and a `Map` role IS inside the first row. `tests/fixtures/nav.card` is the same screen as the 664-line L2 exemplar in **54 lines**, admitted at L0 | + +**What writing it found.** Four gaps, none structural, all recorded rather than +patched over: + +- **A card cannot accumulate a list.** `collection` is a *prop* shape (§5.2), not + a state shape, and there is no append. So waypoints, favourites and any + multi-select are inexpressible — the trip planner lost its "add a stop". This + is the sharpest of the four because it is a whole interaction class. +- **The text roles' argument sets are an artefact of the corpus.** `suffix` and + `glyph` exist only on `TextCaption`; `format` on Hero, Stat and Value but not + Caption; `tint` on Stat and Value but not Hero. Each role has exactly what the + original three cards happened to use on it, and nothing states a reason. +- **No duration format**, so a trip time cannot be written as one — `.money`, + `.compact`, `.time` and `.date` exist but nothing spells 48 minutes. +- **`on_tap` is not on the text roles**, so a tappable label has to be wrapped in + a `Row`. That may be right; it is not written down as a decision. **One gap this did surface.** `activity`'s spec wants an empty state — "Nothing close by" when a collection has no members — and L0 cannot say it. There is no From 622ff4ac85aeb1f8390cf0429356c141b8b86311 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:39:43 -0700 Subject: [PATCH 07/97] docs(ui_l0): a card is not the only thing an app can be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.0 measured L0 against every app this system ships and counted `youtube` and `web` as cards it could not express. They are not cards. A person authored their UI once and the model supplies only an INTENT — which video, which song, which query — so authoring UI is the wrong tool for them and their absence from L0 is not a limitation of it. The AMA routes the intent; the app resolves it. So the corpus divides two ways, and only one row is L0's business. FIVE OF THE SIX LLM-authored cards are written and admitted at L0. The sixth, `weather-activity`, is a composition of two that already are and names no uncatalogued capability — its `sys.weathernum` / `sys.placesnum` / `sys.aqinum` are the index-and-count companions a declared collection and `$state` replace — but it is not counted as proven, because it has not been written. "Five of eight" would have counted two fixed apps as failures of a language they were never meant to use. AND THE SPLIT IS WHAT §4 WANTS ANYWAY. `apps/youtube/app.md` currently tells the model "YOU choose the videos — the card cannot search YouTube by itself", so it emits video IDs from memory. Those are FACTS, and a wrong one is a dead embed or the wrong upload — precisely what the no-facts rule exists to prevent, and the same failure `activity`'s "never invent a venue" and `StockPlot`'s symbol-not-a-series both avoid. An app that takes an intent and resolves it against the real service cannot assert a fact that is not true. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ui-profile-l0.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 67582e9..6de6766 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -33,6 +33,36 @@ news, stock — are the profile's reference corpus, so "L0 expresses them" is cl to circular; they shaped every role and capability it has. The interesting results are the others. +**Not every app wants a card at all**, and an earlier version of this section +counted as though they did. `youtube` and `web` are FIXED APPS: a person authored +the UI once, and the model supplies only an intent — which video, which song, +which query. Nothing about them is a limitation of L0, because authoring UI is +the wrong tool for them. The AMA routes an intent to the app; the app resolves it. + +So the corpus divides two ways, and only the first row is L0's business: + +| | who authors the UI | what the model supplies | example | +|---|---|---|---| +| **LLM-authored card** | the model, per request | the whole card | weather, news, stock, activity, nav, weather-activity | +| **Fixed app** | a person, once | an intent | youtube, web | + +**Five of the six LLM-authored cards are written and admitted at L0.** The sixth, +`weather-activity`, is a composition of two that already are, and every capability +it names is catalogued — its `sys.weathernum` / `sys.placesnum` / `sys.aqinum` are +the index-and-count companions that a declared collection and `$state` replace. +It is not counted as proven, because it has not been written. + +That is the number that means something. "Five of eight" would be counting two +fixed apps as failures of a language they were never meant to use. + +**And the split is what §4 wants anyway.** `apps/youtube/app.md` currently tells +the model *"YOU choose the videos — the card cannot search YouTube by itself"*, so +it emits video IDs from memory. Those are FACTS, and a wrong one is a dead embed +or the wrong upload — exactly what the no-facts rule exists to prevent, and the +same failure `activity`'s "never invent a venue" and `StockPlot`'s symbol-not-a- +series both avoid. An app that takes an intent and resolves it against the real +service has no way to assert a fact that is not true. + **`activity` generalises.** It came from a spec written for a different framework and needed one catalog entry (`sys.places`) and no profile change. Two of its requirements came out *better* than the source: the loading guard is From 1e92c7f322c05250bc21edf38107929fa4a8fce4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:04:48 -0700 Subject: [PATCH 08/97] feat(ui_l0): a backend-answered source now survives a loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a `for`, a path is rooted at the BINDER — `m.ticker`, not `movers.0.ticker` — so `source_binding` did not recognise it and no row of a list ever lowered to a live call. The stock card showed this plainly and I read it as normal: the DETAIL view went live while the LIST beside it kept the seeded values, which looks like a stale list rather than a missing feature. A loop frame now records which collection it iterates and at what index, and the lowering rewrites through it. `l0_row_text(sys.movers(0, "symbol"))`, per row, per index. `sys.movers` joins the translation table. Every field in it was checked against a live screener response rather than against the helper's accepted-key list — which is the distinction that produced the `open` bug in `sys.quote`, where the key is accepted and the value is absent from the payload, so emitting the call drew `$—` over a real price. The test asserts row 0 and row 1 each ask for their own index, and that the seeded tickers are GONE. One call reused, or an index that does not advance, would otherwise pass — and a card that shows a stale value beside a live one says nothing about which is which. 847 tests, 19 mutation rules held, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 84 ++++++++++++++++++++++++---- crates/splash-ui-l0/tests/profile.rs | 40 +++++++++++++ 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index a787b14..c22805d 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3953,7 +3953,7 @@ fn realize_inner( // then its declared initial. Without this, an event that wrote card state // would apply and be invisible at the next realization — and a guard on a // state with no value at all would take the wrong branch on first render. - let mut frames: Vec<(String, serde_json::Value)> = Vec::new(); + let mut frames: Vec<(String, serde_json::Value, Option<(String, usize)>)> = Vec::new(); for state in &card.states { let value = store .and_then(|s| s.get(CARD_STATE_KEY, &state.path)) @@ -3962,7 +3962,7 @@ fn realize_inner( .or_else(|| state.initial.clone()) .or_else(|| state.initial_path.as_ref().and_then(|p| data_path(data, p))) .unwrap_or_else(|| initial_for(&state.shape)); - frames.push((state.path.clone(), value)); + frames.push((state.path.clone(), value, None)); } let mut scope = ValueScope { frames, @@ -3987,7 +3987,14 @@ fn realize_inner( /// Bindings introduced by loops and component props, innermost last. struct ValueScope<'a> { - frames: Vec<(String, serde_json::Value)>, + /// A bound name, its value, and — for a loop binder — WHERE the item came + /// from: the source it iterates and the item's index. + /// + /// The provenance is what lets a backend answer a source itself from inside + /// a loop. `m.ticker` is rooted at the binder, not at `movers`, so without + /// this every row in a list falls back to the seeded blob while the detail + /// view beside it goes live — which is exactly what happened. + frames: Vec<(String, serde_json::Value, Option<(String, usize)>)>, data: &'a serde_json::Value, copies: &'a [CopyDecl], } @@ -4044,8 +4051,8 @@ impl ValueScope<'_> { return Some(serde_json::Value::String(text.1.clone())); } - let mut current = match self.frames.iter().rev().find(|(n, _)| n == root) { - Some((_, v)) => v.clone(), + let mut current = match self.frames.iter().rev().find(|(n, _, _)| n == root) { + Some((_, v, _)) => v.clone(), None => self.data.get(root)?.clone(), }; for segment in segments { @@ -4240,7 +4247,7 @@ impl Realizer<'_> { // data and letting the host capture an unfilled prop. (None, _) => param.default.clone().unwrap_or(serde_json::Value::Null), }; - scope.frames.push((param.name.clone(), value)); + scope.frames.push((param.name.clone(), value, None)); bound += 1; } let instance_key = format!("{key}/{}", component.name); @@ -4272,6 +4279,7 @@ impl Realizer<'_> { live.or_else(|| state.initial.clone()) .or(from_path) .unwrap_or_else(|| initial_for(&state.shape)), + None, )); bound += 1; } @@ -4358,13 +4366,22 @@ impl Realizer<'_> { let mut bound = 0usize; if let Some(binder) = element.binders.first() { - scope.frames.push((binder.clone(), item.clone())); + // The provenance a live call needs: `m.ticker` is rooted at + // the binder, so without this every row falls back to the + // seeded blob while the detail beside it goes live. + scope.frames.push(( + binder.clone(), + item.clone(), + Some((path.to_string(), index)), + )); bound += 1; } if let Some(index_binder) = element.binders.get(1) { - scope - .frames - .push((index_binder.clone(), serde_json::Value::from(index + 1))); + scope.frames.push(( + index_binder.clone(), + serde_json::Value::from(index + 1), + None, + )); bound += 1; } @@ -4405,6 +4422,29 @@ impl Realizer<'_> { /// entirely — a fetch with a hole in it is worse than no fetch, since it /// would silently request the wrong thing. fn source_binding(&self, path: &str, scope: &ValueScope) -> Option { + // Inside a `for`, a path is rooted at the BINDER: `m.ticker`, not + // `movers.0.ticker`. The binder's frame records which collection it + // iterates and at what index, so rewrite through it first. + // + // Without this every row of a list falls back to the seeded value while + // the detail view beside it goes live — which is precisely what the + // stock card did, and it looks like the list is simply stale. + let (root, rest) = path.split_once('.').unwrap_or((path, "")); + let rewritten = scope + .frames + .iter() + .rev() + .find(|(n, _, prov)| n == root && prov.is_some()) + .and_then(|(_, _, prov)| prov.as_ref()) + .map(|(collection, index)| { + if rest.is_empty() { + format!("{collection}.{index}") + } else { + format!("{collection}.{index}.{rest}") + } + }); + let path = rewritten.as_deref().unwrap_or(path); + // `env.locale.lang` roots at the source `env.locale`, not at `env`, so // match the LONGEST declared name that prefixes the path. let declaration = self @@ -4623,6 +4663,30 @@ pub mod makepad { }; Some(format!("sys.stock({symbol:?}, {key:?})")) } + // A mover, by index. `sys.movers` takes the row's position rather + // than a ticker, so the loop index has to reach here — which it does + // because a binding inside a `for` carries the item's index in its + // field path. + // + // Every field below was checked against a live screener response, + // not against the helper's accepted-key list. That distinction is + // why `open` is absent from `sys.quote` above: the key is accepted + // there and the value is not in the payload, so emitting the call + // drew `$—` where the seeded blob held a real price. + "sys.movers" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + "ticker" => "symbol", + "last" => "price", + "pct" => "changepct", + "volume" => "vol", + "mktcap" => "marketcap", + f @ ("name" | "change" | "high" | "low" | "open") => f, + _ => return None, + }; + Some(format!("sys.movers({index}, {key:?})")) + } _ => None, } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index dfbf85b..c1c1089 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4526,3 +4526,43 @@ fn the_nav_trip_planner_is_expressible_at_l0() { "the point is that it is small; this is {lines} lines" ); } + +/// A source answered by the backend must survive a loop. +/// +/// Inside a `for`, a path is rooted at the BINDER — `m.ticker`, not +/// `movers.0.ticker` — so the binding logic did not recognise it and every row +/// of the stock list fell back to the seeded value while the detail view beside +/// it went live. On screen that reads as a stale list, not as a missing feature. +/// +/// The loop frame records which collection it iterates and at what index, which +/// is what lets the lowering rewrite the path and emit the call. +#[test] +fn a_live_source_survives_a_loop() { + let data = serde_json::json!({ + "movers": [{ "ticker": "NVDA", "name": "Nvidia", "last": 1.0, "change": 0.5, "pct": 2.0 }, + { "ticker": "AAPL", "name": "Apple", "last": 2.0, "change": -0.5, "pct": -1.0 }], + "quote": {}, "selected": "", "range": "m1", "env": { "locale": {} }, + "copy": { "movers": "Top Movers" } + }); + let root = realize(STOCK, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + + // Row 0 and row 1 must each call for their OWN index — one call reused, or + // an index that does not advance, is the failure this catches. + assert!( + dsl.contains(r#"sys.movers(0, "symbol")"#), + "the first row must be live:\n{dsl}" + ); + assert!( + dsl.contains(r#"sys.movers(1, "symbol")"#), + "and the second must ask for index 1:\n{dsl}" + ); + // The seeded tickers must be GONE, or the card shows a stale value beside a + // live one and nothing says which is which. + assert!( + !dsl.contains("\"NVDA\"") && !dsl.contains("\"AAPL\""), + "a live call must replace the seeded literal:\n{dsl}" + ); +} From 26f7ff6a8970bb5a5662242d3e7f41d4bea3d5c9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:14:45 -0700 Subject: [PATCH 09/97] refactor(ui_l0): name the scope frame type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clippy was right that a four-deep nested tuple in a signature stops being readable once it grows a third element. `ItemOrigin`, not `Provenance` — that name is taken by §4's copy class, and two unrelated meanings for one word in the same file is how a reader ends up tracing the wrong thing. 848 tests, clippy clean under -D warnings. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index c22805d..91ce6ed 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3953,7 +3953,7 @@ fn realize_inner( // then its declared initial. Without this, an event that wrote card state // would apply and be invisible at the next realization — and a guard on a // state with no value at all would take the wrong branch on first render. - let mut frames: Vec<(String, serde_json::Value, Option<(String, usize)>)> = Vec::new(); + let mut frames: Vec = Vec::new(); for state in &card.states { let value = store .and_then(|s| s.get(CARD_STATE_KEY, &state.path)) @@ -3986,6 +3986,24 @@ fn realize_inner( } /// Bindings introduced by loops and component props, innermost last. +/// A name bound in scope: what it is called, what it holds, and — for a loop +/// binder — where the item came from. +/// +/// Named rather than left as a bare tuple because it grew a third element and +/// clippy was right that four nested types in a signature stop being readable. +type Frame = (String, serde_json::Value, Option); + +/// Which collection a loop binder iterates, and at what index. +/// +/// `ItemOrigin`, not `Provenance` — that name is taken by §4's copy class, and +/// two unrelated meanings for one word in the same file is how a reader ends up +/// tracing the wrong thing. +/// +/// This is what lets a backend answer a source from inside a loop: a path there +/// is rooted at the BINDER, so `m.ticker` has to be rewritten to +/// `movers.0.ticker` before it can be recognised as a source at all. +type ItemOrigin = (String, usize); + struct ValueScope<'a> { /// A bound name, its value, and — for a loop binder — WHERE the item came /// from: the source it iterates and the item's index. @@ -3994,7 +4012,7 @@ struct ValueScope<'a> { /// a loop. `m.ticker` is rooted at the binder, not at `movers`, so without /// this every row in a list falls back to the seeded blob while the detail /// view beside it goes live — which is exactly what happened. - frames: Vec<(String, serde_json::Value, Option<(String, usize)>)>, + frames: Vec, data: &'a serde_json::Value, copies: &'a [CopyDecl], } From 37ff11df1f6cd888d183f40ba77884e36fdfd1e7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:31:03 -0700 Subject: [PATCH 10/97] feat(ui_l0): a loop over a source with no data realizes its declared count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card rendered with NO data blob had no rows at all: `for m, i in movers` iterates what the data holds, and with nothing there the loop produced nothing, so no row was ever lowered and a backend that could have answered every field was never asked. The count is the one thing that backend cannot infer. It can answer field 0 and field 1; it has no way to know how many to ask for. But the card already said: `sys.movers(count: 10)` is a declared row count, so ten placeholder items is what it asked for and the live calls fill them in. Bounded by the realization limit, because the count comes from a generated card and one asking for ten thousand rows should get the cap rather than the request. This is what makes a GENERATED card work at all — a model writes a ledger with no data attached, and until now that rendered as a page of headings with the lists missing. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 43 ++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 91ce6ed..fd92caf 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -4323,10 +4323,25 @@ impl Realizer<'_> { else { return; }; - let Some(collection) = scope.lookup(path) else { - // A source that has not resolved yet renders nothing rather than an - // empty row; pending is the runtime's to surface, not ours to invent. - return; + let collection = match scope.lookup(path) { + Some(c) => c, + // No data for this collection. If it is a SOURCE that declared how + // many it wants, realize that many placeholder items: the card said + // `sys.movers(count: 10)`, so ten rows is what it asked for, and a + // backend that answers the source itself fills them in. + // + // Without this a card rendered with no data blob has no rows at all, + // so nothing inside the loop is ever lowered — and a backend that + // could have answered every field never gets asked. The count is the + // one thing it cannot infer. + None => match self.declared_count(path) { + Some(n) => { + serde_json::Value::Array(vec![serde_json::Value::Object(Default::default()); n]) + } + // Not a counted source: pending is the runtime's to surface, not + // ours to invent. + None => return, + }, }; let Some(items) = collection.as_array() else { self.sink.push( @@ -4439,6 +4454,26 @@ impl Realizer<'_> { /// to look it up. An argument that cannot be resolved drops the binding /// entirely — a fetch with a hole in it is worse than no fetch, since it /// would silently request the wrong thing. + /// How many items a source said it wants, if it is a source and it said. + /// + /// `sys.movers(count: 10)` is the card declaring its own row count. That is + /// the one fact a backend answering the source cannot supply for itself — + /// it can answer field 0, field 1 and so on, but not how many to ask for. + /// + /// Bounded by the realization limit, because the count comes from a + /// generated card and a card asking for ten thousand rows should get the + /// cap rather than the request. + fn declared_count(&self, path: &str) -> Option { + let declaration = self.card.sources.iter().find(|s| s.name == path)?; + let (_, arg) = declaration.args.iter().find(|(n, _)| n == "count")?; + match arg { + SourceArg::Number(n) if *n >= 1.0 => { + Some((*n as usize).min(self.limits.max_collection)) + } + _ => None, + } + } + fn source_binding(&self, path: &str, scope: &ValueScope) -> Option { // Inside a `for`, a path is rooted at the BINDER: `m.ticker`, not // `movers.0.ticker`. The binder's frame records which collection it From d1dcf0915f7bc6625fd656476f89598c3b3fd196 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:12:31 -0700 Subject: [PATCH 11/97] feat(ui_l0): a card lowers what is true now, not what it was handed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight defects with one shape: the card was right, the profile accepted it, the DSL was well-formed, and the screen showed the seed. - A payload bound to a source lowers to a live CALL. The row's text went live while its `value:` resolved at realize time, so the two disagreed — with no seed blob the tap carried `""` and was refused, and with a stale one it carried a different company from the one on screen. - `width`, `align` and a numeric `cond` reach the kit. All three were in the catalog, accepted by the checker, and dropped by the lowering: headlines clipped mid-word, a centred header rendered hard left, and seven forecast rows drew one icon over a week that was not the same every day. - A loop binder passed into a component keeps its provenance, so `for s in feed { StoryRow(story: s) }` — the idiomatic way to write a list — stops falling back to the blob for every row. - A source argument that names another source emits the parent's own call, so a dependent chain does not die at the first hop. - `sys.stock` reads the open from the bar series rather than a key the chart response does not carry. Excluding it left a seeded $181 open under a live $207 price on a +3% day. - `signed_money` redirects to the field that returns the whole string: the currency sits inside the sign, and a prefix cannot express that. - A `value:` the backend can answer goes live, exactly as `text:` does — unless the card declares a `format:`, which is applied to a realized number and would be silently dropped by a call. - `sys.movers` carries `symbols`, so a themed request ranks the universe it named instead of the whole market under a themed title. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 458 ++++++++++++++++-- crates/splash-ui-l0/tests/fixtures/stock.card | 2 +- crates/splash-ui-l0/tests/profile.rs | 236 ++++++++- docs/ui-l0-constructors.toml | 8 +- 4 files changed, 647 insertions(+), 57 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index fd92caf..cc23c73 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2324,7 +2324,21 @@ pub mod catalog { ), ("Panel", &[]), ("Card", &[("on_tap", Event), ("value", Any)]), - ("Col", &[("align", Token(ALIGN)), ("gap", Number)]), + // A column may say how WIDE, because a row of columns has to divide the + // line somehow and only the card knows which column is the one that + // should absorb what is left. A mover row is ticker-and-name beside a + // price: without this every column fits its own text, and a long company + // name wrapped to three lines beside acres of empty space + // ("Dolby Laboratori / es"). This is layout, not styling — it says which + // element yields, not how anything looks. + ( + "Col", + &[ + ("align", Token(ALIGN)), + ("gap", Number), + ("width", TokenOrPath(WIDTH)), + ], + ), ( "Row", &[ @@ -2444,7 +2458,10 @@ pub mod catalog { ("sys.places", &["lat", "lon", "category", "count", "fields"]), ("sys.news", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), - ("sys.movers", &["count", "fields"]), + // `symbols` names the UNIVERSE to rank. Without it the only universe is + // the market-wide day-gainers screener, so "top 10 AI movers" could only + // ever render generic gainers under an AI title -- which is what it did. + ("sys.movers", &["count", "fields", "symbols"]), ("sys.quote", &["ticker", "fields"]), ( "sys.series", @@ -4265,7 +4282,29 @@ impl Realizer<'_> { // data and letting the host capture an unfilled prop. (None, _) => param.default.clone().unwrap_or(serde_json::Value::Null), }; - scope.frames.push((param.name.clone(), value, None)); + // Carry the argument's PROVENANCE into the parameter. + // + // A loop binder records which collection it iterates and at what + // index, which is what lets a row lower to a live call. Passing that + // binder into a component — `for s in feed { StoryRow(story: s) }`, + // the idiomatic way to factor a list — pushed the parameter frame + // with `None`, so `story.title` inside the component had no + // provenance and fell back to the seeded blob. A live card has no + // blob, so every row rendered an em dash while the lead story beside + // it, read directly as `lead.0.title`, went live. + let provenance = match supplied.map(|a| &a.value) { + Some(Operand::Path(p)) | Some(Operand::Predicate { path: p, .. }) => { + let root = p.split('.').next().unwrap_or(p); + scope + .frames + .iter() + .rev() + .find(|(n, _, prov)| n == root && prov.is_some()) + .and_then(|(_, _, prov)| prov.clone()) + } + _ => None, + }; + scope.frames.push((param.name.clone(), value, provenance)); bound += 1; } let instance_key = format!("{key}/{}", component.name); @@ -4463,6 +4502,41 @@ impl Realizer<'_> { /// Bounded by the realization limit, because the count comes from a /// generated card and a card asking for ten thousand rows should get the /// cap rather than the request. + /// The live call for `.`, when the backend answers that + /// source itself. Used for a source argument that depends on another + /// source, which a live card cannot resolve from data. + fn nested_source_call(&self, path: &str, scope: &ValueScope) -> Option { + let (owner, field) = path.split_once('.')?; + let declaration = self.card.sources.iter().find(|s| s.name == owner)?; + let mut args = Vec::new(); + for (name, arg) in &declaration.args { + let resolved = match arg { + SourceArg::Text(t) => t.clone(), + SourceArg::Number(n) => makepad::trim_num(*n), + SourceArg::List(_) if name == "fields" => continue, + SourceArg::List(items) => items.join(","), + // A parent's own argument is usually card STATE — the weather + // fixture reads `sys.geocode(name: state.city)` — so it resolves + // from the scope like any other path. Returning None here + // instead meant the whole chain died at the first hop and every + // reading below it stayed seeded. + // + // One level only: a path that names yet another SOURCE is not + // resolved here, which is what keeps this from recursing. + SourceArg::Path(p) => { + let key = p.strip_prefix("state.").unwrap_or(p); + json_to_key(&scope.lookup(key)?) + } + }; + args.push((name.clone(), resolved)); + } + makepad::vm_call(&SourceBinding { + helper: declaration.helper.clone(), + args, + field: field.to_string(), + }) + } + fn declared_count(&self, path: &str) -> Option { let declaration = self.card.sources.iter().find(|s| s.name == path)?; let (_, arg) = declaration.args.iter().find(|(n, _)| n == "count")?; @@ -4513,13 +4587,30 @@ impl Realizer<'_> { SourceArg::Path(p) => { // `state.x` in a source argument addresses card state; the // view scope names it without the prefix. - let looked_up = scope.lookup(p.strip_prefix("state.").unwrap_or(p))?; - json_to_key(&looked_up) + let key = p.strip_prefix("state.").unwrap_or(p); + match scope.lookup(key) { + Some(v) => json_to_key(&v), + // A source argument that names ANOTHER SOURCE. + // + // `sys.places(lat: place.lat)` depends on `place`, and a + // LIVE card carries no data blob — so the lookup found + // nothing and `?` discarded the whole binding, leaving + // every row an em dash. The dependency is exactly what + // L0 declares, so resolve it the way the backend will: + // emit the parent's own live call in the argument. + None => self.nested_source_call(key, scope)?, + } } SourceArg::Text(t) => t.clone(), SourceArg::Number(n) => makepad::trim_num(*n), - // A field list is structural, not a value the backend passes on. - SourceArg::List(_) => continue, + // `fields:` is structural — which keys the card wants back — and + // is not passed on. Any OTHER list is a value: `symbols: [NVDA, + // AMD]` is the universe to rank, and the model writes it as a + // list because every other list-shaped argument is one. Dropping + // it silently left the card ranking the whole market under + // whatever title it had been given. + SourceArg::List(_) if name == "fields" => continue, + SourceArg::List(items) => items.join(","), }; args.push((name.clone(), resolved)); } @@ -4682,7 +4773,7 @@ pub mod makepad { /// says `sys.quote(ticker:)` and the VM says `sys.stock(symbol, key)`. This /// table is the whole of the translation, and a helper or field missing from /// it means the seeded value is used — never a wrong call. - fn vm_call(binding: &SourceBinding) -> Option { + pub(super) fn vm_call(binding: &SourceBinding) -> Option { let arg = |name: &str| { binding .args @@ -4696,22 +4787,27 @@ pub mod makepad { // What this helper can actually answer, verified against a live // response rather than against its documentation. // - // `open` is NOT here even though `sys.stock` accepts it: it - // resolves `regularMarketOpen`, and that key is absent from the - // Yahoo chart response while `regularMarketDayHigh`, `…DayLow`, - // `regularMarketPrice` and `chartPreviousClose` are all present. - // Emitting the call rendered `$—` on device where the seeded blob - // held a real opening price. Market cap and P/E are absent from - // the helper outright. + // `open` was excluded here for a while, because `sys.stock` + // resolved it from `regularMarketOpen` — a key absent from the + // Yahoo chart response — and emitting the call drew `$—` beside + // two live values. That was worked around at the wrong layer: + // falling back left a SEEDED opening price under a live one, and + // a stale number that looks real is worse than a visible gap. + // The helper reads the bar series now, so the call is emitted. // - // This is the case the fallback exists for, and it took putting - // it on a phone to find — the DSL was well-formed and the unit - // tests were green. + // Market cap and P/E are still absent from the helper outright — + // the chart endpoint does not carry them — so those two fall back + // and always will until something fetches them. + // + // Every entry here is verified against a live response rather + // than against the helper's documentation, which is what the + // `open` episode cost: the DSL was well-formed and the unit tests + // were green, and only a phone showed the number was wrong. let key = match binding.field.as_str() { "last" => "price", "pct" => "changepct", "volume" => "vol", - f @ ("name" | "change" | "high" | "low") => f, + f @ ("name" | "change" | "changemoney" | "high" | "low" | "open") => f, _ => return None, }; Some(format!("sys.stock({symbol:?}, {key:?})")) @@ -4726,6 +4822,122 @@ pub mod makepad { // why `open` is absent from `sys.quote` above: the key is accepted // there and the value is not in the payload, so emitting the call // drew `$—` where the seeded blob held a real price. + // A place NAME resolved to a fact. `geocodenum` for the numbers the + // other helpers take as arguments, `geocode` for the words. + "sys.geocode" => { + let name = arg("name")?; + match binding.field.as_str() { + "lat" | "lon" => Some(format!("sys.geocodenum({name:?}, {:?})", binding.field)), + "name" | "country" | "admin1" | "timezone" => { + Some(format!("sys.geocode({name:?}, {:?})", binding.field)) + } + _ => None, + } + } + // The forecast. L0 names a FIELD; open-meteo wants a path, and the + // daily ones are indexed by the row being drawn. + "sys.weather" => { + let lat = arg("lat")?; + let lon = arg("lon")?; + let (row, field) = match binding.field.split_once('.') { + Some((i, f)) if i.parse::().is_ok() => (i, f), + _ => ("0", binding.field.as_str()), + }; + let path = match field { + "temp" => "current.temperature_2m".to_string(), + "feels" => "current.apparent_temperature".to_string(), + "humidity" => "current.relative_humidity_2m".to_string(), + "wind" => "current.wind_speed_10m".to_string(), + "pressure" => "current.surface_pressure".to_string(), + "hi" => format!("daily.temperature_2m_max.{row}"), + "lo" => format!("daily.temperature_2m_min.{row}"), + "uv" => format!("daily.uv_index_max.{row}"), + "precip" => format!("daily.precipitation_probability_max.{row}"), + // The condition is a WMO code the host turns into a word, so + // the card never states weather it has not observed. + "cond" => { + return Some(format!( + "sys.weatherword({lat}, {lon}, {:?})", + format!("daily.weather_code.{row}") + )) + } + "dayname" => return Some(format!("sys.dayname(\"en\", {row}, \"en\")")), + _ => return None, + }; + Some(format!("sys.weather({lat}, {lon}, {path:?})")) + } + // Sunrise and sunset are STRINGS in the forecast the weather helper + // already fetches; `sys.daylight` answers only the arc progress, so + // the three L0 fields come from two different helpers. + "sys.daylight" => { + let lat = arg("lat")?; + let lon = arg("lon")?; + match binding.field.as_str() { + "rise" => Some(format!("sys.weather({lat}, {lon}, \"daily.sunrise.0\")")), + "set" => Some(format!("sys.weather({lat}, {lon}, \"daily.sunset.0\")")), + "now" => Some(format!("sys.daylight({lat}, {lon})")), + _ => None, + } + } + "sys.moonphase" => match binding.field.as_str() { + // The VM spells it `illum`; L0 spells it out. + "illumination" => Some("sys.moonphase(\"illum\")".to_string()), + "name" | "phase" => Some(format!("sys.moonphase({:?})", binding.field)), + _ => None, + }, + "sys.airquality" => { + let lat = arg("lat")?; + let lon = arg("lon")?; + let path = match binding.field.as_str() { + "aqi" => "current.us_aqi", + "pm25" => "current.pm2_5", + "pm10" => "current.pm10", + "ozone" => "current.ozone", + _ => return None, + }; + Some(format!("sys.airquality({lat}, {lon}, {path:?})")) + } + // A headline feed, indexed like the movers list. + "sys.news" => { + let (index, field) = binding.field.split_once('.')?; + let row: u32 = index.parse().ok()?; + // `offset` is why the feed starts BELOW the lead. Ignoring it + // made row 0 of "latest" the lead story again. + let offset: u32 = arg("offset").and_then(|v| v.parse().ok()).unwrap_or(0); + let index = row + offset; + let key = match field { + "comments" => "comments", + f @ ("title" | "url" | "author" | "points" | "id") => f, + _ => return None, + }; + Some(format!("sys.news({index}, {key:?})")) + } + "sys.photo" => { + let query = arg("query")?; + Some(format!("sys.photo({query:?})")) + } + // The activity app's whole data surface. Missing from this table, a + // declared `sys.places` source fell to the seeded blob -- which a + // LIVE card does not have -- so every venue row rendered an em dash + // and the card looked like a fetch that never landed. Measured: + // "list museums in Kyoto" produced eight rows of "—". + "sys.places" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let lat = arg("lat")?; + let lon = arg("lon")?; + let category = arg("category").unwrap_or_default(); + // L0 says `distance`; the VM answers `dist`. Same translation + // job as `ticker`/`symbol` above. + let key = match field { + "distance" => "dist", + f @ ("name" | "lat" | "lon" | "category") => f, + _ => return None, + }; + Some(format!( + "sys.places({lat}, {lon}, {category:?}, {index}, {key:?})" + )) + } "sys.movers" => { let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; @@ -4735,10 +4947,16 @@ pub mod makepad { "pct" => "changepct", "volume" => "vol", "mktcap" => "marketcap", - f @ ("name" | "change" | "high" | "low" | "open") => f, + f @ ("name" | "change" | "changemoney" | "high" | "low" | "open") => f, _ => return None, }; - Some(format!("sys.movers({index}, {key:?})")) + let universe = binding + .args + .iter() + .find(|(n, _)| n == "symbols") + .map(|(_, v)| v.clone()) + .unwrap_or_default(); + Some(format!("sys.movers({index}, {key:?}, {universe:?})")) } _ => None, } @@ -4880,6 +5098,29 @@ pub mod makepad { suffix, format: format_kind, } = decoration_of(node); + // A `value:` the backend can answer goes LIVE, exactly as `text:` does. + // + // This branch built its string from the REALIZED literal and never + // looked at the bindings, so every number on a card — temperature, + // high/low, price, distance — rendered whatever the host had seeded no + // matter how many capabilities were translated. Only `text:` went live, + // which is why a card could show a live city name above a stale + // temperature. + // + // NOT when the card declares a `format:`. Scaling and currency are + // applied HERE, to a realized number; a live call returns a string this + // side never sees, so going live would silently drop the format — + // measured, `compact` stopped turning 41200000 into "41.2M". A helper + // that formats its own output (`sys.movers` volume) already reads right; + // one that does not keeps the seeded value until the format can travel + // with the call. + if format_kind.is_none() { + if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == "value") { + if let Some(call) = vm_call(binding) { + return decorate(call, &glyph, unit, &suffix); + } + } + } match value { Some(NodeValue::Missing) => "\"—\"".into(), Some(v) => { @@ -5006,9 +5247,25 @@ pub mod makepad { format_kind: Option<&str>, ) -> Option { let (_, binding) = node.bindings.iter().find(|(n, _)| n == "value")?; + // `signed_money` puts the currency INSIDE the sign — `+$7.13` — and a + // prefix cannot express that: `"$" + "+7.13"` is `$+7.13`. This used to + // give up and keep the seeded value, which put a fixture's `+$3.10` + // beside a live `+3.55%`, two numbers describing one move and + // disagreeing. The helper composes the whole string, so the binding is + // redirected to the field that returns it already ordered. + let binding = &if format_kind == Some("signed_money") && binding.field == "change" { + SourceBinding { + field: "changemoney".to_owned(), + ..binding.clone() + } + } else { + binding.clone() + }; let call = vm_call(binding)?; let prefix = match format_kind { - None | Some("signed_pct") => String::new(), + // Already whole: the helper returned the sign and the symbol in the + // right order, so nothing may be prepended. + None | Some("signed_pct") | Some("signed_money") => String::new(), Some("money") => "$".to_owned(), Some(_) => return None, }; @@ -6362,6 +6619,7 @@ pub mod kit { /// loses its temperature bars still looks complete (§1.1). fn kit_fn(role: &str) -> Option<&'static str> { Some(match role { + "Field" => "l0_field", "Surface" => "l0_surface", "Col" => "l0_col", "Row" => "l0_row", @@ -6435,6 +6693,25 @@ pub mod kit { let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { return None; }; + // A payload bound to a source is emitted as a LIVE CALL, not as the + // value realization happened to see. + // + // The two disagree whenever the row's text is live and its payload is + // not, which is every card with no seed blob: the screen said `ATKR` and + // the tap carried `""`, so the write was refused and the row read as + // dead. Worse when a blob IS present and stale — the tap then carries a + // different company from the one the user is looking at. + // + // The result is a DSL EXPRESSION, so the caller emits it unquoted. + if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == "value") { + if let Some(call) = makepad::vm_call(binding) { + let head = serde_json::json!({ "e": event, "k": node.key }); + let head = head.to_string(); + // `{"e":…,"k":…}` → `l0:{"e":…,"k":…,"v":"` + + `"}` + let open = format!("l0:{},\"v\":\"", head.trim_end_matches('}')); + return Some(format!("{open:?} + {call} + {:?}", "\"}")); + } + } let value = match arg(node, "value") { Some(NodeValue::Text(t)) => t.clone(), Some(NodeValue::Number(n)) => makepad::trim_num(*n), @@ -6442,7 +6719,7 @@ pub mod kit { _ => String::new(), }; let json = serde_json::json!({ "e": event, "k": node.key, "v": value }); - Some(format!("l0:{json}")) + Some(format!("{:?}", format!("l0:{json}"))) } /// A visualisation's argument, as the kit takes it. @@ -6460,20 +6737,79 @@ pub mod kit { } } + /// A declared width, as the kit call that applies it. + /// + /// Composed around the role rather than threaded into it, for the reason + /// `tint` is: `width` is optional on every text role, and a parameter on + /// all seven kit functions would have six passing a default forever. + /// + /// `fit` returns `None` because it is already this backend's default, and + /// emitting a wrapper for it would say the same thing twice. The fixed + /// tokens pass the TOKEN and not a pixel count: how wide a rank column is + /// is the theme's answer, and deciding here would put styling in the + /// lowering. + fn width_wrap(node: &UiNode) -> Option<(&'static str, String)> { + let Some(NodeValue::Token(t)) = arg(node, "width") else { + return None; + }; + match t.as_str() { + "fill" => Some(("l0_wide(", ")".into())), + "rank" | "day" | "temp" => Some(("l0_colw(", format!(", {t:?})"))), + _ => None, + } + } + + /// A `Field`'s commit target, carrying what the user typed. + /// + /// `on_commit` is not `on_tap` and must not be wrapped in a hit target: the + /// payload is the TEXT, which does not exist until the moment of commit, so + /// the target is assembled at that moment from the value the backend hands + /// back. `$$` is the placeholder the backend substitutes. + fn commit_target(node: &UiNode) -> Option { + let Some(NodeValue::Event(event)) = arg(node, "on_commit") else { + return None; + }; + let json = serde_json::json!({ "e": event, "k": node.key, "v": "$$" }); + Some(format!("l0:{json}")) + } + fn element(node: &UiNode, depth: usize, out: &mut String) { + // A `Field` carries its own commit target and must NOT be wrapped in a + // tap: a hit target over a text input eats the focus, and the payload + // here is what was typed rather than what the row was bound to. + if node.kind == "Field" { + let _ = write!( + out, + "l0_field({}, {}, {:?})", + makepad::expr_of(node, "text"), + makepad::expr_of(node, "placeholder"), + commit_target(node).unwrap_or_default() + ); + return; + } // 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:?}, "); + // The wrapper sizes like what it WRAPS. + // + // A filling wrapper around an intrinsic thing takes the whole line + // and the thing sits at its left edge. The first chip in a row of + // chips ate the row; then the weather card's hero temperature — a + // tappable `TextHero` inside a centred column — stopped centring, + // because what the column had to place was a full-width wrapper and + // not the six characters inside it. The place name and the icon + // centred correctly beside it, which is what made it look like a + // font problem rather than a layout one. + // + // A text role that ASKED to fill is not intrinsic, so it keeps the + // filling wrapper. + let asked_to_fill = + matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fill"); + let intrinsic = + node.kind == "Chip" || (node.kind.starts_with("Text") && !asked_to_fill); + let f = if intrinsic { "l0_tap_fit" } else { "l0_tap" }; + let _ = write!(out, "{f}({target}, "); element_untapped(node, depth, out); out.push(')'); return; @@ -6481,7 +6817,47 @@ pub mod kit { element_untapped(node, depth, out); } + /// A declared child alignment, as the kit call that applies it. + /// + /// `start` returns `None`: it is the default, and a wrapper that restated it + /// on every container would make the one container that asked for something + /// indistinguishable from the dozen that did not. + /// + /// Which AXIS this means is the kit's to decide, not the lowering's — a + /// column aligns horizontally and a row vertically, and only the thing + /// holding the flow knows which it is. + fn align_wrap(node: &UiNode) -> Option<(&'static str, String)> { + let Some(NodeValue::Token(t)) = arg(node, "align") else { + return None; + }; + match t.as_str() { + "center" | "end" => Some(("l0_aligned(", format!(", {t:?})"))), + _ => None, + } + } + + /// Wrappers COMPOSE around a role — the pattern `tint` established. + /// + /// `width` and `align` are optional on many roles and meaningless on most, + /// so a parameter on every kit function would have nearly all of them + /// carrying a default forever. Each attribute the catalog admits needs an + /// entry HERE or it is accepted by the profile and silently discarded, which + /// is this layer's recurring defect and the reason the list is explicit. fn element_untapped(node: &UiNode, depth: usize, out: &mut String) { + let wraps: Vec<(&'static str, String)> = [width_wrap(node), align_wrap(node)] + .into_iter() + .flatten() + .collect(); + for (open, _) in &wraps { + out.push_str(open); + } + element_unsized(node, depth, out); + for (_, close) in wraps.iter().rev() { + out.push_str(close); + } + } + + fn element_unsized(node: &UiNode, depth: usize, out: &mut String) { let Some(f) = kit_fn(&node.kind) else { let _ = write!(out, "l0_unsupported({:?})", node.kind); return; @@ -6540,13 +6916,21 @@ pub mod kit { "Photo" => { let _ = write!(out, "{f}({})", makepad::expr_of(node, "src")); } + // `cond` is a NUMBER — the WMO code the forecast returns — and this + // matched only `Text` and `Token`, so every one of them fell through + // to `""`. All seven forecast rows drew the same default icon over a + // week that was cloudy, rainy and clear on different days, and + // nothing on screen said so: a wrong icon looks exactly like a right + // one. `scalar_of` takes whichever the card bound. + // + // `size` says WHERE the icon sits — hero block, forecast row, tile — + // and the kit decides how big each of those is. "WeatherIcon" => { - let cond = match arg(node, "cond") { - Some(NodeValue::Text(c)) => format!("{c:?}"), - Some(NodeValue::Token(c)) => format!("{c:?}"), - _ => "\"\"".into(), + let size = match arg(node, "size") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "row".to_owned(), }; - let _ = write!(out, "{f}({cond})"); + let _ = write!(out, "{f}({}, {size:?})", scalar_of(node, "cond")); } "TextStat" => { let _ = write!(out, "{f}({}, {})", makepad::valued(node), direction(node)); diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index c9d3d23..a9373f6 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -40,7 +40,7 @@ view list Col { Panel { for m in movers key m.ticker { Row(align: .center, on_tap: open_quote, value: m.ticker) { - Col(gap: 2) { + Col(gap: 2, width: .fill) { TextRow(text: m.ticker) TextCaption(text: m.name, width: .fill) } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index c1c1089..6b9a09b 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -667,9 +667,14 @@ fn a_realized_card_lowers_to_renderable_dsl() { dsl.contains("\"Kyoto\""), "the bound city should be concrete" ); + // Sources the backend can answer lower to LIVE calls. This asserted the + // opposite — that realization had resolved everything from the seeded blob — + // which was true only while `weather` and `photo` had no translation. A card + // that renders the seed is a card that shows what the host happened to hand + // it, not what is true now. assert!( - !dsl.contains("sys."), - "realization already resolved sources: {dsl}" + dsl.contains("sys.weather("), + "the forecast must be live, not seeded: {dsl}" ); } @@ -960,24 +965,27 @@ fn a_capability_the_backend_cannot_answer_keeps_its_seeded_value() { ); assert!(dsl.contains("58.3"), "P/E keeps its seeded value:\n{dsl}"); - // `open` is the one this test exists for. `sys.stock` ACCEPTS the key, so - // the mapping looked correct and the DSL was well-formed — but it resolves - // `regularMarketOpen`, which the Yahoo chart response does not carry, while - // `regularMarketDayHigh` and `…DayLow` beside it do. On device that rendered - // `$—` where the seeded blob held a real opening price. + // `open` USED to be in this test, as the case the fallback existed for. + // That was the wrong lesson. `sys.stock` accepted the key and resolved + // `regularMarketOpen`, which the Yahoo chart response does not carry — so + // the call drew `$—`, and excluding it left a SEEDED opening price sitting + // under a live one: $181 open beneath a $207 price on a +3% day, arithmetic + // that does not work and was the only thing on screen that said so. // - // A helper accepting a key is not evidence it can answer it, and no unit - // test could have found this: it took a screenshot. + // Falling back is right when nothing upstream can answer. It is not a way to + // paper over a helper reading the wrong key. The open IS in the response, + // under `indicators.quote.0.open.0`, so the helper was fixed and the call is + // emitted like any other. assert!( - !dsl.contains(r#"sys.stock("NVDA", "open")"#), - "open must not be emitted live — the field is absent upstream:\n{dsl}" + dsl.contains(r#"sys.stock("NVDA", "open")"#), + "open resolves upstream now and must be live:\n{dsl}" ); assert!( - dsl.contains("$181"), - "open keeps its seeded value instead:\n{dsl}" + !dsl.contains("$181"), + "the seeded opening price must be gone, not sitting beside a live one:\n{dsl}" ); - // High and low DO resolve, so they must still be live — otherwise the fix - // for `open` could quietly have been "stop emitting calls at all". + // High and low must still be live too — otherwise a regression here could + // pass by quietly emitting no calls at all. assert!( dsl.contains(r#"sys.stock("NVDA", "high")"#) && dsl.contains(r#"sys.stock("NVDA", "low")"#), "high and low resolve upstream and must stay live:\n{dsl}" @@ -4384,8 +4392,12 @@ view root Surface { for p, i in parks key p.id { TextCaption(text: p.distance, s ("makepad", makepad::lower(&root)), ("kit", splash_ui_l0::kit::lower(&root)), ] { + // `sys.places` is answered live now, so the caption is the CALL plus + // the suffix rather than the seeded literal plus the suffix. What this + // test guards is that the suffix survives on a text-valued caption at + // all — it was silently dropped there while working on `value:`. assert!( - dsl.contains("300 m away"), + dsl.contains(r#"+ " away""#), "{name} dropped the suffix from a text-valued caption:\n{dsl}" ); } @@ -4486,6 +4498,121 @@ view root Surface { ); } +/// A declared `width` must REACH the kit — the profile's recurring defect. +/// +/// The catalog admits `width` on five text roles, `check_ui_l0` accepted it, and +/// the kit lowering dropped it: every text role went through one arm that +/// emitted `f(body)` and nothing else. The news list asked for +/// `TextRow(text: story.title, width: .fill)` and got a non-wrapping row, so +/// every story title clipped mid-word — "A new approach to incremental c" — while +/// the same card through `makepad::lower` wrapped correctly. +/// +/// That is what makes this class hard to see: the card is right, the profile +/// accepts it, one backend honours it, and the screen looks like a card whose +/// titles are simply short. +#[test] +fn a_declared_width_must_reach_the_kit() { + const CARD: &str = r#" +# level: L0 +# model: news +source feed sys.news(count: 2, fields: [title, points]) +view root Surface { + Panel { + for s, i in feed key s.title { + Row { + TextRow(text: i, width: .rank) + TextRow(text: s.title, width: .fill) + TextCaption(value: s.points) + } + } + } +} +"#; + let data = serde_json::json!({ + "feed": [{"title": "A new approach to incremental compilation", "points": 288}] + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + + // `.fill` is what lets a long headline wrap rather than run off the edge. + assert!( + dsl.contains("l0_wide(l0_row_text("), + "width: .fill must wrap the role in the kit's filling helper:\n{dsl}" + ); + // A fixed column passes the TOKEN — the theme owns how wide "rank" is, and a + // lowering that emitted a pixel count would be styling. + assert!( + dsl.contains("l0_colw(l0_row_text(") && dsl.contains(", \"rank\")"), + "a fixed-width token must reach the kit as the token:\n{dsl}" + ); + // The caption declared no width, so it must not be wrapped at all: a + // default restated on every node is a default nobody can change. + let caption = dsl + .lines() + .find(|l| l.contains("l0_caption(")) + .expect("the caption lowers"); + assert!( + !caption.contains("l0_wide") && !caption.contains("l0_colw"), + "an undeclared width must emit no wrapper:\n{caption}" + ); +} + +/// `align` and a numeric `cond` must reach the kit — the same defect twice more. +/// +/// Both were found by looking at the screen rather than at the tests, which is +/// the point: the profile ACCEPTS an attribute it lists, so a lowering that +/// drops one produces a card that is valid, renders, and is wrong. The weather +/// card centres its header and draws a different icon per day; without these it +/// rendered hard left with seven identical suns, and nothing failed. +#[test] +fn align_and_a_numeric_condition_must_reach_the_kit() { + const CARD: &str = r#" +# level: L0 +# model: weather +source now sys.weather(lat: 35, lon: 135, fields: [temp, cond]) +source week sys.weather(lat: 35, lon: 135, days: 2, fields: [dayname, cond]) +view root Surface { + Col(align: .center) { + WeatherIcon(cond: now.cond, size: .hero) + TextHero(value: now.temp) + } + for d, i in week.days key d.dayname { + Row(align: .center) { + TextRow(text: d.dayname, width: .day) + WeatherIcon(cond: d.cond, size: .row) + } + } +} +"#; + let data = serde_json::json!({ + "now": {"temp": 18, "cond": 2}, + "week": {"days": [{"dayname": "Mon", "cond": 3}, {"dayname": "Tue", "cond": 0}]} + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + + assert!( + dsl.contains("l0_aligned(l0_col(") && dsl.contains(", \"center\")"), + "align: .center must reach the kit:\n{dsl}" + ); + // A NUMBER. Matching only text and tokens emptied every one of these. + assert!( + dsl.contains("l0_weathericon(2, \"hero\")"), + "a numeric cond and its size must both reach the kit:\n{dsl}" + ); + // And the per-item conditions must DIFFER — one shared value for every row + // is exactly what the bug produced, and a test that only checked "a number + // arrives" would have passed on it. + assert!( + dsl.contains("l0_weathericon(3, \"row\")") && dsl.contains("l0_weathericon(0, \"row\")"), + "each loop item's own condition must reach the kit:\n{dsl}" + ); +} + /// The nav card §1.0 said to write instead of argue about. /// /// The shipping trip planner is 664 lines of Splash DSL and classifies at L2. @@ -4552,11 +4679,11 @@ fn a_live_source_survives_a_loop() { // Row 0 and row 1 must each call for their OWN index — one call reused, or // an index that does not advance, is the failure this catches. assert!( - dsl.contains(r#"sys.movers(0, "symbol")"#), + dsl.contains(r#"sys.movers(0, "symbol", "")"#), "the first row must be live:\n{dsl}" ); assert!( - dsl.contains(r#"sys.movers(1, "symbol")"#), + dsl.contains(r#"sys.movers(1, "symbol", "")"#), "and the second must ask for index 1:\n{dsl}" ); // The seeded tickers must be GONE, or the card shows a stale value beside a @@ -4566,3 +4693,76 @@ fn a_live_source_survives_a_loop() { "a live call must replace the seeded literal:\n{dsl}" ); } + +/// `signed_money` reaches the screen live, beside its own percentage. +/// +/// The two describe ONE move. The percentage was live and the money was not, so +/// the stock header rendered a fixture's `+$3.10` next to a live `+3.55%` — +/// numbers that cannot both be true, on the same line, in the same colour. +/// +/// The cause was a formatting one: `signed_money` puts the currency inside the +/// sign (`+$7.13`) and a lowering can only PREPEND, so `"$" + "+7.13"` would +/// have rendered `$+7.13`. Giving up and keeping the seeded value looked like +/// the safe choice and was the wrong one — a stale number that still looks live +/// is exactly what §4 exists to prevent. +#[test] +fn a_signed_money_change_is_live_not_seeded() { + let data = serde_json::json!({ + "movers": [], "selected": "NVDA", "range": "m1", "env": {"locale":{}}, + "quote": {"name":"NVIDIA","last":184.2,"change":3.1,"pct":1.7,"open":181.0, + "high":185.6,"low":180.2,"volume":41200000.0, + "mktcap":4520000000000.0,"pe":58.3}, + "copy": {"movers":"Top Movers","open":"Open","high":"High","low":"Low", + "volume":"Volume","mktcap":"Mkt Cap","pe":"P/E"} + }); + let report = realize(STOCK, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.expect("root")); + + assert!( + dsl.contains(r#"sys.stock("NVDA", "changemoney")"#), + "the money change must be a live call:\n{dsl}" + ); + // And nothing prepends a second currency symbol — the helper already put it + // where it belongs. + assert!( + !dsl.contains(r#""$" + sys.stock("NVDA", "changemoney")"#), + "the sign is inside the symbol; a prefix would render `$+7.13`:\n{dsl}" + ); + // The seeded figure must be gone rather than sitting beside the live one. + assert!( + !dsl.contains("+$3.10"), + "the seeded money change must not survive:\n{dsl}" + ); + // Its percentage stays live too — a regression that stopped emitting calls + // entirely would otherwise satisfy the assertions above. + assert!( + dsl.contains(r#"sys.stock("NVDA", "changepct")"#), + "the percentage beside it must stay live:\n{dsl}" + ); +} + +/// A list factored into a COMPONENT still lowers to live calls, at its own index. +/// +/// `for s in feed { StoryRow(story: s) }` is the idiomatic way to write a list, +/// and passing the binder into a component dropped the provenance that makes a +/// row live — so every row rendered the seeded blob (an em dash, on a live card) +/// while the lead story beside it, read directly, went live. +#[test] +fn a_list_through_a_component_stays_live() { + let data = serde_json::json!({"env": {"locale": {"lang": "en"}}, "selected": "", + "feed": [{"id":"a","title":"T","author":"x","points":1}, + {"id":"b","title":"U","author":"y","points":2}]}); + let root = realize(NEWS, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + // `feed` declares offset: 1, so its first row is story 1 — not the lead. + assert!( + dsl.contains(r#"sys.news(1, "title")"#), + "row 0 of the feed is story 1:\n{dsl}" + ); + assert!( + dsl.contains(r#"sys.news(2, "title")"#), + "and row 1 is story 2:\n{dsl}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 57869df..ea2e567 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -66,9 +66,12 @@ width = { kind = "width" } on_tap = { kind = "event" } value = { kind = "any" } +# `width` says which column absorbs the leftover space. A row of columns has to +# divide the line somehow, and only the card knows which one should yield. [Col] align = { kind = "token", tokens = ["start", "center", "end"] } gap = { kind = "number" } +width = { kind = "width" } [Row] align = { kind = "token", tokens = ["start", "center", "end", "baseline"] } @@ -289,7 +292,10 @@ args = ["count", "offset", "fields"] args = ["id", "fields"] [sources."sys.movers"] -args = ["count", "fields"] +# `symbols` names the universe to rank; omitted means the whole market. Without +# it a themed request ("top AI movers") could only render market-wide gainers +# under a themed title. +args = ["count", "fields", "symbols"] [sources."sys.quote"] args = ["ticker", "fields"] From 4068630471c1756bd15d47285a280c2788c0891e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:12:57 -0700 Subject: [PATCH 12/97] feat(ui_l0): a read must name a field the card asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card already says what it needs — `fields: [ticker, name, last]` — and nothing compared the two halves of the card against each other. Both ways of getting it wrong passed: misspell a field, or read one that is real but was never requested. Each renders an em dash, which on screen is indistinguishable from a value still in flight. So each capability declares what it can ANSWER, and a `fields:` list is checked against it, as is every read off the root it backs. A loop binder inherits its collection's vocabulary, so `d.dayname` is checked the same way `week.days` is. `aggregate:` pools into the same set. It describes a different level — `week.min_lo` is a property of the week, not of a day — and separating them needs a per-capability schema this does not have. Pooling accepts a read at the wrong level and still rejects a name the card never asked for, which is the defect that ships. The vocabularies live in the TOML as well as in Rust, checked both ways. The TOML is what the agent-facing catalog is generated from, so a vocabulary that lived only in Rust would be one the model writing cards never sees — and it would be refused for naming a field the documentation never offered it. What this does not catch: a field can be declared, accepted, and still unanswerable by a given backend. Closing that needs a conformance test per backend. This table is what such a test would check against; it is not the test. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 260 +++++++++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 140 +++++++++++++++ docs/ui-l0-constructors.toml | 18 ++ 3 files changed, 418 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index cc23c73..7ff5c3b 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2472,6 +2472,115 @@ pub mod catalog { pub fn source(name: &str) -> Option<&'static [&'static str]> { SOURCES.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) } + + /// What each capability can ANSWER: the field names a card may request from + /// it, and — for the ones that take no `fields:` — read off it. + /// + /// Until this existed the word "field" meant something different in four + /// places and none was checked against another: the card's `fields:` list, + /// the shapes `Record`/`Collection` which carried no fields at all, the + /// adapter's hand-written key translation, and whatever the upstream JSON + /// happens to contain. A card could request a field that does not exist and + /// get an em dash, which reads on screen as data that has not arrived yet. + /// + /// These are L0's OWN names, not any backend's. `sys.weather` reaches + /// open-meteo by raw JSON path and `sys.quote` reaches Yahoo through keys + /// like `regularMarketPrice`; translating is the adapter's job, and putting + /// backend spellings here would make the profile depend on one host. + /// + /// WHAT THIS DOES NOT CATCH. A field can be declared here, accepted by the + /// checker, and still unanswerable by a given backend — `sys.quote` has an + /// arm for `open` that resolves a key absent from the response it fetches, + /// which rendered `$—` over a real price. Closing that needs a conformance + /// test per backend asserting it answers everything declared here. This + /// table is what such a test would check against; it is not the test. + pub const ANSWERS: &[(&str, &[&str])] = &[ + ( + "sys.geocode", + &[ + "lat", + "lon", + "name", + "country", + "admin1", + "timezone", + "population", + ], + ), + ( + "sys.weather", + &[ + "temp", + "feels", + "hi", + "lo", + "cond", + "humidity", + "wind", + "pressure", + "uv", + "visibility", + "precip", + "dayname", + "days", + ], + ), + ("sys.daylight", &["rise", "set", "now"]), + ("sys.airquality", &["aqi", "pm25", "pm10", "ozone"]), + ("sys.moonphase", &["phase", "illumination", "name"]), + // A photo is a URL, not a record. No field is readable off it. + ("sys.photo", &[]), + ("sys.locale", &["lang", "temp_unit"]), + ("sys.gps", &["lat", "lon", "accuracy", "ok"]), + ("sys.search", &["id", "name", "lat", "lon", "distance"]), + ("sys.route", &["duration", "distance", "steps"]), + ( + "sys.places", + &["id", "name", "distance", "lat", "lon", "category"], + ), + ( + "sys.news", + &["id", "title", "author", "points", "comments", "url"], + ), + ( + "sys.news_item", + &["id", "title", "author", "points", "comments", "url"], + ), + ( + "sys.movers", + &[ + "ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", + "mktcap", "pe", "currency", "exchange", + ], + ), + ( + "sys.quote", + &[ + "ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", + "mktcap", "pe", "currency", "exchange", + ], + ), + ("sys.series", &["points", "min", "max"]), + ]; + + /// Extra scalars `aggregate:` may ask a capability to compute over the rows + /// it returns. Separate from `ANSWERS` because they sit at a different level + /// — `week.min_lo` is a property of the WEEK, not of a day. + pub const AGGREGATES: &[(&str, &[&str])] = &[ + ("sys.weather", &["min_lo", "max_hi"]), + ("sys.series", &["min", "max"]), + ]; + + pub fn answers(name: &str) -> Option<&'static [&'static str]> { + ANSWERS.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + } + + pub fn aggregates(name: &str) -> &'static [&'static str] { + AGGREGATES + .iter() + .find(|(n, _)| *n == name) + .map_or(&[], |(_, a)| *a) + } } // ──────────────────────────────────────────────────────────────────── validation ── @@ -2989,6 +3098,41 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { ); continue; }; + // A requested field must be one the capability ANSWERS. + // + // `fields:` was accepted as any list of words, so a card could ask for + // something that does not exist and the host would return nothing for + // it — an em dash, which is what a value still in flight looks like too. + // A capability with no vocabulary declared is left alone rather than + // treated as answering nothing. + for (name, arg) in &source.args { + let SourceArg::List(requested) = arg else { + continue; + }; + let (known, what) = match name.as_str() { + "fields" => (catalog::answers(&source.helper), "answer"), + "aggregate" => (Some(catalog::aggregates(&source.helper)), "aggregate"), + _ => continue, + }; + let Some(known) = known else { continue }; + for field in requested { + if !known.iter().any(|k| k == field) { + sink.push( + source.line, + 1, + format!( + "{} cannot {what} {field:?}. It {}", + source.helper, + if known.is_empty() { + "returns a value with no fields at all".to_owned() + } else { + format!("offers: {}", known.join(", ")) + } + ), + ); + } + } + } for (name, arg) in &source.args { // The VALUE is a binding too. Checking only the argument's name let // a source carry any path to the host — the helper was constrained @@ -3129,6 +3273,45 @@ struct Scope { /// Card state, tracked separately so reading it from a component is a /// specific diagnostic rather than "unknown name". forbidden_state: Vec, + /// For a root backed by a source that declared `fields:`, the names a + /// reader may name off it. + /// + /// A card ALREADY says what it needs — `fields: [ticker, name, last]` — and + /// nothing compared the views against it. `m.tickr` type-checked, so did + /// `m.marketcap`, a field the host was never asked to fetch. Both render an + /// em dash, which is indistinguishable from data that has not arrived. + /// + /// A root with no entry is unchecked: `sys.gps` and `sys.locale` take no + /// field list, and a component prop is a record whose shape the card cannot + /// see from here. + fields: Vec<(String, Vec)>, +} + +/// What each source was asked for, as the names a reader may then name. +/// +/// `fields:` and `aggregate:` both land in one set. They describe different +/// levels — `fields:` on a multi-day forecast describes the DAYS while +/// `aggregate:` describes the record around them — and separating them needs a +/// per-capability schema this does not have. Pooling them accepts a read at the +/// wrong level and still rejects a name the card never asked for, which is the +/// defect that ships. +fn declared_fields(card: &Card) -> Vec<(String, Vec)> { + let list = |source: &SourceDecl, want: &str| -> Option> { + source.args.iter().find_map(|(n, a)| match a { + SourceArg::List(v) if n == want => Some(v.clone()), + _ => None, + }) + }; + card.sources + .iter() + .filter_map(|s| { + // No `fields:` at all means the capability does not take one — + // `sys.gps`, `sys.locale`. Unchecked rather than checked as empty. + let mut names = list(s, "fields")?; + names.extend(list(s, "aggregate").unwrap_or_default()); + Some((s.name.clone(), names)) + }) + .collect() } impl Scope { @@ -3151,6 +3334,7 @@ impl Scope { copies: card.copies.clone(), events, forbidden_state: Vec::new(), + fields: declared_fields(card), } } @@ -3178,6 +3362,35 @@ impl Scope { copies: card.copies.clone(), events, forbidden_state: card.states.iter().map(|s| root_of(&s.path)).collect(), + // A component sees the card's sources, so a read off one is checked + // here too. Its own props are not: a `record` prop's shape is + // whatever the caller passes, and §5.2 does not make the caller + // declare it. + fields: declared_fields(card), + } + } + + /// Whether `root` names something whose fields are known, and if so whether + /// `field` is one of them. + fn field_is_declared(&self, root: &str, field: &str) -> Option { + let (_, names) = self.fields.iter().find(|(r, _)| r == root)?; + Some(names.iter().any(|n| n == field)) + } + + /// Record that a source exposes a nested collection under `field`. + /// + /// Derived from the card rather than declared: `for d, i in week.days` is + /// what says a forecast has `days`. Without this the loop over it would be + /// rejected by the very rule that makes the read of `d.dayname` safe. + fn note_structural(&mut self, path: &str) { + let (root, field) = match path.split_once('.') { + Some(parts) => parts, + None => return, + }; + if let Some((_, names)) = self.fields.iter_mut().find(|(r, _)| r == root) { + if !names.iter().any(|n| n == field) { + names.push(field.to_owned()); + } } } @@ -3244,7 +3457,21 @@ fn walk( .. }) = element.args.first() { + // `for d, i in week.days` says a forecast HAS days. Register it + // before checking, or the rule that makes `d.dayname` safe + // rejects the loop that introduces `d`. + scope.note_structural(p); check_path(p, scope, *line, *column, sink); + // The item binder answers for whatever the collection's source + // was asked for. `i` is the index and has no fields, so only the + // first binder is bound. + if let Some(binder) = element.binders.first() { + if let Some((_, names)) = + scope.fields.iter().find(|(r, _)| *r == root_of(p)).cloned() + { + scope.fields.push((binder.clone(), names)); + } + } } } "when" => { @@ -3760,6 +3987,39 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut // The whole path, so a declared name can be matched by its own segments. if scope.knows(path) { + // Known root, but is the FIELD one the card asked for? + // + // A card declares `fields: [ticker, name, last]` and nothing compared + // the views against it, so `m.tickr` and `m.marketcap` both passed — + // one a typo, one a field the host was never asked to fetch. Each + // renders an em dash, which on screen is indistinguishable from data + // still in flight. + if let Some((root, rest)) = path.split_once('.') { + // A numeric segment is an INDEX into a collection, not a field: + // `lead.0.title` takes the first story and reads its title. Treating + // it as a field rejected every indexed read in the news card. + let mut segments = rest + .split('.') + .skip_while(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())); + let field = segments.next().unwrap_or(""); + // Only the first field segment: deeper paths reach into a record + // whose own shape needs the per-capability schema. + if !field.is_empty() && !field.starts_with('$') && !field.starts_with('[') { + if let Some(false) = scope.field_is_declared(root, field) { + let (_, names) = scope.fields.iter().find(|(r, _)| r == root).unwrap(); + sink.push( + line, + column, + format!( + "{root:?} was not asked for {field:?}. A source answers only the \ + fields its declaration requests, so this renders as missing — \ + add it to `fields:` or read one of: {}", + names.join(", ") + ), + ); + } + } + } return; } if scope.forbidden_state.contains(&root) { diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 6b9a09b..f068c63 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -2091,6 +2091,73 @@ fn the_source_catalog_matches_the_toml_spec() { } } +/// The field vocabularies must agree between Rust and the TOML too. +/// +/// The TOML is what the agent-facing catalog is GENERATED from, so a vocabulary +/// that lives only in Rust is one the model writing cards never sees — it would +/// be refused for naming a field the documentation never offered it. The +/// arguments have been checked both ways since they existed; the fields are new +/// and need the same discipline or they drift the first time one is added. +#[test] +fn the_field_vocabularies_match_the_toml_spec() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + let mut documented: Vec<(String, String, Vec)> = Vec::new(); + let mut current: Option = None; + for line in TOML.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("[sources.\"") { + current = rest.strip_suffix("\"]").map(|s| s.to_string()); + } else if line.starts_with('[') { + current = None; + } else if let Some(name) = ¤t { + for (key, label) in [("answers = [", "answers"), ("aggregates = [", "aggregates")] { + if let Some(rest) = line.strip_prefix(key) { + documented.push(( + name.clone(), + label.to_string(), + rest.trim_end_matches(']') + .split(',') + .map(|f| f.trim().trim_matches('"').to_string()) + .filter(|f| !f.is_empty()) + .collect(), + )); + } + } + } + } + + // Every capability declares a vocabulary, even an empty one: silence would + // be indistinguishable from "not written down yet", and the checker treats + // an absent vocabulary as "do not check". + assert_eq!( + documented.iter().filter(|(_, k, _)| k == "answers").count(), + catalog::ANSWERS.len(), + "every capability must document its answerable fields" + ); + + for (name, kind, fields) in &documented { + let rust: &[&str] = match kind.as_str() { + "answers" => catalog::answers(name) + .unwrap_or_else(|| panic!("{name:?} documents fields but Rust declares none")), + _ => catalog::aggregates(name), + }; + assert_eq!( + rust, + fields.as_slice(), + "{kind} for {name:?} disagree between Rust and the TOML" + ); + } + for (name, fields) in catalog::ANSWERS { + assert!( + documented + .iter() + .any(|(n, k, f)| n == name && k == "answers" && f == fields), + "{name:?} answers {fields:?} in Rust and the TOML does not say so" + ); + } +} + // ─── parse-then-discard: forms recognised but whose values were dropped ────── // // Every one of these parsed cleanly and then lost the value. That is the single @@ -4694,6 +4761,79 @@ fn a_live_source_survives_a_loop() { ); } +/// A read must name a field the card ASKED FOR. +/// +/// A source answers `fields:` and nothing else, so a view that names anything +/// outside that list renders an em dash — which on screen is indistinguishable +/// from a value still in flight. Two ways to get there and both used to pass: +/// misspell a field, or read one that is real but was never requested. +/// +/// This needs no per-capability schema. The card already says what it needs; +/// nothing compared the two halves of the card against each other. +#[test] +fn a_read_must_name_a_field_the_source_was_asked_for() { + let card = |body: &str| { + format!( + "# level: L0\n# model: stock\n\ + source movers sys.movers(count: 3, fields: [ticker, name, last])\n\ + view root Surface {{\n for m, i in movers key m.ticker {{\n Row {{ {body} }}\n }}\n}}\n" + ) + }; + let why = |src: &str| -> String { + check_ui_l0_named("card", src) + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect::>() + .join(" | ") + }; + + // The honest case still passes. + assert!( + check_ui_l0_named("card", &card("TextRow(text: m.ticker)")).valid, + "a requested field must be readable" + ); + + // A typo. The field does not exist anywhere. + let typo = why(&card("TextRow(text: m.tickr)")); + assert!( + typo.contains("tickr") && typo.contains("ticker"), + "a misspelled field must be refused and the alternatives offered: {typo}" + ); + + // Real field, never requested — the subtler half, and the one a spell-check + // would miss. `sys.movers` can answer `marketcap`; this card did not ask. + let unasked = why(&card("TextValue(value: m.marketcap)")); + assert!( + unasked.contains("marketcap"), + "a field the card never requested must be refused: {unasked}" + ); + + // An INDEX is not a field. `lead.0.title` takes the first story and reads + // its title, and treating `0` as a field rejected every indexed read in the + // news card — a false positive that would have made the rule unshippable. + const INDEXED: &str = "# level: L0\n# model: news\n\ + source lead sys.news(count: 1, fields: [title, author])\n\ + view root Surface { TextTitle(text: lead.0.title) }\n"; + assert!( + check_ui_l0_named("card", INDEXED).valid, + "an indexed read must survive: {}", + why(INDEXED) + ); + + // A source that takes no field list is not checked as if it had an empty + // one — `sys.locale` has no `fields:` argument at all. + const NO_FIELDS: &str = "# level: L0\n# model: weather\n\ + source env.locale sys.locale()\n\ + state u { shape: text, initial: env.locale.temp_unit }\n\ + view root Surface { TextRow(text: u) }\n"; + assert!( + check_ui_l0_named("card", NO_FIELDS).valid, + "a capability with no field list must stay unchecked: {}", + why(NO_FIELDS) + ); +} + /// `signed_money` reaches the screen live, beside its own percentage. /// /// The two describe ONE move. The percentage was live and the money was not, so diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index ea2e567..0c3a6d1 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -238,24 +238,32 @@ tokens = ["fill", "fit", "day", "rank", "temp"] [sources."sys.geocode"] args = ["name"] +answers = ["lat", "lon", "name", "country", "admin1", "timezone", "population"] [sources."sys.weather"] args = ["lat", "lon", "days", "fields", "aggregate"] +answers = ["temp", "feels", "hi", "lo", "cond", "humidity", "wind", "pressure", "uv", "visibility", "precip", "dayname", "days"] +aggregates = ["min_lo", "max_hi"] [sources."sys.daylight"] args = ["lat", "lon"] +answers = ["rise", "set", "now"] [sources."sys.airquality"] args = ["lat", "lon"] +answers = ["aqi", "pm25", "pm10", "ozone"] [sources."sys.moonphase"] args = ["lat", "lon"] +answers = ["phase", "illumination", "name"] [sources."sys.photo"] args = ["query"] +answers = [] [sources."sys.locale"] args = [] +answers = ["lang", "temp_unit"] # Nearby venues of one category, around a point. The card names WHERE and WHAT # KIND; the host answers with a collection. @@ -269,36 +277,46 @@ args = [] # something else is. [sources."sys.gps"] args = [] +answers = ["lat", "lon", "accuracy", "ok"] # Free-text place search, for what a `Field` collected. The query is a path into # declared state, so the card cannot search for something it did not first # receive from the user. [sources."sys.search"] args = ["query", "count", "fields"] +answers = ["id", "name", "lat", "lon", "distance"] # A trip's facts — duration, distance, the step list. The ROUTE ITSELF is the # `Map` widget's to fetch; this is what the card needs to write on the screen # beside it, and asking twice is cheaper than making the card carry a polyline. [sources."sys.route"] args = ["from", "to", "via", "mode", "fields"] +answers = ["duration", "distance", "steps"] [sources."sys.places"] args = ["lat", "lon", "category", "count", "fields"] +answers = ["id", "name", "distance", "lat", "lon", "category"] [sources."sys.news"] args = ["count", "offset", "fields"] +answers = ["id", "title", "author", "points", "comments", "url"] [sources."sys.news_item"] args = ["id", "fields"] +answers = ["id", "title", "author", "points", "comments", "url"] [sources."sys.movers"] # `symbols` names the universe to rank; omitted means the whole market. Without # it a themed request ("top AI movers") could only render market-wide gainers # under a themed title. args = ["count", "fields", "symbols"] +answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] [sources."sys.quote"] args = ["ticker", "fields"] +answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] [sources."sys.series"] args = ["ticker", "range", "points", "fields", "aggregate"] +answers = ["points", "min", "max"] +aggregates = ["min", "max"] From db171f241f8935fb443deb9a2d6843e27e67d512 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:13:26 -0700 Subject: [PATCH 13/97] feat(ui_l0): a durable collection is a source, written through a transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile §5.12. State is a CURSOR into data rather than data itself: `selected = "NVDA"` is which entity the card is looking at. Once that is clear, a watchlist is a persisted set of the same thing `selected` holds one of — not a new kind of storage, the same kind of reference at a different lifetime. That is the fourth value §5.10's lifetime axis was missing, and the reason §8 question 8 could not be answered under a state mechanism. What is stored is REFERENCES, never facts. A watchlist is `["NVDA"]`, not a list of rows carrying names and prices: a stored price is wrong within a second of being written, and a stale number that still looks live is precisely what §4 exists to prevent. The host joins the stored references to live quotes and returns the rows, so the card never sees the store and L0 needs no join operator. One grammar addition: an event may target a source rather than a state. `append($value)` and `remove($value)` are total for the reason `cycle` is — the card names the operation and the runtime performs it. Dispatch reports the write instead of performing it, and the source goes stale so §5.9's lifecycle re-fetches it. The confinement argument is untouched: L0 is safe because it has no expression form to evaluate, not because taps are inert. A capability absent from the writable table is read-only, which is why `sys.prefs` is. A preference write must name WHICH preference and a transition targets a bare source name, so `prefs: set($value)` cannot say `units`. That needs a dotted target, which is grammar this does not have; declaring the capability writable first would ship a write nobody could aim. Reorder is absent for the same kind of reason — `move(ticker, position)` needs two payloads and `value:` carries one. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 324 +++++++++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 172 +++++++++++++- docs/ui-l0-constructors.toml | 50 +++++ docs/ui-profile-l0.md | 157 ++++++++++++- 4 files changed, 686 insertions(+), 17 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 7ff5c3b..7aee6b5 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -895,10 +895,23 @@ enum Form { Toggle, Cycle, Clear, + /// §5.12's two collection forms. Unlike the four above, these do NOT write a + /// cell: the target is a source backed by a durable store, and dispatch + /// hands the write to the host. Total for the same reason `cycle` is — the + /// card names the operation and the runtime performs it. + Append, + Remove, /// Anything else — an expression, which L0 has no form for. NotTotal(String), } +impl Form { + /// Whether this form writes a durable collection rather than a state cell. + fn is_collection(&self) -> bool { + matches!(self, Form::Append | Form::Remove) + } +} + /// What `set(…)` assigns. Each is a value the runtime already holds; none is /// computed, so the transition stays total. #[derive(Clone, Debug, PartialEq)] @@ -1651,6 +1664,42 @@ impl<'a> Parser<'a> { } (Form::Cycle, members) } + // §5.12: the two collection forms. Both take `$value` and nothing + // else — `remove($value)` matches an exact payload rather than + // evaluating a predicate, which is what keeps them the same shape as + // `cycle`: runtime logic the card NAMES and does not write. + "append" | "remove" => { + let verb = t.text.clone(); + self.at += 1; + let mut ok = false; + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + if self.peek().is_some_and(|t| t.is_punct("$")) { + self.at += 1; + match self.ident() { + Some(name) if name == "value" => ok = true, + Some(other) => self.sink.at( + &t, + format!("`${other}` is not a payload; the only one is `$value`"), + ), + None => {} + } + } + self.expect_punct(")"); + } + if ok { + if verb == "append" { + (Form::Append, Vec::new()) + } else { + (Form::Remove, Vec::new()) + } + } else { + ( + Form::NotTotal(format!("{verb} takes $value and nothing else")), + Vec::new(), + ) + } + } other => { self.at += 1; (Form::NotTotal(other.to_string()), Vec::new()) @@ -2467,6 +2516,21 @@ pub mod catalog { "sys.series", &["ticker", "range", "points", "fields", "aggregate"], ), + // §5.12's durable capabilities. `sys.watchlist` takes no selector — it + // IS the user's list — and the host joins the stored tickers to live + // quotes, so a card asks for the fields it wants to show and never + // learns that a store exists. + ("sys.watchlist", &["fields"]), + ("sys.prefs", &["fields"]), + // Free-text ticker lookup, so a card can offer something the top-movers + // list does not happen to contain. `sys.search` is the PLACE search and + // answers a different question; naming them apart is what stops a card + // asking a geocoder for a company. + ("sys.symbol_search", &["query", "count", "fields"]), + // The user's saved places. Same shape as `sys.watchlist`: no selector, + // because it IS the list, and the host joins each stored place to a + // live reading. + ("sys.cities", &["fields"]), ]; pub fn source(name: &str) -> Option<&'static [&'static str]> { @@ -2561,6 +2625,31 @@ pub mod catalog { ], ), ("sys.series", &["points", "min", "max"]), + // The host joins the stored tickers to live quotes, so a watchlist row + // answers everything a quote does. The STORE holds only the ticker. + ( + "sys.watchlist", + &[ + "ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", + "mktcap", "pe", "currency", "exchange", + ], + ), + ("sys.prefs", &["units", "range"]), + // Verified against the live endpoint, not its documentation: `longname` + // comes back null for plenty of listings, so `name` falls back to + // `shortname` in the helper. `kind` distinguishes an equity from a + // crypto or an ETF, because a search for "nvid" returns all three and a + // card that cannot say which is offering the user a coin. + ("sys.symbol_search", &["ticker", "name", "exchange", "kind"]), + // `name`, `lat` and `lon` come from the store — they IDENTIFY the place. + // Everything else is a reading fetched at those coordinates each time it + // is read, so a saved city can never show yesterday's temperature. + ( + "sys.cities", + &[ + "name", "lat", "lon", "temp", "feels", "hi", "lo", "cond", "humidity", "wind", + ], + ), ]; /// Extra scalars `aggregate:` may ask a capability to compute over the rows @@ -2571,6 +2660,35 @@ pub mod catalog { ("sys.series", &["min", "max"]), ]; + /// Capabilities backed by a durable store, and the transitions each accepts + /// (§5.12). + /// + /// A capability absent from this table is READ-ONLY: a card may bind it and + /// may not write it. That is the safe default — a fetch is not a thing a tap + /// should be able to mutate, and listing the writable ones explicitly means + /// granting the power is a deliberate edit rather than an omission. + /// + /// What is stored is REFERENCES, never facts. `sys.watchlist` holds tickers; + /// the values beside them on screen are fetched every time. §4's no-facts + /// rule does not stop applying because the data went to disk. + pub const MUTABLE: &[(&str, &[&str])] = &[ + ("sys.watchlist", &["append", "remove"]), + ("sys.cities", &["append", "remove"]), + // `sys.prefs` is READ-ONLY here, deliberately and temporarily. + // + // A preference write has to name WHICH preference, and a transition's + // target is a bare source name: `event set_units { prefs: set($value) }` + // says nothing about `units`. Naming it needs a dotted target + // (`prefs.units: set($value)`), which is grammar this slice does not + // have. Declaring the capability writable before that exists would ship + // a write nobody could aim, so a card can read a preference and not yet + // change one. + ]; + + pub fn mutable(name: &str) -> Option<&'static [&'static str]> { + MUTABLE.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + } + pub fn answers(name: &str) -> Option<&'static [&'static str]> { ANSWERS.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) } @@ -2696,6 +2814,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &card_readable, &card_events, &card.copies, + &card.sources, sink, ); for component in &card.components { @@ -2727,6 +2846,10 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &readable, &events, &card.copies, + // A component may write a durable collection too: the card's + // sources are in scope for it (§5.3 forbids card STATE, not + // sources), so the same rules must reach here. + &card.sources, sink, ); } @@ -2790,10 +2913,72 @@ fn check_event_batch( readable: &[String], event_names: &[String], copies: &[CopyDecl], + sources: &[SourceDecl], sink: &mut Diagnostics, ) { for event in events { for transition in &event.transitions { + // §5.12: a transition may target a SOURCE when that source is backed + // by a durable store. The write leaves the card entirely — dispatch + // hands it to the host — so none of the shape checks below apply, + // and this is settled before them rather than inside them. + if let Some(source) = sources.iter().find(|s| s.name == transition.target) { + let accepted = catalog::mutable(&source.helper); + let verb = match &transition.form { + Form::Append => "append", + Form::Remove => "remove", + Form::Set(_) => "set", + Form::Clear => "clear", + Form::Toggle => "toggle", + Form::Cycle => "cycle", + Form::NotTotal(_) => "", + }; + match accepted { + // Read-only by default: binding a fetch is not permission to + // write it, and a capability earns that by being listed. + None => sink.push( + transition.line, + transition.column, + format!( + "{:?} is a source and cannot be written — {} is read-only. \ + Only a capability backed by a durable store accepts a \ + transition (profile §5.12)", + transition.target, source.helper + ), + ), + Some(verbs) if !verbs.contains(&verb) => sink.push( + transition.line, + transition.column, + format!( + "{} does not accept `{verb}`; it accepts: {}", + source.helper, + verbs.join(", ") + ), + ), + Some(_) => {} + } + continue; + } + // A collection form on anything else is refused HERE rather than + // falling through to the shape checks, which would report it as a + // bad `set` on a state and send the reader looking in the wrong + // place. There is no list-shaped cell: §5.12 is deliberate that a + // durable collection is a source, because a card is regenerated per + // request and a cell would be empty again the next time. + if transition.form.is_collection() { + sink.push( + transition.line, + transition.column, + format!( + "`append`/`remove` write a durable collection, and {:?} is not one. \ + Declare it as a source over a store-backed capability — card state \ + cannot hold a list, and would be reset on the next request anyway \ + (profile §5.12)", + transition.target + ), + ); + continue; + } let Some(state) = states.iter().find(|s| s.path == transition.target) else { sink.push( transition.line, @@ -5218,6 +5403,51 @@ pub mod makepad { .unwrap_or_default(); Some(format!("sys.movers({index}, {key:?}, {universe:?})")) } + // §5.12. Indexed like `sys.movers` — the host holds the user's list + // in order, so a row is addressed by position — but every value + // beside the ticker is FETCHED. The store holds only the reference, + // which is why this lowers to a live call at all rather than to the + // realized literal: a stored price would render stale and look live. + "sys.watchlist" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + "ticker" => "symbol", + "last" => "price", + "pct" => "changepct", + "volume" => "vol", + "mktcap" => "marketcap", + f @ ("name" | "change" | "changemoney" | "high" | "low" | "open") => f, + _ => return None, + }; + Some(format!("sys.watchlist({index}, {key:?})")) + } + // A saved place. `name`/`lat`/`lon` identify it and come from the + // store; the readings beside them are fetched, which is why this + // lowers to a call rather than to the realized literal. + "sys.cities" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + f @ ("name" | "lat" | "lon" | "temp" | "feels" | "hi" | "lo" | "cond" + | "humidity" | "wind") => f, + _ => return None, + }; + Some(format!("sys.cities({index}, {key:?})")) + } + // The query is a path into declared state, so it reaches the helper + // as whatever the user committed — the card never builds it. + "sys.symbol_search" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let query = arg("query")?; + let key = match field { + "ticker" => "symbol", + f @ ("name" | "exchange" | "kind") => f, + _ => return None, + }; + Some(format!("sys.symbol_search({query:?}, {index}, {key:?})")) + } _ => None, } } @@ -6071,7 +6301,26 @@ pub fn dispatch_with_data( payload: Option<&serde_json::Value>, data: &serde_json::Value, ) -> bool { - !dispatch_writes(source, store, instance_key, event, payload, data).is_empty() + let (changed, durable) = dispatch_writes(source, store, instance_key, event, payload, data); + !changed.is_empty() || !durable.is_empty() +} + +/// A write to a durable collection that the HOST must perform (§5.12). +/// +/// L0 reports it and never performs it, exactly as `source_plan` reports a fetch +/// it never performs. The card names a capability; only the host knows what +/// answers it, and only the host owns the store. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionWrite { + /// The declared source name the card wrote through — `watch`. + pub source: String, + /// The capability behind it — `sys.watchlist`. + pub helper: String, + /// `append`, `remove`, `set` or `clear`, already checked against what the + /// capability declares it accepts. + pub op: String, + /// The payload the tapped element carried. Empty for `clear`. + pub value: String, } /// What a dispatch did, and what it obliges the host to do next. @@ -6093,6 +6342,9 @@ pub struct DispatchOutcome { /// The declared sources those writes invalidate, following the dependency /// cascade. The host should refetch these before the next realization. pub stale: Vec, + /// §5.12 writes the host must perform against its durable store, in order. + /// L0 reports them and never performs them. + pub writes: Vec, } /// Apply an event and report what it invalidated. @@ -6108,18 +6360,29 @@ pub fn dispatch_reporting( payload: Option<&serde_json::Value>, data: &serde_json::Value, ) -> DispatchOutcome { - let changed = dispatch_writes(source, store, instance_key, event, payload, data); - if changed.is_empty() { + let (changed, writes) = dispatch_writes(source, store, instance_key, event, payload, data); + if changed.is_empty() && writes.is_empty() { return DispatchOutcome::default(); } // `stale_sources` takes the changed names; a write to `selected` is a change // to `selected` as a source argument reads it. - let names: Vec<&str> = changed.iter().map(String::as_str).collect(); - let stale = stale_sources(source, &names); + // + // A durable write invalidates the source it wrote THROUGH, and anything + // reading that source, by the same cascade. Appending to a watchlist and not + // refetching it is a card that swallowed the tap. + let mut names: Vec<&str> = changed.iter().map(String::as_str).collect(); + names.extend(writes.iter().map(|w| w.source.as_str())); + let mut stale = stale_sources(source, &names); + for w in &writes { + if !stale.contains(&w.source) { + stale.push(w.source.clone()); + } + } DispatchOutcome { applied: true, changed, stale, + writes, } } @@ -6130,10 +6393,10 @@ fn dispatch_writes( event: &str, payload: Option<&serde_json::Value>, data: &serde_json::Value, -) -> Vec { +) -> (Vec, Vec) { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { - return Vec::new(); + return (Vec::new(), Vec::new()); }; let card = Parser::new(&tokens, &mut sink).parse_card(); @@ -6170,11 +6433,11 @@ fn dispatch_writes( &instance_key[..boundary], Some((component, schema_id(component))), ), - None => return Vec::new(), + None => return (Vec::new(), Vec::new()), }, None => match card.events.iter().find(|e| e.name == event) { Some(d) => (d, &card.states, CARD_STATE_KEY, None), - None => return Vec::new(), + None => return (Vec::new(), Vec::new()), }, }; @@ -6187,10 +6450,41 @@ fn dispatch_writes( // a half-applied event, which is the thing atomicity exists to prevent. // Stage every write first, commit only if the whole batch resolves. let mut staged: Vec<(String, serde_json::Value)> = Vec::new(); + // §5.12 writes are staged alongside the cells, so §3's atomicity covers both: + // a batch that sets a preference AND appends to a list must do neither if the + // preference cannot be resolved. + let mut durable: Vec = Vec::new(); for transition in &declared.transitions { + // A source target leaves the card. Nothing is written here — the host + // owns the store — so this records the write and moves on, the same way + // `source_plan` records a fetch it will never perform. + if let Some(decl) = card.sources.iter().find(|s| s.name == transition.target) { + let op = match &transition.form { + Form::Append => "append", + Form::Remove => "remove", + Form::Clear => "clear", + Form::Set(_) => "set", + _ => return (Vec::new(), Vec::new()), + }; + // All but `clear` carry the payload, so a tap without one is a no-op + // rather than a write of nothing — the same choice `set` makes. + let value = match (op, payload) { + ("clear", _) => String::new(), + (_, Some(serde_json::Value::String(v))) => v.clone(), + (_, Some(v)) => v.to_string(), + (_, None) => return (Vec::new(), Vec::new()), + }; + durable.push(CollectionWrite { + source: decl.name.clone(), + helper: decl.helper.clone(), + op: op.to_owned(), + value, + }); + continue; + } let Some(state) = states.iter().find(|s| s.path == transition.target) else { - return Vec::new(); + return (Vec::new(), Vec::new()); }; // The declared initial, path-valued or not. `clear` and a first `cycle` // both fell back to the SHAPE default here, so a card whose initial @@ -6229,10 +6523,10 @@ fn dispatch_writes( // that has to be checked at runtime: every other set form is // decided against the declared shape at check time. Some(v) if value_fits_shape(&state.shape, v) => v.clone(), - Some(_) => return Vec::new(), + Some(_) => return (Vec::new(), Vec::new()), // No payload is a no-op rather than a silent clear: falling // back to the initial would look like a deliberate reset. - None => return Vec::new(), + None => return (Vec::new(), Vec::new()), }, // A path READS. Treating it as the payload meant // `n: set(config.answer)` wrote whatever the tap carried — or @@ -6241,13 +6535,13 @@ fn dispatch_writes( Some(v) if value_fits_shape(&state.shape, &v) => v, // Unresolvable or ill-shaped: the batch cannot complete, and // §3 says a batch is all or nothing. - _ => return Vec::new(), + _ => return (Vec::new(), Vec::new()), }, SetSource::Token(t) | SetSource::Text(t) => serde_json::Value::String(t.clone()), SetSource::Num(n) => serde_json::Value::from(*n), SetSource::Bool(b) => serde_json::Value::Bool(*b), }, - _ => return Vec::new(), + _ => return (Vec::new(), Vec::new()), }; staged.push((transition.target.clone(), next)); } @@ -6262,7 +6556,7 @@ fn dispatch_writes( written.push(target); } } - written + (written, durable) } // ───────────────────────────────────────────────────────────────── source plan ── diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index f068c63..9b4247c 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -2111,7 +2111,11 @@ fn the_field_vocabularies_match_the_toml_spec() { } else if line.starts_with('[') { current = None; } else if let Some(name) = ¤t { - for (key, label) in [("answers = [", "answers"), ("aggregates = [", "aggregates")] { + for (key, label) in [ + ("answers = [", "answers"), + ("aggregates = [", "aggregates"), + ("writes = [", "writes"), + ] { if let Some(rest) = line.strip_prefix(key) { documented.push(( name.clone(), @@ -2140,6 +2144,11 @@ fn the_field_vocabularies_match_the_toml_spec() { let rust: &[&str] = match kind.as_str() { "answers" => catalog::answers(name) .unwrap_or_else(|| panic!("{name:?} documents fields but Rust declares none")), + // Which transitions a store-backed capability accepts (§5.12). A + // capability the TOML says is writable and Rust does not would be a + // card refused for doing what the documentation offered. + "writes" => catalog::mutable(name) + .unwrap_or_else(|| panic!("{name:?} documents writes but Rust says read-only")), _ => catalog::aggregates(name), }; assert_eq!( @@ -2156,6 +2165,17 @@ fn the_field_vocabularies_match_the_toml_spec() { "{name:?} answers {fields:?} in Rust and the TOML does not say so" ); } + // The other direction for writes. A capability that is writable in Rust and + // silent in the TOML grants a power the documentation never mentions, which + // is the worse half of a drift — the agent never learns it exists. + for (name, verbs) in catalog::MUTABLE { + assert!( + documented + .iter() + .any(|(n, k, v)| n == name && k == "writes" && v == verbs), + "{name:?} accepts {verbs:?} in Rust and the TOML does not say so" + ); + } } // ─── parse-then-discard: forms recognised but whose values were dropped ────── @@ -4834,6 +4854,156 @@ fn a_read_must_name_a_field_the_source_was_asked_for() { ); } +/// §5.12: a transition may target a source backed by a durable store. +/// +/// The card that motivates it — tap a mover, keep it. Every earlier attempt at +/// this had to put the list in card state, where `check_card` accepted it, +/// dispatch reported success, and the list rendered empty: `set($value)` wrote a +/// string into a collection-shaped cell. Worse, a card is regenerated per +/// request, so even a working list-shaped cell would have been empty the next +/// time the user asked — which is why this is a source and not a longer-lived +/// kind of state. +#[test] +fn a_durable_collection_is_written_through_a_source() { + const CARD: &str = r#" +# level: L0 +# model: stock +source movers sys.movers(count: 5, fields: [ticker, name, last]) +source watch sys.watchlist(fields: [ticker, name, last, pct]) + +event keep { watch: append($value) } +event drop { watch: remove($value) } + +view root Surface { + Panel { + for m, i in movers key m.ticker { + Row(on_tap: keep, value: m.ticker) { TextRow(text: m.ticker) } + } + } + Panel { + for w, i in watch key w.ticker { + Row(on_tap: drop, value: w.ticker) { TextRow(text: w.ticker) } + } + } +} +"#; + let why = |src: &str| -> String { + check_ui_l0_named("card", src) + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect::>() + .join(" | ") + }; + assert!( + check_ui_l0_named("card", CARD).valid, + "a watchlist card must be admitted: {}", + why(CARD) + ); + + // A source NOT backed by a store is read-only. Binding a fetch is not + // permission to write it, and the default has to be refusal. + let readonly = CARD.replace("watch: append($value)", "movers: append($value)"); + let msg = why(&readonly); + assert!( + msg.contains("read-only") && msg.contains("sys.movers"), + "writing a fetched source must be refused: {msg}" + ); + + // A store-backed capability accepts only the transitions it declares. + // `sys.watchlist` is a list — `toggle` is meaningless on one. + let wrong_verb = CARD.replace("watch: append($value)", "watch: toggle"); + let msg = why(&wrong_verb); + assert!( + msg.contains("does not accept") && msg.contains("append"), + "an unaccepted transition must be refused and the accepted ones named: {msg}" + ); + + // And the reverse: a collection form on a STATE is refused with the reason, + // not reported as a malformed `set`. + const ON_STATE: &str = r#" +# level: L0 +# model: stock +source movers sys.movers(count: 3, fields: [ticker]) +state watch { shape: collection, initial: [] } +event keep { watch: append($value) } +view root Surface { + for m, i in movers key m.ticker { Row(on_tap: keep, value: m.ticker) { TextRow(text: m.ticker) } } +} +"#; + let msg = why(ON_STATE); + assert!( + msg.contains("durable collection") && msg.contains("source"), + "a collection form on card state must say where the list belongs: {msg}" + ); + + // `remove` takes the payload and nothing else. A predicate here would be an + // expression, which is the one thing L0 does not have. + let predicate = CARD.replace("watch: remove($value)", "watch: remove(w.pct < 0)"); + assert!( + !check_ui_l0_named("card", &predicate).valid, + "remove must not accept a predicate" + ); +} + +/// A tap on a durable collection reports the write and invalidates the source. +/// +/// The write is REPORTED, never performed — the same separation `source_plan` +/// keeps. L0 has no store and must not acquire one: the host owns durability, +/// and a card that could write directly would be a card that could persist a +/// fact (§4). +#[test] +fn a_durable_write_is_reported_and_makes_its_source_stale() { + const CARD: &str = r#" +# level: L0 +# model: stock +source movers sys.movers(count: 5, fields: [ticker, name]) +source watch sys.watchlist(fields: [ticker, name, last]) +event keep { watch: append($value) } +view root Surface { + for m, i in movers key m.ticker { + Row(on_tap: keep, value: m.ticker) { TextRow(text: m.ticker) } + } + for w, i in watch key w.ticker { TextRow(text: w.ticker) } +} +"#; + let mut store = splash_ui_l0::InstanceStore::default(); + let out = splash_ui_l0::dispatch_reporting( + CARD, + &mut store, + "root", + "keep", + Some(&serde_json::Value::String("NVDA".into())), + &serde_json::Value::Null, + ); + + assert!(out.applied, "a durable write is an applied event"); + assert_eq!(out.writes.len(), 1, "exactly one write: {:?}", out.writes); + let w = &out.writes[0]; + assert_eq!((w.op.as_str(), w.value.as_str()), ("append", "NVDA")); + assert_eq!( + (w.source.as_str(), w.helper.as_str()), + ("watch", "sys.watchlist"), + "the host needs both the bound name and the capability behind it" + ); + + // Nothing went into the card's own store. If it had, the list would be + // per-card again and every regenerated card would start empty. + assert!( + out.changed.is_empty(), + "a durable write writes no cell: {:?}", + out.changed + ); + + // And the source must be refetched, or the row the user just added does not + // appear until something else happens to invalidate it. + assert!( + out.stale.contains(&"watch".to_string()), + "the written source must go stale: {:?}", + out.stale + ); +} + /// `signed_money` reaches the screen live, beside its own percentage. /// /// The two describe ONE move. The percentage was live and the money was not, so diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 0c3a6d1..ac797fa 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -320,3 +320,53 @@ answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "pr args = ["ticker", "range", "points", "fields", "aggregate"] answers = ["points", "min", "max"] aggregates = ["min", "max"] + +# ─── durable capabilities (profile §5.12) ───────────────────────────────────── +# Backed by a store rather than by a fetch, and the only ones a transition may +# write. What is STORED is references — a ticker, a token — never the values +# rendered beside them: §4's no-facts rule does not stop applying because the +# data went to disk, and a stored price is wrong a second after it is written. +# +# `sys.watchlist` takes no selector because it IS the user's list. The host reads +# the stored tickers, fetches each quote, and returns the rows — so a card asks +# for the fields it wants to show and never learns a store exists. +[sources."sys.watchlist"] +args = ["fields"] +answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] +writes = ["append", "remove"] + +# READ-ONLY for now. A preference write must name WHICH preference, and a +# transition targets a bare source name — `prefs: set($value)` cannot say +# `units`. That needs a dotted target, which does not exist yet, so a card may +# read a preference and not yet change one. +[sources."sys.prefs"] +args = ["fields"] +answers = ["units", "range"] + +# Free-text TICKER lookup. `sys.search` is the PLACE search and answers a +# different question; a card asking a geocoder for a company gets nonsense, so +# they are named apart. +# +# Verified against the live endpoint: `longname` is null for many listings, so +# `name` falls back to the short one. `kind` separates an equity from a crypto +# or an ETF — a search for "nvid" returns all three, and a card that cannot say +# which is offering the user a coin. +[sources."sys.symbol_search"] +args = ["query", "count", "fields"] +answers = ["ticker", "name", "exchange", "kind"] + +# The user's saved PLACES. Same shape as `sys.watchlist` — no selector, because +# it IS the list — and the host joins each stored place to a live reading. +# +# A place is stored as a NAME and nothing else — the purest form of the rule +# that a durable collection keeps references, never facts. Coordinates and every +# reading are resolved by the host: geocode the name, then read the weather at +# what comes back, each fetch URL-cached. +# +# An earlier design stored `name|lat|lon` to save the geocode. The card cannot +# produce that: L0 has no way to build a composite value, which is exactly the +# point of it having no expression form. The language was right. +[sources."sys.cities"] +args = ["fields"] +answers = ["name", "lat", "lon", "temp", "feels", "hi", "lo", "cond", "humidity", "wind"] +writes = ["append", "remove"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6de6766..79ffba1 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -739,6 +739,9 @@ An earlier version of this paragraph offered card state "with a declared migrati home for durable values. **There is no card-state migration** — no syntax, no implementation. Card state is reset like any other, and §8 question 8 records this as an open question rather than a mechanism, because durability needs a storage contract this profile does not have. +§5.12 provides that contract — durable values are *references* read as a source, not cells — but +card state itself is still not it: there is nowhere durable to put a CELL, +and there is not meant to be. ### 5.9 Source lifecycle @@ -817,6 +820,10 @@ card, session). `selected` is card-shared by visibility but *navigation* by effe looks like a durable preference. Classifying by owner alone answers where a value lives, not how long it should, which is why question 8 in §8 stays open. +The lifetime axis here has three values and needed a fourth. §5.12 supplies it: a durable value is +not a longer-lived cell but a **reference** the host stores and a source resolves, which is why +it does not appear in this section's taxonomy at all. + #### 5.10.1 Moving kind 2 down requires a protocol, not just a move Suppose component-local state moved to the component kit. L0 would no longer know `expanded` @@ -906,6 +913,154 @@ capability names and a backend's helper names lives in that backend's lowering. Both are the price of reaching live data without a second fetch layer, and both are recorded here rather than discovered later. +### 5.12 What is durable + +**Status: implemented, and proved on a phone.** `sys.watchlist` with `append` +and `remove`, a versioned store at `~/.config/octos-app/user.json`, and the +write path from a tap to disk. The test that matters is the one no unit test can +run: tap a row, force-stop the app, relaunch, and the entry is still there — with +its price fetched fresh, because the file held `["ATKR"]` and nothing else. + +Written before the implementation, deliberately, so the implementation had +something to diverge from. Two things did diverge and both are recorded below: +`sys.prefs` is read-only, and the tap payload had to become a live call. + +This answers the storage and migration halves of §8 question 8. The third half — +what a user is *entitled* to get back — is a product decision and stays open. + +#### State is a cursor, not data + +`selected = "NVDA"` is not a value the card holds. It is **which entity the card +is looking at** — a reference into data the card does not own. `range = "m1"` is +which slice. Once state is read as a cursor rather than as a value, three things +that look unrelated turn out to be one thing at three lifetimes: + +| | what it holds | lives for | +|---|---|---| +| `selected` | one entity reference | the card | +| a watchlist | a set of entity references | indefinitely | +| `quote(NVDA)` | the entity itself | never stored; always fetched | + +A watchlist is a **persisted set of the same thing `selected` holds one of**. That +is the whole of it — not a new kind of storage, the same kind of reference at a +different lifetime. §5.10 names a cell's lifetime as instance, card or session; +this is the fourth value that axis is missing, and the reason question 8 could not +be answered under a state mechanism. + +#### What is stored: references, never facts + +§4's no-facts rule extends to the store, and for the same reason. A stored price is +wrong within a second of being written, and a stale number that still looks live is +precisely the failure §4 exists to prevent — the shipping card drew a seeded `$181` +open beside two live values and nothing on screen distinguished them. + +So a watchlist is `["NVDA", "AAPL"]`. It is not a list of rows carrying names and +prices. **Identity is durable; facts are not**, and the store may hold only the +first. Ordering is part of identity here: the array's order is the user's order. + +#### A durable collection is a source, not state + +It reads as a `source`, which keeps §5 intact — state remains disposable UI state +and this never becomes a second kind of cell. + +``` +source watch sys.watchlist(fields: [ticker, name, last, pct]) +``` + +**The host performs the join.** It reads the stored references, fetches the live +quotes, and returns the rows. The card never sees the store, and L0 needs no join +operator — which it does not have and must not acquire. + +#### Mutation: an event may target a source + +One grammar addition, and only one: + +``` +event add { watch: append($value) } +event drop { watch: remove($value) } +``` + +The target is a source rather than a state, and the capability declares which +transitions it accepts. Dispatch routes the write to the host instead of to the +`InstanceStore`; the source goes stale and §5.9's lifecycle re-fetches it. + +**This does not weaken the confinement argument.** L0 is safe because it has no +expression form to evaluate, not because taps are inert — `cycle(a, b, c)` already +advances an enum with wrapping, which is runtime logic the card names and does not +write. `append($value)` is the same shape. `remove($value)` matches an exact +payload; it does not evaluate a predicate, and it must not be allowed to. + +#### Why the card cannot hold this instead + +Cards are **regenerated per request**. Asking for the same app twice produces a new +card and a new session. `state watch { initial: [] }` would therefore be empty +again on the next request, and would look like it worked until someone came back. +That is the decisive argument, and it is why durability is not a state feature no +matter how convenient a list-shaped cell would be. + +#### Entities, and the disagreement they remove + +Today a `quote` is a node and `NVDA` is not, so two reads of the same company are +unrelated values that happen to share a ticker. That is why a percentage could +render red while the price beside it rose: the tint resolved from one snapshot and +the value from another. Normalising by entity id — one `NVDA` that a watchlist +references and a detail card reads — makes the disagreement unrepresentable rather +than something to keep in sync. It is the piece that makes this a graph rather than +a pile of records, and it is worth doing for that alone. + +#### Migration + +The store outlives the cards that read it, and a regenerated card will ask for a +different shape. §5.8's discipline applies unchanged: **a version stamp**, so an +old file is recognised rather than misread; **tolerate unknown fields**, so a card +asking for something never stored gets "missing" rather than a failure; and **drop +rather than guess** when a shape changes, because feeding version-A data to +version-B code produces a card that renders confidently and wrongly. + +#### What the implementation changed + +**`sys.prefs` is read-only.** A preference write must name WHICH preference, and +a transition targets a bare source name: `event set_units { prefs: set($value) }` +says nothing about `units`. That needs a dotted target (`prefs.units: +set($value)`), which is grammar this does not have. A card may read a preference +and not yet change one. Declaring the capability writable before the target can +be aimed would have shipped a write nobody could use. + +**A tap payload had to become a live call.** `Row(on_tap: keep, value: m.ticker)` +resolved its payload at realize time while the row's own text lowered to a live +call, so the two could disagree — and with no seed blob the payload was simply +empty, so the first watchlist tap was refused. Where a payload has a source +binding the backend can answer, the lowering now emits the call: + +``` +l0_tap("l0:{…,\"v\":\"" + sys.movers(0, "symbol") + "\"}", …) +``` + +That was a pre-existing defect in every tappable bound row, not one this section +introduced. It surfaced here because a watchlist is the first feature where the +payload is the whole point rather than a convenience. + +**Reorder is absent.** `move(ticker, position)` needs two payloads and `value:` +carries one. `append` and `remove` cover adding and removing; ordering is the +order things were added until the grammar grows a second slot. + +#### What is deliberately not adopted + +The obvious reference here is GraphQL, and two of its three ideas are worth taking: +a typed schema per capability, and normalisation by entity id. The third is not. + +- **No query language.** Arguments, variables, aliases and directives are a second + language to parse, validate and confine, and L0's entire safety claim is that + there is nothing to evaluate. The schema and the colocation are expressible in + the grammar that already exists. +- **No resolver graph.** GraphQL assumes one endpoint over a traversable schema. + These capabilities are heterogeneous — Yahoo, Photon, Open-Meteo, OSRM — with no + joins between them. The only planning value is dependency ordering, and + `source_plan` already does that. +- **Not for over-fetching.** `sys.movers` fetches one fixed URL regardless of what + `fields:` names, so narrowing the list saves nothing on the wire. This is adopted + for correctness. If it is ever argued for on performance, the argument is wrong. + --- ## 6. What makes L0 terminate @@ -1010,7 +1165,7 @@ construct and keep the widest. | ~~5~~ | ~~Named slots~~ — **settled in §5.5.** `slot name` in the component, `into name { … }` at the call site, anonymous slot unchanged as the default. An `into` naming a slot that does not exist is rejected rather than silently dropping its children | | ~~6~~ | ~~An explicit state migration~~ — **settled in §5.8.** `keep: true` opts a cell out of the schema-change reset, and only while that field's own shape is unchanged | | ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and tests assert the two agree in both directions — constructor names and shared token sets, and for source capabilities the arguments too | -| **8** | **What, if anything, is durable.** Card state resets with the card; §5.8 previously claimed a card-state migration that does not exist. `units` and `city` look like preferences a user expects to survive a restart. Answering needs a storage contract, a migration story, and a decision about what a user is entitled to get back — none of which belongs under a state mechanism | +| **8** | **What, if anything, is durable.** **Two thirds settled in §5.12, and shipped.** The storage contract — durable *references*, never facts, read as a source and written through a declared transition on it — and the migration story (§5.8's discipline over a versioned store) are implemented and verified across an app restart on device. What stays open is the third part, and it is not a technical question: **what a user is entitled to get back.** A watchlist obviously; a scroll position obviously not; `units` and `city` are the genuinely arguable middle, and nothing in this document can settle them. The reframe that made the rest tractable: state is a **cursor** into data rather than data, so a watchlist is a persisted set of the same thing `selected` holds one of — the fourth value §5.10's lifetime axis was missing | | **10** | **How a card says a collection is empty.** `activity` wants "Nothing close by" when a list has no members, and L0 has no length, no count and no emptiness predicate — a guard cannot tell an empty collection from a full one, so the card silently renders nothing where it should say something. Every list-shaped card wants this. The narrow fix is a predicate against a collection's emptiness, NOT a `.count` — a count is a number, and a number in a card is one operator away from arithmetic | | **9** | **Whether component-local state (§5.10 kind 2) moves to the component kit.** The corpus says it would cost little — six kind-1 declarations against one kind-2. §5.10.1 says it cannot move alone: identity, mount/unmount and invalidation must move with it, and that protocol is unwritten. Open until the protocol exists, not until the split looks tidy | From c055552b45c0f97c22cb10d10eae025d1857146e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:13:57 -0700 Subject: [PATCH 14/97] feat(ui_l0): admit a card that declares L1, and give it arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7 says a record needing a wider grammar is rejected "until the level is explicitly raised", so raising it explicitly is the whole point. Without this the classifier could name L1 and never admit one — which is what the roadmap meant by "can name them but cannot check them". A card declaring `level: L1` is parsed; L2 stays refused before parsing, because imperative widget commands are a different grammar rather than a wider one, and nothing below parses them. The expression form is arithmetic over already-declared values, with the usual precedence. The model supplies the FORMULA and the runtime computes it, which is what keeps §4's no-facts rule true one level up: a card may combine facts it declared and still cannot state one it never observed. L0 enforces that structurally by refusing a literal in a value position; L1 cannot, because a coefficient is a legitimate literal — `temp * 9 / 5 + 32` is a formula — so the rule becomes that an expression must READ something. `1547 * 3.2` reads nothing and is a fabricated fact wearing arithmetic. An unresolved operand, a division by zero and a non-finite result all render as the em dash a missing binding already does. A zero would be a fabricated number, which is the failure this level is closest to. Lowering carries the SHAPE rather than the computed number: a value computed at realization is the answer for whatever the host seeded, and a live card is seeded with nothing. Every operand of an expression is registered as a dependency, or reconciliation would under-approximate the patch set and show stale data. `valid` now means valid AT ITS LEVEL. It read `level == Level::L0`, which was right while L0 was the only level admitted — an L1 card reached the end with no diagnostics and was still reported invalid, with nothing to say why. The normative profile does not yet define L1; §7 licenses the admission and no section states the grammar. That is a documentation gap, and it is deliberate to leave it to a spec pass rather than invent it here. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 285 +++++++++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 89 +++++++++ 2 files changed, 363 insertions(+), 11 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 7aee6b5..e7e762d 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -224,14 +224,28 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { // A construct outside the grammar tells us the level before parsing can // finish, and a level diagnostic is far more useful than "unexpected token". + // A card that DECLARES L1 is parsed, not refused. + // + // §7 says a record needing a wider grammar is rejected "until the level is + // explicitly raised" — so raising it explicitly is the whole point. Without + // this the classifier could name L1 and never admit one, which is what + // `roadmap.md` meant by "can name them but cannot check them". L2 stays + // refused here: imperative widget commands are a different grammar, not a + // wider one, and nothing below parses them. + let declared_l1 = header + .as_ref() + .and_then(|h| h.level.as_deref()) + .is_some_and(|l| l.trim() == "L1"); if let Some((level, diag)) = classify_beyond_l0(&tokens) { - sink.push(diag.line, diag.column, diag.message); - let mut report = sink.into_report(level); - report.header = header.clone(); - // Compare here too: this is the path a card that UNDER-declares its - // level takes, and it was the one path that skipped the comparison. - check_header(&header, level, &mut report); - return report; + if !(declared_l1 && level == Level::L1) { + sink.push(diag.line, diag.column, diag.message); + let mut report = sink.into_report(level); + report.header = header.clone(); + // Compare here too: this is the path a card that UNDER-declares its + // level takes, and it was the one path that skipped the comparison. + check_header(&header, level, &mut report); + return report; + } } let mut parser = Parser::new(&tokens, &mut sink); @@ -241,7 +255,8 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { } validate(&card, &mut sink); - let mut report = sink.into_report(Level::L0); + let derived = if declared_l1 { Level::L1 } else { Level::L0 }; + let mut report = sink.into_report(derived); report.closure = component_closure(&card); report.header = header.clone(); check_header(&header, report.level, &mut report); @@ -406,7 +421,14 @@ impl Diagnostics { UiL0Report { header: None, closure: Vec::new(), - valid: self.items.is_empty() && level == Level::L0, + // Valid AT ITS LEVEL, not "is L0". + // + // This read `level == Level::L0`, which was right while L0 was the + // only level this checker admitted: an L1 card reached here with no + // diagnostics and was still reported invalid, with nothing to say + // why. L2 is refused before parsing, so anything arriving here is L0 + // or a card that declared L1. + valid: self.items.is_empty() && level != Level::L2, level, diagnostics: self.items, diagnostics_truncated: self.truncated, @@ -987,6 +1009,16 @@ enum Operand { /// right operand, so `active: range == .d1` realized as the VALUE of /// `range` rather than as a boolean — live in stock.card, and invisible /// because the device golden recorded the wrong rendering as correct. + /// **L1 only.** Arithmetic over already-declared values: `shares * quote.last`. + /// + /// The model supplies the FORMULA; the runtime computes it. That split is + /// what keeps §4's no-facts rule true one level up — a card may combine + /// facts it declared, and still cannot state one it never observed. + Expr { + lhs: Box, + op: String, + rhs: Box, + }, Predicate { path: String, cmp: String, @@ -2098,7 +2130,61 @@ impl<'a> Parser<'a> { out } + /// An argument value, including an L1 arithmetic expression. + /// + /// Precedence is the usual one — `*` `/` `%` bind tighter than `+` `-` — so + /// `cost + shares * price` means what it reads as. Left-associative. fn parse_operand(&mut self) -> Operand { + let lhs = self.parse_term(); + self.parse_additive(lhs) + } + + fn parse_additive(&mut self, mut lhs: Operand) -> Operand { + while let Some(op) = self.peek().cloned() { + if !(op.is_punct("+") || op.is_punct("-")) { + break; + } + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&op, "nesting is too deep".into()); + break; + } + self.at += 1; + self.depth += 1; + let rhs = self.parse_term(); + self.depth -= 1; + lhs = Operand::Expr { + lhs: Box::new(lhs), + op: op.text, + rhs: Box::new(rhs), + }; + } + lhs + } + + fn parse_term(&mut self) -> Operand { + let mut lhs = self.parse_atom(); + while let Some(op) = self.peek().cloned() { + if !(op.is_punct("*") || op.is_punct("/") || op.is_punct("%")) { + break; + } + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&op, "nesting is too deep".into()); + break; + } + self.at += 1; + self.depth += 1; + let rhs = self.parse_atom(); + self.depth -= 1; + lhs = Operand::Expr { + lhs: Box::new(lhs), + op: op.text, + rhs: Box::new(rhs), + }; + } + lhs + } + + fn parse_atom(&mut self) -> Operand { let Some(t) = self.peek().cloned() else { return Operand::Str(String::new()); }; @@ -2126,7 +2212,7 @@ impl<'a> Parser<'a> { } self.at += 1; self.depth += 1; - let rhs = self.parse_operand(); + let rhs = self.parse_term(); self.depth -= 1; return Operand::Predicate { path, @@ -2163,6 +2249,24 @@ fn literal_of(operand: &Operand) -> serde_json::Value { Operand::Path(p) | Operand::Predicate { path: p, .. } => { serde_json::Value::String(p.clone()) } + // An expression has no literal form; it is computed at realization. + Operand::Expr { .. } => serde_json::Value::Null, + } +} + +/// Every path an operand reads, however deeply nested in an expression. +fn expr_paths(operand: &Operand, out: &mut Vec) { + match operand { + Operand::Path(p) => out.push(p.clone()), + Operand::Predicate { path, rhs, .. } => { + out.push(path.clone()); + expr_paths(rhs, out); + } + Operand::Expr { lhs, rhs, .. } => { + expr_paths(lhs, out); + expr_paths(rhs, out); + } + _ => {} } } @@ -2187,9 +2291,47 @@ fn scope_value(scope: &ValueScope, operand: &Operand) -> Option Some(serde_json::Value::from(*n)), // A nested comparison is not in the grammar. Operand::Predicate { .. } => None, + Operand::Expr { lhs, op, rhs } => eval_expr(scope, lhs, op, rhs), } } +/// Evaluate an L1 arithmetic expression over resolved values. +/// +/// Returns `None` when either side is unresolved — a missing operand must render +/// as the em dash a missing binding already does, never as a zero, which would be +/// a fabricated number wearing arithmetic. +fn eval_expr( + scope: &ValueScope, + lhs: &Operand, + op: &str, + rhs: &Operand, +) -> Option { + let a = scope_value(scope, lhs)?.as_f64()?; + let b = scope_value(scope, rhs)?.as_f64()?; + let v = match op { + "+" => a + b, + "-" => a - b, + "*" => a * b, + "/" => { + if b == 0.0 { + return None; + } + a / b + } + "%" => { + if b == 0.0 { + return None; + } + a % b + } + _ => return None, + }; + if !v.is_finite() { + return None; + } + Some(serde_json::Value::from(v)) +} + /// Compare two resolved values. Shared by guards (`when a == b`) and by /// predicate arguments (`active: range == .d1`), so the two cannot drift. fn compare(left: Option, cmp: &str, right: Option) -> bool { @@ -3965,6 +4107,29 @@ fn check_arg( check_path(r, scope, arg.line, arg.column, sink); } } + // §4's no-facts rule, one level up. + // + // L0 refuses a literal in a value position outright — a decidable, + // structural check. L1 cannot, because a coefficient is a legitimate + // literal: `temp * 9 / 5 + 32` is a FORMULA. So the rule becomes: an + // expression must READ something. `1547 * 3.2` reads nothing, and is a + // fabricated fact wearing arithmetic. + (expr @ Operand::Expr { .. }, _) => { + let mut paths = Vec::new(); + expr_paths(expr, &mut paths); + if paths.is_empty() { + sink.push( + arg.line, + arg.column, + "an expression must read a declared source or state: every operand here \ + is a literal, which states a fact rather than computing one (profile §4)" + .to_string(), + ); + } + for p in paths { + check_path(&p, scope, arg.line, arg.column, sink); + } + } _ => {} } @@ -4222,6 +4387,23 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut // ─────────────────────────────────────────────────────────────────── realization ── +/// An L1 expression, resolved for lowering. +/// +/// A realized number is not enough: `shares * quote.last` computed at +/// realization is the answer for the data the host happened to seed, and a live +/// card has none. The backend needs the SHAPE — which operands are live calls +/// and which are constants — so it can emit arithmetic the VM evaluates against +/// data that arrives later. +#[derive(Clone, Debug, PartialEq)] +pub enum ExprPart { + /// An operand answered by a live capability call. + Call(SourceBinding), + /// An operand already resolved to a constant — a coefficient, or a value no + /// backend can answer. + Const(String), + Bin(Box, String, Box), +} + /// A realized node. Renderer-neutral: a backend maps `kind` to its own widget. #[derive(Clone, Debug, PartialEq)] pub struct UiNode { @@ -4243,6 +4425,8 @@ pub struct UiNode { /// Additive on purpose: a consumer that ignores this sees exactly what it /// saw before. pub bindings: Vec<(String, SourceBinding)>, + /// For each argument that is an L1 expression, its resolved shape. + pub exprs: Vec<(String, ExprPart)>, } /// Where an argument's value came from, with the source's own arguments already @@ -4665,6 +4849,7 @@ impl Realizer<'_> { args: Vec::new(), children: Vec::new(), bindings: Vec::new(), + exprs: Vec::new(), }; for arg in &element.args { let value = self.value(&element.name, &arg.name, &arg.value, scope); @@ -4673,6 +4858,11 @@ impl Realizer<'_> { node.bindings.push((arg.name.clone(), binding)); } } + if matches!(arg.value, Operand::Expr { .. }) { + if let Some(shape) = self.expr_shape(&arg.value, scope) { + node.exprs.push((arg.name.clone(), shape)); + } + } node.args.push((arg.name.clone(), value)); } for (segment, child) in sibling_segments(&element.children) { @@ -4993,6 +5183,31 @@ impl Realizer<'_> { } } + /// Resolve an expression into live calls and constants. + /// + /// A `Path` the backend can answer becomes a call; anything else becomes the + /// value realization already resolved. Returning `None` for an operand that + /// resolves to nothing keeps a half-resolved expression from lowering — it + /// renders as the missing binding it is. + fn expr_shape(&self, operand: &Operand, scope: &ValueScope) -> Option { + match operand { + Operand::Expr { lhs, op, rhs } => Some(ExprPart::Bin( + Box::new(self.expr_shape(lhs, scope)?), + op.clone(), + Box::new(self.expr_shape(rhs, scope)?), + )), + Operand::Num(n) => Some(ExprPart::Const(makepad::trim_num(*n))), + Operand::Path(p) => match self.source_binding(p, scope) { + Some(binding) => Some(ExprPart::Call(binding)), + None => { + let v = scope.lookup(p)?; + Some(ExprPart::Const(makepad::trim_num(v.as_f64()?))) + } + }, + _ => None, + } + } + fn source_binding(&self, path: &str, scope: &ValueScope) -> Option { // Inside a `for`, a path is rooted at the BINDER: `m.ticker`, not // `movers.0.ticker`. The binder's frame records which collection it @@ -5100,6 +5315,13 @@ impl Realizer<'_> { Operand::Predicate { path, cmp, rhs } => { NodeValue::Bool(compare(scope.lookup(path), cmp, scope_value(scope, rhs))) } + Operand::Expr { lhs, op, rhs } => match eval_expr(scope, lhs, op, rhs) { + Some(v) => match v.as_f64() { + Some(n) => NodeValue::Number(n), + None => NodeValue::Missing, + }, + None => NodeValue::Missing, + }, Operand::Path(p) => match scope.lookup(p) { Some(serde_json::Value::String(s)) => NodeValue::Text(s), Some(serde_json::Value::Bool(b)) => NodeValue::Bool(b), @@ -5134,7 +5356,7 @@ fn json_to_key(value: &serde_json::Value) -> String { /// discipline holding — the card declares questions, and only the *realized /// output* contains answers. pub mod makepad { - use super::{NodeValue, SourceBinding, UiNode}; + use super::{ExprPart, NodeValue, SourceBinding, UiNode}; use std::fmt::Write as _; // Matching octos-one's `plan/common.rs`, so a lowered card sits beside the @@ -5204,6 +5426,11 @@ pub mod makepad { /// will. Emitting a call the VM cannot answer would render an em dash where /// a real number was available. pub(super) fn expr_of(node: &UiNode, arg_name: &str) -> String { + if let Some((_, shape)) = node.exprs.iter().find(|(n, _)| n == arg_name) { + if let Some(rendered) = render_expr(shape) { + return rendered; + } + } if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == arg_name) { if let Some(call) = vm_call(binding) { return call; @@ -5212,6 +5439,20 @@ pub mod makepad { text_of(arg(node, arg_name)) } + /// An `ExprPart` as backend arithmetic. Parenthesised at every join, so the + /// tree's shape survives regardless of the target VM's precedence rules. + pub(super) fn render_expr(part: &ExprPart) -> Option { + match part { + ExprPart::Const(v) => Some(v.clone()), + ExprPart::Call(binding) => vm_call(binding), + ExprPart::Bin(lhs, op, rhs) => Some(format!( + "({} {op} {})", + render_expr(lhs)?, + render_expr(rhs)? + )), + } + } + /// The VM helper that answers a declared capability, if one does. /// /// The names differ because the two vocabularies were designed apart: L0 @@ -5605,6 +5846,15 @@ pub mod makepad { // one that does not keeps the seeded value until the format can travel // with the call. if format_kind.is_none() { + // An L1 expression: emit the ARITHMETIC, so a live operand is still + // fetched and the multiply happens against data that arrives later. + // Realization already computed a number, but only for whatever the + // host seeded — which for a live card is nothing. + if let Some((_, shape)) = node.exprs.iter().find(|(n, _)| n == "value") { + if let Some(rendered) = render_expr(shape) { + return decorate(rendered, &glyph, unit, &suffix); + } + } if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == "value") { if let Some(call) = vm_call(binding) { return decorate(call, &glyph, unit, &suffix); @@ -6981,6 +7231,19 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec { + let mut paths = Vec::new(); + expr_paths(expr, &mut paths); + for p in paths { + let root = root_of(&p); + if !binders.contains(&root) { + reads.push(root); + } + } + } _ => {} } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 9b4247c..03dfd07 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -688,6 +688,7 @@ fn an_unmapped_constructor_is_visible_rather_than_dropped() { args: vec![], children: vec![], bindings: vec![], + exprs: vec![], }; let dsl = makepad::lower(&node); assert!(dsl.contains("no makepad lowering for Hologram"), "{dsl}"); @@ -5076,3 +5077,91 @@ fn a_list_through_a_component_stays_live() { "and row 1 is story 2:\n{dsl}" ); } + +// ─────────────────────────────────────────────────────────────────────── L1 ── + +/// An L1 card is ADMITTED when it declares its level. +/// +/// §7: "a record needing a wider grammar is rejected until the level is +/// explicitly raised" — so raising it explicitly must work. Before this the +/// classifier could name L1 and never accept one. +#[test] +fn a_declared_l1_card_is_admitted() { + const CARD: &str = r#"# level: L1 +source quote sys.quote(ticker: state.sym, fields: [last]) +state sym { shape: text, initial: "NVDA" } +state shares { shape: number, initial: 10 } +view root Surface { TextHero(value: shares * quote.last) } +"#; + let report = check_ui_l0_named("portfolio", CARD); + assert!(report.valid, "must be admitted: {:#?}", report.diagnostics); + assert_eq!(report.level, Level::L1); +} + +/// The same card WITHOUT the header is still refused — escalation is never silent. +#[test] +fn the_same_card_at_l0_is_refused() { + const CARD: &str = r#"# level: L0 +source quote sys.quote(ticker: state.sym, fields: [last]) +state sym { shape: text, initial: "NVDA" } +state shares { shape: number, initial: 10 } +view root Surface { TextHero(value: shares * quote.last) } +"#; + let report = check_ui_l0_named("portfolio", CARD); + assert!(!report.valid, "arithmetic is not in L0"); +} + +/// §4 one level up: an expression must READ something. A coefficient is fine; +/// an expression made only of literals states a fact rather than computing one. +#[test] +fn an_expression_of_only_literals_is_refused() { + const CARD: &str = r#"# level: L1 +state shares { shape: number, initial: 10 } +view root Surface { TextHero(value: 1547 * 3.2) } +"#; + let report = check_ui_l0_named("fabricator", CARD); + assert!( + !report.valid, + "a literal-only expression is a fabricated fact" + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("must read a declared")), + "{:#?}", + report.diagnostics + ); +} + +/// A coefficient IS allowed — `temp * 9 / 5 + 32` is a formula, not a fact. +#[test] +fn a_coefficient_is_not_a_fabricated_fact() { + const CARD: &str = r#"# level: L1 +source now sys.weather(lat: 1.0, lon: 2.0, fields: [temp]) +view root Surface { TextHero(value: now.temp * 9 / 5 + 32) } +"#; + let report = check_ui_l0_named("converter", CARD); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// An expression over a LIVE source lowers to arithmetic the backend evaluates — +/// not to the number realization happened to compute from seeded data. +#[test] +fn an_expression_over_a_live_source_lowers_live() { + const CARD: &str = r#"# level: L1 +source quote sys.quote(ticker: state.sym, fields: [last]) +state sym { shape: text, initial: "NVDA" } +state shares { shape: number, initial: 10 } +view root Surface { TextHero(value: shares * quote.last) } +"#; + let data = serde_json::json!({"sym": "NVDA", "shares": 10, "quote": {"last": 5.0}}); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert!( + dsl.contains("sys.stock(") && dsl.contains('*'), + "the price must stay live and the multiply must reach the backend:\n{dsl}" + ); +} From db1cbf05b344936c35648ee637bb5f95187fdbfc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:14:35 -0700 Subject: [PATCH 15/97] docs(roadmap): bring the L0 section current with what L0 does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three entries had gone stale against the branch. The test count was 85 and is 203. `StockPlot` and `AqiContour` no longer lower without their data arrays — the five visualisations carry their arguments as of the commit that stopped lowering them as markers. "L1 and L2 are unimplemented" is the sentence the L1 work quotes as what it was fixing, so it cannot also describe the result. Split in two: L1 is implemented, and implemented AHEAD OF ITS SPECIFICATION, which is the part worth recording — the checker admits a level the normative profile does not define. L2 is still unimplemented, and refused before parsing rather than merely unhandled. Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmap.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index f676002..01dff55 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -388,7 +388,7 @@ against an empty host surface. Three reference cards exercise it. **Implemented** in `splash-core::ui_l0`: parser, validator, per-instance state store, event dispatch, a renderer-neutral realizer, a source plan, and static dependency tracking for -reconciliation. 85 tests; the three reference cards are accepted and the shipping nav card is +reconciliation. 203 tests; the three reference cards are accepted and the shipping nav card is rejected as L2; four cases render on a OnePlus 6T through an unmodified downstream host, checked against golden images. @@ -435,10 +435,15 @@ Remaining: realization still rebuilds the tree rather than patching those points. - **`makepad::lower` is in the wrong crate.** It belongs in a backend crate, not in `splash-core`, which otherwise names no renderer. -- **`StockPlot` and `AqiContour` lower without their data arrays**, so both - render as empty frames on device. -- **L1 and L2 are unimplemented.** Both are specified only as what L0 excludes, - so the level classifier can name them but cannot check them. +- **L1 is implemented ahead of its specification.** A card declaring `level: L1` + is admitted and gets an expression form — arithmetic over already-declared + values, where an expression must read something so a literal-only formula + stays a fabricated fact. But the profile is titled Level 0 and specifies L1 + only as what L0 excludes: §7 licenses the admission and no section states the + grammar, the evaluation rules, or the §4 argument. The checker currently + blesses a level no document defines, and the spec pass is owed. +- **L2 is unimplemented and refused before parsing.** Imperative widget commands + are a different grammar rather than a wider one, so nothing below parses them. ## Before a stable language release From d5c095d1f045a2f4449b39835fa46c45a9fd4df5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:40:43 -0700 Subject: [PATCH 16/97] docs(ui_l0): specify the L1 expression form, and stop claiming L1 is unimplemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both consequences of the checker having grown a construct while the document that defines it did not. §7's kit paragraph still said L1 and L2 "remain specified only as what L0 excludes. Neither is implemented, and the classifier can name them but not check them." That was true when written and is now false in half — it contradicted both the implementation and the roadmap. Split: L2 is still exactly that; L1 is not. §9 specifies the one construct L1 admits and deliberately nothing else, so a second construct needs its own pass before it is accepted. It was written after the implementation, which is the wrong order, and §9.8 is the length it is because of that. Writing it found three things the code had not said out loud: - A guard's right-hand side escapes both checks. `when n == nosuch * 2` is accepted — an expression there is checked against no declared name, is not subject to the must-read rule, and is not registered as a dependency. The same guard with a bare path is correctly refused, so the hole is the expression form specifically, and realization evaluates it anyway. Recorded as a defect rather than a limit. - The no-facts rule bounds an expression's OPERANDS, not its result. `quote.last * 0 + 1547` reads a source, computes, and produces a fabricated number. L0's version of §4 is structural; L1's is a predicate on the operand set, and the difference is not cosmetic. - There is no grouping and no unary minus, so precedence is fixed and `(a + b) * c` is inexpressible. Both are limits of what was written rather than decisions. §9.7 says what the confinement argument costs, because this is the part that must not be inherited by accident: L0's claim is that there is no evaluator in the path, and L1 has one. What replaces it — a closed arithmetic evaluator over already-resolved values, no name resolution, no reachable capability, over a tree bounded at parse — is still strong and is a DIFFERENT claim. §1 now says so at the point where the stronger version is stated, so it cannot be read as covering both levels. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ui-profile-l0.md | 184 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 79ffba1..02480c2 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -26,6 +26,11 @@ wrong in an interesting direction: **L0 has no expression form, so there is noth evaluate.** Realization is a pure walk over the parsed tree with data substituted. The reference implementation contains no reference to the VM whatsoever. +**This paragraph is about L0 and does not extend to L1.** L1 has an expression form and +therefore an evaluator; §9.7 states what the confinement argument becomes there, and it is a +narrower claim than this one. Citing "nothing to evaluate" for a card requires first +establishing that the card is L0. + ### 1.0 What Level 0 is NOT for Measured, on the app cards this system already ships. Three of them — weather, @@ -1199,9 +1204,10 @@ an instance key survives without any change to the VM, and `docs/scoped-state.md 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. +above — are a separate matter from the kit work; the two senses of "L1" are easy to conflate and +this paragraph previously did. L2 remains specified only as "what L0 excludes", is unimplemented, +and is refused before parsing. L1 is no longer in that state: **§9 specifies the one construct +the checker admits**, and the rest of L1 is still only what L0 excludes. 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 @@ -1246,3 +1252,175 @@ this document lacks is a defect here rather than in the cards. Two of the settle from that rule rather than from argument — question 1 was answered by noticing both branching cards had already written the complementary-guard form without anyone specifying it, and question 7 by the cards exhausting the token sets in the course of being written. + +--- + +## 9. Level 1 — the expression form + +**Status: the construct below is implemented; this section specifies it and nothing else.** + +This is **not a complete L1 profile.** L1 was specified as "what L0 excludes", and the checker +then grew the ability to admit one construct — an arithmetic expression — while that sentence +was still the whole of the definition. So the checker blessed a level no document described. +This section closes exactly that gap and no more: everything else about L1 remains "what L0 +excludes", and a second construct will need its own specification before it is admitted. + +Written after the implementation, which is the wrong order and is why §9.8 is as long as it is. + +### 9.1 Admission + +A card is admitted at L1 by **declaring it**: + +``` +# level: L1 +``` + +Without the header the same card is refused, and refused with a level diagnostic rather than a +syntax error. §7's rule is unchanged — a record needing a wider grammar is rejected until the +level is explicitly raised, and escalation is never silent. + +**L2 is still refused before parsing.** Imperative widget commands are a *different* grammar +rather than a wider one, and nothing below the classifier parses them, so admitting a declared +L2 card would produce a parse failure dressed as an acceptance. + +**Over-declaration is accepted, and this deviates from §7.** §7 derives a card's level as the +maximum any record requires. For L1 the *declared* level wins instead: a card that says +`# level: L1` and uses no expression is reported L1, not L0. Over-declaring is the conservative +direction — it asks a host for more than the card needs, never less — but the consequence is +that a reported level is not always a derived one, and a host comparing the two will not learn +that a card outgrew its own header downwards. + +### 9.2 The grammar + +One production is extended. `operand` was a literal, a path or a comparison; it gains a binary +arithmetic form: + +``` +operand = term , { add-op , term } ; +term = atom , { mul-op , atom } ; +atom = literal | path | predicate ; +add-op = "+" | "-" ; +mul-op = "*" | "/" | "%" ; +``` + +Multiplicative operators bind tighter than additive ones and both associate left, so +`temp * 9 / 5 + 32` means what it reads as. Nesting is bounded at parse by the same limit every +other nested construct uses (`DEFAULT_MAX_SYNTAX_NESTING`, 128). + +**There is no grouping.** Parentheses are not in the production above, so precedence is fixed +and cannot be overridden: `(a + b) * c` is not writable at L1. **There is no unary minus inside +an expression** either — a negative literal is admitted where a literal is admitted, and `n * -1` +is not. Both are limits of what was implemented rather than decisions, and both are recorded in +§9.8. + +**The specified position is an argument value**, which is where the motivating cards need one: + +``` +TextHero(value: shares * quote.last) +``` + +That is the only position this section admits. What the implementation additionally parses is a +defect and is recorded in §9.8. + +### 9.3 §4's no-facts rule, one level up + +L0 keeps §4 structurally: a literal in a value position is refused outright, which is decidable +because a `view` is a typed tree. **L1 cannot use that check**, because at L1 a literal in a value +position is often legitimate — a coefficient. `temp * 9 / 5 + 32` is a formula, and `9`, `5` and +`32` are not claims about the world. + +So the rule changes shape rather than relaxing: + +> **An expression must read at least one declared source or state.** + +`shares * quote.last` reads two and is a computation. `1547 * 3.2` reads nothing: it states a +fact wearing arithmetic, and is refused. Every path an expression reads — at any depth — is +checked against declared names exactly as a bare binding is, so an expression cannot launder an +undeclared name past the check that a plain path would fail. + +This is the whole of the §4 argument at L1, and it is weaker than L0's in a way worth naming. +L0's version is *structural*: there is no position in which a fabricated number can appear. +L1's is a *predicate on the operand set*: a card that reads one real value and combines it with +invented coefficients passes. `quote.last * 0 + 1547` reads a source, computes, and produces a +fabricated number. **The rule bounds what an expression may be made of; it does not bound what +an expression may produce**, and closing that needs an argument this section does not have. + +### 9.4 Evaluation + +Both operands must resolve to numbers. When either does not, the expression is **missing** — +rendered as the em dash a missing binding already renders as, and never as a zero. A zero would +be a fabricated number, which is the failure the level is closest to. + +| Case | Result | +|---|---| +| An operand does not resolve, or is not a number | Missing | +| Division or remainder by zero | Missing | +| A result that is not finite | Missing | +| A comparison as an operand | Missing | + +Evaluation is one pass over a tree already bounded at parse. It performs no name resolution — the +operands were resolved before it runs — makes no call, and cannot reach a capability, a component +or the host. + +### 9.5 Lowering + +A backend receives the **shape** of an expression, not the number realization computed: +each operand is either a live capability call or a constant, and each join carries its operator. +Every join is parenthesised on the way out, so the target VM's own precedence rules cannot +change what the card meant. + +This is not an optimisation. A value computed at realization is the answer for whatever data the +host happened to seed, and a live card is seeded with nothing — so lowering the computed number +would put arithmetic over absent data on the screen, which is §5.11's defect one level up. + +### 9.6 What L1 does not change + +**Termination.** §6's five conditions are untouched and still sufficient. An expression is a +finite tree bounded at parse, evaluated in one pass with no calls, no recursion into the card +and no iteration, so it contributes constant work per node and cannot raise an event. + +**Reconciliation.** Every path an expression reads is registered as a dependency. Under-approximating +here would show stale data, which §5.9 is explicit about. + +**Everything in §§1–5, 7 and 8** applies unchanged. L1 is L0 plus this production. + +### 9.7 What this costs the confinement argument + +L0's confinement claim is structural in the strongest available sense: there is no expression +form, so there is nothing to evaluate, so realization never enters an evaluator at all. + +**That claim does not survive L1, and no wording should suggest it does.** L1 has an evaluator. +What replaces the L0 claim is narrower and should be stated as what it is: + +> A closed arithmetic evaluator over already-resolved values — five total operators over +> floating-point numbers, no name resolution at evaluation time, no operand that can name a +> capability, no host surface reachable from it, over a tree bounded at parse. + +That is still a strong property, and it is a *different* property. "No execution machinery in the +path" is an L0 property. Any argument that cites it for a card must first establish the card is +L0, which is precisely what the level in the report is for. + +### 9.8 Known defects and limits + +Recorded rather than patched over, in the order they would bite. + +- **A guard's right-hand side escapes both checks.** `when n == nosuch * 2` is *accepted*: an + expression in a guard is not checked against declared names and is not subject to §9.3's + must-read rule, and its operands are not registered as dependencies. The same guard with a bare + path — `when n == nosuch` — is correctly refused, so the hole is the expression form + specifically. Realization *does* evaluate it. This section admits expressions in argument + positions only; the implementation parses one here and then checks nothing, which is the worst + of the three possible behaviours. **This is a defect, not a limit.** +- **Comparison and arithmetic have no defined relative precedence.** `active: x == a + b` parses + as `(x == a) + b` rather than as a comparison against a sum, and evaluates to missing. Nothing + rejects it. +- **No grouping and no unary minus**, per §9.2. `(a + b) * c` is inexpressible, which is the + first thing an author will reach for after a formula that does not match the fixed precedence. +- **The no-facts rule bounds operands, not results** (§9.3). A fabricated number can be computed + from one real reading and invented coefficients. +- **Arithmetic is the only construct.** No comparison chain, no conditional, no string + operation, no aggregate over a collection. §8 question 10 — how a card says a collection is + empty — is *not* answered here, and a count is still one operator away from the arithmetic + this section admits, which is the reason it was left alone. +- **`Form` is unchanged**, so a transition still cannot compute. `set(shares * 2)` is not + admitted, and §6's condition 3 continues to hold for the reason it did at L0. From eeb1698d827aa5ae10c71c840d1ef17fce802bb6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:51:39 -0700 Subject: [PATCH 17/97] fix(ui_l0): a tap that writes the value already there is not a change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host rebuilds a card on any non-empty dispatch outcome, and every transition was reported as a write whether or not the value moved. So tapping the chip that is already selected cost a full realize, a full lowering, a full VM pass over every live call on the card, and a widget rebuild — to arrive at the identical screen. On the stock card that is 11 nodes and every `sys.movers` call re-issued for nothing; the weather card is 62 nodes and 28 calls. Tapping the selected chip is the ordinary way to hit this, and every card with a range or filter row has one. The comparison is against the EFFECTIVE current value, which the function already computed for `toggle` and `cycle`: an earlier write in the same batch, else the stored cell, else the declared initial. So the first tap on a chip that matches the initial is a no-op too, before any cell exists to compare against. Storage is unchanged — the cell is still written, and `changed` now means "moved" rather than "was assigned". That is the field a host reads to decide whether to rebuild, and the one `stale_sources` reads to decide what to refetch; neither wants a target that holds what it held. §3's atomicity is untouched: the batch is staged and validated in full before any of this, and a batch that moves one cell and restates another still applies and reports the one that moved. This does not touch the other half. A tap that DOES change something still rebuilds the whole card, because `record_dependencies`, `dirty_records` and `patch_points` compute the patch set and nothing calls them. That is the roadmap's "reconciliation is derived but not applied", and it stays deferred deliberately: a full rebuild is always correct, and patching is correct only once the dependency tracking is complete enough to trust — the profile is explicit that under-approximating it shows stale data on screen. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 46 ++++++++++++++----- crates/splash-ui-l0/tests/profile.rs | 68 ++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index e7e762d..2ff7bf4 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6587,7 +6587,10 @@ pub struct DispatchOutcome { /// Whether any transition committed. False means refused or unknown — see /// §3, a batch that does not fully resolve does not partially apply. pub applied: bool, - /// The state paths written, in the order the batch wrote them. + /// The state paths whose value MOVED, in the order the batch wrote them. A + /// transition that writes the value already there is committed and not + /// reported: a host rebuilds on any non-empty outcome, and rebuilding a card + /// to redraw the identical screen is the cost of tapping a selected chip. pub changed: Vec, /// The declared sources those writes invalidate, following the dependency /// cascade. The host should refetch these before the next realization. @@ -6699,7 +6702,9 @@ fn dispatch_writes( // left the earlier writes standing when a later one could not be computed — // a half-applied event, which is the thing atomicity exists to prevent. // Stage every write first, commit only if the whole batch resolves. - let mut staged: Vec<(String, serde_json::Value)> = Vec::new(); + // (target, next, effective current) — the third is what decides whether this + // transition is a change at all. + let mut staged: Vec<(String, serde_json::Value, serde_json::Value)> = Vec::new(); // §5.12 writes are staged alongside the cells, so §3's atomicity covers both: // a batch that sets a preference AND appends to a list must do neither if the // preference cannot be resolved. @@ -6747,8 +6752,8 @@ fn dispatch_writes( let current = staged .iter() .rev() - .find(|(t, _)| *t == transition.target) - .map(|(_, v)| v.clone()) + .find(|(t, _, _)| *t == transition.target) + .map(|(_, v, _)| v.clone()) .or_else(|| store.get(instance_key, &transition.target).cloned()) .or_else(|| declared_initial.clone()) .unwrap_or_else(|| initial_for(&state.shape)); @@ -6793,18 +6798,37 @@ fn dispatch_writes( }, _ => return (Vec::new(), Vec::new()), }; - staged.push((transition.target.clone(), next)); + staged.push((transition.target.clone(), next, current)); } - // Commit, and report what was written. The targets are what a host needs - // to know which sources went stale -- discarding them is why §5.9's - // invalidation story had pieces that nothing joined. + // Commit, and report what CHANGED. The targets are what a host needs to know + // which sources went stale -- discarding them is why §5.9's invalidation + // story had pieces that nothing joined. + // + // A transition that writes the value already there is not a change, and + // reporting it as one is not free: a host rebuilds a card on any non-empty + // outcome, so tapping the already-selected chip cost a full realize, a full + // lowering, a full VM pass over every live call on the card, and a widget + // rebuild -- to arrive at the identical screen. Measured on the stock card, + // that is 11 nodes and every `sys.movers` call re-issued for nothing; the + // weather card is 62 nodes and 28 calls. + // + // The comparison is against the EFFECTIVE current value -- an earlier write + // in this same batch, else the stored cell, else the declared initial -- so + // a first tap that selects what was already the initial is a no-op too. + // + // The batch is still staged and validated in full before any of this: §3's + // atomicity is about whether a batch may partially apply, not about which of + // its transitions moved. A batch that sets one cell to a new value and + // another to the value it holds reports the first and rebuilds once. let mut written = Vec::new(); - for (target, value) in staged { + for (target, value, previous) in staged { + let unchanged = value == previous; store.set(instance_key, &target, value); - if !written.contains(&target) { - written.push(target); + if unchanged || written.contains(&target) { + continue; } + written.push(target); } (written, durable) } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 03dfd07..56e26f2 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5165,3 +5165,71 @@ view root Surface { TextHero(value: shares * quote.last) } "the price must stay live and the multiply must reach the backend:\n{dsl}" ); } + +/// A transition that writes the value already there is not a change. +/// +/// A host rebuilds a card on any non-empty outcome, so reporting a no-op cost a +/// full realize, a full lowering, a full VM pass over every live call, and a +/// widget rebuild — to arrive at the identical screen. Tapping the chip that is +/// already selected is the ordinary way to hit it, and it happens on every card +/// with a range or filter row. +/// +/// The comparison is against the EFFECTIVE current value — an earlier write in +/// the same batch, else the stored cell, else the declared initial — so +/// selecting what was already the initial is a no-op on the FIRST tap too, +/// before any cell exists to compare against. +#[test] +fn writing_the_value_already_there_is_not_a_change() { + const CARD: &str = r#" +# level: L0 +# model: t +state range { shape: enum[d1, w1], initial: .d1 } +event set_range { range: set($value) } +view root Surface { + Chip(text: "1D", active: range == .d1, on_tap: set_range, value: "d1") + Chip(text: "1W", active: range == .w1, on_tap: set_range, value: "w1") +} +"#; + let data = serde_json::json!({ "env": { "locale": {} } }); + let mut store = splash_ui_l0::InstanceStore::default(); + let tap = |store: &mut splash_ui_l0::InstanceStore, v: &str| { + splash_ui_l0::dispatch_reporting( + CARD, + store, + "root", + "set_range", + Some(&serde_json::Value::String(v.into())), + &data, + ) + }; + + // `d1` IS the declared initial and no cell exists yet, so the first tap on + // the already-selected chip changes nothing. + let out = tap(&mut store, "d1"); + assert!( + !out.applied && out.changed.is_empty(), + "selecting the initial is a no-op: {out:?}" + ); + // A real move still reports, and still invalidates. + let out = tap(&mut store, "w1"); + assert!( + out.applied && out.changed.iter().any(|c| c == "range"), + "a real change must still report: {out:?}" + ); + // And the same tap repeated is a no-op against the STORED cell. + let out = tap(&mut store, "w1"); + assert!( + !out.applied && out.changed.is_empty(), + "re-tapping the selected chip is a no-op: {out:?}" + ); + // The cell still holds `w1` — what changed is REPORTING, not storage. Asked + // behaviourally rather than by reading the store, because card state lives + // under its own fixed key (§5.1) and this is the property that matters: + // going back to `d1` is a real change, which it could not be if the no-op + // tap had reset or dropped the cell. + let out = tap(&mut store, "d1"); + assert!( + out.applied && out.changed.iter().any(|c| c == "range"), + "the cell must still hold w1, so d1 is a move: {out:?}" + ); +} From 782a8308ddd9b6cd8a384b95d7b07256fba09cd1 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:04:14 -0700 Subject: [PATCH 18/97] fix(ui_l0): every operand a record reads is a dependency, in a guard too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation is deferred because a full rebuild is always correct and patching is correct only if the dependency set is complete. Measuring that set found it was not: three under-approximations, the direction the profile is explicit about, because each one puts a stale value on screen while the card looks fine. - **A comparison's right operand.** `active: a == b` reads `b`, and the scan matched only the left path, so a chip's selected state never re-realized when the thing it compares against moved. - **A guard's right operand beyond a bare path.** `when a == b * 2` reported nothing. Same cause: operand forms matched one at a time, and the list was written before the form existed. - **A state reaching a view only through a source argument.** `dirty_records` filtered on the changed names, so `sel` dirtied nothing for `sys.quote(ticker: sel)` read as `q.last` — no record reads `sel`, they read `q`. That is the stock card's exact shape. `patch_points` already followed the source cascade and this did not; two functions answering one question differently is worse than either being wrong, because the coarse one is what a host reaches for first. The scan now walks every operand with `expr_paths` — the same function §4's must-read rule and the checker use — so a form it learns is picked up here rather than needing a third arm in a third place. The same guard right-hand side also escaped CHECKING, recorded as a defect in §9.8: an undeclared name reached evaluation through the one position that did not look inside its operand, and §4's must-read rule was skipped with it. Both now apply there. §9.8 is updated rather than left to contradict the code. `patch_points` is unchanged and its exclusion of ancestors is not a gap: patching a descendant suffices, and patching `root` IS the full rebuild this exists to avoid. This does not turn patching on. It makes the set the profile says patching requires trustworthy enough to turn on, which is the part that belongs in this crate. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 96 ++++++++++++++++------- crates/splash-ui-l0/tests/profile.rs | 111 +++++++++++++++++++++++++++ docs/ui-profile-l0.md | 17 ++-- 3 files changed, 190 insertions(+), 34 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 2ff7bf4..4dc8e97 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3813,8 +3813,31 @@ fn walk( // The right operand is a binding too. Unchecked, an undeclared // name on the right resolved to nothing and the comparison // decided the branch on that absence. - if let Some(Operand::Path(r)) = element.rhs.as_ref() { - check_path(r, scope, *line, *column, sink); + // + // EVERY path in it, not just a bare one. This matched + // `Operand::Path` alone, so `when a == nosuch * 2` was accepted + // at L1 — an undeclared name reaching evaluation through the one + // position that did not look inside its operand. §4's rule that + // an expression must READ something was skipped here for the + // same reason, so a guard could compare against a fabricated + // literal. Both are the argument-position rules; a guard is not + // a place they stop applying. + if let Some(rhs) = element.rhs.as_ref() { + let mut paths = Vec::new(); + expr_paths(rhs, &mut paths); + if matches!(rhs, Operand::Expr { .. }) && paths.is_empty() { + sink.push( + *line, + *column, + "an expression must read a declared source or state: every operand \ + here is a literal, which states a fact rather than computing one \ + (profile §4)" + .to_string(), + ); + } + for r in paths { + check_path(&r, scope, *line, *column, sink); + } } } } @@ -7223,10 +7246,29 @@ pub fn patch_points(source: &str, changed: &[&str]) -> Vec { /// The measurable claim behind "one event, one state change, one reconciliation /// pass": toggling a unit should touch the records that read it, not the tree. pub fn dirty_records(source: &str, changed: &[&str]) -> Vec { - let roots: Vec = changed.iter().map(root_of).collect(); + // Follow the SOURCE cascade first, exactly as `patch_points` does. + // + // This filtered on the changed roots alone, so a state that reaches a view + // only through a source argument dirtied nothing: `sys.quote(ticker: sel)` + // read by `TextHero(value: q.last)` reported no record when `sel` changed, + // because no record reads `sel` — they read `q`. That is the stock card's + // whole shape, and it is the under-approximating direction, which the + // profile is explicit shows stale data. + // + // `patch_points` had this and this did not, which is the more dangerous half + // of a disagreement between two functions answering one question: the coarse + // one is what a host reaches for first. + let mut sink = Diagnostics::default(); + let invalidated = match lex(source, &mut sink) { + Some(tokens) => { + let card = Parser::new(&tokens, &mut sink).parse_card(); + invalidated_by(&card, changed) + } + None => changed.iter().map(root_of).collect(), + }; record_dependencies(source) .into_iter() - .filter(|d| d.reads.iter().any(|r| roots.iter().any(|c| c == r))) + .filter(|d| d.reads.iter().any(|r| invalidated.iter().any(|c| c == r))) .map(|d| d.record) .collect() } @@ -7247,32 +7289,32 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec { - let root = root_of(p); - if !binders.contains(&root) { - reads.push(root); - } - } - // Every operand of an expression is a dependency. Missing these - // would under-approximate the patch set, and the profile is explicit - // that under-approximating shows stale data. - expr @ Operand::Expr { .. } => { - let mut paths = Vec::new(); - expr_paths(expr, &mut paths); - for p in paths { - let root = root_of(&p); - if !binders.contains(&root) { - reads.push(root); - } - } + // EVERY path an operand reads is a dependency, however it is nested. + // + // This matched shapes one at a time and missed two of them, in the direction + // that is a correctness bug rather than a cost: a comparison's right operand + // (`active: a == b` never re-realized when `b` changed) and a guard's right + // operand beyond a bare path (`when a == b * 2` likewise). Both render a + // stale screen, which is what this whole mechanism exists to prevent. + // + // `expr_paths` already walks all three forms, and is the same function §4's + // must-read rule and the checker use — so a shape it learns is picked up + // here for free rather than needing a third arm added in a third place. + let collect = |operand: &Operand, reads: &mut Vec| { + let mut paths = Vec::new(); + expr_paths(operand, &mut paths); + for p in paths { + let root = root_of(&p); + if !binders.contains(&root) { + reads.push(root); } - _ => {} } + }; + for arg in &element.args { + collect(&arg.value, reads); } - if let Some(Operand::Path(p)) = element.rhs.as_ref() { - reads.push(root_of(p)); + if let Some(rhs) = element.rhs.as_ref() { + collect(rhs, reads); } if element.is_reference || !element.name.is_empty() { diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 56e26f2..bdc1ea0 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5233,3 +5233,114 @@ view root Surface { "the cell must still hold w1, so d1 is a move: {out:?}" ); } + +/// Every path an operand reads is a dependency, however it is nested. +/// +/// Reconciliation is only safe if this set is complete: over-approximating +/// re-realizes too much, under-approximating shows a stale screen, and the +/// second is the one that ships a wrong number. Two shapes were missed because +/// the scan matched operand forms one at a time — a comparison's RIGHT operand, +/// and a guard's right operand beyond a bare path. +#[test] +fn every_operand_a_record_reads_is_a_dependency() { + // `active: a == b` reads `b`. It reported nothing, so a chip's selected + // state never re-realized when the thing it compares against moved. + const PREDICATE: &str = r#" +state a { shape: text, initial: "x" } +state b { shape: text, initial: "y" } +view root Surface { Chip(text: "c", active: a == b) } +"#; + assert!( + dirty_records(PREDICATE, &["b"]).contains(&"root".to_string()), + "a comparison's right operand is read: {:?}", + dirty_records(PREDICATE, &["b"]) + ); + + // The same, one level up: a guard whose right operand is an expression. + const GUARD_EXPR: &str = r#"# level: L1 +state a { shape: number, initial: 1 } +state b { shape: number, initial: 2 } +view root Surface { when a == b * 2 { Rule() } } +"#; + assert!( + dirty_records(GUARD_EXPR, &["b"]).contains(&"root".to_string()), + "a guard's expression operands are read: {:?}", + dirty_records(GUARD_EXPR, &["b"]) + ); + + // A binder still shadows — `it` is loop-local, not a card dependency. + const LOOP: &str = r#" +source items sys.news(count: 3, fields: [title]) +view root Surface { for it, i in items key it.title { TextRow(text: it.title) } } +"#; + assert!( + dirty_records(LOOP, &["it"]).is_empty(), + "a loop binder is not a dependency" + ); +} + +/// A state that reaches a view only THROUGH a source argument is a dependency +/// of that view. +/// +/// `dirty_records` filtered on the changed names alone, so `sel` dirtied nothing +/// here: no record reads `sel`, they read `q`. That is the stock card's exact +/// shape — `sys.quote(ticker: state.selected)` — and the coarse function is the +/// one a host reaches for first. `patch_points` already followed the cascade, +/// and two functions answering one question differently is worse than either. +#[test] +fn a_state_a_source_reads_dirties_that_sources_readers() { + const CARD: &str = r#" +source q sys.quote(ticker: sel, fields: [last]) +state sel { shape: text, initial: "NVDA" } +view root Surface { TextHero(value: q.last) } +"#; + assert!( + dirty_records(CARD, &["sel"]).contains(&"root".to_string()), + "changing the ticker must dirty the view that shows the quote: {:?}", + dirty_records(CARD, &["sel"]) + ); + // And the two functions now agree about it. + assert!(patch_points(CARD, &["sel"]).contains(&"root".to_string())); +} + +/// A guard's right-hand side is checked like any other operand. +/// +/// §9.8 recorded this as a defect: an expression there was matched only as a +/// bare path, so an undeclared name reached evaluation through the one position +/// that did not look inside its operand, and §4's must-read rule was skipped +/// with it. +#[test] +fn a_guards_right_operand_is_checked_like_any_other() { + let why = |c: &str| { + check_ui_l0_named("g", c) + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect::>() + .join("; ") + }; + const UNDECLARED: &str = r#"# level: L1 +state n { shape: number, initial: 2 } +view root Surface { when n == nosuch * 2 { Rule() } } +"#; + assert!( + why(UNDECLARED).contains("not a declared name"), + "an undeclared name in a guard expression: {}", + why(UNDECLARED) + ); + const FABRICATED: &str = r#"# level: L1 +state n { shape: number, initial: 2 } +view root Surface { when n == 3 * 4 { Rule() } } +"#; + assert!( + why(FABRICATED).contains("must read a declared"), + "§4 applies in a guard too: {}", + why(FABRICATED) + ); + const HONEST: &str = r#"# level: L1 +state n { shape: number, initial: 2 } +state m { shape: number, initial: 1 } +view root Surface { when n == m * 2 { Rule() } } +"#; + assert!(check_ui_l0_named("g", HONEST).valid, "{}", why(HONEST)); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 02480c2..9a331c9 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1404,13 +1404,16 @@ L0, which is precisely what the level in the report is for. Recorded rather than patched over, in the order they would bite. -- **A guard's right-hand side escapes both checks.** `when n == nosuch * 2` is *accepted*: an - expression in a guard is not checked against declared names and is not subject to §9.3's - must-read rule, and its operands are not registered as dependencies. The same guard with a bare - path — `when n == nosuch` — is correctly refused, so the hole is the expression form - specifically. Realization *does* evaluate it. This section admits expressions in argument - positions only; the implementation parses one here and then checks nothing, which is the worst - of the three possible behaviours. **This is a defect, not a limit.** +- ~~**A guard's right-hand side escapes both checks.**~~ **Fixed.** `when n == nosuch * 2` was + *accepted*: an expression in a guard was matched only as a bare path, so it was checked against + no declared name, was not subject to §9.3's must-read rule, and its operands were not registered + as dependencies — while realization evaluated it anyway. A guard's right operand is now walked + like any other operand, by the same function, so all three apply there. Two neighbouring + under-approximations went with it: a comparison's right operand in an argument + (`active: a == b` never re-realized when `b` moved) and, in the coarse dependency query, a state + reaching a view only through a source argument. **A guard is still not a position this section + admits an expression in** — the implementation parses one and now checks it correctly, which is + a narrower gap between the two than it was, and still a gap. - **Comparison and arithmetic have no defined relative precedence.** `active: x == a + b` parses as `(x == a) + b` rather than as a comparison against a sum, and evaluates to missing. Nothing rejects it. From cc6f70dc353a02b99ae7214af2f883d9e6eaf44a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:13:51 -0700 Subject: [PATCH 19/97] docs(roadmap): reconciliation stays deferred, and now there is a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item read as an unfinished optimisation. It is a decided one, and the deciding evidence was missing: nobody had measured the rebuild it exists to avoid. Measured on a OnePlus 6. Thirty taps each triggering a complete rebuild of the weather card — 62 nodes, 28 live calls, the whole kit script re-evaluated, the widget tree discarded and rebuilt — take the same wall clock as thirty taps on a dead region. The difference sits inside the ±25 ms noise of the method and came out negative on one of two runs. The parts that sounded expensive are not. Realize plus lower is 0.14–0.22 ms. The `sys.*` helpers are URL-cached, so a rebuild's live calls are cache hits and not fetches. And an 11-node card costs the same per tap as a 62-node one, which is the tell: the fixed overhead dominates and the tree work is free at these sizes. So patching would save nothing measurable, while costing the renderer a per-record addressing scheme it does not have. That is a better reason to defer than the one previously recorded, which was only that a full rebuild is always correct. Two things WERE worth doing and are done. Work that accomplished nothing — a transition writing the value already in the cell reported a change, so tapping the selected chip rebuilt the card to redraw the identical screen. And the dependency tracking, which had three under-approximations that would each have put a stale value on screen; those were live defects in `dirty_records` and in the checker on their own merits, independent of whether patching ever ships. Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmap.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 01dff55..f093a48 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -430,9 +430,27 @@ Remaining: a shared helper definition. That mapping is what would end the duplication of `sys.*` across four hosts, where every divergence so far has produced a device-visible bug. -- **Reconciliation is derived but not applied.** `record_dependencies`, +- **Reconciliation is derived and deliberately not applied.** `record_dependencies`, `dirty_records` and `patch_points` compute what a state write invalidates; - realization still rebuilds the tree rather than patching those points. + realization still rebuilds the tree rather than patching those points. **Measured + on a OnePlus 6, that costs nothing worth recovering**: 30 taps each triggering a + complete rebuild of the weather card — 62 nodes, 28 live calls, the whole kit + script re-evaluated, the widget tree discarded and rebuilt — take the same wall + clock as 30 taps on a dead region, with the difference inside the ±25 ms noise of + the method and negative on one run. The parts assumed expensive are not: realize + plus lower is 0.14–0.22 ms, and the `sys.*` helpers are URL-cached, so a rebuild's + live calls are cache hits rather than fetches. A 62-node card and an 11-node card + cost the same, which is the tell that the fixed overhead dominates. So the tree + work is free at these card sizes, and patching would save nothing measurable while + costing the renderer a per-record addressing scheme it does not have. + + What *was* worth fixing is the work that accomplished nothing: a transition + writing the value already in the cell reported a change, so tapping the selected + chip rebuilt the card to redraw the identical screen. That is fixed. The + dependency tracking was also completed — three under-approximations, each of which + would have put a stale value on screen — but on its own merits: they were live + defects in `dirty_records` and in the checker regardless of whether patching ever + ships. Revisit if a card ever gets large enough to move the number. - **`makepad::lower` is in the wrong crate.** It belongs in a backend crate, not in `splash-core`, which otherwise names no renderer. - **L1 is implemented ahead of its specification.** A card declaring `level: L1` From cf6c879edc31efd6d85b7410739ac01259b355c3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:25:22 -0700 Subject: [PATCH 20/97] fix(ui_l0): a tint is as live as the number it tints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FOUND ON A PHONE. The top-movers list drew four positive rows and coloured two of them red — `+29.45%` red, `+29.20%` green, `+26.47%` red. The percentages were live calls and the tints were resolved from the seeded blob, whose signs ran `+ - + -`. That is the pattern that was on the screen, exactly. Two numbers describing one move, side by side, contradicting each other, and nothing on screen saying which to believe. Not introduced by making values live — REVEALED by it. Before that both halves were seeded, so both were stale together and agreed. Half a fix is what turned a hidden staleness into a visible contradiction, and that is the general lesson worth keeping: a value and its decoration must resolve from the same place, or fixing one of them makes the card worse. `direction` read the realized value and emitted a literal, never consulting the bindings, so a `tint:` bound to a source was never a call. It is now, at the row's own index, so the tint and the value cannot come from different rows. No kit change is needed, which is why this is small: `l0_tint` branches on `dir > 0` / `dir < 0`, so it takes the SIGN of whatever it is handed and a raw change value works exactly as `1` and `-1` did. A declared tint the backend cannot answer still falls back to the realized sign — the same choice every other binding makes. `None` now means "no tint declared" rather than "the seed said zero", so a live tint wraps even where the seeded sign was neutral. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 40 ++++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 51 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 4dc8e97..03f8228 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -7549,6 +7549,36 @@ pub mod kit { } } + /// The tint argument, as a LIVE call where the backend can answer it. + /// + /// `direction` reads the realized value, which is the seeded one. Once + /// `value:` went live the two disagreed on screen: the top-movers list drew + /// `+29.45%` in red because the seed behind that row was negative, and + /// `+29.20%` in green two rows down because that one was positive. Four + /// positive rows, two of them red — two numbers describing one move, + /// side by side, contradicting each other. That is precisely what §4 exists + /// to prevent, and making values live without making tints live is what + /// turned a hidden staleness into a visible contradiction. + /// + /// No kit change is needed: `l0_tint` branches on `dir > 0` / `dir < 0`, so + /// it takes the SIGN of whatever it is handed and a raw change value works + /// exactly as `1` or `-1` did. + /// + /// `None` means "do not tint at all" — no `tint:` was declared. A declared + /// tint the backend cannot answer still falls back to the realized sign, + /// which is the same choice every other binding makes. + fn tint_expr(node: &UiNode) -> Option { + if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == "tint") { + if let Some(call) = makepad::vm_call(binding) { + return Some(call); + } + } + match direction(node) { + 0 => None, + d => Some(d.to_string()), + } + } + fn children(node: &UiNode, depth: usize, out: &mut String) { out.push_str("[\n"); for (i, child) in node.children.iter().enumerate() { @@ -7816,7 +7846,10 @@ pub mod kit { let _ = write!(out, "{f}({}, {size:?})", scalar_of(node, "cond")); } "TextStat" => { - let _ = write!(out, "{f}({}, {})", makepad::valued(node), direction(node)); + // `l0_stat` takes the direction as a parameter rather than + // composing it, so an untinted stat passes the neutral `0`. + let dir = tint_expr(node).unwrap_or_else(|| "0".to_owned()); + let _ = write!(out, "{f}({}, {dir})", makepad::valued(node)); } // Five text roles may carry a tint, and the stock LIST tints a // `TextValue` while the detail tints a `TextStat`. Lowering only the @@ -7824,12 +7857,13 @@ pub mod kit { // percentages rendered white, and "this one fell" stopped being // said at all. `tint` is §1.1's instructive case for exactly this: // red-versus-green is presentation, but the SIGN is meaning. - "TextTitle" | "TextBody" | "TextCaption" | "TextValue" if direction(node) != 0 => { + "TextTitle" | "TextBody" | "TextCaption" | "TextValue" if tint_expr(node).is_some() => { // Always through `valued`: it falls back to `text:` and // decorates either way. Branching here meant a `text:` carrying a // `suffix:` skipped the decoration entirely. let body = makepad::valued(node); - let _ = write!(out, "l0_tinted({f}({body}), {})", direction(node)); + let dir = tint_expr(node).unwrap_or_else(|| "0".to_owned()); + let _ = write!(out, "l0_tinted({f}({body}), {dir})"); } // The five data visualisations. Each takes its declared arguments in // the catalog's order — no defaults, because a bar drawn against a diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index bdc1ea0..4c0f35f 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5344,3 +5344,54 @@ view root Surface { when n == m * 2 { Rule() } } "#; assert!(check_ui_l0_named("g", HONEST).valid, "{}", why(HONEST)); } + +/// A tint must be as live as the number it tints. +/// +/// FOUND ON A PHONE, not in a test. The top-movers list drew four positive +/// rows and coloured two of them red: `+29.45%` red, `+29.20%` green, +/// `+26.47%` red. The percentages were live calls and the tints were resolved +/// from the seeded blob, whose signs happened to run `+ - + -`. Two numbers +/// describing one move, side by side, contradicting each other — which is the +/// failure §4 exists to prevent. +/// +/// This was not introduced by making values live; it was REVEALED by it. Before +/// that both were seeded, so both were stale together and agreed. Half a fix is +/// what turned a hidden staleness into a visible contradiction, and that is the +/// general lesson: a value and its decoration must resolve from the same place. +#[test] +fn a_tint_is_as_live_as_the_value_it_tints() { + let data = serde_json::json!({ + "movers": [ + {"ticker":"NVDA","name":"Nvidia","last":1.0,"change":0.5,"pct":2.0}, + {"ticker":"AAPL","name":"Apple","last":2.0,"change":-0.5,"pct":-1.0}], + "quote": {}, "series": {}, "selected": "", "range": "m1", + "env": {"locale": {}}, "copy": {"movers": "Top Movers"} + }); + let root = realize(STOCK, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + + // The tint is a CALL, at the row's own index — not the seeded sign. + for row in 0..2 { + let want = format!(r#"sys.movers({row}, "change""#); + assert!( + dsl.contains(&want), + "row {row} must tint from a live call:\n{dsl}" + ); + } + // And the seeded signs must be gone. `-1` was what the second row lowered + // to, and it is exactly what made a rising stock render red. + assert!( + !dsl.contains("l0_tinted(l0_value(sys.movers(1, \"changepct\", \"\")), -1)"), + "a seeded tint beside a live value is the defect:\n{dsl}" + ); + // A row's tint and its value must read the SAME row. One shared index, or + // an index that does not advance, is how they disagree in the first place. + let first = + dsl.find(r#"l0_tinted(l0_value(sys.movers(1, "changepct", "")), sys.movers(1, "change""#); + assert!( + first.is_some(), + "value and tint must come from one row:\n{dsl}" + ); +} From 2083a5f572742371521689618ac14441292ef201 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:37:52 -0700 Subject: [PATCH 21/97] fix(ui_l0): a scalar argument is live where the backend can answer it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third place that read a realized value and emitted a literal without consulting the bindings. `WeatherIcon(cond: d.cond)` lowered the weather code realization happened to see: right by accident against a seed blob, and on a live card — which carries no blob — every icon in a seven-day forecast fell back to the same default. This is the icon, so it is the hardest of the three to notice. A wrong number can be checked against another number on the same screen, which is how the tint was caught. A wrong icon looks exactly like a right one. Found by writing a card that had not existed before: a composed city-picks app reading `sys.cities`, where the whole row is live and the icon was the one element still seeded. The weather card's hero condition now lowers to `sys.weatherword(…)` rather than to its realized code, and the test that pinned the literal is updated to pin the call — the size still has to travel with it, which was that test's original defect and is unrelated to where the value comes from. A loop row whose per-index condition the backend cannot answer still falls back, which is the same choice every other binding makes. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 14 ++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 13 +++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 03f8228..13c42f8 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -7641,7 +7641,21 @@ pub mod kit { /// arity, and a card that failed to supply a bound would otherwise not /// parse. Zero at least draws something visibly wrong, where a parse error /// takes the whole card down. + /// A scalar argument — live where the backend can answer it. + /// + /// The bindings were never consulted here, so `WeatherIcon(cond: d.cond)` + /// lowered the weather code realization happened to see. Against a seed blob + /// that is right by accident; on a live card, which carries no blob, every + /// icon in a seven-day forecast fell back to the same default. Exactly the + /// defect `tint` had, in the other place that reads a realized value and + /// emits a literal — and the icon is the harder one to notice, because a + /// wrong icon looks precisely like a right one. fn scalar_of(node: &UiNode, name: &str) -> String { + if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == name) { + if let Some(call) = makepad::vm_call(binding) { + return call; + } + } match arg(node, name) { Some(NodeValue::Number(n)) => makepad::trim_num(*n), Some(NodeValue::Text(t)) => format!("{t:?}"), diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 4c0f35f..77e0f9f 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4687,10 +4687,15 @@ view root Surface { dsl.contains("l0_aligned(l0_col(") && dsl.contains(", \"center\")"), "align: .center must reach the kit:\n{dsl}" ); - // A NUMBER. Matching only text and tokens emptied every one of these. - assert!( - dsl.contains("l0_weathericon(2, \"hero\")"), - "a numeric cond and its size must both reach the kit:\n{dsl}" + // The hero's condition is LIVE. This asserted `l0_weathericon(2, "hero")` — + // the realized number — which was right while no scalar consulted its + // binding. A `cond` the backend can answer now lowers to the call, so the + // icon is the code that is true when the card draws rather than the one the + // host happened to seed. The size must still travel with it: that half was + // the original defect and is unrelated to where the value comes from. + assert!( + dsl.contains("l0_weathericon(sys.weatherword(") && dsl.contains(", \"hero\")"), + "the hero's cond must go live and keep its size:\n{dsl}" ); // And the per-item conditions must DIFFER — one shared value for every row // is exactly what the bug produced, and a test that only checked "a number From e37cb294fc0a3ec4a7b8bd99e8a711b1453bf36b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:48:10 -0700 Subject: [PATCH 22/97] fix(ui_l0): an L1 operand that is a live call is coerced to a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FOUND ON A PHONE, and invisible to the whole crate. Every `sys.*` helper answers with a STRING — that is what a card renders and what concatenation composes, so `"$" + sys.stock(…)` is how every live value reaches the screen. Arithmetic needs the other thing, and the VM subtracts strings to NaN: the composed city card drew `≈NaN°` in every row while the temperature and humidity beside it, from the same capability and the same row, were correct. So §9.5's claim was half true. The backend did receive the SHAPE and did emit the arithmetic; the arithmetic just could not run. Nothing in this crate could have caught it — the DSL was well-formed, the operands were the right calls at the right indices, and 208 tests were green. `render_expr` now wraps each live operand in `sys.num(…)`, a coercion added alongside the helpers rather than folded into them: `geocodenum` and `aqinum` exist because two specific fields were needed as numbers by other calls, whereas L1 can ask for arithmetic over any numeric field of any capability. The coercion belongs at the value, not at the source. A constant is not wrapped — it is already a number in the DSL — and a non-numeric value yields NaN rather than zero, because a zero is a fabricated number and arithmetic that treats missing data as nothing is the failure §4 exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 13 +++++++- crates/splash-ui-l0/tests/profile.rs | 50 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 13c42f8..68f8956 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5467,7 +5467,18 @@ pub mod makepad { pub(super) fn render_expr(part: &ExprPart) -> Option { match part { ExprPart::Const(v) => Some(v.clone()), - ExprPart::Call(binding) => vm_call(binding), + // A live call is COERCED. Every `sys.*` helper answers with a + // string, because a string is what a card renders and what + // concatenation composes — `"$" + sys.stock(…)` is how every live + // value reaches the screen. Arithmetic needs the other thing, and + // string subtraction evaluates to NaN: measured on device, an L1 + // card drew "≈NaN°" in every row while every other value on the same + // row was correct. + // + // The coercion is emitted here rather than assumed of the helpers, + // because L1 can ask for arithmetic over any numeric field of any + // capability and the helpers cannot all change shape for it. + ExprPart::Call(binding) => vm_call(binding).map(|c| format!("sys.num({c})")), ExprPart::Bin(lhs, op, rhs) => Some(format!( "({} {op} {})", render_expr(lhs)?, diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 77e0f9f..2b9802c 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5400,3 +5400,53 @@ fn a_tint_is_as_live_as_the_value_it_tints() { "value and tint must come from one row:\n{dsl}" ); } + +/// An L1 operand that is a live call is COERCED to a number. +/// +/// FOUND ON A PHONE. Every `sys.*` helper answers with a string — that is what a +/// card renders and what concatenation composes, so `"$" + sys.stock(…)` is how +/// every live value reaches the screen. Arithmetic needs the other thing, and +/// string subtraction evaluates to NaN: the composed city card drew `≈NaN°` in +/// every row while the temperature and humidity beside it were correct. +/// +/// So §9.5's claim — that a backend receives the shape and evaluates the +/// arithmetic against data arriving later — was true of the shape and false of +/// the arithmetic, and nothing in the crate could have caught it: the DSL was +/// well-formed and the operands were the right calls. +#[test] +fn an_l1_operand_that_is_a_live_call_is_coerced_to_a_number() { + const CARD: &str = r#"# level: L1 +source q sys.quote(ticker: "NVDA", fields: [last, open]) +view root Surface { TextHero(value: q.last - q.open) } +"#; + let data = serde_json::json!({ "q": { "last": 5.0, "open": 4.0 }, "env": { "locale": {} } }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert!( + dsl.contains("sys.num(sys.stock("), + "a live operand must be coerced, or the VM subtracts strings:\n{dsl}" + ); + // Both sides, and the tree's shape preserved around them. + assert_eq!( + dsl.matches("sys.num(").count(), + 2, + "each live operand is coerced once:\n{dsl}" + ); + // A constant needs no coercion — it is already a number in the DSL. + const COEFF: &str = r#"# level: L1 +source now sys.weather(lat: 1.0, lon: 2.0, fields: [temp]) +view root Surface { TextHero(value: now.temp * 9) } +"#; + let d2 = serde_json::json!({ "now": { "temp": 10.0 }, "env": { "locale": {} } }); + let r2 = realize(COEFF, &d2, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl2 = splash_ui_l0::kit::lower(&r2); + assert_eq!( + dsl2.matches("sys.num(").count(), + 1, + "only the call is coerced, not the coefficient:\n{dsl2}" + ); +} From fdf8b88311b721ae38d91c39b973160c0d09e14a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:57:14 -0700 Subject: [PATCH 23/97] test(ui_l0): make the catalog executable, and fix the column count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog says what a card MAY say. Nothing connected it to what a backend DOES with what was said, so "the profile accepts it" and "the screen shows it" were independent facts — and the gap was invisible, because a test asserts what the code does and the code was the thing dropping the attribute. Measured cost of relying on discipline instead: `width`, `align`, a numeric `cond`, `tint`, every scalar argument, an L1 expression and the whole `Map` role were each admitted and then dropped or frozen. Six were found by looking at a phone. The design named the generator — the kit's own comment says "each attribute the catalog admits needs an entry HERE or it is accepted by the profile and silently discarded, which is this layer's recurring defect" — and answered it with care rather than with structure. Two conformance tests over `catalog::CONSTRUCTORS`, both DIFFERENTIAL, because that is the only formulation that survives contact: - **Reachability.** Two cards differing in one attribute's value must lower differently. A marker-based first attempt reported nine false positives, because `unit: .money` reaches the output as `$` and `tint:` as a direction rather than as the value. - **Liveness.** The same attribute bound once to a source the backend answers and once to a state cell holding the same number must lower differently. My first attempt asserted only that SOME call appeared anywhere — with every argument bound that passed even with `tint` deliberately broken, so I checked both tests against deliberate regressions before trusting them. Both now catch them. `Grid.cols` is fixed: the column count was hardcoded to two, which is what every card in the corpus asks for, so a `Grid(cols: 3)` rendered as pairs with nothing saying it had been overruled. Two allowlists record what is still broken, and may only shrink. The honest contents: - **`Map` is lowered by neither backend.** So `tests/fixtures/nav.card` — which §1.0 cites as settling its central argument, "the same screen as the 664-line L2 exemplar in 54 lines" — draws an error box where the map goes. Admitted at L0 is true; the same screen is not. - **`unit` reaches neither backend as a token.** `.c` and `.f` both lower to `value + "°"`, byte for byte, so the weather card's units toggle — the one interaction it advertises, wired through state, dispatch and re-render — changes nothing, and temperatures are always Celsius. The spec says "do not convert: the runtime formats by the unit token"; the runtime never receives the token. Closing it is a design decision, not a patch. - `Surface`/`Photo` `pad`, and `width` on a `Field`. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 18 +- crates/splash-ui-l0/tests/profile.rs | 312 +++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 68f8956..53bb294 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6189,13 +6189,25 @@ pub mod makepad { let _ = writeln!(out, "{p}}}"); } "Grid" => { - // Two columns, emitted as rows of two so the existing layout - // engine needs no grid primitive. + // Emitted as rows of `cols` so the existing layout engine needs + // no grid primitive. + // + // The column count was hardcoded to two, which is what every + // card in the corpus asks for — so `cols:` was accepted by the + // catalog, checked, and then ignored, and a `Grid(cols: 3)` + // rendered as pairs with nothing saying it had been overruled. + // Found by the conformance test rather than by a card. + let cols = match arg(node, "cols") { + Some(NodeValue::Number(n)) if *n >= 1.0 => *n as usize, + // A grid that did not say is a grid of two, which is what + // the corpus means by a detail grid. + _ => 2, + }; let _ = writeln!( out, "{p}View{{ width: Fill height: Fit flow: Down spacing: 8" ); - for pair in node.children.chunks(2) { + for pair in node.children.chunks(cols) { let _ = writeln!( out, "{p} View{{ width: Fill height: Fit flow: Right spacing: 8" diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 2b9802c..f029218 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5450,3 +5450,315 @@ view root Surface { TextHero(value: now.temp * 9) } "only the call is coerced, not the coefficient:\n{dsl2}" ); } + +// ─── the catalog, made executable ───────────────────────────────────────────── +// +// The catalog says what a card MAY say. Nothing connected it to what a backend +// DOES with what was said, so "the profile accepts it" and "the screen shows it" +// were independent facts — and the gap was invisible, because a test asserts +// what the code does, and the code was the thing dropping the attribute. +// +// Measured cost of relying on discipline instead: `width`, `align`, a numeric +// `cond`, `tint`, every scalar argument, and the whole `Map` role were each +// admitted by the catalog and then silently dropped or frozen by a lowering. +// Six were found by looking at a phone rather than by any test. +// +// The check is DIFFERENTIAL rather than by marker: build two cards that differ +// only in one attribute's value and require the lowered output to differ. That +// asks the only question worth asking — "does changing this change the screen?" +// — and needs no knowledge of how each attribute renders. A marker-based version +// of this test reported nine false positives, because `unit: .money` reaches the +// output as `$` and `tint:` as a direction rather than as the value. + +/// Two distinct values for one attribute, as written in a card. +fn two_values(kind: &splash_ui_l0::catalog::ArgKind) -> Option<(String, String)> { + use splash_ui_l0::catalog::ArgKind::*; + Some(match kind { + // Every token, not a chosen pair: the first is usually the DEFAULT and + // a default legitimately emits nothing, so `start`-vs-`baseline` would + // report a working `align` as inert. The caller requires only that SOME + // token changes the output. + Token(set) | TokenOrPath(set) => { + if set.len() < 2 { + return None; + } + (format!(".{}", set[0]), format!(".{}", set[1])) + } + // Small and adjacent: a column count only shows up against a child + // count, so 11-vs-4242 chunked four children into one row either way and + // reported a working `cols:` as inert. + Number => ("1".into(), "3".into()), + Text | Any => ("\"P1\"".into(), "\"P4242\"".into()), + // Bound paths, so §4 is satisfied. The two differ in SIGN as well as + // magnitude: `tint` lowers to a DIRECTION, so two positive values are + // indistinguishable and the probe would call a working tint inert. + Path | Data => ("sa".into(), "sc".into()), + Event => ("ev_a".into(), "ev_b".into()), + // A predicate has no second form to vary that is not also a different + // shape; `a_predicate_argument_evaluates_to_a_boolean` covers it. + Bool => return None, + }) +} + +/// Attributes the catalog admits whose value changes NOTHING in either lowering. +/// +/// This list may only shrink, and shrinking it is a deliberate edit. A new entry +/// means an attribute was added to the catalog and to no lowering — a card that +/// is accepted, renders, and is wrong. +const INERT: &[(&str, &str)] = &[ + // Dropped by both. Every card in the corpus passes `.page`, which is also + // the hardcoded default, so this is invisible today and wrong the first time + // a card asks for `.tight`. + ("Surface", "pad"), + ("Photo", "pad"), + // THE WHOLE ROLE. `Map` is admitted, checked, and lowered by neither + // backend: the kit emits `l0_unsupported("Map")` and makepad emits + // "no makepad lowering for Map". So `tests/fixtures/nav.card` — which §1.0 + // cites as settling its central argument, "the same screen as the 664-line + // L2 exemplar in 54 lines" — draws an error box where the map goes. Admitted + // at L0 is true; the same screen is not. Closing it needs a kit function and + // a node kind, which live in other repos. + ("Map", "from"), + ("Map", "to"), + ("Map", "via"), + ("Map", "mode"), + ("Map", "zoom"), + // `unit` reaches NEITHER backend as a token: `.c` and `.f` both lower to + // `value + "°"`, byte for byte. So the weather card's units toggle — the one + // interaction it advertises, wired through state, dispatch and re-render — + // changes nothing on screen, and the temperature is always Celsius because + // the live call asks open-meteo for `current.temperature_2m` with no unit. + // + // The spec says "do not convert: the runtime formats by the unit token". + // The runtime never receives the unit token. Closing it is a DESIGN decision + // rather than a patch: either the unit travels to the helper (so the fetch + // asks for the right one) or the card is allowed to convert, and the second + // is what L0 has no expression form for. + ("TextHero", "unit"), + ("TextCaption", "unit"), + ("TextValue", "unit"), + ("Tile", "unit"), + // A text input is not wrapped by the width composer — the `Field` branch + // returns before it — so a field cannot be told how wide to be. + ("Field", "width"), +]; + +#[test] +fn changing_a_declared_attribute_must_change_the_lowering() { + const CONTAINERS: &[&str] = &["Surface", "Photo", "Panel", "Card", "Col", "Row", "Grid"]; + let mut inert: Vec<(String, String)> = Vec::new(); + + for (role, args) in splash_ui_l0::catalog::CONSTRUCTORS { + for (attr, kind) in *args { + let Some((first, second)) = two_values(kind) else { + continue; + }; + // Every OTHER attribute is filled too, so the role is instantiated + // the way a card would instantiate it — several roles refuse a + // partial argument set, and a probe that omits them tests nothing. + let has_value = args.iter().any(|(n, _)| *n == "value"); + let others: Vec = args + .iter() + .filter(|(n, _)| n != attr) + // `valued()` prefers `value:` over `text:` deliberately, so a + // probe that fills both is asking which one wins, not whether + // `text` reaches anything. + .filter(|(n, _)| !(has_value && *n == "text")) + // and the converse: probing `text` while `value` is filled asks + // which wins, not whether `text` reaches anything. + .filter(|(n, _)| !(*attr == "text" && *n == "value")) + .filter_map(|(n, k)| two_values(k).map(|(v, _)| format!("{n}: {v}"))) + .collect(); + let build = |value: &str| { + let mut all = vec![format!("{attr}: {value}")]; + all.extend(others.iter().cloned()); + let arglist = format!("({})", all.join(", ")); + // Four children, because a column count is only observable + // against something to divide: one child chunks into one row + // whatever `cols:` says, which reported a working `cols` inert. + let body = if CONTAINERS.contains(role) { + " { Rule() Rule() Rule() Rule() }" + } else { + "" + }; + let head = "state sa { shape: number, initial: 11 }\n\ + state sb { shape: number, initial: 4242 }\n\ + state sc { shape: number, initial: -7 }\n\ + event ev_a { sa: set(1) }\n\ + event ev_b { sa: set(2) }\n"; + if *role == "Surface" || *role == "Photo" { + format!("{head}view root {role}{arglist} {{ Rule() Rule() Rule() Rule() }}\n") + } else { + format!("{head}view root Surface {{ {role}{arglist}{body} }}\n") + } + }; + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let lower = |card: &str| -> Option { + if !check_ui_l0_named("probe", card).valid { + return None; + } + let root = realize(card, &data, RealizeLimits::default()).root?; + Some(format!( + "{}\n{}", + splash_ui_l0::kit::lower(&root), + makepad::lower(&root) + )) + }; + let (a, b) = (build(&first), build(&second)); + let (Some(la), Some(lb)) = (lower(&a), lower(&b)) else { + // A probe the checker refuses proves nothing about the lowering. + // Skipped rather than failed: several roles constrain their + // arguments against each other in ways this generator does not + // model, and a false failure here would train people to ignore + // the list. + continue; + }; + if la == lb { + inert.push((role.to_string(), attr.to_string())); + } + } + } + + let known: Vec<(String, String)> = INERT + .iter() + .map(|(r, a)| (r.to_string(), a.to_string())) + .collect(); + let mut fresh: Vec<_> = inert.iter().filter(|p| !known.contains(p)).collect(); + fresh.sort(); + assert!( + fresh.is_empty(), + "changing these attributes changed NOTHING in either lowering — the \ + catalog admits them, the checker accepts them, and the screen ignores \ + them: {fresh:#?}" + ); + let mut fixed: Vec<_> = known.iter().filter(|p| !inert.contains(p)).collect(); + fixed.sort(); + assert!( + fixed.is_empty(), + "these now reach a lowering — delete them from INERT: {fixed:#?}" + ); +} + +/// A bound attribute must lower to a LIVE CALL where the backend can answer it. +/// +/// The companion to the reachability check, and the half that caught more. An +/// attribute can reach a lowering and still be wrong: `tint`, every scalar, a +/// `value:` and a tap payload all reached the output as the number REALIZATION +/// happened to see — right by accident against a seed blob, and on a live card, +/// which carries no blob, frozen or empty. +/// +/// That failure is worse than a drop, because a drop leaves a gap and this +/// leaves a plausible number. It is what put a stale `$181` open under a live +/// `$207` price, and a red `+29.45%` beside a green `+29.20%`. +/// +/// DIFFERENTIAL, and it has to be: the same attribute is bound once to a source +/// the backend answers and once to a state cell holding the same number. A +/// lowering that emits the call distinguishes them; one that reaches for the +/// realized value cannot, and produces identical output. An earlier version of +/// this test asserted only that SOME call appeared anywhere in the output — with +/// every argument bound, that passed even with `tint` deliberately broken. +#[test] +fn a_bound_attribute_must_lower_to_a_live_call() { + use splash_ui_l0::catalog::ArgKind; + const CONTAINERS: &[&str] = &["Surface", "Photo", "Panel", "Card", "Col", "Row", "Grid"]; + // Lowered by neither backend, so there is no call to look for. Tracked in + // INERT rather than counted twice. + const SKIP_ROLES: &[&str] = &["Map"]; + + let mut stale: Vec<(String, String)> = Vec::new(); + let mut checked = 0usize; + for (role, args) in splash_ui_l0::catalog::CONSTRUCTORS { + if SKIP_ROLES.contains(role) { + continue; + } + for (attr, kind) in *args { + if !matches!(kind, ArgKind::Path | ArgKind::Data) { + continue; + } + // `one` is what varies: the attribute under test binds a SOURCE in + // the first card and a STATE in the second. Everything else stays + // source-bound in both, so any difference is this attribute's. + let build = |one: &str| { + let all: Vec = args + .iter() + .filter_map(|(n, k)| match k { + ArgKind::Path | ArgKind::Data if n == attr => Some(format!("{n}: {one}")), + ArgKind::Path | ArgKind::Data => Some(format!("{n}: q.last")), + ArgKind::Text => Some(format!("{n}: q.name")), + ArgKind::Token(set) | ArgKind::TokenOrPath(set) => { + Some(format!("{n}: .{}", set[0])) + } + ArgKind::Number => Some(format!("{n}: 2")), + _ => None, + }) + .collect(); + let arglist = format!("({})", all.join(", ")); + let body = if CONTAINERS.contains(role) { + " { Rule() }" + } else { + "" + }; + let head = "source q sys.quote(ticker: \"NVDA\", fields: [last, name])\n\ + state held { shape: number, initial: 1 }\n"; + if *role == "Surface" || *role == "Photo" { + format!("{head}view root {role}{arglist} {{ Rule() }}\n") + } else { + format!("{head}view root Surface {{ {role}{arglist}{body} }}\n") + } + }; + // The state's initial IS the seeded source value, so the two cards + // realize to the same number and only the LOWERING can tell them + // apart. + let data = serde_json::json!({ + "q": { "last": 1.0, "name": "seeded" }, "held": 1.0, + "env": { "locale": {} }, "copy": {} + }); + let lower = |card: &str| -> Option { + if !check_ui_l0_named("probe", card).valid { + return None; + } + let root = realize(card, &data, RealizeLimits::default()).root?; + Some(format!( + "{}\n{}", + splash_ui_l0::kit::lower(&root), + makepad::lower(&root) + )) + }; + let (Some(live), Some(from_state)) = (lower(&build("q.last")), lower(&build("held"))) + else { + continue; + }; + checked += 1; + if live == from_state { + stale.push((role.to_string(), attr.to_string())); + } + } + } + assert!( + checked >= 20, + "the probe generator built almost nothing ({checked} attributes) — a \ + vacuous pass here is worse than a failure" + ); + + let known: Vec<(String, String)> = STALE + .iter() + .map(|(r, a)| (r.to_string(), a.to_string())) + .collect(); + let mut fresh: Vec<_> = stale.iter().filter(|p| !known.contains(p)).collect(); + fresh.sort(); + assert!( + fresh.is_empty(), + "these are bound to a capability the backend ANSWERS and still lower to \ + the realized value — on a live card, which carries no seed blob, they \ + render frozen or empty: {fresh:#?}" + ); + let mut fixed: Vec<_> = known.iter().filter(|p| !stale.contains(p)).collect(); + fixed.sort(); + assert!( + fixed.is_empty(), + "these now lower to a call — delete them from STALE: {fixed:#?}" + ); +} + +/// Bound attributes that still lower to the realized value. Must only shrink. +const STALE: &[(&str, &str)] = &[]; From ebc0c470f4efd1a5380c959a145983bd5c3c42a1 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:15:20 -0700 Subject: [PATCH 24/97] feat(ui_l0): a Map draws a live route, and gps/search answer live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Map` was admitted by the catalog and lowered by NEITHER backend, so `tests/fixtures/nav.card` — which §1.0 cites as settling its central argument, "the same screen as the 664-line L2 exemplar in 54 lines" — drew "no makepad lowering for Map" where the map goes. Admitted at L0 was true; the same screen was not. The conformance test found it; no card would have, because the card is correct. Three things, and the order matters. **`sys.gps` and `sys.search` gained `vm_call` arms.** Both exist in the VM — `gps`, `search` and `searchnum` are all registered — and neither was translated, so every coordinate a nav card read fell back to the seed. I had first concluded the VM lacked them, from a grep that only matched string literals and missed every `id_lut!` registration; the helpers were there the whole time. **`Map` lowers to a real `MapView`.** The catalog note says the widget fetches its own route. It does not: `nav_polyline` is a live field `MapView` renders and never populates. But `sys.navroute` answers the polyline, and the helper's own comment prescribes this exact pairing — so the fetch is the card's declared source resolved into a call, the shape every other live value already takes. A trip endpoint names a SOURCE rather than coordinates, so each is asked for its own axis: `from: here` becomes `sys.gps("lat")` and `sys.gps("lon")`. MapView{ nav_mode: "3d" zoom: 16 center_lat: sys.gps("lat") center_lon: sys.gps("lon") nav_polyline: sys.navroute(sys.gps("lat"), sys.gps("lon"), sys.searchnum("SFO", 0, "lat"), sys.searchnum("SFO", 0, "lon"), "polyline") } **A test that pins it**, including that none of the seeded coordinates appear as literals — the failure mode here is a plausible map centred on last week's position, not a blank one. What is NOT closed, and is recorded rather than guessed: - **The kit still emits `l0_unsupported("Map")`.** The device renders through the kit, so nav's map is still a placeholder ON DEVICE. Closing it needs an `l0_map` in `_kit.splash` and a `NodeKind::Map` arm in the host's widget emitter — both in other repos. `NodeKind::Map` and `NavMap` already exist and already parse, so the node half is done. - **`via` is not emitted.** The helper carries it in a sixth argument and threading a card's list through needs that list rendered the way `sys.route` renders it. - **`sys.gps` is fed by the Android LocationListener**, which is the path that currently crashes the shipping activity on a missing `LocationListener$-CC` desugaring class. This arm makes the card correct; it does not make GPS usable. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 97 ++++++++++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 91 ++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 11 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 53bb294..566bbbf 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5641,6 +5641,40 @@ pub mod makepad { // LIVE card does not have -- so every venue row rendered an em dash // and the card looked like a fetch that never landed. Measured: // "list museums in Kyoto" produced eight rows of "—". + // The device's last-known fix, read synchronously from the platform + // global. No network, so unlike every other arm here it cannot be + // pending — but it CAN be absent, and the VM answers -9999 for a + // number with no fix rather than an em dash. A card guards with + // `ok` before trusting the coordinates. + // + // On Android this is fed by the platform LocationListener, which is + // the same path that currently crashes the shipping activity with a + // missing `LocationListener$-CC` desugaring class. So this arm makes + // the card correct and does not by itself make GPS usable. + "sys.gps" => { + let key = match binding.field.as_str() { + "accuracy" => "acc", + f @ ("lat" | "lon" | "ok") => f, + _ => return None, + }; + Some(format!("sys.gps({key:?})")) + } + // Free-text PLACE search, indexed like `sys.places`. `searchnum` + // answers the numbers, `search` the words — the same split + // `geocode`/`geocodenum` uses, and for the same reason: a coordinate + // fed to another call has to arrive as a number. + "sys.search" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let query = arg("query")?; + match field { + "lat" | "lon" => Some(format!("sys.searchnum({query:?}, {index}, {field:?})")), + "name" => Some(format!("sys.search({query:?}, {index}, \"name\")")), + // `id` and `distance` have no answer in the helper, so they + // fall back rather than emitting a call that returns "". + _ => None, + } + } "sys.places" => { let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; @@ -6128,6 +6162,69 @@ pub mod makepad { children(node, depth, out); let _ = writeln!(out, "{p}}}"); } + // The card names a TRIP; the widget draws the route. + // + // `Map` was admitted by the catalog and lowered by NEITHER backend, + // so `nav.card` — which §1.0 cites as settling its central argument, + // "the same screen as the 664-line L2 exemplar in 54 lines" — drew + // "no makepad lowering for Map" where the map goes. Admitted at L0 + // was true; the same screen was not. + // + // `MapView` does not fetch its own route despite the catalog note + // saying so: `nav_polyline` is a live field it renders and does not + // populate. But `sys.navroute` answers the polyline, and the helper's + // own comment prescribes exactly this pairing — so the fetch is the + // card's declared source resolved into a call, which is the same + // shape every other live value takes. + "Map" => { + // Both endpoints are SOURCE names rather than coordinates, so + // each is asked for its own axis: `from: here` becomes + // `sys.gps("lat")` and `sys.gps("lon")`. + let coord = |name: &str, axis: &str| -> Option { + let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; + // A collection answers `0.lat` and a scalar source answers + // `lat`; try the scalar spelling first. + for field in [axis.to_owned(), format!("0.{axis}")] { + let probe = SourceBinding { + field, + ..binding.clone() + }; + if let Some(call) = vm_call(&probe) { + return Some(call); + } + } + None + }; + // The widget's own vocabulary: 1 is the chase camera, 2 the + // north-up route preview, anything else a flat map. + let mode = match arg(node, "mode") { + Some(NodeValue::Token(t)) if t == "drive" => "3d", + Some(NodeValue::Token(t)) if t == "plan" => "plan", + _ => "", + }; + let zoom = match arg(node, "zoom") { + Some(NodeValue::Number(n)) => trim_num(*n), + _ => "15".to_owned(), + }; + let _ = write!( + out, + "{p}MapView{{ width: Fill height: 240 nav_mode: {mode:?} zoom: {zoom}" + ); + if let (Some(a), Some(o)) = (coord("from", "lat"), coord("from", "lon")) { + let _ = write!(out, " center_lat: {a} center_lon: {o}"); + if let (Some(b), Some(p2)) = (coord("to", "lat"), coord("to", "lon")) { + // `via` is carried by the helper's sixth argument and is + // not emitted yet: it is a value the card supplies as a + // list, and threading it needs the list rendered the way + // `sys.route` renders it. Recorded rather than guessed. + let _ = write!( + out, + " nav_polyline: sys.navroute({a}, {o}, {b}, {p2}, \"polyline\")" + ); + } + } + let _ = writeln!(out, " }}"); + } "Photo" => { // Overlay: photo, scrim, then the column. A `Fit` overlay takes // its tallest child, so the image needs a fixed height or the diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index f029218..ecfc396 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5511,18 +5511,16 @@ const INERT: &[(&str, &str)] = &[ // a card asks for `.tight`. ("Surface", "pad"), ("Photo", "pad"), - // THE WHOLE ROLE. `Map` is admitted, checked, and lowered by neither - // backend: the kit emits `l0_unsupported("Map")` and makepad emits - // "no makepad lowering for Map". So `tests/fixtures/nav.card` — which §1.0 - // cites as settling its central argument, "the same screen as the 664-line - // L2 exemplar in 54 lines" — draws an error box where the map goes. Admitted - // at L0 is true; the same screen is not. Closing it needs a kit function and - // a node kind, which live in other repos. + // `from`/`to`/`via` name SOURCES, and this check varies a state-bound value + // — which a `Map` correctly ignores, because a trip endpoint is a place and + // not a number. Their liveness is asserted by + // `a_bound_attribute_must_lower_to_a_live_call` instead, which binds a real + // capability. `via` is additionally not emitted yet: the helper carries it in + // a sixth argument and threading a card's list through needs the list + // rendered the way `sys.route` renders it. ("Map", "from"), ("Map", "to"), ("Map", "via"), - ("Map", "mode"), - ("Map", "zoom"), // `unit` reaches NEITHER backend as a token: `.c` and `.f` both lower to // `value + "°"`, byte for byte. So the weather card's units toggle — the one // interaction it advertises, wired through state, dispatch and re-render — @@ -5661,8 +5659,10 @@ fn changing_a_declared_attribute_must_change_the_lowering() { fn a_bound_attribute_must_lower_to_a_live_call() { use splash_ui_l0::catalog::ArgKind; const CONTAINERS: &[&str] = &["Surface", "Photo", "Panel", "Card", "Col", "Row", "Grid"]; - // Lowered by neither backend, so there is no call to look for. Tracked in - // INERT rather than counted twice. + // `Map` is checked by `a_map_lowers_a_live_route` instead. This generator + // binds every argument to one capability, and a trip endpoint is a PLACE — + // asking `sys.quote` for a latitude answers nothing, so the generic probe + // would report a working `Map` as stale. const SKIP_ROLES: &[&str] = &["Map"]; let mut stale: Vec<(String, String)> = Vec::new(); @@ -5762,3 +5762,72 @@ fn a_bound_attribute_must_lower_to_a_live_call() { /// Bound attributes that still lower to the realized value. Must only shrink. const STALE: &[(&str, &str)] = &[]; + +/// A `Map` draws a LIVE route between two declared places. +/// +/// `Map` was admitted by the catalog and lowered by neither backend, so +/// `nav.card` — which §1.0 cites as settling its central argument, "the same +/// screen as the 664-line L2 exemplar in **54 lines**" — drew an error box where +/// the map goes. Admitted at L0 was true; the same screen was not. +/// +/// The catalog note said the widget fetches its own route. It does not: +/// `nav_polyline` is a live field `MapView` renders and never populates. So the +/// fetch is the card's declared source resolved into a call, which is the shape +/// every other live value already takes — and the pairing the helper's own +/// comment prescribes. +#[test] +fn a_map_lowers_a_live_route() { + const CARD: &str = r#" +source here sys.gps() +source dest sys.search(query: state.q, count: 1, fields: [name, lat, lon]) +state q { shape: text, initial: "SFO" } +view root Surface { + TextRow(text: dest.0.name) + Map(mode: .drive, from: here, to: dest, zoom: 16) +} +"#; + let report = check_ui_l0_named("nav", CARD); + assert!(report.valid, "{:#?}", report.diagnostics); + // Seeded coordinates the lowering COULD fall back to. Reaching for them + // instead of emitting the calls is the defect. + let data = serde_json::json!({ + "here": { "lat": 37.3, "lon": -122.0, "ok": 1 }, + "dest": { "0": { "name": "X", "lat": 37.4, "lon": -121.9 } }, + "q": "SFO", "env": { "locale": {} }, "copy": {} + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let mk = makepad::lower(&root); + + assert!( + !mk.contains("no makepad lowering for Map"), + "the role must lower at all:\n{mk}" + ); + // The camera is the widget's own vocabulary, not L0's token. + assert!( + mk.contains("nav_mode: \"3d\"") && mk.contains("zoom: 16"), + "the declared mode and zoom must reach the widget:\n{mk}" + ); + // Every coordinate LIVE, from the source each endpoint names — a device fix + // for the origin and a place search for the destination. + assert!( + mk.contains("center_lat: sys.gps(\"lat\")") && mk.contains("center_lon: sys.gps(\"lon\")"), + "the origin must centre the map on the live fix:\n{mk}" + ); + assert!( + mk.contains("nav_polyline: sys.navroute("), + "the route must be fetched, not seeded:\n{mk}" + ); + assert!( + mk.contains("sys.searchnum(\"SFO\", 0, \"lat\")"), + "the destination's coordinates come from the search that found it:\n{mk}" + ); + // And none of the seeded numbers may appear as a literal. + for seeded in ["37.3", "-122", "37.4", "-121.9"] { + assert!( + !mk.contains(seeded), + "{seeded} is the seeded coordinate and must not be lowered:\n{mk}" + ); + } +} From 734f1f247a96dfe8c736602a42fb766e3afc6bf7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:50:54 -0700 Subject: [PATCH 25/97] feat(ui_l0): the kit lowers a Map too, so nav's route draws on device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing only `makepad::lower` left the error box exactly where it was on a phone. The device renders through the KIT — `l0_card.rs` calls `kit::lower` — so the half that shipped was the half still broken, and a green suite would have hidden it. That is the same asymmetry that let `width`, `align` and a numeric `cond` reach one backend and not the other. `l0_map(mode, zoom, lat, lon, poly)` in the kit, and the two lowerings now share `map_mode` and `map_route` rather than each deciding what `.drive` means. Two backends answering that question independently is the shape of every defect this profile keeps finding, so it is answered once. The kit emits: l0_map("3d", 16, sys.gps("lat"), sys.gps("lon"), sys.navroute(sys.gps("lat"), sys.gps("lon"), sys.searchnum("SFO", 0, "lat"), sys.searchnum("SFO", 0, "lon"), "polyline")) Every coordinate live, and the route fetched when the card draws. The test now asserts BOTH paths, including that no seeded coordinate is lowered as a literal — the failure mode here is a plausible map centred on somewhere the user is not, which looks like a working map. The host side lands in the two dependent repos, additively: a `polyline: Option` on `Attrs` (the tree carries geometry, not a request — a backend that re-fetched from an origin and destination would fetch again on every rebuild), an `l0_map` in `_kit.splash`, the property read in the host's tree-walk, and a `MapView` arm in its widget emitter. `NodeKind::Map`/`NavMap` already existed and already parsed, so the node half needed nothing. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 113 ++++++++++++++++++--------- crates/splash-ui-l0/tests/profile.rs | 12 +++ 2 files changed, 87 insertions(+), 38 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 566bbbf..f8dae40 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5487,6 +5487,56 @@ pub mod makepad { } } + /// Which member of the map family a `Map` is, in the WIDGET's vocabulary. + /// + /// Shared by both lowerings rather than written twice. Two backends that + /// each decide independently what `.drive` means is the shape of every + /// defect this profile keeps finding. + pub(super) fn map_mode(node: &UiNode) -> &'static str { + match arg(node, "mode") { + Some(NodeValue::Token(t)) if t == "drive" => "3d", + Some(NodeValue::Token(t)) if t == "plan" => "plan", + // `flat` and an unstated mode are the same thing to the widget: the + // route, drawn without the 2.5D camera. + _ => "", + } + } + + /// A `Map`'s centre and route, as live calls: `(lat, lon, polyline)`. + /// + /// Both endpoints name a SOURCE rather than coordinates, so each is asked for + /// its own axis — `from: here` becomes `sys.gps("lat")` and `sys.gps("lon")`. + /// An endpoint whose capability cannot answer a coordinate yields a neutral + /// centre and no route, because a map drawn from a seeded position is a map + /// of somewhere the user is not. + pub(super) fn map_route(node: &UiNode) -> (String, String, String) { + let coord = |name: &str, axis: &str| -> Option { + let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; + // A collection answers `0.lat` and a scalar source answers `lat`. + for field in [axis.to_owned(), format!("0.{axis}")] { + let probe = SourceBinding { + field, + ..binding.clone() + }; + if let Some(call) = vm_call(&probe) { + return Some(call); + } + } + None + }; + let (Some(a), Some(o)) = (coord("from", "lat"), coord("from", "lon")) else { + return ("0".into(), "0".into(), "\"\"".into()); + }; + let poly = match (coord("to", "lat"), coord("to", "lon")) { + // `via` is carried by the helper's sixth argument and is not emitted + // yet: it is a value the card supplies as a list, and threading it + // needs that list rendered the way `sys.route` renders it. + (Some(b), Some(p)) => format!("sys.navroute({a}, {o}, {b}, {p}, \"polyline\")"), + _ => "\"\"".to_owned(), + }; + (a, o, poly) + } + /// The VM helper that answers a declared capability, if one does. /// /// The names differ because the two vocabularies were designed apart: L0 @@ -6177,51 +6227,19 @@ pub mod makepad { // card's declared source resolved into a call, which is the same // shape every other live value takes. "Map" => { - // Both endpoints are SOURCE names rather than coordinates, so - // each is asked for its own axis: `from: here` becomes - // `sys.gps("lat")` and `sys.gps("lon")`. - let coord = |name: &str, axis: &str| -> Option { - let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; - // A collection answers `0.lat` and a scalar source answers - // `lat`; try the scalar spelling first. - for field in [axis.to_owned(), format!("0.{axis}")] { - let probe = SourceBinding { - field, - ..binding.clone() - }; - if let Some(call) = vm_call(&probe) { - return Some(call); - } - } - None - }; - // The widget's own vocabulary: 1 is the chase camera, 2 the - // north-up route preview, anything else a flat map. - let mode = match arg(node, "mode") { - Some(NodeValue::Token(t)) if t == "drive" => "3d", - Some(NodeValue::Token(t)) if t == "plan" => "plan", - _ => "", - }; + let (lat, lon, poly) = map_route(node); + let mode = map_mode(node); let zoom = match arg(node, "zoom") { Some(NodeValue::Number(n)) => trim_num(*n), _ => "15".to_owned(), }; let _ = write!( out, - "{p}MapView{{ width: Fill height: 240 nav_mode: {mode:?} zoom: {zoom}" + "{p}MapView{{ width: Fill height: 240 nav_mode: {mode:?} zoom: {zoom} \ + center_lat: {lat} center_lon: {lon}" ); - if let (Some(a), Some(o)) = (coord("from", "lat"), coord("from", "lon")) { - let _ = write!(out, " center_lat: {a} center_lon: {o}"); - if let (Some(b), Some(p2)) = (coord("to", "lat"), coord("to", "lon")) { - // `via` is carried by the helper's sixth argument and is - // not emitted yet: it is a value the card supplies as a - // list, and threading it needs the list rendered the way - // `sys.route` renders it. Recorded rather than guessed. - let _ = write!( - out, - " nav_polyline: sys.navroute({a}, {o}, {b}, {p2}, \"polyline\")" - ); - } + if poly != "\"\"" { + let _ = write!(out, " nav_polyline: {poly}"); } let _ = writeln!(out, " }}"); } @@ -7623,6 +7641,7 @@ pub mod kit { fn kit_fn(role: &str) -> Option<&'static str> { Some(match role { "Field" => "l0_field", + "Map" => "l0_map", "Surface" => "l0_surface", "Col" => "l0_col", "Row" => "l0_row", @@ -7963,6 +7982,24 @@ pub mod kit { "Photo" => { let _ = write!(out, "{f}({})", makepad::expr_of(node, "src")); } + // The trip, as the kit takes it: which member of the map family, how + // close, where to centre, and the route already resolved. + // + // The route is a CALL — `sys.navroute` over both endpoints' own + // coordinate calls — so the geometry is fetched when the card draws + // rather than baked at realization. The kit was emitting + // `l0_unsupported("Map")` until now, which is what put an error box + // in the middle of the nav card ON DEVICE, since the device renders + // through this path and not through `makepad::lower`. + "Map" => { + let (lat, lon, poly) = makepad::map_route(node); + let _ = write!( + out, + "{f}({:?}, {}, {lat}, {lon}, {poly})", + makepad::map_mode(node), + scalar_of(node, "zoom"), + ); + } // `cond` is a NUMBER — the WMO code the forecast returns — and this // matched only `Text` and `Token`, so every one of them fell through // to `""`. All seven forecast rows drew the same default icon over a diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index ecfc396..ecb4584 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5823,6 +5823,18 @@ view root Surface { mk.contains("sys.searchnum(\"SFO\", 0, \"lat\")"), "the destination's coordinates come from the search that found it:\n{mk}" ); + // THE KIT TOO, because the device renders through it. Fixing only + // `makepad::lower` left the error box exactly where it was on a phone, which + // is the half that matters and the half a green test suite would have hidden. + let kit = splash_ui_l0::kit::lower(&root); + assert!( + !kit.contains("l0_unsupported(\"Map\")"), + "the kit must lower the role, not report it unsupported:\n{kit}" + ); + assert!( + kit.contains("l0_map(\"3d\", 16, sys.gps(\"lat\"), sys.gps(\"lon\"), sys.navroute("), + "the kit passes the mode, the zoom, the centre and the live route:\n{kit}" + ); // And none of the seeded numbers may appear as a literal. for seeded in ["37.3", "-122", "37.4", "-121.9"] { assert!( From 246d3f0dc7c8d01f25b1a4f0837a5356eeaa05e6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:59:13 -0700 Subject: [PATCH 26/97] fix(ui_l0): a Map needs the shipping card's geometry, not a guessed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map lowered and drew a correct map in a letterbox, slowly. All three causes were already written down in `a2app/apps/nav/app.md`, which is a working four-map reference with a "MANDATORY rules" section, and I had invented parameters instead of reading it. - **Height must be a large FIXED pixel value.** That spec says `Fill`/ `Fit` "resolve to 0 and hide the map", and names 812 for a full-bleed screen and 452/384 for a panel. The kit asked for 240. - **`min_zoom`/`max_zoom` must be widened.** The widget defaults to 11..17 and CLAMPS into it, so a card asking to see a whole city silently got a street. - **`use_network: true`** alongside `use_local_mbtiles: false`, and `nav_route_width` — 14 from the car, 40 for a whole-route preview, because the ribbon is drawn in ground metres. Verified on device: the nav card now draws the 2.5D chase view with the route ribbon, the vehicle puck and live tiles, where it drew an error box two commits ago and a letterbox one commit ago. This also corrects something I claimed earlier. I measured a full card rebuild as costing nothing worth recovering and recorded that in the roadmap. That holds for the cards I measured and NOT for a live map: the nav spec says a rebuild "would tear down the live `MapView`", which is why its drive screen is a no-rebuild `fn tick()` card. So the reconciliation deferral is still right for the corpus and wrong for any card holding a map, and an L0 `Map` inside a rebuilding card pays that cost on every tap. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index f8dae40..8b03d40 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6235,7 +6235,7 @@ pub mod makepad { }; let _ = write!( out, - "{p}MapView{{ width: Fill height: 240 nav_mode: {mode:?} zoom: {zoom} \ + "{p}MapView{{ width: Fill height: 452 nav_mode: {mode:?} zoom: {zoom} \ center_lat: {lat} center_lon: {lon}" ); if poly != "\"\"" { From 05e3ea66a162f4866a85c8e9c4e5608458de0c63 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:13:09 -0700 Subject: [PATCH 27/97] fix(ui_l0): a map guards on having a position, and stops at 0% CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both complaints were real and I had caused half of one of them. NOT FULL SCREEN. The kit asked for 452 — the shipping card's PANEL size, for a screen with content above the map. A card that has a map is a card where the map is the screen, so it gets 812, that card's full-bleed size. SEVERE STUTTER. Measured, and the cause was not what I assumed: stock card, no map 0% 229 MB flat nav card, map, 3d mode 69% 1.9 GB nav card, map, plan mode 96-441% 3.0 GB and climbing trip card, real coordinates 0-3.3% 0.9 GB flat The mode was irrelevant. `sys.gps` answers **-9999** with no fix, so the map was centred on an impossible latitude — and I had widened the zoom clamp to the shipping card's 3..19, which let it fit a garbage extent and load tiles at world scale. That card has valid coordinates; a lowering cannot guarantee them. The widening is reverted and the widget's own 11..17 clamp is what bounds the damage. The card now guards: `when here.ok == 1 { Map(…) }`. §5.9 exists for exactly this — "no fix yet" is a state to branch on, not a number to render — and `sys.gps` answers `ok` precisely so a card can ask. An unguarded map is a map of nowhere, which is worse than no map. Verified on device with two searched places and no GPS: the route draws full-bleed along the real road network at 0% CPU and stable memory. `.drive` still lowers to the static preview, and that change is a §4 one rather than a performance one — it did nothing for the numbers above. A chase camera follows a vehicle; following needs a position updated every frame; L0 has no loop to supply one, so the widget animates along the polyline on a timer and draws motion the user is not making. The lesson is the one behind the complaint: the working nav app had every answer, and where I read it I got it right, and where I extrapolated from it — the zoom clamp — I made things worse than before I started. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 29 +++- crates/splash-ui-l0/tests/fixtures/nav.card | 11 +- crates/splash-ui-l0/tests/profile.rs | 145 ++++++++++++++------ 3 files changed, 138 insertions(+), 47 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 8b03d40..0f1dfc5 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5494,10 +5494,29 @@ pub mod makepad { /// defect this profile keeps finding. pub(super) fn map_mode(node: &UiNode) -> &'static str { match arg(node, "mode") { - Some(NodeValue::Token(t)) if t == "drive" => "3d", - Some(NodeValue::Token(t)) if t == "plan" => "plan", - // `flat` and an unstated mode are the same thing to the widget: the - // route, drawn without the 2.5D camera. + // `.drive` lowers to the STATIC preview, not to the chase camera, + // and this is a §4 argument rather than a performance one. + // + // A chase camera follows a vehicle, and following needs a position + // updated every frame. L0 has no loop to supply one — that is what + // `fn tick()` is for, and `fn tick()` is L2. Handed a route and no + // position, the widget animates along the polyline on a timer: it + // draws motion the user is not making. That is a fabricated fact in + // the one currency a map trades in, and §4 does not stop applying + // because the invented value is a camera pose. + // + // It is also what made the card stutter. The widget's own settle gate + // exists because a map that keeps asking for frames "was pinning the + // GPU at ~100%", and a follow mode is permanently in motion so it + // never settles. Measured at 69% CPU and 1.9 GB resident on a + // OnePlus 6 for one card holding one map. + // + // So both modes that MOVE are lowered to the one that does not. When + // L0 gains a way to declare a live position, `.drive` can mean what + // it says. + Some(NodeValue::Token(t)) if t == "drive" || t == "plan" => "plan", + // `flat` and an unstated mode are the same thing to the widget: no + // nav shader at all, which also means no route ribbon. _ => "", } } @@ -6235,7 +6254,7 @@ pub mod makepad { }; let _ = write!( out, - "{p}MapView{{ width: Fill height: 452 nav_mode: {mode:?} zoom: {zoom} \ + "{p}MapView{{ width: Fill height: 812 nav_mode: {mode:?} zoom: {zoom} \ center_lat: {lat} center_lon: {lon}" ); if poly != "\"\"" { diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 03c28e4..7a3ff5e 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -86,7 +86,14 @@ view root Surface { TextCaption(value: trip.distance, suffix: copy.away) } } - # The card names the TRIP. The widget fetches its own route. - Map(mode: .drive, from: here, to: dest_place, zoom: 16) + # The card names the TRIP, and GUARDS it on having a position. + # + # `sys.gps` answers -9999 with no fix, so an unguarded map is a map of an + # impossible place: it fits its camera to a garbage extent and loads tiles + # without bound. §5.9 exists for exactly this — "no fix yet" is a state the + # card can branch on, not a number to render. + when here.ok == 1 { + Map(mode: .drive, from: here, to: dest_place, zoom: 16) + } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index ecb4584..9aee73b 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5470,30 +5470,32 @@ view root Surface { TextHero(value: now.temp * 9) } // of this test reported nine false positives, because `unit: .money` reaches the // output as `$` and `tint:` as a direction rather than as the value. -/// Two distinct values for one attribute, as written in a card. -fn two_values(kind: &splash_ui_l0::catalog::ArgKind) -> Option<(String, String)> { +/// Every distinct value worth trying for one attribute, as written in a card. +/// +/// ALL of a token set, not a chosen pair. The first token is usually the default +/// and a default legitimately emits nothing, and two different tokens can +/// legitimately lower to the same thing — `.plan` and `.drive` both mean the +/// static route preview, because a chase camera needs a per-frame position L0 +/// cannot supply. The caller requires only that SOME pair differs. +fn probe_values(kind: &splash_ui_l0::catalog::ArgKind) -> Option> { use splash_ui_l0::catalog::ArgKind::*; Some(match kind { - // Every token, not a chosen pair: the first is usually the DEFAULT and - // a default legitimately emits nothing, so `start`-vs-`baseline` would - // report a working `align` as inert. The caller requires only that SOME - // token changes the output. Token(set) | TokenOrPath(set) => { if set.len() < 2 { return None; } - (format!(".{}", set[0]), format!(".{}", set[1])) + set.iter().map(|t| format!(".{t}")).collect() } // Small and adjacent: a column count only shows up against a child // count, so 11-vs-4242 chunked four children into one row either way and // reported a working `cols:` as inert. - Number => ("1".into(), "3".into()), - Text | Any => ("\"P1\"".into(), "\"P4242\"".into()), + Number => vec!["1".into(), "3".into()], + Text | Any => vec!["\"P1\"".into(), "\"P4242\"".into()], // Bound paths, so §4 is satisfied. The two differ in SIGN as well as // magnitude: `tint` lowers to a DIRECTION, so two positive values are // indistinguishable and the probe would call a working tint inert. - Path | Data => ("sa".into(), "sc".into()), - Event => ("ev_a".into(), "ev_b".into()), + Path | Data => vec!["sa".into(), "sc".into()], + Event => vec!["ev_a".into(), "ev_b".into()], // A predicate has no second form to vary that is not also a different // shape; `a_predicate_argument_evaluates_to_a_boolean` covers it. Bool => return None, @@ -5521,21 +5523,6 @@ const INERT: &[(&str, &str)] = &[ ("Map", "from"), ("Map", "to"), ("Map", "via"), - // `unit` reaches NEITHER backend as a token: `.c` and `.f` both lower to - // `value + "°"`, byte for byte. So the weather card's units toggle — the one - // interaction it advertises, wired through state, dispatch and re-render — - // changes nothing on screen, and the temperature is always Celsius because - // the live call asks open-meteo for `current.temperature_2m` with no unit. - // - // The spec says "do not convert: the runtime formats by the unit token". - // The runtime never receives the unit token. Closing it is a DESIGN decision - // rather than a patch: either the unit travels to the helper (so the fetch - // asks for the right one) or the card is allowed to convert, and the second - // is what L0 has no expression form for. - ("TextHero", "unit"), - ("TextCaption", "unit"), - ("TextValue", "unit"), - ("Tile", "unit"), // A text input is not wrapped by the width composer — the `Field` branch // returns before it — so a field cannot be told how wide to be. ("Field", "width"), @@ -5548,7 +5535,7 @@ fn changing_a_declared_attribute_must_change_the_lowering() { for (role, args) in splash_ui_l0::catalog::CONSTRUCTORS { for (attr, kind) in *args { - let Some((first, second)) = two_values(kind) else { + let Some(candidates) = probe_values(kind) else { continue; }; // Every OTHER attribute is filled too, so the role is instantiated @@ -5565,7 +5552,9 @@ fn changing_a_declared_attribute_must_change_the_lowering() { // and the converse: probing `text` while `value` is filled asks // which wins, not whether `text` reaches anything. .filter(|(n, _)| !(*attr == "text" && *n == "value")) - .filter_map(|(n, k)| two_values(k).map(|(v, _)| format!("{n}: {v}"))) + .filter_map(|(n, k)| { + probe_values(k).and_then(|v| v.first().map(|f| format!("{n}: {f}"))) + }) .collect(); let build = |value: &str| { let mut all = vec![format!("{attr}: {value}")]; @@ -5602,16 +5591,16 @@ fn changing_a_declared_attribute_must_change_the_lowering() { makepad::lower(&root) )) }; - let (a, b) = (build(&first), build(&second)); - let (Some(la), Some(lb)) = (lower(&a), lower(&b)) else { - // A probe the checker refuses proves nothing about the lowering. - // Skipped rather than failed: several roles constrain their - // arguments against each other in ways this generator does not - // model, and a false failure here would train people to ignore - // the list. + // A probe the checker refuses proves nothing about the lowering, so + // it contributes no output rather than a failure: several roles + // constrain their arguments against each other in ways this + // generator does not model, and a false failure would train people + // to ignore the list. + let outputs: Vec = candidates.iter().filter_map(|v| lower(&build(v))).collect(); + if outputs.len() < 2 { continue; - }; - if la == lb { + } + if outputs.iter().all(|o| *o == outputs[0]) { inert.push((role.to_string(), attr.to_string())); } } @@ -5804,9 +5793,14 @@ view root Surface { !mk.contains("no makepad lowering for Map"), "the role must lower at all:\n{mk}" ); - // The camera is the widget's own vocabulary, not L0's token. + // The camera is the widget's own vocabulary, not L0's token — and `.drive` + // lowers to the STATIC preview. A chase camera follows a vehicle, following + // needs a position updated every frame, and L0 has no loop to supply one; the + // widget handed a route and no position animates along it on a timer, drawing + // motion the user is not making. §4 does not stop applying because the + // invented value is a camera pose. assert!( - mk.contains("nav_mode: \"3d\"") && mk.contains("zoom: 16"), + mk.contains("nav_mode: \"plan\"") && mk.contains("zoom: 16"), "the declared mode and zoom must reach the widget:\n{mk}" ); // Every coordinate LIVE, from the source each endpoint names — a device fix @@ -5832,7 +5826,7 @@ view root Surface { "the kit must lower the role, not report it unsupported:\n{kit}" ); assert!( - kit.contains("l0_map(\"3d\", 16, sys.gps(\"lat\"), sys.gps(\"lon\"), sys.navroute("), + kit.contains("l0_map(\"plan\", 16, sys.gps(\"lat\"), sys.gps(\"lon\"), sys.navroute("), "the kit passes the mode, the zoom, the centre and the live route:\n{kit}" ); // And none of the seeded numbers may appear as a literal. @@ -5843,3 +5837,74 @@ view root Surface { ); } } + +/// Token pairs a card TOGGLES between must be distinguishable in the lowering. +/// +/// `changing_a_declared_attribute_must_change_the_lowering` asks whether SOME +/// pair of an attribute's tokens differs, which is the right question for +/// "does this attribute reach a backend at all" and the wrong one for a pair a +/// card cycles. `unit` passes that check because `.pct` lowers to `%` and +/// `.money` to `$` — while `.c` and `.f`, the two a weather card actually +/// toggles, are byte-identical. +/// +/// The general test got STRONGER and lost this case. Both are needed. +#[test] +fn a_token_pair_a_card_toggles_must_change_the_lowering() { + // (role, attribute, first, second). Each pair is one a shipping card cycles. + const PAIRS: &[(&str, &str, &str, &str)] = &[("TextHero", "unit", "c", "f")]; + // Pairs that are still indistinguishable. May only shrink. + // + // `.c` vs `.f`: both lower to `value + "°"`, so the weather card's units + // toggle — the one interaction it advertises, wired through state, dispatch + // and a re-render — changes nothing on screen, and every temperature is + // Celsius because the live call asks open-meteo for `current.temperature_2m` + // with no unit at all. + // + // The weather spec says "do not convert: the runtime formats by the unit + // token". The runtime never receives the token. Closing it is a DESIGN + // decision rather than a patch: either the unit travels to the helper, so the + // fetch asks for the right one, or the card converts — and converting is + // exactly what L0 has no expression form for. + const IDENTICAL: &[(&str, &str)] = &[("TextHero", "unit")]; + + let mut same: Vec<(String, String)> = Vec::new(); + for (role, attr, first, second) in PAIRS { + let card = |tok: &str| { + format!( + "state held {{ shape: number, initial: 21 }}\n\ + view root Surface {{ {role}(value: held, {attr}: .{tok}) }}\n" + ) + }; + let data = serde_json::json!({ "held": 21.0, "env": { "locale": {} }, "copy": {} }); + let lower = |src: &str| -> String { + let root = realize(src, &data, RealizeLimits::default()) + .root + .expect("the probe realizes"); + format!( + "{}\n{}", + splash_ui_l0::kit::lower(&root), + makepad::lower(&root) + ) + }; + if lower(&card(first)) == lower(&card(second)) { + same.push((role.to_string(), attr.to_string())); + } + } + let known: Vec<(String, String)> = IDENTICAL + .iter() + .map(|(r, a)| (r.to_string(), a.to_string())) + .collect(); + let mut fresh: Vec<_> = same.iter().filter(|p| !known.contains(p)).collect(); + fresh.sort(); + assert!( + fresh.is_empty(), + "a card toggles between these tokens and the lowering cannot tell them \ + apart, so the toggle changes nothing on screen: {fresh:#?}" + ); + let mut fixed: Vec<_> = known.iter().filter(|p| !same.contains(p)).collect(); + fixed.sort(); + assert!( + fixed.is_empty(), + "these are distinguishable now — delete them from IDENTICAL: {fixed:#?}" + ); +} From 11dbda5eede9e106dd97aaa6aa1b5ede633b4784 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:42:46 -0700 Subject: [PATCH 28/97] fix(ui_l0): a comparison binds looser than arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `active: x == a + b` parsed as `(x == a) + b` — arithmetic over a boolean. It evaluated to missing and nothing rejected it, so the card was accepted, drew blank, and gave no diagnostic. That is the worst failure shape available: wrong, valid and silent. The cause was one call. A comparison's right side took a TERM, so it bound tighter than `+`. It now takes a whole operand, which makes a comparison the loosest thing in one — and makes `x == a + b` mean what it reads as. Bounded by the same depth guard every other nested construct uses. I had planned to REFUSE the mixture instead, which would have been the lesser fix: refusing tells an author their card is wrong, and this makes it right. The reading nobody wants is the one the parser had. The right side is also checked like any other operand now that it can hold arithmetic. Matching only `Path` would have let `x == nope + b` past with `nope` undeclared, which is the defect `when selected != absent` already cost once; and §9.3 reaches it, so `x == 3 * 4` compares against a number computed from nothing and is refused. Verified by evaluation and not only by parsing: with k=5 the branch is taken and with k=6 it is not, so the comparison is against the sum rather than against `a`. §9.8 is updated. That leaves L1's remaining gaps as capability rather than correctness — no grouping, no unary minus, arithmetic as the only construct — plus the one open argument: §9.3 bounds an expression's OPERANDS and not its result, so `q.last * 0 + 1547` still passes. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 37 ++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 76 ++++++++++++++++++++++++++++ docs/ui-profile-l0.md | 11 ++-- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 0f1dfc5..6873e49 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2212,7 +2212,18 @@ impl<'a> Parser<'a> { } self.at += 1; self.depth += 1; - let rhs = self.parse_term(); + // The WHOLE right side, arithmetic included. This took a + // term, so a comparison bound TIGHTER than `+`: at L1 + // `active: x == a + b` parsed as `(x == a) + b`, which is + // arithmetic on a boolean — it evaluated to missing, and + // nothing rejected it, so the card rendered blank with no + // diagnostic. A comparison is the loosest thing in an + // operand, which is what makes `x == a + b` mean what it + // reads as. + // + // The recursion is bounded by the depth guard above, the + // same one every other nested construct uses. + let rhs = self.parse_operand(); self.depth -= 1; return Operand::Predicate { path, @@ -4124,10 +4135,26 @@ fn check_arg( (Operand::Path(path), _) => check_path(path, scope, arg.line, arg.column, sink), (Operand::Predicate { path, rhs, .. }, _) => { check_path(path, scope, arg.line, arg.column, sink); - // The right operand is a binding too. Leaving it unchecked is how - // `when selected != absent` took its branch with `absent` undeclared. - if let Operand::Path(r) = rhs.as_ref() { - check_path(r, scope, arg.line, arg.column, sink); + // EVERY path in the right operand, not just a bare one. Leaving it + // unchecked is how `when selected != absent` took its branch with + // `absent` undeclared — and now that a comparison's right side can + // hold arithmetic, matching only `Path` would let `x == a + b` past + // with `a` and `b` undeclared. + let mut paths = Vec::new(); + expr_paths(rhs, &mut paths); + // §9.3 applies to a comparison's right side too: `x == 3 * 4` computes + // a number from nothing and compares against it. + if matches!(rhs.as_ref(), Operand::Expr { .. }) && paths.is_empty() { + sink.push( + arg.line, + arg.column, + "an expression must read a declared source or state: every operand here \ + is a literal, which states a fact rather than computing one (profile §4)" + .to_string(), + ); + } + for p in paths { + check_path(&p, scope, arg.line, arg.column, sink); } } // §4's no-facts rule, one level up. diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 9aee73b..7769ba5 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5908,3 +5908,79 @@ fn a_token_pair_a_card_toggles_must_change_the_lowering() { "these are distinguishable now — delete them from IDENTICAL: {fixed:#?}" ); } + +/// A comparison is the LOOSEST thing in an operand, so `x == a + b` means what +/// it reads as. +/// +/// A comparison's right side took a TERM, so it bound tighter than `+`: at L1 +/// `active: x == a + b` parsed as `(x == a) + b` — arithmetic over a boolean. It +/// evaluated to missing and nothing rejected it, so the card was accepted, drew +/// blank, and gave no diagnostic. That is the worst failure shape available: a +/// card that is wrong, valid, and silent. +#[test] +fn a_comparison_binds_looser_than_arithmetic() { + const CARD: &str = r#"# level: L1 +state k { shape: number, initial: 5 } +state a { shape: number, initial: 2 } +state b { shape: number, initial: 3 } +copy yes { class: vocabulary, en: "MATCHED" } +view root Surface { + when k == a + b { TextRow(text: copy.yes) } +} +"#; + assert!( + check_ui_l0_named("c", CARD).valid, + "{:#?}", + check_ui_l0_named("c", CARD).diagnostics + ); + // The comparison is against the SUM, so the branch turns on 5 == 2 + 3. + for (k, taken) in [(5.0, true), (6.0, false)] { + let data = serde_json::json!({ + "k": k, "a": 2.0, "b": 3.0, + "env": { "locale": {} }, "copy": { "yes": "MATCHED" } + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert_eq!( + dsl.contains("MATCHED"), + taken, + "k={k} compared against a + b:\n{dsl}" + ); + } + + // And the right side is CHECKED like any other operand, now that it can hold + // arithmetic: an undeclared name in it is refused rather than resolving to + // nothing and deciding the branch on that absence. + let why = |c: &str| { + check_ui_l0_named("c", c) + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect::>() + .join("; ") + }; + const UNDECLARED: &str = r#"# level: L1 +source q sys.quote(ticker: "N", fields: [last]) +state k { shape: number, initial: 2 } +view root Surface { Chip(text: "c", active: k == nope + q.last) } +"#; + assert!( + why(UNDECLARED).contains("not a declared name"), + "{}", + why(UNDECLARED) + ); + // §9.3 reaches here too: a comparison against a computed literal compares + // against a fabricated number. + const FABRICATED: &str = r#"# level: L1 +source q sys.quote(ticker: "N", fields: [last]) +state k { shape: number, initial: 2 } +view root Surface { Chip(text: "c", active: k == 3 * 4) TextRow(text: q.last) } +"#; + assert!( + why(FABRICATED).contains("must read a declared"), + "{}", + why(FABRICATED) + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 9a331c9..3aa94b3 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1414,9 +1414,14 @@ Recorded rather than patched over, in the order they would bite. reaching a view only through a source argument. **A guard is still not a position this section admits an expression in** — the implementation parses one and now checks it correctly, which is a narrower gap between the two than it was, and still a gap. -- **Comparison and arithmetic have no defined relative precedence.** `active: x == a + b` parses - as `(x == a) + b` rather than as a comparison against a sum, and evaluates to missing. Nothing - rejects it. +- ~~**Comparison and arithmetic have no defined relative precedence.**~~ **Fixed.** A + comparison's right side took a TERM, so it bound tighter than `+`: `active: x == a + b` parsed + as `(x == a) + b` — arithmetic over a boolean — evaluated to missing, and nothing rejected it, + so the card was accepted, drew blank, and gave no diagnostic. A comparison is now the loosest + thing in an operand, which is what makes `x == a + b` mean what it reads as. Its right side is + also checked like any other operand, since it can now hold arithmetic: an undeclared name in it + is refused, and §9.3 reaches it, so `x == 3 * 4` compares against a fabricated number and is + refused too. - **No grouping and no unary minus**, per §9.2. `(a + b) * c` is inexpressible, which is the first thing an author will reach for after a formula that does not match the fixed precedence. - **The no-facts rule bounds operands, not results** (§9.3). A fabricated number can be computed From 56c1e9ace6e9a17edefdcf83bb3535c8303070cc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:49:48 -0700 Subject: [PATCH 29/97] feat(ui_l0): close L1's three remaining holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GROUPING. Precedence was fixed and unoverridable, so `(a + b) * c` — the first thing an author reaches for after a formula that does not fit it — could not be written. A `(` in an operand is unambiguous: every call-shaped form in the grammar is reached by its verb. A NEGATIVE COEFFICIENT. `n * -1` was refused, so subtracting a scaled reading had no spelling. A negated LITERAL becomes a negative literal rather than an expression, which keeps it a coefficient and keeps a bare `value: -1` refused by §4's original rule — the right answer for a bare `-1`, reached by the right route. AN EXPRESSION THAT READS VALUES AND IGNORES THEM. This is the one §9.8 recorded as needing an argument rather than a patch, so here is the argument: a formula is a formula because its answer MOVES when its inputs move. The expression is evaluated under three assignments of its reads and an answer that never changes is a constant the model wrote with extra steps. refused last * 0 + 1547 · last - last + 99 · (last - last) * k + 5 admitted last - open · last * 9 / 5 + 32 · (last + open) * k · last / open The assignments differ per PATH as well as per round, because binding every read to one number would make `a - b` constant and condemn a correct formula. Unresolvable in every round — a division by a probed zero — is not degenerate; §9.4 already renders that as missing. `apply_op` and `fold_expr` are shared by realization and by the probe, so the arithmetic a card is CHECKED against cannot drift from the arithmetic it is EVALUATED with. Two implementations of the same five operators is the defect shape this profile keeps finding. §9.2, §9.3 and §9.8 updated. This does not make L1 as strong as L0, and the section says so: L0's guarantee is structural — no position admits a fabricated number — while L1's is a pair of decidable checks that between them refuse the constructions a fabrication has available. The remaining distance is that L1 must ASK where L0 has nothing to ask. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 211 +++++++++++++++++++++++---- crates/splash-ui-l0/tests/profile.rs | 106 ++++++++++++++ docs/ui-profile-l0.md | 49 +++++-- 3 files changed, 319 insertions(+), 47 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 6873e49..ce860e4 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -1049,6 +1049,12 @@ impl<'a> Parser<'a> { self.tokens.get(self.at) } + /// One token further on, for a decision that needs to see two: a `-` is a + /// negative literal or a negation depending on what follows it. + fn peek_at(&self, ahead: usize) -> Option<&Token> { + self.tokens.get(self.at + ahead) + } + fn next(&mut self) -> Option { let t = self.tokens.get(self.at).cloned(); if t.is_some() { @@ -2188,6 +2194,53 @@ impl<'a> Parser<'a> { let Some(t) = self.peek().cloned() else { return Operand::Str(String::new()); }; + // GROUPING. Precedence was fixed and unoverridable, so `(a + b) * c` — + // the first thing an author reaches for after a formula that does not fit + // it — could not be written at all. A `(` here is unambiguous: an + // argument value never otherwise starts with one, because every + // call-shaped form in the grammar (`set`, `cycle`, `append`) is reached + // by its verb. + if t.is_punct("(") { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&t, "nesting is too deep".into()); + return Operand::Str(String::new()); + } + self.at += 1; + self.depth += 1; + let inner = self.parse_operand(); + self.depth -= 1; + self.expect_punct(")"); + return inner; + } + // UNARY MINUS. `x * -1` was refused, so a negative coefficient — the + // ordinary way to subtract a scaled reading — had no spelling. + // + // A negated LITERAL becomes a negative literal rather than an expression, + // so it stays a coefficient: wrapping it as `0 - 1` would make + // `value: -1` an expression that reads nothing, which §9.3 refuses, and + // that is the right answer for a bare `-1` but reached by the wrong + // route. A negated PATH is arithmetic and lowers as such. + if t.is_punct("-") { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&t, "nesting is too deep".into()); + return Operand::Str(String::new()); + } + if let Some(next) = self.peek_at(1).cloned() { + if next.kind == Kind::Num { + self.at += 2; + return Operand::Num(-next.text.parse::().unwrap_or(0.0)); + } + } + self.at += 1; + self.depth += 1; + let inner = self.parse_atom(); + self.depth -= 1; + return Operand::Expr { + lhs: Box::new(Operand::Num(0.0)), + op: "-".to_string(), + rhs: Box::new(inner), + }; + } match t.kind { Kind::Token => { self.at += 1; @@ -2319,28 +2372,91 @@ fn eval_expr( ) -> Option { let a = scope_value(scope, lhs)?.as_f64()?; let b = scope_value(scope, rhs)?.as_f64()?; + apply_op(a, op, b).map(serde_json::Value::from) +} + +/// The five operators, in one place. +/// +/// Shared by realization and by §9.3's degeneracy probe, so the arithmetic a +/// card is CHECKED against cannot drift from the arithmetic it is EVALUATED +/// with — which is the mistake this profile keeps finding in other shapes. +fn apply_op(a: f64, op: &str, b: f64) -> Option { let v = match op { "+" => a + b, "-" => a - b, "*" => a * b, - "/" => { - if b == 0.0 { - return None; - } - a / b - } - "%" => { - if b == 0.0 { - return None; - } - a % b - } + // A zero divisor yields missing, not zero: a fabricated number is the + // failure §4 exists to prevent, and it does not become acceptable + // because arithmetic produced it. + "/" | "%" if b == 0.0 => return None, + "/" => a / b, + "%" => a % b, _ => return None, }; - if !v.is_finite() { - return None; + v.is_finite().then_some(v) +} + +/// Evaluate an expression with every path resolved by `resolve`. +/// +/// The shape §9.3's probe needs: the same tree, the same operators, arbitrary +/// values for the reads. +fn fold_expr(operand: &Operand, resolve: &dyn Fn(&str) -> Option) -> Option { + match operand { + Operand::Num(n) => Some(*n), + Operand::Path(p) => resolve(p), + Operand::Expr { lhs, op, rhs } => { + apply_op(fold_expr(lhs, resolve)?, op, fold_expr(rhs, resolve)?) + } + // A comparison is a boolean, not a number, and arithmetic over one is + // not in the grammar. + _ => None, } - Some(serde_json::Value::from(v)) +} + +/// Whether an expression's value is INDEPENDENT of everything it reads. +/// +/// §9.3 requires an expression to read something, which stops `1547 * 3.2` and +/// does not stop `quote.last * 0 + 1547` — one real reading laundering a +/// fabricated number past the rule. That gap was recorded as needing an argument +/// nobody had; this is the argument. +/// +/// A formula is a formula because its answer MOVES when its inputs move. So the +/// expression is evaluated with its reads bound to several distinct assignments, +/// and an answer that never changes is a constant the model wrote with extra +/// steps. `temp * 9 / 5 + 32` moves; `last * 0 + 1547` does not. +/// +/// Distinct values PER PATH, varied across rounds, because binding every read to +/// the same number would make `a - b` constant and condemn a correct formula. +/// Three rounds of coprime-ish values: an expression that is constant across all +/// three and not constant in general is not something the five arithmetic +/// operators can express. +/// +/// Unresolvable in every round (a division by a probed zero, say) is NOT +/// degenerate — that is a partial expression, and §9.4 already renders it as +/// missing. +fn expr_is_constant(expr: &Operand) -> bool { + let mut paths = Vec::new(); + expr_paths(expr, &mut paths); + paths.sort(); + paths.dedup(); + if paths.is_empty() { + // The must-read rule owns this case and reports it better. + return false; + } + let mut seen: Vec = Vec::new(); + for (base, step) in [(2.0, 1.0), (5.0, 3.0), (11.0, 7.0)] { + let resolve = |p: &str| -> Option { + paths + .iter() + .position(|q| q == p) + .map(|i| base + step * i as f64) + }; + match fold_expr(expr, &resolve) { + Some(v) => seen.push(v), + None => return false, + } + } + seen.windows(2).all(|w| w[0] == w[1]) } /// Compare two resolved values. Shared by guards (`when a == b`) and by @@ -3836,15 +3952,26 @@ fn walk( if let Some(rhs) = element.rhs.as_ref() { let mut paths = Vec::new(); expr_paths(rhs, &mut paths); - if matches!(rhs, Operand::Expr { .. }) && paths.is_empty() { - sink.push( - *line, - *column, - "an expression must read a declared source or state: every operand \ - here is a literal, which states a fact rather than computing one \ - (profile §4)" - .to_string(), - ); + if matches!(rhs, Operand::Expr { .. }) { + if paths.is_empty() { + sink.push( + *line, + *column, + "an expression must read a declared source or state: every \ + operand here is a literal, which states a fact rather than \ + computing one (profile §4)" + .to_string(), + ); + } else if expr_is_constant(rhs) { + sink.push( + *line, + *column, + "this expression reads declared values and IGNORES them: its \ + answer is the same whatever they are, so it states a fact \ + rather than computing one (profile §4)" + .to_string(), + ); + } } for r in paths { check_path(&r, scope, *line, *column, sink); @@ -4144,14 +4271,25 @@ fn check_arg( expr_paths(rhs, &mut paths); // §9.3 applies to a comparison's right side too: `x == 3 * 4` computes // a number from nothing and compares against it. - if matches!(rhs.as_ref(), Operand::Expr { .. }) && paths.is_empty() { - sink.push( - arg.line, - arg.column, - "an expression must read a declared source or state: every operand here \ - is a literal, which states a fact rather than computing one (profile §4)" - .to_string(), - ); + if matches!(rhs.as_ref(), Operand::Expr { .. }) { + if paths.is_empty() { + sink.push( + arg.line, + arg.column, + "an expression must read a declared source or state: every operand here \ + is a literal, which states a fact rather than computing one (profile §4)" + .to_string(), + ); + } else if expr_is_constant(rhs) { + sink.push( + arg.line, + arg.column, + "this expression reads declared values and IGNORES them: its answer is \ + the same whatever they are, so it states a fact rather than computing \ + one (profile §4). `x * 0 + 1547` is 1547" + .to_string(), + ); + } } for p in paths { check_path(&p, scope, arg.line, arg.column, sink); @@ -4175,6 +4313,15 @@ fn check_arg( is a literal, which states a fact rather than computing one (profile §4)" .to_string(), ); + } else if expr_is_constant(expr) { + sink.push( + arg.line, + arg.column, + "this expression reads declared values and IGNORES them: its answer is the \ + same whatever they are, so it states a fact rather than computing one \ + (profile §4). `x * 0 + 1547` is 1547" + .to_string(), + ); } for p in paths { check_path(&p, scope, arg.line, arg.column, sink); diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 7769ba5..a12e958 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5984,3 +5984,109 @@ view root Surface { Chip(text: "c", active: k == 3 * 4) TextRow(text: q.last) } why(FABRICATED) ); } + +/// L1's remaining holes, closed: grouping, a negative coefficient, and an +/// expression that reads values and ignores them. +/// +/// The third is the one that needed an argument rather than a patch. §9.3 asked +/// an expression to READ something, which stops `1547 * 3.2` and does not stop +/// `quote.last * 0 + 1547` — one real reading laundering a fabricated number. +/// The argument: a formula is a formula because its answer MOVES when its inputs +/// move, so evaluate it under several assignments and refuse an answer that never +/// changes. +#[test] +fn an_expression_must_depend_on_what_it_reads() { + let ok = |body: &str| { + let card = format!( + "# level: L1\nsource q sys.quote(ticker: \"N\", fields: [last, open])\n\ + state k {{ shape: number, initial: 2 }}\nview root Surface {{ {body} }}\n" + ); + check_ui_l0_named("x", &card).valid + }; + + // GROUPING. Precedence was fixed and unoverridable. + assert!(ok("TextHero(value: (q.last + q.open) * k)"), "grouping"); + assert!( + ok("TextHero(value: ((q.last + 1) * (k + 2)) / q.open)"), + "nested grouping" + ); + // A NEGATIVE COEFFICIENT — the ordinary way to subtract a scaled reading. + assert!(ok("TextHero(value: q.last * -1)"), "negative coefficient"); + // A bare negative literal is still refused, by §4's original rule: a + // measurement the model wrote, in a position that renders one. + assert!(!ok("TextHero(value: -1)"), "a bare literal is still a fact"); + + // DEGENERATE. Each reads something real and ignores it. + for fake in [ + "q.last * 0 + 1547", + "q.last - q.last + 99", + "(q.last - q.last) * k + 5", + "q.last * 0.0", + ] { + assert!( + !ok(&format!("TextHero(value: {fake})")), + "{fake} is a constant with extra steps and must be refused" + ); + } + // HONEST. Each answer moves with its inputs. + for real in [ + "q.last - q.open", + "q.last * 9 / 5 + 32", + "(q.last + q.open) * k", + "q.last / q.open", + "q.last * -1", + ] { + assert!( + ok(&format!("TextHero(value: {real})")), + "{real} is a formula and must be admitted" + ); + } + + // The probe must not condemn a difference. Binding every read to the SAME + // number would make `a - b` constant, which is why the assignments differ + // per path as well as per round. + assert!( + ok("TextHero(value: q.last - q.open)"), + "a - b is not constant" + ); + + // And it reaches a comparison's right side, which can hold arithmetic now. + const GUARD: &str = r#"# level: L1 +source q sys.quote(ticker: "N", fields: [last]) +state k { shape: number, initial: 2 } +view root Surface { when k == q.last * 0 + 7 { Rule() } TextRow(text: q.last) } +"#; + assert!( + !check_ui_l0_named("g", GUARD).valid, + "a guard comparing against a fabricated constant must be refused" + ); +} + +/// Grouping changes the ANSWER, not just the parse. +#[test] +fn grouping_overrides_precedence() { + let tree = |expr: &str| { + let card = format!( + "# level: L1\nstate a {{ shape: number, initial: 2 }}\n\ + state b {{ shape: number, initial: 3 }}\nstate c {{ shape: number, initial: 4 }}\n\ + view root Surface {{ TextHero(value: {expr}) }}\n" + ); + let data = serde_json::json!({ + "a": 2.0, "b": 3.0, "c": 4.0, "env": { "locale": {} }, "copy": {} + }); + let root = realize(&card, &data, RealizeLimits::default()) + .root + .expect("realizes"); + splash_ui_l0::kit::lower(&root) + }; + // The DSL carries the EXPRESSION, so the tree's shape is the evidence: the + // backend evaluates it against data that arrives later. + assert!( + tree("a + b * c").contains("(2 + (3 * 4))"), + "multiplication binds tighter" + ); + assert!( + tree("(a + b) * c").contains("((2 + 3) * 4)"), + "and grouping overrides that" + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 3aa94b3..b972737 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1307,11 +1307,13 @@ Multiplicative operators bind tighter than additive ones and both associate left `temp * 9 / 5 + 32` means what it reads as. Nesting is bounded at parse by the same limit every other nested construct uses (`DEFAULT_MAX_SYNTAX_NESTING`, 128). -**There is no grouping.** Parentheses are not in the production above, so precedence is fixed -and cannot be overridden: `(a + b) * c` is not writable at L1. **There is no unary minus inside -an expression** either — a negative literal is admitted where a literal is admitted, and `n * -1` -is not. Both are limits of what was implemented rather than decisions, and both are recorded in -§9.8. +**Grouping and a leading minus are both in the grammar.** `atom` also admits `"(" , operand , ")"` +and a negated atom, so `(a + b) * c` overrides the fixed precedence and `n * -1` is the ordinary +way to subtract a scaled reading. A negated *literal* is a negative literal rather than an +expression, so it stays a coefficient — which also means a bare `value: -1` is refused by §4's +original rule, as a measurement the model wrote in a position that renders one. + +A comparison is the **loosest** thing in an operand, so `x == a + b` compares against the sum. **The specified position is an argument value**, which is where the motivating cards need one: @@ -1338,12 +1340,27 @@ fact wearing arithmetic, and is refused. Every path an expression reads — at a checked against declared names exactly as a bare binding is, so an expression cannot launder an undeclared name past the check that a plain path would fail. -This is the whole of the §4 argument at L1, and it is weaker than L0's in a way worth naming. -L0's version is *structural*: there is no position in which a fabricated number can appear. -L1's is a *predicate on the operand set*: a card that reads one real value and combines it with -invented coefficients passes. `quote.last * 0 + 1547` reads a source, computes, and produces a -fabricated number. **The rule bounds what an expression may be made of; it does not bound what -an expression may produce**, and closing that needs an argument this section does not have. +That rule alone bounds what an expression is made OF and not what it may PRODUCE, and the gap is +real: `quote.last * 0 + 1547` reads a source, computes, and yields a fabricated number. So there +is a second rule. + +> **An expression's answer must MOVE when its inputs move.** + +A formula is a formula because it depends on what it reads. So the expression is evaluated under +several assignments of its reads, and an answer that never changes is a constant the model wrote +with extra steps — refused. `temp * 9 / 5 + 32` moves; `last * 0 + 1547`, `last - last + 99` and +`(last - last) * k + 5` do not. + +The assignments differ **per path** as well as per round, because binding every read to one number +would make `a - b` constant and condemn a correct formula. Three rounds of coprime-ish values: an +expression constant across all three and not constant in general is not something five arithmetic +operators can express. Unresolvable in every round — a division by a probed zero — is *not* +degenerate; that is a partial expression, and §9.4 already renders it as missing. + +The two rules together are still not L0's *structural* guarantee, which is that no position admits +a fabricated number at all. They are a pair of decidable checks that between them refuse the +constructions a fabrication has available. The remaining distance is that L1 must ASK these +questions where L0 has no question to ask. ### 9.4 Evaluation @@ -1422,10 +1439,12 @@ Recorded rather than patched over, in the order they would bite. also checked like any other operand, since it can now hold arithmetic: an undeclared name in it is refused, and §9.3 reaches it, so `x == 3 * 4` compares against a fabricated number and is refused too. -- **No grouping and no unary minus**, per §9.2. `(a + b) * c` is inexpressible, which is the - first thing an author will reach for after a formula that does not match the fixed precedence. -- **The no-facts rule bounds operands, not results** (§9.3). A fabricated number can be computed - from one real reading and invented coefficients. +- ~~**No grouping and no unary minus.**~~ **Fixed**, per §9.2. `(a + b) * c` and `n * -1` are both + admitted, and a bare `value: -1` is still refused by §4's original rule. +- ~~**The no-facts rule bounds operands, not results.**~~ **Fixed**, per §9.3's second rule: an + expression's answer must move when its inputs move, so `last * 0 + 1547` is refused. This was + recorded as needing an argument rather than a patch, and the argument is that a formula depends + on what it reads. - **Arithmetic is the only construct.** No comparison chain, no conditional, no string operation, no aggregate over a collection. §8 question 10 — how a card says a collection is empty — is *not* answered here, and a count is still one operator away from the arithmetic From 89173ffa7dddf9861af5f9d920dac501ab88eeed Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:35:35 -0700 Subject: [PATCH 30/97] fix(ui_l0): sys.search answers an unindexed read, and 1 equals 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects between "the model wrote a correct nav card" and "the card shows anything". Both found by adding one log line for ACCEPTED cards — refusals were logged and acceptances were not, so a card that passed the checker and rendered nothing but em dashes gave nothing to read. What the model actually declared, once it could be seen: state dest { shape: text, initial: "Osaka Castle" } So the spec change worked and the destination was there. The rest was mine. **`sys.search` demanded an index.** A `count: 1` search is one place and a card reads it as a record — `dest_place.name`, not `dest_place.0.name`. The arm required `.`, returned None for the spelling the card used, and fell back to the seed. An unindexed read is now row 0. **`==` compared JSON shapes, not numbers.** `when here.ok == 1` took its branch when the host injected a float and silently did not when it injected an integer — the same guard, the same card, the same value, deciding differently on a representation the card cannot see. It cost the map: correct card, accepted by the checker, live destination, and no `Map` in the realized tree at all. The ordering operators already coerced through `as_f64`. Only equality did not, which is the half nobody tested — and equality is the one every guard in every card uses. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 37 ++++++++++++++++++- crates/splash-ui-l0/tests/profile.rs | 55 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index ce860e4..91474fc 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2468,6 +2468,28 @@ fn compare(left: Option, cmp: &str, right: Option a == b, + "!=" => a != b, + "<" => a < b, + "<=" => a <= b, + ">" => a > b, + ">=" => a >= b, + _ => false, + }; + } match cmp { "==" => left == right, "!=" => left != right, @@ -5907,8 +5929,19 @@ pub mod makepad { // `geocode`/`geocodenum` uses, and for the same reason: a coordinate // fed to another call has to arrive as a number. "sys.search" => { - let (index, field) = binding.field.split_once('.')?; - index.parse::().ok()?; + // An UNINDEXED read is row 0. + // + // A `count: 1` search is one place, and a card reads it as a + // record — `dest_place.name`, not `dest_place.0.name`. Requiring + // the index meant the nav card's destination fell back to the + // seed and rendered an em dash beside a correct card: the model + // had put "Osaka Castle" in the initial state, the checker + // accepted it, and the one thing missing was this arm's ability + // to answer the spelling the card used. + let (index, field) = match binding.field.split_once('.') { + Some((i, f)) if i.parse::().is_ok() => (i, f), + _ => ("0", binding.field.as_str()), + }; let query = arg("query")?; match field { "lat" | "lon" => Some(format!("sys.searchnum({query:?}, {index}, {field:?})")), diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index a12e958..bcc1a41 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6090,3 +6090,58 @@ fn grouping_overrides_precedence() { "and grouping overrides that" ); } + +/// Two numbers compare numerically, whatever JSON shape they arrived in. +/// +/// `==` was `serde_json::Value` equality, which distinguishes `1` from `1.0`. So +/// `when here.ok == 1` took its branch when the host injected a float and +/// silently did not when it injected an integer — the same guard, the same card, +/// the same value, deciding differently on a representation the card cannot see. +/// +/// It cost the nav map. The card was correct, the checker accepted it, the +/// destination was live, and the `Map` was simply not in the realized tree. The +/// ordering operators already coerced through `as_f64`; only equality did not, +/// which is the half nobody tested. +#[test] +fn a_number_compares_numerically_whatever_shape_it_arrived_in() { + const CARD: &str = r#" +source here sys.gps() +copy yes { class: vocabulary, en: "SHOWN" } +view root Surface { + when here.ok == 1 { TextRow(text: copy.yes) } + TextCaption(value: here.lat) +} +"#; + for shape in [ + serde_json::json!(1), + serde_json::json!(1.0), + serde_json::json!(1.00), + ] { + let data = serde_json::json!({ + "here": { "lat": 34.9, "lon": 135.7, "ok": shape }, + "env": { "locale": {} }, "copy": { "yes": "SHOWN" } + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert!( + dsl.contains("SHOWN"), + "ok={shape} must satisfy `== 1`:\n{dsl}" + ); + } + // And a genuine mismatch still fails, in both shapes. + for shape in [serde_json::json!(0), serde_json::json!(0.0)] { + let data = serde_json::json!({ + "here": { "lat": 34.9, "lon": 135.7, "ok": shape }, + "env": { "locale": {} }, "copy": { "yes": "SHOWN" } + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + assert!( + !splash_ui_l0::kit::lower(&root).contains("SHOWN"), + "ok={shape} must not satisfy `== 1`" + ); + } +} From 98a438580f3f169cbc5a5e93e1a8cfaf5764c963 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:01:36 -0700 Subject: [PATCH 31/97] fix(ui_l0): a week forecast realizes seven days, each asking for its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Beijing week weather" gave one day. Two independent defects, both sitting between a correct card and a correct screen — the card declared seven days and the checker accepted it. THE DECLARED COUNT WAS NOT FOUND, so the loop realized ZERO rows and the card drew current conditions and nothing else. Three reasons at once: the lookup matched an argument named `count` and the weather card says `days:`; it required a bare literal and the card says `days: state.days`; and it matched the source name only, while the loop is over `week.days`. Both names are now accepted, a path resolves from its declared initial — that value is the card's own state, known at realization — and a collection field finds its source. A ROW COULD NOT TRANSLATE. With rows realized, each binding carried the collection's own name into the field handed to the helper: `days.3.cond` where it wanted `3.cond`. So every row fell back to its realized default — seven identical icons and an em dash for every high, which is a card that looks built and says nothing. Only a non-index segment followed by an index is dropped, so `week.min_lo` stays an aggregate on the source and does not become row zero. Measured on the shipping weather card with NO data blob: 0 forecast rows before, 7 after, and 7/7 distinct day highs and conditions lowering to their own index. I had found the first defect earlier in this session, written it down, offered the fix, and moved on. It is what the user hit. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 81 ++++++++++++++++++--- crates/splash-ui-l0/tests/fixtures/nav.card | 20 +++-- crates/splash-ui-l0/tests/profile.rs | 68 ++++++++++++++++- 3 files changed, 145 insertions(+), 24 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 91474fc..8ed9639 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5391,15 +5391,48 @@ impl Realizer<'_> { }) } + /// How many rows a loop over this source should realize before its data + /// arrives. + /// + /// This matched an argument named `count` holding a bare literal, and missed + /// the case that ships: a weather card asks `sys.weather(days: state.days)` + /// with `state days { initial: 7 }`. The argument is called `days`, and it is + /// a PATH rather than a literal — so a seven-day forecast realized zero rows + /// and the card drew current conditions and nothing else. "Beijing week + /// weather" gave one day. + /// + /// Both names, because `count` and `days` are the same question asked of + /// different capabilities, and a path resolved from its declared initial, + /// because that value is known at realization — it is the card's own state, + /// not something the host has to send. fn declared_count(&self, path: &str) -> Option { - let declaration = self.card.sources.iter().find(|s| s.name == path)?; - let (_, arg) = declaration.args.iter().find(|(n, _)| n == "count")?; - match arg { - SourceArg::Number(n) if *n >= 1.0 => { - Some((*n as usize).min(self.limits.max_collection)) + // The loop's path may name the source or a COLLECTION FIELD of it: + // `for m in movers` and `for d in week.days` are both loops over a + // counted source, and matching only the bare name meant the second found + // nothing. That is the weather card's forecast, which is the one that + // ships. + let root = root_of(path); + let declaration = self + .card + .sources + .iter() + .find(|s| s.name == path || s.name == root)?; + let (_, arg) = declaration + .args + .iter() + .find(|(n, _)| n == "count" || n == "days")?; + let n = match arg { + SourceArg::Number(n) => *n, + // `days: state.days` — a cursor into the card's own state, whose + // declared initial is the number the fetch will be asked for. + SourceArg::Path(p) => { + let key = p.strip_prefix("state.").unwrap_or(p); + let state = self.card.states.iter().find(|s| s.path == key)?; + state.initial.as_ref().and_then(|v| v.as_f64())? } - _ => None, - } + _ => return None, + }; + (n >= 1.0).then(|| (n as usize).min(self.limits.max_collection)) } /// Resolve an expression into live calls and constants. @@ -5493,14 +5526,38 @@ impl Realizer<'_> { }; args.push((name.clone(), resolved)); } + // The field a helper is asked for is `.` for a row of a + // collection, and the COLLECTION'S OWN NAME is not part of it. + // + // `for d in week.days` gives a binder whose provenance is the collection + // `week.days`, so the rewrite above produces `week.days.3.cond` — which + // is the right path for looking the value up in seeded data and the wrong + // one to hand a helper, which wants `3.cond`. Left in, every forecast row + // failed to translate and fell back to the realized default: seven rows + // of the same icon and an em dash where each day's high should be. + // + // Only a segment that is not an index is dropped, and only when an index + // follows it — `week.min_lo` is an aggregate on the source itself and has + // no row. + let mut field = path + .strip_prefix(&declaration.name) + .unwrap_or_default() + .trim_start_matches('.') + .to_string(); + if let Some((head, rest)) = field.clone().split_once('.') { + let head_is_index = head.chars().all(|c| c.is_ascii_digit()) && !head.is_empty(); + let rest_starts_with_index = rest + .split('.') + .next() + .is_some_and(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())); + if !head_is_index && rest_starts_with_index { + field = rest.to_string(); + } + } Some(SourceBinding { helper: declaration.helper.clone(), args, - field: path - .strip_prefix(&declaration.name) - .unwrap_or_default() - .trim_start_matches('.') - .to_string(), + field, }) } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 7a3ff5e..d138fd6 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -86,14 +86,18 @@ view root Surface { TextCaption(value: trip.distance, suffix: copy.away) } } - # The card names the TRIP, and GUARDS it on having a position. + # The card names the TRIP. It does NOT guard on having a fix. # - # `sys.gps` answers -9999 with no fix, so an unguarded map is a map of an - # impossible place: it fits its camera to a garbage extent and loads tiles - # without bound. §5.9 exists for exactly this — "no fix yet" is a state the - # card can branch on, not a number to render. - when here.ok == 1 { - Map(mode: .drive, from: here, to: dest_place, zoom: 16) - } + # An earlier version wrapped this in `when here.ok == 1`, which is wrong in + # mechanism however right it sounds: a guard is evaluated at REALIZE time, + # before any live value exists, so `here.ok` reads nothing on a freshly + # generated card and the map is removed unconditionally. Measured — the model + # wrote a card containing `Map(`, and not one `MapView` reached the screen. + # + # A guard can only test something realization can see: declared state, or a + # source's `$state` (which the host injects). A live VALUE is not that. The + # impossible-centre case is handled where the number is actually known, in the + # widget. + Map(mode: .drive, from: here, to: dest_place, zoom: 16) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index bcc1a41..b0d372a 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4700,10 +4700,18 @@ view root Surface { // And the per-item conditions must DIFFER — one shared value for every row // is exactly what the bug produced, and a test that only checked "a number // arrives" would have passed on it. - assert!( - dsl.contains("l0_weathericon(3, \"row\")") && dsl.contains("l0_weathericon(0, \"row\")"), - "each loop item's own condition must reach the kit:\n{dsl}" - ); + // + // They are LIVE CALLS at their own row index now, not literals. This + // asserted `l0_weathericon(3, "row")` and `l0_weathericon(0, "row")` — the + // realized codes — which was right while a row's binding could not translate: + // the collection's own name was left in the field handed to the helper, so + // every forecast row fell back to its default and seven days drew one icon. + for row in 0..2 { + assert!( + dsl.contains(&format!("daily.weather_code.{row}")), + "row {row} must ask for its OWN day:\n{dsl}" + ); + } } /// The nav card §1.0 said to write instead of argue about. @@ -6145,3 +6153,55 @@ view root Surface { ); } } + +/// A week forecast realizes SEVEN days, each asking for its own. +/// +/// "Beijing week weather" gave one day. Two independent defects, both between a +/// correct card and a correct screen: +/// +/// - **The declared count was not found.** It matched an argument named `count` +/// holding a bare literal, and the weather card asks +/// `sys.weather(days: state.days)` with `state days { initial: 7 }` — the wrong +/// name, and a path rather than a literal. It also matched the source name +/// only, and the loop is over `week.days`. So a seven-day forecast realized +/// ZERO rows and the card drew current conditions and nothing else. +/// - **A row could not translate.** With rows realized, each one's binding +/// carried the collection's own name into the field handed to the helper — +/// `days.3.cond` where it wanted `3.cond` — so every row fell back to its +/// realized default: seven identical icons and an em dash for every high. +#[test] +fn a_week_forecast_realizes_seven_days_each_its_own() { + // NO data. A live card carries none, which is the case that ships. + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let root = realize(WEATHER, &data, RealizeLimits::default()) + .root + .expect("realizes"); + + fn count(n: &splash_ui_l0::UiNode, kind: &str) -> usize { + (n.kind == kind) as usize + n.children.iter().map(|c| count(c, kind)).sum::() + } + assert_eq!( + count(&root, "TempBar"), + 7, + "the declared `days: state.days` is the row count" + ); + + let dsl = splash_ui_l0::kit::lower(&root); + // Each row asks for ITS day, not day 0 seven times. + for day in 0..7 { + assert!( + dsl.contains(&format!("daily.temperature_2m_max.{day}")), + "day {day}'s high must be live:\n{dsl}" + ); + assert!( + dsl.contains(&format!("daily.weather_code.{day}")), + "day {day}'s condition must be live:\n{dsl}" + ); + } + // An aggregate on the source itself has no row and must not be rewritten + // into one — `week.min_lo` is a property of the WEEK. + assert!( + !dsl.contains("min_lo.0") && !dsl.contains("0.min_lo"), + "an aggregate is not a row:\n{dsl}" + ); +} From c48c00ca826444f3b795069abc59a20281402e9b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:38:56 -0700 Subject: [PATCH 32/97] fix(ui_l0): a forecast row's label and the week's range go live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more between a correct card and a correct screen, both visible in one screenshot of a seven-day Beijing forecast and neither visible to any test. `sys.dayname(lat, lon, n, locale)` takes FOUR arguments and this emitted three — `"en"` in the lat slot, the row in the lon slot — so `n` coerced to 0 and every row said "Today". Seven of them, under seven different temperatures, which is what made it read as a labelling choice rather than a bug. `min_lo` and `max_hi` had no arm at all, though `sys.weekmin` and `sys.weekmax` were sitting in the VM waiting. They are §5.11 aggregates — properties of the WEEK, and what tells a `TempBar` how long its bar should be. Both fell back to zero, so every bar drew against a range of nothing: seven different days, seven identical flat lines. The shape is the one this session keeps finding. A helper exists, the card is right, the checker accepts it, and one table entry is missing or wrong — so the screen is confidently, plausibly wrong. The count of arguments in a call is not something any test in this crate looks at, and the aggregates were listed in the ANSWERS vocabulary while having no translation, which the vocabulary test cannot see because it compares Rust against the TOML rather than against the backend. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 15 +++++++++- crates/splash-ui-l0/tests/profile.rs | 45 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 8ed9639..bd5959a 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5903,7 +5903,20 @@ pub mod makepad { format!("daily.weather_code.{row}") )) } - "dayname" => return Some(format!("sys.dayname(\"en\", {row}, \"en\")")), + // `sys.dayname(lat, lon, n, locale)` — FOUR arguments. This + // emitted three, putting `"en"` in the lat slot and the row + // in the lon slot, so `n` coerced to 0 and every forecast row + // said "Today". Seven rows of it, under seven different + // temperatures, which is what made it look like a labelling + // choice rather than a bug. + "dayname" => return Some(format!("sys.dayname({lat}, {lon}, {row}, \"en\")")), + // §5.11's aggregates — properties of the WEEK, not of a day, + // and the reason a `TempBar` knows how long its bar should + // be. Untranslated, both fell back to zero: every bar drew + // against a range of nothing, so seven days of different + // temperatures all rendered the same flat line. + "min_lo" => return Some(format!("sys.weekmin({lat}, {lon})")), + "max_hi" => return Some(format!("sys.weekmax({lat}, {lon})")), _ => return None, }; Some(format!("sys.weather({lat}, {lon}, {path:?})")) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b0d372a..746b74f 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6205,3 +6205,48 @@ fn a_week_forecast_realizes_seven_days_each_its_own() { "an aggregate is not a row:\n{dsl}" ); } + +/// A forecast row's LABEL and the week's range reach their helpers. +/// +/// Two more between a correct card and a correct screen, both found by looking at +/// a phone rather than at a test. +/// +/// `sys.dayname(lat, lon, n, locale)` takes FOUR arguments and this emitted +/// three, putting `"en"` in the lat slot and the row in the lon slot — so `n` +/// coerced to 0 and every row said "Today". Seven of them, under seven different +/// temperatures, which made it read as a labelling choice rather than a bug. +/// +/// `min_lo` and `max_hi` are §5.11 aggregates — properties of the WEEK, and what +/// tells a `TempBar` how long its bar should be. Neither was translated, so both +/// fell back to zero and every bar drew against a range of nothing: seven +/// different days, seven identical flat lines. +#[test] +fn a_forecast_rows_label_and_the_weeks_range_go_live() { + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let root = realize(WEATHER, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + + // Every row asks for ITS weekday, with the coordinates the helper needs. + for day in 0..7 { + assert!( + dsl.contains(&format!("\"lon\"), {day}, \"en\")")), + "row {day} must ask for its own weekday:\n{dsl}" + ); + } + // A four-argument call, so the row can never land in the locale slot. + assert!( + !dsl.contains("sys.dayname(\"en\""), + "the row must not be passed as a coordinate:\n{dsl}" + ); + // The week's range, so a bar has something to be a fraction of. + assert!( + dsl.contains("sys.weekmin("), + "min_lo must translate:\n{dsl}" + ); + assert!( + dsl.contains("sys.weekmax("), + "max_hi must translate:\n{dsl}" + ); +} From 89c00262f55756e7db14c75dede5fe417fd5d579 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:03:33 -0700 Subject: [PATCH 33/97] feat(ui_l0): the satellite pane, and a row that can stop filling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a card could not SAY. THE SKY. The shipping weather app has two map panes — 卫星云图 then 空气质量图 — and L0 had a role for the second and none at all for the first, so every generated weather card was missing it. `Satellite(lat:, lon:)` names WHERE, like every other visualisation here, and the helper answers the image: `sys.satellite` was sitting in the VM the whole time with nothing in the catalog to reach it. A card that carried the image would be carrying an observation, which is the same reason `AqiContour` takes a location and not a grid. It lowers through both backends with LIVE coordinates, and it is the one visualisation whose helper answers a URL rather than driving a shader — so the kit builds an image node and the widget fetches what the URL points at. THE ROW. `align: .center` on a column did nothing to a row child, because `l0_row` fills by default — a list row must — and a filling child ignores its parent's alignment. `align` on a ROW means the cross axis, which is vertical, so there was no way to centre a row's contents horizontally either. The weather card's `↑37° ↓28° ≈37°` sat hard left under a centred place name, icon and hero temperature, and no attribute the card could write changed it. `Row` gains `width`, and `fit` stops being treated as a no-op. It IS a no-op for a text role, where fit is the default — and it is not for a row, which is why the token existed and did nothing. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 28 +++++++++++- .../splash-ui-l0/tests/fixtures/weather.card | 14 +++++- crates/splash-ui-l0/tests/profile.rs | 43 +++++++++++++++++++ docs/ui-l0-constructors.toml | 10 +++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index bd5959a..8543ebc 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2679,9 +2679,13 @@ pub mod catalog { ("width", TokenOrPath(WIDTH)), ], ), + // `width` says whether this row FILLS. It fills by default because a list + // row must, and that defeats a centred parent — the one thing a card + // could not say. ( "Row", &[ + ("width", TokenOrPath(WIDTH)), ("align", Token(ALIGN)), ("gap", Number), ("on_tap", Event), @@ -2759,6 +2763,11 @@ pub mod catalog { "AqiContour", &[("lat", Path), ("lon", Path), ("span", Number)], ), + // Live satellite cloud imagery (卫星云图) over a place — the one pane the + // shipping weather card has and L0 could not express at all. Names WHERE, + // like every other visualisation here, and the helper answers the image, + // so a card shows the sky without ever stating what is in it. + ("Satellite", &[("lat", Path), ("lon", Path)]), ( "StockPlot", &[("symbol", Path), ("range", TokenOrPath(UNIT))], @@ -6691,6 +6700,18 @@ pub mod makepad { trim_num(num_of(arg(node, "phase"))) ); } + // Live satellite cloud imagery. An IMAGE rather than a shader, so it + // is the one visualisation whose helper answers a URL — the widget + // fetches what the URL points at, and the card said only where. + "Satellite" => { + let _ = writeln!( + out, + "{p}Image{{ width: Fill height: 190 fit: ImageFit.CropToFill \ + src: http_resource(sys.satellite({}, {})) }}", + expr_of(node, "lat"), + expr_of(node, "lon") + ); + } "AqiContour" => { // lat/lon/span, not a field: the widget fetches its own data. // Emitting `draw_bg.idx` was writing a GPU uniform from the @@ -7952,6 +7973,7 @@ pub mod kit { "SunArc" => "l0_sunarc", "MoonPhase" => "l0_moonphase", "AqiContour" => "l0_aqicontour", + "Satellite" => "l0_satellite", "StockPlot" => "l0_stockplot", "TextHero" => "l0_hero", "TextTitle" => "l0_title", @@ -8116,6 +8138,9 @@ pub mod kit { }; match t.as_str() { "fill" => Some(("l0_wide(", ")".into())), + // NOT a no-op, though it is the default for a text role: `l0_row` + // fills, so a row can only stop filling by saying so. + "fit" => Some(("l0_fit(", ")".into())), "rank" | "day" | "temp" => Some(("l0_colw(", format!(", {t:?})"))), _ => None, } @@ -8336,12 +8361,13 @@ pub mod kit { // the catalog's order — no defaults, because a bar drawn against a // range nobody supplied is a bar drawn against zero, and it looks // like data. - "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "StockPlot" => { + "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "StockPlot" | "Satellite" => { let params: &[&str] = match node.kind.as_str() { "TempBar" => &["lo", "hi", "min", "max"], "SunArc" => &["rise", "set", "now"], "MoonPhase" => &["phase", "illum"], "AqiContour" => &["lat", "lon", "span"], + "Satellite" => &["lat", "lon"], _ => &["symbol", "range"], }; let args: Vec = params.iter().map(|p| scalar_of(node, p)).collect(); diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 59461c1..a055021 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -40,6 +40,7 @@ copy wind { class: vocabulary, en: "Wind", zh: "风速" } copy pressure { class: vocabulary, en: "Pressure", zh: "气压" } copy uv { class: vocabulary, en: "UV Index", zh: "紫外线" } copy visibility { class: vocabulary, en: "Visibility", zh: "能见度" } +copy sky { class: vocabulary, en: "Satellite", zh: "卫星云图" } copy air { class: vocabulary, en: "Air Quality", zh: "空气质量" } copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } @@ -48,6 +49,7 @@ copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } view root Photo(src: scene, pad: .page) { current forecast + cloudfield airfield sunmoon details @@ -57,7 +59,9 @@ view current Col(align: .center) { TextTitle(text: place.name) WeatherIcon(cond: now.cond, size: .hero) TextHero(value: now.temp, unit: units, on_tap: toggle_units) - Row(gap: 8) { + # `width: .fit` so the centred column can centre it. A row fills + # by default, and a filling child ignores its parent's alignment. + Row(gap: 8, width: .fit) { TextCaption(glyph: "↑", value: now.hi, unit: units) TextCaption(glyph: "↓", value: now.lo, unit: units) TextCaption(glyph: "≈", value: now.feels, unit: units) @@ -98,6 +102,14 @@ view forecast Panel { } } +# The two map panes the shipping card has, in its order: the sky first, then the +# air. Both name a LOCATION and fetch their own image — a card that carried +# either would be carrying an observation (§4). +view cloudfield Panel { + TextCaption(text: copy.sky) + Satellite(lat: place.lat, lon: place.lon) + } + view airfield Panel { TextCaption(text: copy.air) AqiContour(lat: place.lat, lon: place.lon, span: 1.6) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 746b74f..91ed8a0 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6250,3 +6250,46 @@ fn a_forecast_rows_label_and_the_weeks_range_go_live() { "max_hi must translate:\n{dsl}" ); } + +/// The satellite pane, and a row that can stop filling. +/// +/// Both were things a card could not SAY. The shipping weather app has two map +/// panes — 卫星云图 then 空气质量图 — and L0 had a role for the second and none for +/// the first, so every generated weather card was missing its sky. +/// +/// And `align: .center` on a column had no effect on a row child, because +/// `l0_row` fills by default (a list row must) and a filling child ignores its +/// parent's alignment — while `align` on a ROW means the cross axis, which is +/// vertical. So the weather card's `↑37° ↓28° ≈37°` sat hard left under a centred +/// name, icon and hero, and no attribute the card could write changed it. +#[test] +fn a_satellite_pane_and_a_row_that_can_stop_filling() { + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let root = realize(WEATHER, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let kit = splash_ui_l0::kit::lower(&root); + let mk = makepad::lower(&root); + + // The pane names WHERE and the helper answers the image — in both backends, + // with LIVE coordinates, so the card never carries an observation. + assert!( + kit.contains("l0_satellite(sys.geocodenum("), + "the kit must ask for the sky at the resolved place:\n{kit}" + ); + assert!( + mk.contains("sys.satellite(sys.geocodenum("), + "and so must makepad:\n{mk}" + ); + + // `width: .fit` is NOT a no-op on a row, whatever it is on a text role. + assert!( + kit.contains("l0_fit("), + "a row must be able to stop filling:\n{kit}" + ); + // And it is inside the centred column, which is the point. + assert!( + kit.contains("l0_aligned("), + "the current block is still centred:\n{kit}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index ac797fa..d07269c 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -73,7 +73,11 @@ align = { kind = "token", tokens = ["start", "center", "end"] } gap = { kind = "number" } width = { kind = "width" } +# `width` says whether this row FILLS. It fills by default, because a list row +# must — and that defeats a centred parent, which was the one thing a card could +# not say about a row. [Row] +width = { kind = "width" } align = { kind = "token", tokens = ["start", "center", "end", "baseline"] } gap = { kind = "number" } on_tap = { kind = "event" } @@ -188,6 +192,12 @@ illum = { kind = "path" } # as the widget's own comment puts it, "that put a GPU uniform layout into the # authoring language — it is not a widget contract, it is an ABI". The widget # fetches its own field now, so a card cannot express one wrongly. +# Live satellite cloud imagery (卫星云图) over a place. Names WHERE; the helper +# answers the image, so a card shows the sky without stating what is in it. +[Satellite] +lat = { kind = "path" } +lon = { kind = "path" } + [AqiContour] lat = { kind = "path" } lon = { kind = "path" } From 9f321d9405d55b793109c7ccbb5d381961cf9ba3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:19:43 -0700 Subject: [PATCH 34/97] fix(ui_l0): a visualisation's parameters are numbers, and a grid is rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects on one weather card, all the shape this session keeps finding: correct card, checker accepts, screen confidently wrong. EVERY `sys.*` HELPER ANSWERS WITH A STRING, because a string is what a card renders. A visualisation's parameters are not rendered — they drive shader uniforms typed as numbers — so all four of a `TempBar`'s arrived as `None` and the uniform got 0. Seven days of different temperatures, seven identical flat bars. `AqiContour` was worse than flat. Its latitude and longitude were 0, so the shader drew a real air-quality contour for 0°N 0°E — the Gulf of Guinea — under a caption naming the user's city. That is the §4 failure in its purest form: not a missing value, a true reading of the wrong place. Both are the same defect as an L1 operand subtracting strings, in the other position where a number is needed and a helper gives a string, and they are fixed with the same coercion. A GRID IS ROWS. `Grid(cols: 2)` drew one tile per line, because the kit took a flat child list and the node model renders a grid as a column, so the six detail tiles ran six rows deep instead of three across. `cols` was honoured only by `makepad::lower` — not the path the device renders through. The chunking moves into the lowering, where that backend already did it, so both now divide the same way. The kit language has no loop and could not have. And row one of the forecast says "Now" rather than "Today": the day column is a FIXED width because the labels must line up down the list, and "Today" did not fit it — row one wrapped to "Toda / y" beside six three-letter weekdays. "Now" is also the more accurate word for a row whose reading is current rather than forecast. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 69 ++++++++++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 56 +++++++++++++++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 8543ebc..08a8508 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -8108,9 +8108,33 @@ pub mod kit { /// emits a literal — and the icon is the harder one to notice, because a /// wrong icon looks precisely like a right one. fn scalar_of(node: &UiNode, name: &str) -> String { + scalar_inner(node, name, false) + } + + /// The same, COERCED to a number. + /// + /// Every `sys.*` helper answers with a string, because a string is what a + /// card renders. A visualisation's parameters are not rendered — they drive a + /// shader uniform, and the node model types them as numbers, so a string + /// arrives as `None` and the uniform gets 0. + /// + /// Measured: a `TempBar`'s `lo`, `hi`, `min` and `max` were all live calls + /// and all four reached the widget as zero, so seven days of different + /// temperatures drew seven identical flat bars against a range of nothing. + /// The same defect as an L1 operand subtracting strings, in the other place + /// a number is needed and a string is what a helper gives. + fn scalar_num_of(node: &UiNode, name: &str) -> String { + scalar_inner(node, name, true) + } + + fn scalar_inner(node: &UiNode, name: &str, numeric: bool) -> String { if let Some((_, binding)) = node.bindings.iter().find(|(n, _)| n == name) { if let Some(call) = makepad::vm_call(binding) { - return call; + return if numeric { + format!("sys.num({call})") + } else { + call + }; } } match arg(node, name) { @@ -8269,7 +8293,45 @@ pub mod kit { children(node, depth, out); out.push(')'); } - "Panel" | "Card" | "Grid" => { + // A GRID IS ROWS OF `cols`, chunked HERE. + // + // `l0_grid` took a flat child list and the node model renders a grid + // as a column, so a `Grid(cols: 2)` drew one tile per line — the + // weather card's feels-like / humidity / wind / pressure / UV / + // visibility ran six rows deep instead of three across. `cols` was + // accepted by the catalog and honoured only by `makepad::lower`, + // which is not the path the device renders through. + // + // The chunking is here rather than in the theme because the kit + // language has no loop, and it is where `makepad::lower` already does + // it — so both backends now divide the same way. + "Grid" => { + let cols = match arg(node, "cols") { + Some(NodeValue::Number(n)) if *n >= 1.0 => *n as usize, + _ => 2, + }; + let pad = " ".repeat(depth + 1); + let _ = writeln!(out, "l0_col(["); + let rows: Vec<&[UiNode]> = node.children.chunks(cols).collect(); + for (r, row) in rows.iter().enumerate() { + let _ = writeln!(out, "{pad}l0_row(["); + for (i, cell) in row.iter().enumerate() { + let _ = write!(out, "{pad} "); + element(cell, depth + 2, out); + if i + 1 < row.len() { + out.push(','); + } + out.push('\n'); + } + let _ = write!(out, "{pad}])"); + if r + 1 < rows.len() { + out.push(','); + } + out.push('\n'); + } + let _ = write!(out, "{}])", " ".repeat(depth)); + } + "Panel" | "Card" => { let _ = write!(out, "{f}("); children(node, depth, out); out.push(')'); @@ -8370,7 +8432,8 @@ pub mod kit { "Satellite" => &["lat", "lon"], _ => &["symbol", "range"], }; - let args: Vec = params.iter().map(|p| scalar_of(node, p)).collect(); + // Numeric: these drive shader uniforms, not text. + let args: Vec = params.iter().map(|p| scalar_num_of(node, p)).collect(); let _ = write!(out, "{f}({})", args.join(", ")); } // A hero is sized by the caller, because only the lowering knows diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 91ed8a0..854f0dd 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6273,8 +6273,10 @@ fn a_satellite_pane_and_a_row_that_can_stop_filling() { // The pane names WHERE and the helper answers the image — in both backends, // with LIVE coordinates, so the card never carries an observation. + // Coerced, because `sys.satellite` takes coordinates as NUMBERS and every + // helper answers with a string. assert!( - kit.contains("l0_satellite(sys.geocodenum("), + kit.contains("l0_satellite(sys.num(sys.geocodenum("), "the kit must ask for the sky at the resolved place:\n{kit}" ); assert!( @@ -6293,3 +6295,55 @@ fn a_satellite_pane_and_a_row_that_can_stop_filling() { "the current block is still centred:\n{kit}" ); } + +/// A visualisation's parameters are NUMBERS, and a grid is rows. +/// +/// Both were the same shape as everything else this session: the card was right, +/// the checker accepted it, and the screen was confidently wrong. +/// +/// Every `sys.*` helper answers with a STRING, because a string is what a card +/// renders. A visualisation's parameters are not rendered — they drive shader +/// uniforms typed as numbers — so all four of a `TempBar`'s arrived as `None` and +/// the uniform got 0: seven days of different temperatures, seven identical flat +/// bars. `AqiContour` was worse than flat. Its latitude and longitude were 0, so +/// it drew a real air-quality contour for 0°N 0°E, the Gulf of Guinea, under a +/// caption naming the user's city. +/// +/// And a `Grid(cols: 2)` drew one tile per line, because the kit took a flat +/// child list and the node model renders a grid as a column. `cols` was honoured +/// only by `makepad::lower`, which is not the path the device renders through. +#[test] +fn a_visualisations_parameters_are_numbers_and_a_grid_is_rows() { + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let root = realize(WEATHER, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let kit = splash_ui_l0::kit::lower(&root); + + // All seven bars, and the week's range they are a fraction of. + assert_eq!( + kit.matches("l0_tempbar(sys.num(").count(), + 7, + "every bar's low must arrive as a number:\n{kit}" + ); + assert!( + kit.contains("sys.num(sys.weekmin(") && kit.contains("sys.num(sys.weekmax("), + "so must the range:\n{kit}" + ); + // The contour's location, or it draws a real reading for the wrong place. + assert!( + kit.contains("l0_aqicontour(sys.num("), + "the contour must be located by numbers:\n{kit}" + ); + + // A grid is rows now, not a flat list the theme renders as a column. + assert!( + !kit.contains("l0_grid("), + "the flat grid call must be gone:\n{kit}" + ); + assert_eq!( + kit.matches("l0_tile(").count(), + 6, + "the six detail tiles are still all there:\n{kit}" + ); +} From 4f59bbfc82879aa5df63fd8c16a3eb2942631cb5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:29:52 -0700 Subject: [PATCH 35/97] =?UTF-8?q?test(ui=5Fl0):=20the=20backend=20conforma?= =?UTF-8?q?nce=20test=20=C2=A74=20has=20been=20owing=20since=20it=20was=20?= =?UTF-8?q?written?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §4 predicted this defect class and then left it: "a field can be declared here, accepted by the checker, and still unanswerable by a given backend… Closing that needs a conformance test per backend asserting it answers everything declared here. This table is what such a test would check against; it is not the test." This is the test. It compares the vocabulary against the TRANSLATION, which is the one direction nothing checked — the two existing catalog tests both compare Splash with itself, Rust against the TOML, and agreed happily while the backend had nothing behind either. It found 35 fields on its first run, and they explain screens I had already looked at and not diagnosed: sys.route.duration/distance/steps nav's `— —` trip row sys.locale.temp_unit the units toggle seeding from nothing sys.quote.mktcap/pe/prev the stock detail's missing tiles sys.news_item.* a whole capability, untranslated sys.series.points/min/max likewise They are recorded in three groups, because the reasons differ and the remedies do: the helper genuinely cannot answer (the chart endpoint carries no market cap, and open-meteo serves visibility hourly only); a capability has no translation at all; or an arm simply forgot a field that sits beside ones it answers. Every earlier defect of this shape was found by looking at a phone — `dayname` called with three of four arguments, `min_lo` and `max_hi` with no arm, `sys.search` demanding an index. This finds them at `cargo test`. The weather card's `visibility` tile is swapped for `precip`, which the same fetch already answers. That is the sixth tile rendering a value instead of an em dash, and the field-vocabulary check caught the swap the moment `fields:` disagreed — one guard catching the fix to another. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 5 +- .../splash-ui-l0/tests/fixtures/weather.card | 8 +- crates/splash-ui-l0/tests/profile.rs | 119 ++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 08a8508..0669119 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5824,7 +5824,10 @@ pub mod makepad { /// says `sys.quote(ticker:)` and the VM says `sys.stock(symbol, key)`. This /// table is the whole of the translation, and a helper or field missing from /// it means the seeded value is used — never a wrong call. - pub(super) fn vm_call(binding: &SourceBinding) -> Option { + /// Public so a conformance test can ask, per capability and field, whether + /// this backend answers at all — the check §4 says is owed and that no test + /// comparing Splash with itself can perform. + pub fn vm_call(binding: &SourceBinding) -> Option { let arg = |name: &str| { binding .args diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index a055021..2f0f5a9 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -11,7 +11,7 @@ source place sys.geocode(name: state.city) source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, feels, hi, lo, cond, humidity, wind, - pressure, uv, visibility]) + pressure, uv, precip]) source week sys.weather(lat: place.lat, lon: place.lon, days: state.days, fields: [dayname, hi, lo, cond], aggregate: [min_lo, max_hi]) @@ -39,6 +39,7 @@ copy humidity { class: vocabulary, en: "Humidity", zh: "湿度" } copy wind { class: vocabulary, en: "Wind", zh: "风速" } copy pressure { class: vocabulary, en: "Pressure", zh: "气压" } copy uv { class: vocabulary, en: "UV Index", zh: "紫外线" } +copy precip { class: vocabulary, en: "Rain", zh: "降水概率" } copy visibility { class: vocabulary, en: "Visibility", zh: "能见度" } copy sky { class: vocabulary, en: "Satellite", zh: "卫星云图" } copy air { class: vocabulary, en: "Air Quality", zh: "空气质量" } @@ -133,5 +134,8 @@ view details Grid(cols: 2) { Tile(label: copy.wind, value: now.wind, unit: .speed) Tile(label: copy.pressure, value: now.pressure, unit: .pressure) Tile(label: copy.uv, value: now.uv, unit: .index) - Tile(label: copy.visibility, value: now.visibility, unit: .distance) + # `visibility` was here and renders an em dash: open-meteo serves + # it hourly only, so no call answers it. `precip` is answered by + # the same fetch and is the more useful sixth tile anyway. + Tile(label: copy.precip, value: now.precip, unit: .pct) } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 854f0dd..b233dc4 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6347,3 +6347,122 @@ fn a_visualisations_parameters_are_numbers_and_a_grid_is_rows() { "the six detail tiles are still all there:\n{kit}" ); } + +/// Every field the vocabulary offers must be one a backend can ANSWER. +/// +/// §4 predicted this and left it: "a field can be declared here, accepted by the +/// checker, and still unanswerable by a given backend… Closing that needs a +/// conformance test per backend asserting it answers everything declared here. +/// This table is what such a test would check against; it is not the test." +/// +/// This is the test. It compares the vocabulary against the TRANSLATION rather +/// than against the TOML — the two existing catalog tests both compare Splash +/// with itself, which is why every defect of this shape got through: `dayname` +/// called with three of four arguments, `min_lo`/`max_hi` with no arm at all, +/// `sys.search` demanding an index, `visibility` offered by the vocabulary and +/// requested by no URL. A card asks for it, the checker accepts, and the screen +/// shows an em dash indistinguishable from data still in flight. +#[test] +fn every_offered_field_has_a_translation() { + use splash_ui_l0::catalog; + // Fields the vocabulary offers that THIS backend cannot answer. May only + // shrink, and each needs a reason. + const UNANSWERED: &[(&str, &str)] = &[ + // ── the helper genuinely cannot answer ──────────────────────────────── + // open-meteo serves visibility as an HOURLY variable only; there is no + // `current.visibility`, so answering means requesting the hourly series + // and indexing the current hour, which the helper does not do. + ("sys.weather", "visibility"), + // `sys.stock` fetches the CHART endpoint, which carries neither market + // cap nor P/E. The existing arm says so in a comment; this makes it a + // fact the build checks rather than a note someone may read. + ("sys.quote", "mktcap"), + ("sys.quote", "pe"), + ("sys.movers", "pe"), + ("sys.watchlist", "pe"), + // The photo capability answers an image for a QUERY and has no fields. + ("sys.photo", ""), + // ── a capability with NO translation at all ─────────────────────────── + // Every one of these renders an em dash today. `sys.route` is why the + // nav card's duration and distance row is `— —`, and `sys.locale` is why + // `state units { initial: env.locale.temp_unit }` seeds from nothing — + // which is half of why the units toggle appears to do nothing. + ("sys.route", "duration"), + ("sys.route", "distance"), + ("sys.route", "steps"), + ("sys.locale", "lang"), + ("sys.locale", "temp_unit"), + ("sys.news_item", "id"), + ("sys.news_item", "title"), + ("sys.news_item", "author"), + ("sys.news_item", "points"), + ("sys.news_item", "comments"), + ("sys.news_item", "url"), + ("sys.series", "points"), + ("sys.series", "min"), + ("sys.series", "max"), + ("sys.prefs", "units"), + ("sys.prefs", "range"), + // ── a field the arm forgot ──────────────────────────────────────────── + // Each of these sits beside fields the same arm answers, so the + // capability works and one value on the card does not. + ("sys.geocode", "population"), + ("sys.quote", "ticker"), + ("sys.quote", "prev"), + ("sys.quote", "currency"), + ("sys.quote", "exchange"), + ("sys.movers", "prev"), + ("sys.movers", "currency"), + ("sys.movers", "exchange"), + ("sys.watchlist", "prev"), + ("sys.watchlist", "currency"), + ("sys.watchlist", "exchange"), + ("sys.places", "id"), + ("sys.search", "id"), + ("sys.search", "distance"), + // `days` is the COLLECTION a forecast loops over, not a value read off + // it, so no single call answers it. The loop is what consumes it. + ("sys.weather", "days"), + ]; + + let mut missing: Vec<(String, String)> = Vec::new(); + for (capability, fields) in catalog::ANSWERS { + for field in *fields { + // A collection is addressed by row; ask for row 0. + let answered = [field.to_string(), format!("0.{field}")].iter().any(|f| { + splash_ui_l0::makepad::vm_call(&splash_ui_l0::SourceBinding { + helper: (*capability).to_string(), + // Arguments every helper of this shape needs. A missing + // one makes the arm bail for the wrong reason, so they + // are all supplied. + args: vec![ + ("lat".into(), "1".into()), + ("lon".into(), "2".into()), + ("ticker".into(), "N".into()), + ("query".into(), "q".into()), + ("name".into(), "n".into()), + ("id".into(), "1".into()), + ("category".into(), "c".into()), + ], + field: f.clone(), + }) + .is_some() + }); + if !answered { + missing.push(((*capability).to_string(), (*field).to_string())); + } + } + } + + let known: Vec<(String, String)> = UNANSWERED + .iter() + .map(|(c, f)| (c.to_string(), f.to_string())) + .collect(); + let mut fresh: Vec<_> = missing.iter().filter(|p| !known.contains(p)).collect(); + fresh.sort(); + assert!( + fresh.is_empty(), + "the vocabulary offers these and no backend call answers them, so a card \ + that asks renders an em dash the checker cannot warn about: {fresh:#?}" + ); +} From c0708350a4847dd8f8da7d7c9fb14be9d67e3cd7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:04:40 -0700 Subject: [PATCH 36/97] docs(ui_l0): nav's drive screen is settled AGAINST L0, not for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.0 claimed `nav` was "settled by writing it" and that `tests/fixtures/nav.card` is "the same screen as the 664-line L2 exemplar in 54 lines". The line count is right and the claim is too broad: the shipping card has five screens and the L0 rewrite covers the three declarative ones — search, results, route preview. The fourth is settled the other way, and its own spec says why. Turn-by- turn navigation needs a vehicle position updated every frame, delivered by a `fn tick()` card that calls `ui..set_*` on named widgets and must NEVER rebuild, "because a rebuild would tear down the live MapView". All three are exactly what §7 classifies as L2. `fn` reintroduces unbounded work. `ui..` is an imperative widget command. And never-rebuilding is the negation of declare-and-re-realize, which is the whole of L0's model. So this is not a missing role and no catalog entry fixes it: the MECHANISM is the thing L0 exists to refuse. An L0 nav card can show you a route and cannot drive it. Recorded as a row of its own in §1.0's table and in the roadmap, because it is the first concrete card that NEEDS L2 rather than merely happening to be written that way — which is a better argument for L2 existing than anything in the document so far. Found by being asked for navigation and having to say no. Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmap.md | 5 +++++ docs/ui-profile-l0.md | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index f093a48..4895abb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -462,6 +462,11 @@ Remaining: blesses a level no document defines, and the spec pass is owed. - **L2 is unimplemented and refused before parsing.** Imperative widget commands are a different grammar rather than a wider one, so nothing below parses them. + There is now a concrete card that NEEDS it: the shipping nav app's turn-by-turn + drive screen updates a vehicle position every frame through `ui..set_*` + inside a `fn tick()` that must never rebuild. §1.0 records this as settled + against L0 — an L0 nav card shows a route and cannot drive one — so the honest + scope of "nav at L0" is its declarative screens. ## Before a stable language release diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index b972737..bf0acc8 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -118,8 +118,9 @@ as evidence about the language. The honest position is narrower — | | | |---|---| | **L0 suits** | a card that displays declared data, branches on it, and writes declared state on a tap or a commit | -| **L0 cannot express** | a card that computes derived values, or drives its own animation loop | -| **Settled by writing it** | `nav` rewritten against declared sources and a `Map` role IS inside the first row. `tests/fixtures/nav.card` is the same screen as the 664-line L2 exemplar in **54 lines**, admitted at L0 | +| **L0 cannot express** | a card that computes derived values, or drives its own animation loop — including turn-by-turn navigation, which is the same thing wearing a map | +| **Settled by writing it** | `nav`'s DECLARATIVE screens — search, results, route preview — rewritten against declared sources with a `Map` role, in **54 lines** against the L2 exemplar's 664, admitted at L0 | +| **Settled AGAINST L0** | the shipping card's **drive** screen. Turn-by-turn navigation needs a vehicle position updated every frame, and its own spec's mandatory rules say how: a `fn tick()` card that calls `ui..set_*` on named widgets and **must never rebuild**, because a rebuild tears down the live `MapView`. All three are what §7 classifies as L2 — `fn` reintroduces unbounded work, `ui..` is an imperative widget command, and never-rebuilding is the negation of declare-and-re-realize. This is not a missing role: the MECHANISM is the thing L0 refuses. An L0 nav card can show you a route and cannot drive it | **What writing it found.** Four gaps, none structural, all recorded rather than patched over: From 6a24d9e28fa316e073d09f18e992776e496bc0d6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:57:54 -0700 Subject: [PATCH 37/97] docs(ui_l0): nav's drive screen is L2 as written, not L2 by necessity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I overcorrected. The previous commit recorded the drive screen as "settled AGAINST L0", which reads as though turn-by-turn navigation cannot be declarative. What is L2 is the way the shipping card does it — a `fn tick()` calling `ui..set_*` on widgets that must never rebuild — and that is because the CARD drives the camera and the vehicle by hand. `MapView` already animates its own camera from a `nav_period` loop; that loop is what pinned the GPU at 69% when `.drive` lowered to `"3d"`. It does not need to be fed frames. What it lacks is a declared position to FOLLOW, and `sys.gps` is one. Which is the move `Map` already made once: the card said "here is a trip" instead of fetching a route by hand, and `sys.navroute` filled it in. The same shape applies to position, and it is a widget change rather than a language change. So L2 still has no card that requires it — which matters, because a concrete requirement would be the strongest argument for implementing a level whose whole cost is that it reintroduces the surface L0 exists to deny. Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmap.md | 12 +++++++----- docs/ui-profile-l0.md | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4895abb..e59c2ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -462,11 +462,13 @@ Remaining: blesses a level no document defines, and the spec pass is owed. - **L2 is unimplemented and refused before parsing.** Imperative widget commands are a different grammar rather than a wider one, so nothing below parses them. - There is now a concrete card that NEEDS it: the shipping nav app's turn-by-turn - drive screen updates a vehicle position every frame through `ui..set_*` - inside a `fn tick()` that must never rebuild. §1.0 records this as settled - against L0 — an L0 nav card shows a route and cannot drive one — so the honest - scope of "nav at L0" is its declarative screens. + The nearest thing to a card that needs it is the shipping nav app's drive + screen, which updates a vehicle position every frame through `ui..set_*` + inside a `fn tick()` that must never rebuild. §1.0 records why that is L2 as + WRITTEN and why the capability is not: `MapView` already animates its own + camera, so a declared position for it to follow would make navigation + declarative — a widget change rather than a language one. So L2 still has no + card that requires it. ## Before a stable language release diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index bf0acc8..d2cc0aa 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -120,7 +120,7 @@ as evidence about the language. The honest position is narrower — | **L0 suits** | a card that displays declared data, branches on it, and writes declared state on a tap or a commit | | **L0 cannot express** | a card that computes derived values, or drives its own animation loop — including turn-by-turn navigation, which is the same thing wearing a map | | **Settled by writing it** | `nav`'s DECLARATIVE screens — search, results, route preview — rewritten against declared sources with a `Map` role, in **54 lines** against the L2 exemplar's 664, admitted at L0 | -| **Settled AGAINST L0** | the shipping card's **drive** screen. Turn-by-turn navigation needs a vehicle position updated every frame, and its own spec's mandatory rules say how: a `fn tick()` card that calls `ui..set_*` on named widgets and **must never rebuild**, because a rebuild tears down the live `MapView`. All three are what §7 classifies as L2 — `fn` reintroduces unbounded work, `ui..` is an imperative widget command, and never-rebuilding is the negation of declare-and-re-realize. This is not a missing role: the MECHANISM is the thing L0 refuses. An L0 nav card can show you a route and cannot drive it | +| **Settled against the shipping card, NOT against L0** | the **drive** screen. As written it is L2 and unambiguously so — a `fn tick()` calling `ui..set_*` on named widgets that must never rebuild, which is `fn` plus imperative widget commands plus the negation of declare-and-re-realize. But that is because the CARD drives the camera and the vehicle by hand. `MapView` already animates its own camera from a `nav_period` loop; what it lacks is a declared position to follow, and `sys.gps` is one. So the same move `Map` already made — the card names the trip and the widget fetches the route — would make navigation declarative: the card names the trip and the widget follows the fix. That is a widget change, not a language change. **The mechanism the shipping card uses is L2; turn-by-turn navigation is not inherently L2**, and an L0 nav card shows a route only until the widget can follow one | **What writing it found.** Four gaps, none structural, all recorded rather than patched over: From 5cc2d5e5d9d8b9056d5efd90b0ed001a59e204a5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:31:13 -0700 Subject: [PATCH 38/97] fix(ui_l0): a source argument goes live whether or not a blob seeded it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source argument naming another source resolved by consulting the data blob first and emitting the parent's live call only when the lookup found nothing. A live card carries no blob, so on a device the fallback was always taken and everything worked. A card previewed against seed data took the other path and lowered the SEED into the argument — two different lowerings of one card, selected by whether a blob happened to carry the key. The preview and the device were not testing the same program. It surfaced writing a navigation card. The position a turn instruction is computed from lowered to a fixed point on the map, so every instruction was the first one forever: the banner said the same thing however far the car drove. A seeded fix is the one value such a card must never be allowed to keep. The dependency is exactly what L0 declares, so the declaration wins and the scope is the fallback. --- crates/splash-ui-l0/src/lib.rs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 0669119..476edd7 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5509,17 +5509,27 @@ impl Realizer<'_> { // `state.x` in a source argument addresses card state; the // view scope names it without the prefix. let key = p.strip_prefix("state.").unwrap_or(p); - match scope.lookup(key) { - Some(v) => json_to_key(&v), - // A source argument that names ANOTHER SOURCE. - // - // `sys.places(lat: place.lat)` depends on `place`, and a - // LIVE card carries no data blob — so the lookup found - // nothing and `?` discarded the whole binding, leaving - // every row an em dash. The dependency is exactly what - // L0 declares, so resolve it the way the backend will: - // emit the parent's own live call in the argument. - None => self.nested_source_call(key, scope)?, + // A source argument that names ANOTHER SOURCE resolves to + // that source's own live call — `sys.places(lat: place.lat)` + // depends on `place`, and the dependency is exactly what L0 + // declares, so it is emitted the way the backend will read it. + // + // THE SOURCE IS TRIED FIRST, and the order is the whole point. + // It used to be the other way round: the scope was consulted + // and the live call was the fallback for when the lookup found + // nothing. A live card carries no data blob, so on a device + // that path was taken and everything worked — and a card + // previewed against seed data lowered the SEED into the + // argument instead. Two different lowerings of one card, + // selected by whether a blob happened to carry the key. + // + // It surfaced on a navigation card, where the position a turn + // instruction is computed from lowered to a fixed point on the + // map, so every instruction was the first one forever. A + // seeded fix is the one value such a card must never keep. + match self.nested_source_call(key, scope) { + Some(call) => call, + None => json_to_key(&scope.lookup(key)?), } } SourceArg::Text(t) => t.clone(), From a3c35756a22824540e77afdd6c4506c831ae6e08 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:33:14 -0700 Subject: [PATCH 39/97] feat(ui_l0): nav gains a drive screen, and the camera has to earn it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.0 recorded the drive screen as "settled against the shipping card, NOT against L0": the shipping mechanism is unambiguously L2 — a `fn tick()` calling `ui..set_*` on widgets that must never rebuild — but only because the CARD drove the camera by hand. The prediction was that a declared position would move it to L0, and that this was a widget change rather than a language change. It held. Three pieces, and the third was the trap. `Map(at:)` — a declared position for the camera to follow. `.drive` now means the chase camera exactly when `at:` is supplied and the static preview otherwise, so the mode that MOVES is available precisely when the card said where the user is. `sys.step` — the next manoeuvre and the distance left, from the trip's four coordinates plus the device's own two. `sys.route` describes the whole trip and never changes as it is driven; this answers relative to a position. A widget mode that FOLLOWS rather than simulates. The widget already had a "2d" follow camera and pointing `.drive` at it would have looked like a finished feature: it drives a vehicle along the route at an assumed 34 mph off a looping clock. It looks exactly like navigating. Anything bound to it reports a trip that is not happening, and §4 does not stop applying because the invented value is a camera pose. "follow" takes the card's declared fix and moves when, and only when, the device does. The same fabrication was load-bearing in the L2 exemplar's banner, which fed `sys.navstep` a progress of `sys.navsecs(period) * 15.2` and so announced turns, and arrived on schedule, from a parked car. `sys.step` has the host project the fix onto the route instead. Also here, because the nav card could not be written without them: - `sys.route` takes FOUR COORDINATES. A route needs four numbers and an argument carries one value, so `from`/`to` as place names had nothing to resolve into — which is why the capability had no translation and the duration/distance row read `— —` beneath a route that drew correctly. - Both endpoints are editable, always. The `Field` sat behind `when dest == ""`, so a card that opened with the trip already known — every card whose request named the places — had no input at all. Measured on device: the model produced a card containing only a `Map`. - `screen` is an enum, not a flag. A guard tests a declared name against a declared value, so there is no total form for the false case of a bool. `sys.route`'s duration and distance leave the UNANSWERED allowlist, where they had sat since the release AFTER the arm that answers them was written: the translation probe never supplied coordinates, the arm bailed, and the allowlist recorded a defect that was already fixed. An allowlist nothing verifies reports the codebase as more broken than it is, which is the direction that stops anyone acting on it. --- crates/splash-ui-l0/src/lib.rs | 193 +++++++++++++++---- crates/splash-ui-l0/tests/fixtures/nav.card | 190 +++++++++++++------ crates/splash-ui-l0/tests/profile.rs | 200 +++++++++++++++++++- docs/ui-l0-constructors.toml | 25 ++- docs/ui-profile-l0.md | 34 +++- 5 files changed, 542 insertions(+), 100 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 476edd7..9c3ec36 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2643,6 +2643,10 @@ pub mod catalog { ("from", Path), ("to", Path), ("via", Path), + // The live position the camera follows. See `map_mode`: this is + // what lets `.drive` mean the chase camera rather than the static + // preview, because a followed position is a measured one. + ("at", Path), ("zoom", Number), ], ), @@ -2803,7 +2807,26 @@ pub mod catalog { ("sys.locale", &[]), ("sys.gps", &[]), ("sys.search", &["query", "count", "fields"]), - ("sys.route", &["from", "to", "via", "mode", "fields"]), + // COORDINATES, not places. A route needs four numbers and an argument + // carries one value, so `from`/`to` as place names could never be + // resolved into a call — which is why this capability had no translation + // and the nav card's duration and distance row was `— —` beneath a route + // that drew correctly. Each coordinate is a read off the source that + // found the place, which the existing argument resolution already turns + // into a live call. + ( + "sys.route", + &["from_lat", "from_lon", "to_lat", "to_lon", "mode", "fields"], + ), + // Where you are ON a route. Takes the trip's four coordinates and the + // device's own two, and answers relative to them — the half of navigation + // a route cannot give, because a route never changes as you drive it. + ( + "sys.step", + &[ + "from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "fields", + ], + ), ("sys.places", &["lat", "lon", "category", "count", "fields"]), ("sys.news", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), @@ -2898,6 +2921,7 @@ pub mod catalog { ("sys.gps", &["lat", "lon", "accuracy", "ok"]), ("sys.search", &["id", "name", "lat", "lon", "distance"]), ("sys.route", &["duration", "distance", "steps"]), + ("sys.step", &["instruction", "remaining", "progress"]), ( "sys.places", &["id", "name", "distance", "lat", "lon", "category"], @@ -5523,10 +5547,11 @@ impl Realizer<'_> { // argument instead. Two different lowerings of one card, // selected by whether a blob happened to carry the key. // - // It surfaced on a navigation card, where the position a turn - // instruction is computed from lowered to a fixed point on the - // map, so every instruction was the first one forever. A - // seeded fix is the one value such a card must never keep. + // It surfaced on `sys.step(at_lat: here.lat)`: the progress a + // turn instruction is computed from lowered to `37.3`, a fixed + // point on the map, so every instruction was the first one + // forever. The seeded fix is the one value a navigation card + // must never be allowed to keep. match self.nested_source_call(key, scope) { Some(call) => call, None => json_to_key(&scope.lookup(key)?), @@ -5766,26 +5791,38 @@ pub mod makepad { /// defect this profile keeps finding. pub(super) fn map_mode(node: &UiNode) -> &'static str { match arg(node, "mode") { - // `.drive` lowers to the STATIC preview, not to the chase camera, - // and this is a §4 argument rather than a performance one. + // `.drive` means the chase camera EXACTLY WHEN the card declared a + // live position for it to follow, and the static preview otherwise. + // This is a §4 rule, not a performance compromise. // - // A chase camera follows a vehicle, and following needs a position - // updated every frame. L0 has no loop to supply one — that is what - // `fn tick()` is for, and `fn tick()` is L2. Handed a route and no - // position, the widget animates along the polyline on a timer: it - // draws motion the user is not making. That is a fabricated fact in - // the one currency a map trades in, and §4 does not stop applying - // because the invented value is a camera pose. + // Following needs a position that updates as the user moves. L0 has + // no loop to supply one — that is what `fn tick()` is for, and + // `fn tick()` is L2 — so an earlier version lowered BOTH moving modes + // to the one that does not move. Handed a route and no position, the + // widget animates along the polyline on a timer: it draws motion the + // user is not making, which is a fabricated fact in the one currency + // a map trades in, and §4 does not stop applying because the invented + // value is a camera pose. // - // It is also what made the card stutter. The widget's own settle gate + // It is also what made the card stutter: the widget's settle gate // exists because a map that keeps asking for frames "was pinning the - // GPU at ~100%", and a follow mode is permanently in motion so it - // never settles. Measured at 69% CPU and 1.9 GB resident on a + // GPU at ~100%", and a timer-driven follow is permanently in motion + // so it never settles. Measured at 69% CPU and 1.9 GB resident on a // OnePlus 6 for one card holding one map. // - // So both modes that MOVE are lowered to the one that does not. When - // L0 gains a way to declare a live position, `.drive` can mean what - // it says. + // `at:` is that missing declaration — a source answering `lat`/`lon`, + // in practice `sys.gps`. With it, the camera follows a MEASUREMENT: + // the frames are the ones the fix earns, so the map settles whenever + // the user is standing still, and nothing is invented. Without it the + // old argument holds unchanged, so a card that asks to drive and + // declares no position still gets the honest preview. + // `"follow"`, NOT the widget's `"2d"`. They render the same + // projection and differ in where the camera comes from: `"2d"` drives + // a simulated vehicle along the route at an assumed speed off a + // looping clock, which is the fabrication this whole rule exists to + // refuse — and a convincing one, because it looks exactly like + // navigating. `"follow"` takes the position the card declared. + Some(NodeValue::Token(t)) if t == "drive" && live_position(node).is_some() => "follow", Some(NodeValue::Token(t)) if t == "drive" || t == "plan" => "plan", // `flat` and an unstated mode are the same thing to the widget: no // nav shader at all, which also means no route ribbon. @@ -5793,28 +5830,43 @@ pub mod makepad { } } + /// One axis of a `Map` endpoint, as a live call. + /// + /// An endpoint names a SOURCE rather than coordinates, so each is asked for + /// its own axis — `at: here` becomes `sys.gps("lat")` and `sys.gps("lon")`. + fn map_coord(node: &UiNode, name: &str, axis: &str) -> Option { + let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; + // A collection answers `0.lat` and a scalar source answers `lat`. + for field in [axis.to_owned(), format!("0.{axis}")] { + let probe = SourceBinding { + field, + ..binding.clone() + }; + if let Some(call) = vm_call(&probe) { + return Some(call); + } + } + None + } + + /// The live position a `Map` was told to follow, if it was told one. + /// + /// This is the whole difference between a camera that reports where the user + /// is and one that invents it — see `map_mode`. Both axes must resolve: half + /// a fix is not a position. + pub(super) fn live_position(node: &UiNode) -> Option<(String, String)> { + Some((map_coord(node, "at", "lat")?, map_coord(node, "at", "lon")?)) + } + /// A `Map`'s centre and route, as live calls: `(lat, lon, polyline)`. /// - /// Both endpoints name a SOURCE rather than coordinates, so each is asked for - /// its own axis — `from: here` becomes `sys.gps("lat")` and `sys.gps("lon")`. - /// An endpoint whose capability cannot answer a coordinate yields a neutral - /// centre and no route, because a map drawn from a seeded position is a map - /// of somewhere the user is not. + /// The centre is the live position when one was declared, and the route's + /// start otherwise — a driving map is centred on the driver, a planning map + /// on the trip. An endpoint whose capability cannot answer a coordinate + /// yields a neutral centre and no route, because a map drawn from a seeded + /// position is a map of somewhere the user is not. pub(super) fn map_route(node: &UiNode) -> (String, String, String) { - let coord = |name: &str, axis: &str| -> Option { - let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; - // A collection answers `0.lat` and a scalar source answers `lat`. - for field in [axis.to_owned(), format!("0.{axis}")] { - let probe = SourceBinding { - field, - ..binding.clone() - }; - if let Some(call) = vm_call(&probe) { - return Some(call); - } - } - None - }; + let coord = |name: &str, axis: &str| map_coord(node, name, axis); let (Some(a), Some(o)) = (coord("from", "lat"), coord("from", "lon")) else { return ("0".into(), "0".into(), "\"\"".into()); }; @@ -5825,7 +5877,14 @@ pub mod makepad { (Some(b), Some(p)) => format!("sys.navroute({a}, {o}, {b}, {p}, \"polyline\")"), _ => "\"\"".to_owned(), }; - (a, o, poly) + // The route is always the declared trip; only the CENTRE moves to the + // driver. Centring on the fix without keeping the trip's endpoints would + // redraw the route from wherever the user happens to be, which is a + // different trip from the one the card states. + match live_position(node) { + Some((at_lat, at_lon)) => (at_lat, at_lon, poly), + None => (a, o, poly), + } } /// The VM helper that answers a declared capability, if one does. @@ -6008,6 +6067,60 @@ pub mod makepad { // the same path that currently crashes the shipping activity with a // missing `LocationListener$-CC` desugaring class. So this arm makes // the card correct and does not by itself make GPS usable. + // A TRIP's own facts — how long and how far — from the same cached + // fetch the map's polyline comes from, so asking costs nothing extra. + "sys.route" => { + let key = match binding.field.as_str() { + "duration" => "min", + "distance" => "km", + // The step list is a collection a card loops over, not a + // scalar a call answers. + _ => return None, + }; + let a = arg("from_lat")?; + let o = arg("from_lon")?; + let b = arg("to_lat")?; + let p = arg("to_lon")?; + Some(format!("sys.navroute({a}, {o}, {b}, {p}, {key:?})")) + } + // Navigation's live half, and the one place a fabricated number was + // load-bearing in the app this replaces. + // + // `sys.navstep` needs a progress-along-the-route in metres. The + // original nav app supplied `sys.navsecs(period) * 15.2` — a looping + // clock times an assumed 34 mph — so the card announced turns for a + // vehicle that was moving whether or not anything was. It read as a + // demo because it WAS one: the instruction advanced on a timer. + // + // `sys.navprog` answers the same slot from the device's own fix, by + // projecting it onto the route. Every argument is now a measurement, + // so an instruction changes because the device moved. Both helpers + // share `sys.navroute`'s one cached fetch. + "sys.step" => { + let key = match binding.field.as_str() { + "instruction" => "instr", + // The helper calls it `rem`. `"remain"` is what the field is + // called in L0's vocabulary and would have answered "" — the + // helper returns the empty string for a field it does not + // know, so the banner would have shown a live instruction + // above a blank distance, which reads as "still loading" + // rather than "asked for the wrong key". + "remaining" => "rem", + "progress" => "progress", + _ => return None, + }; + let a = arg("from_lat")?; + let o = arg("from_lon")?; + let b = arg("to_lat")?; + let p = arg("to_lon")?; + let at_lat = arg("at_lat")?; + let at_lon = arg("at_lon")?; + let along = format!("sys.navprog({a}, {o}, {b}, {p}, {at_lat}, {at_lon})"); + if key == "progress" { + return Some(along); + } + Some(format!("sys.navstep({a}, {o}, {b}, {p}, {along}, {key:?})")) + } "sys.gps" => { let key = match binding.field.as_str() { "accuracy" => "acc", diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index d138fd6..d06fe3d 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -6,8 +6,8 @@ # `a2app/apps/nav/exemplars/trip-planner.splash` is 664 lines and classifies at # L2: 30 `let` bindings, 83 assignments, 128 conditionals, 606 operators, and a # `fn tick()` that recomputes route geometry every frame. This is the same -# screen — pick an origin, a destination, up to two stops, see the route and its -# ETA — written against declared sources instead. +# screen — pick an origin, a destination, see the route, then drive it — written +# against declared sources instead. # # THE COMPARISON IS THE POINT. Everything that shrank was compensation: # @@ -21,83 +21,163 @@ # - It called `sys.navroute` itself and pushed a polyline into the widget. # `Map` takes the trip and fetches its own route, exactly as `StockPlot` # takes a symbol and `AqiContour` takes a location. +# - It drove the turn banner from a CLOCK — `sys.navsecs(period) * 15.2`, a +# looping timer times an assumed 34 mph. See `source step` below. -source here sys.gps() -source found sys.search(query: state.query, count: 5, fields: [id, name, lat, lon]) -source trip sys.route(from: here, to: dest_place, mode: .drive, - fields: [duration, distance]) -source dest_place sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon]) +# Where the device is. This is the one declaration that makes navigating — as +# opposed to previewing a route — expressible at all. +# +# A follow camera needs a position that updates as the user moves, and L0 has no +# loop to supply one: that is what `fn tick()` is for, and `fn tick()` is L2. So +# `Map(mode: .drive)` used to lower to the static preview, because a widget handed +# a route and no position animates along it on a timer and draws motion the user +# is not making. §4 does not stop applying because the invented value is a camera +# pose. +# +# `sys.gps` is that position, declared. Everything downstream of it — the camera, +# the turn instruction, the distance remaining — moves because the device did. +source here sys.gps() + +source found sys.search(query: state.query, count: 5, fields: [id, name, lat, lon]) + +# Both endpoints, each resolved by the search that found it. +source origin_place sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon]) +source dest_place sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon]) + +# The trip's own facts, from FOUR COORDINATES. +# +# `sys.route(from: here, to: dest_place)` read better and could not work: a route +# needs four numbers and an argument carries one value, so a place name had +# nothing to resolve into. The duration and distance rendered `— —` under a route +# that drew correctly, because the map resolved its endpoints and the text beside +# it could not. +source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + mode: .drive, fields: [duration, distance]) + +# Where we are ON the trip: the next manoeuvre, and what is left of it. +# +# `sys.route` describes the whole trip and never changes as it is driven. This +# takes the same four coordinates PLUS the device's own two, so the host projects +# the fix onto the route and answers relative to it. +# +# The two extra arguments are the whole difference between navigating and playing +# a demo. The 664-line exemplar fed its banner `sys.navsecs(period) * 15.2` and +# announced turns for a vehicle moving at an assumed 34 mph whether or not +# anything was moving — it arrived on schedule from a parked car. +source step sys.step(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + at_lat: here.lat, at_lon: here.lon, + fields: [instruction, remaining]) source env.locale sys.locale() -state query { shape: text, initial: "" } # what the user is typing -state dest { shape: text, initial: "" } # the chosen destination +# BOTH endpoints are editable state, and both are always on screen. +# +# An earlier version had only `dest`, behind `when dest == ""`, so a card that +# opened with the trip already known — which is every card whose request named +# the places — had no input at all. Measured: the model generated a card +# containing only a `Map`, and there was no way to change where you were going. +state origin { shape: text, initial: "" } # empty ⇒ nothing to route from +state dest { shape: text, initial: "" } # empty ⇒ nothing to route to +state query { shape: text, initial: "" } # what the user is typing + +# Planning or driving — an ENUM, not a flag, and the reason is §3. +# +# A `bool` reads better and cannot express the second screen. A guard tests a +# declared name against a declared value, so `when driving` names the true case +# and there is no total form for the false one: `when driving == false` asks the +# checker to accept an undeclared literal, and a `not` operator would be the +# expression form L0 does not have. Two named screens have two guards that each +# say which screen they are, which is what a card should have said anyway. +state screen { shape: enum[plan, drive], initial: .plan } +event set_origin { origin: set($value), query: clear } +event set_dest { dest: set($value), query: clear } event choose_dest { dest: set($value), query: clear } -event clear_dest { dest: clear, query: clear } +event go { screen: cycle(.plan, .drive) } copy where { class: vocabulary, en: "Where to?" } copy from { class: vocabulary, en: "FROM" } copy to { class: vocabulary, en: "TO" } -copy here_now { class: vocabulary, en: "Current location" } -copy eta { class: vocabulary, en: "ETA" } +copy here_now { class: vocabulary, en: "Starting from…" } copy away { class: vocabulary, en: "away" } copy seeking { class: vocabulary, en: "Finding a route…" } -copy nostop { class: vocabulary, en: "Add a stop" } +copy start { class: vocabulary, en: "Go" } +copy stop { class: vocabulary, en: "End" } +copy left { class: vocabulary, en: "left" } view root Surface { - # ---- the trip, as the card states it ------------------------------------- - Panel { - Row(align: .center, gap: 10) { - TextCaption(text: copy.from) - TextRow(text: here.name) + # ---- planning ------------------------------------------------------------ + when screen == .plan { + # Two fields, ALWAYS. A `Field` shows the state it is bound to and commits a + # replacement, so each row is both the current value and the way to change it + # — no branch, no two-step "clear then retype". + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.from) + Field(text: origin, placeholder: copy.here_now, on_commit: set_origin, width: .fill) + } + Rule() + Row(align: .center, gap: 10) { + TextCaption(text: copy.to) + Field(text: dest, placeholder: copy.where, on_commit: set_dest, width: .fill) + } } - Rule() - Row(align: .center, gap: 10) { - TextCaption(text: copy.to) - # A destination is either chosen or being typed. No branch on a sentinel: - # the state itself says which. - when dest == "" { Field(text: query, placeholder: copy.where, on_commit: choose_dest) } - when dest != "" { - Row(on_tap: clear_dest) { TextRow(text: dest_place.name) } + + # What the user is choosing between. + when dest == "" { + Panel { + for f, i in found key f.id { + Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { + TextRow(text: f.name) + } + Rule() + } } } - } - # ---- what the user is choosing between ----------------------------------- - when dest == "" { - Panel { - for f, i in found key f.id { - Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { - TextRow(text: f.name) + when dest != "" { + # §5.9, where the original compared against -9999. "Not yet" and "failed" + # are different states and the card can say so. + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) } - Rule() } + # The static route preview: no `at:`, so no camera that moves. + # + # This does NOT guard on having a fix. An earlier version wrapped it in + # `when here.ok == 1`, which is wrong in mechanism however right it sounds: + # a guard is evaluated at REALIZE time, before any live value exists, so + # `here.ok` reads nothing on a freshly generated card and the map is removed + # unconditionally. Measured — the model wrote a card containing `Map(`, and + # not one `MapView` reached the screen. + # + # A guard can only test something realization can see: declared state, or a + # source's `$state` (which the host injects). A live VALUE is not that. The + # impossible-centre case is handled where the number is actually known, in + # the widget. + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16) } } - # ---- the route ------------------------------------------------------------ - when dest != "" { - # §5.9, where the original compared against -9999. "Not yet" and "failed" - # are different states and the card can say so. - when trip.$state == .pending { TextBody(text: copy.seeking) } - when trip.$state == .ready { + # ---- driving ------------------------------------------------------------- + when screen == .drive { + # The banner. Every value in it is a measurement: the manoeuvre and the + # distance left both come from the device's own position projected onto the + # route, so this row changes when the car moves and at no other time. + Panel { + TextRow(text: step.instruction) Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) + TextCaption(value: step.remaining, suffix: copy.left) + Chip(text: copy.stop, on_tap: go) } } - # The card names the TRIP. It does NOT guard on having a fix. - # - # An earlier version wrapped this in `when here.ok == 1`, which is wrong in - # mechanism however right it sounds: a guard is evaluated at REALIZE time, - # before any live value exists, so `here.ok` reads nothing on a freshly - # generated card and the map is removed unconditionally. Measured — the model - # wrote a card containing `Map(`, and not one `MapView` reached the screen. - # - # A guard can only test something realization can see: declared state, or a - # source's `$state` (which the host injects). A live VALUE is not that. The - # impossible-centre case is handled where the number is actually known, in the - # widget. - Map(mode: .drive, from: here, to: dest_place, zoom: 16) + # `at:` is what makes `.drive` mean the chase camera rather than the preview. + # The route is still the declared trip; only the camera follows the driver. + Map(mode: .drive, from: origin_place, to: dest_place, at: here, zoom: 17) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b233dc4..2b3facf 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5531,6 +5531,11 @@ const INERT: &[(&str, &str)] = &[ ("Map", "from"), ("Map", "to"), ("Map", "via"), + // `at` is the same: a live POSITION, so a state cell holding a number is + // correctly ignored. Its liveness — that it centres the map on the fix and + // turns `.drive` into the follow camera — is asserted by + // `a_map_lowers_a_live_route`, in the two `at:` cases at its end. + ("Map", "at"), // A text input is not wrapped by the width composer — the `Field` branch // returns before it — so a field cannot be told how wide to be. ("Field", "width"), @@ -5844,6 +5849,124 @@ view root Surface { "{seeded} is the seeded coordinate and must not be lowered:\n{mk}" ); } + + // ── `at:` — the declaration that makes `.drive` mean what it says ──────── + // + // Above, `.drive` lowered to `"plan"`, and that is correct WITHOUT a declared + // position: a follow camera handed a route and no position animates along the + // polyline on a timer, drawing motion the user is not making. §4 does not stop + // applying because the invented value is a camera pose. + // + // `at:` supplies the missing measurement, so the two cards below differ in + // exactly one thing — whether the card said where the user is — and the + // camera mode must follow that and nothing else. + const DRIVING: &str = r#" +source here sys.gps() +source orig sys.search(query: state.o, count: 1, fields: [name, lat, lon]) +source dest sys.search(query: state.q, count: 1, fields: [name, lat, lon]) +state o { shape: text, initial: "HOME" } +state q { shape: text, initial: "SFO" } +view root Surface { + TextRow(text: dest.0.name) + Map(mode: .drive, from: orig, to: dest, at: here, zoom: 16) +} +"#; + let report = check_ui_l0_named("nav", DRIVING); + assert!(report.valid, "{:#?}", report.diagnostics); + let data = serde_json::json!({ + "here": { "lat": 37.3, "lon": -122.0, "ok": 1 }, + "orig": { "0": { "name": "H", "lat": 37.2, "lon": -122.1 } }, + "dest": { "0": { "name": "X", "lat": 37.4, "lon": -121.9 } }, + "o": "HOME", "q": "SFO", "env": { "locale": {} }, "copy": {} + }); + let driving = splash_ui_l0::kit::lower( + &realize(DRIVING, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + // The camera follows, because there is now something real to follow. + assert!( + driving.contains("l0_map(\"follow\","), + "a declared position must turn `.drive` into the follow camera:\n{driving}" + ); + // Centred on the DRIVER — the live fix, not the trip's start. + assert!( + driving.contains("l0_map(\"follow\", 16, sys.gps(\"lat\"), sys.gps(\"lon\")"), + "the follow camera centres on the live fix:\n{driving}" + ); + // And the route is still the declared TRIP. Centring on the fix without + // keeping the endpoints would redraw the route from wherever the user happens + // to be, which is a different trip from the one the card states. + assert!( + driving.contains("sys.navroute(sys.searchnum(\"HOME\", 0, \"lat\")") + && driving.contains("sys.searchnum(\"SFO\", 0, \"lat\")"), + "the route stays the trip the card declared:\n{driving}" + ); +} + +/// Navigation's live half: an instruction that advances because the DEVICE did. +/// +/// This is the one place a fabricated number was load-bearing in the app L0 +/// replaces. `sys.navstep` needs a progress-along-the-route in metres, and the +/// 664-line exemplar supplied `sys.navsecs(period) * 15.2` — a looping clock +/// times an assumed 34 mph. The card announced turns for a vehicle that was +/// moving whether or not anything was; it read as a demo because it was one. +/// +/// `sys.step` takes the trip's four coordinates AND the device's two, so progress +/// is a projection of a real fix onto the route. Every argument is a measurement. +#[test] +fn a_navigation_instruction_advances_only_when_the_device_does() { + const CARD: &str = r#" +source here sys.gps() +source orig sys.search(query: state.o, count: 1, fields: [name, lat, lon]) +source dest sys.search(query: state.q, count: 1, fields: [name, lat, lon]) +source step sys.step(from_lat: orig.0.lat, from_lon: orig.0.lon, + to_lat: dest.0.lat, to_lon: dest.0.lon, + at_lat: here.lat, at_lon: here.lon, + fields: [instruction, remaining]) +state o { shape: text, initial: "HOME" } +state q { shape: text, initial: "SFO" } +view root Surface { + TextRow(text: step.instruction) + TextCaption(value: step.remaining) + Map(mode: .drive, from: orig, to: dest, at: here, zoom: 16) +} +"#; + let report = check_ui_l0_named("nav", CARD); + assert!(report.valid, "{:#?}", report.diagnostics); + let data = serde_json::json!({ + "here": { "lat": 37.3, "lon": -122.0, "ok": 1 }, + "orig": { "0": { "name": "H", "lat": 37.2, "lon": -122.1 } }, + "dest": { "0": { "name": "X", "lat": 37.4, "lon": -121.9 } }, + "step": { "instruction": "seeded turn", "remaining": "999 km" }, + "o": "HOME", "q": "SFO", "env": { "locale": {} }, "copy": {} + }); + let kit = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + // The instruction is fetched for THIS trip, at THIS position. + assert!( + kit.contains("sys.navstep(") && kit.contains("sys.navprog("), + "the instruction must be asked for, and its progress measured:\n{kit}" + ); + // The progress argument reads the device's fix — the whole point. + assert!( + kit.contains("sys.gps(\"lat\"), sys.gps(\"lon\"))"), + "progress along the route must come from the device's own fix:\n{kit}" + ); + // And no clock anywhere. `sys.navsecs` is what the L2 exemplar used, and its + // appearance here would mean the timer came back wearing a source's clothes. + assert!( + !kit.contains("navsecs") && !kit.contains("simsecs"), + "progress must be measured, never clocked:\n{kit}" + ); + // Nor the seeded strings the lowering could have reached for instead. + assert!( + !kit.contains("seeded turn") && !kit.contains("999 km"), + "the seeded instruction must not be lowered:\n{kit}" + ); } /// Token pairs a card TOGGLES between must be distinguishable in the lowering. @@ -6387,8 +6510,10 @@ fn every_offered_field_has_a_translation() { // nav card's duration and distance row is `— —`, and `sys.locale` is why // `state units { initial: env.locale.temp_unit }` seeds from nothing — // which is half of why the units toggle appears to do nothing. - ("sys.route", "duration"), - ("sys.route", "distance"), + // A route's step LIST is a collection a card loops over, not a scalar a + // call answers. Duration and distance used to sit here beside it and no + // longer do: both are answered, and the probe now supplies the + // coordinates that prove it. ("sys.route", "steps"), ("sys.locale", "lang"), ("sys.locale", "temp_unit"), @@ -6443,6 +6568,24 @@ fn every_offered_field_has_a_translation() { ("name".into(), "n".into()), ("id".into(), "1".into()), ("category".into(), "c".into()), + // A trip's four coordinates, and the device's two. + // + // These were missing, and their absence put `sys.route` + // on the allowlist below as an em dash the card could not + // avoid — for two releases AFTER the arm that answers it + // was written. The arm bailed on `arg("from_lat")?`, the + // probe read that as "no translation exists", and the + // allowlist recorded a defect that had been fixed. An + // allowlist that accumulates entries nothing verifies is + // worse than no allowlist: it reports the codebase as + // more broken than it is, which is the one direction that + // stops anyone acting on it. + ("from_lat".into(), "1".into()), + ("from_lon".into(), "2".into()), + ("to_lat".into(), "3".into()), + ("to_lon".into(), "4".into()), + ("at_lat".into(), "5".into()), + ("at_lon".into(), "6".into()), ], field: f.clone(), }) @@ -6466,3 +6609,56 @@ fn every_offered_field_has_a_translation() { that asks renders an em dash the checker cannot warn about: {fresh:#?}" ); } + +/// The nav card routes between two EDITABLE places, and says how far. +/// +/// Three things were wrong at once and each hid the next. +/// +/// The `Field` sat behind `when dest == ""`, so a card opening with the trip +/// already known — which is every card whose request named the places — had no +/// input at all. Measured on device: the model generated a card containing only a +/// `Map`, and there was no way to change where you were going. +/// +/// `sys.route` had no translation, because its `from`/`to` were PLACES: a route +/// needs four numbers and an argument carries one value, so a place name had +/// nothing to resolve into. Duration and distance rendered `— —` beneath a route +/// that drew correctly — the map resolved its endpoints and the text beside it +/// could not. +/// +/// And the map routed from `sys.gps` while the user edited a FROM field the map +/// ignored. +#[test] +fn the_nav_card_routes_between_two_editable_places() { + const NAV: &str = include_str!("fixtures/nav.card"); + let report = check_ui_l0_named("nav", NAV); + assert!(report.valid, "{:#?}", report.diagnostics); + + let data = serde_json::json!({ + "origin": "Saratoga High", "dest": "Stanford University", "query": "", + "found": [], "env": { "locale": {} }, + "copy": { "from": "FROM", "to": "TO", "where": "Where to?", + "here_now": "Starting from…", "away": " away", "seeking": "…", + "eta": "ETA", "nostop": "stop" } + }); + let root = realize(NAV, &data, RealizeLimits::default()) + .root + .expect("realizes"); + let kit = splash_ui_l0::kit::lower(&root); + + // BOTH endpoints editable, always — not one behind a branch that never fires. + assert_eq!( + kit.matches("l0_field(").count(), + 2, + "an origin field and a destination field:\n{kit}" + ); + // The trip's facts, live, from the coordinates of the places that were found. + assert!( + kit.contains("sys.navroute(sys.searchnum(\"Saratoga High\", 0, \"lat\")"), + "duration and distance must be fetched for THIS trip:\n{kit}" + ); + // And the map routes from the origin the user can edit. + assert!( + kit.contains("l0_map(") && kit.contains("sys.searchnum(\"Stanford University\""), + "the map must route between the same two places:\n{kit}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index d07269c..bf3749a 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -45,6 +45,12 @@ mode = { kind = "token", tokens = ["plan", "drive", "flat"] } from = { kind = "path" } to = { kind = "path" } via = { kind = "path" } +# The live position the camera follows — a source answering `lat`/`lon`, in +# practice `sys.gps`. This is what makes `.drive` mean the chase camera instead of +# the static preview: without it there is no measured position and a follow camera +# could only animate on a timer, drawing motion the user is not making. With it, +# every frame the map spends is one a real fix earned. +at = { kind = "path" } zoom = { kind = "number" } # A text field — the one role that lets a card receive something the user typed. @@ -299,10 +305,27 @@ answers = ["id", "name", "lat", "lon", "distance"] # A trip's facts — duration, distance, the step list. The ROUTE ITSELF is the # `Map` widget's to fetch; this is what the card needs to write on the screen # beside it, and asking twice is cheaper than making the card carry a polyline. +# COORDINATES, not places: a route needs four numbers and an argument carries one +# value, so place names could never resolve into a call. [sources."sys.route"] -args = ["from", "to", "via", "mode", "fields"] +args = ["from_lat", "from_lon", "to_lat", "to_lon", "mode", "fields"] answers = ["duration", "distance", "steps"] +# Where you are ON a route: the next instruction, and what is left of the trip. +# +# This is the half of navigation a route cannot answer. `sys.route` describes the +# whole trip and never changes as you drive it; this takes the SAME four trip +# coordinates plus the device's own two, and answers relative to them. +# +# `at_lat`/`at_lon` are what keep it honest. The original nav app fed its +# instruction a timer — `sys.navsecs(period) * speed` — so the card announced +# turns for a vehicle moving at an assumed 34 mph whether or not anything moved. +# Progress here is measured: the position is projected onto the route, so an +# instruction only advances because the device did. +[sources."sys.step"] +args = ["from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "fields"] +answers = ["instruction", "remaining", "progress"] + [sources."sys.places"] args = ["lat", "lon", "category", "count", "fields"] answers = ["id", "name", "distance", "lat", "lon", "category"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index d2cc0aa..1569bfb 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -118,9 +118,39 @@ as evidence about the language. The honest position is narrower — | | | |---|---| | **L0 suits** | a card that displays declared data, branches on it, and writes declared state on a tap or a commit | -| **L0 cannot express** | a card that computes derived values, or drives its own animation loop — including turn-by-turn navigation, which is the same thing wearing a map | +| **L0 cannot express** | a card that computes derived values, or drives its own animation loop | | **Settled by writing it** | `nav`'s DECLARATIVE screens — search, results, route preview — rewritten against declared sources with a `Map` role, in **54 lines** against the L2 exemplar's 664, admitted at L0 | -| **Settled against the shipping card, NOT against L0** | the **drive** screen. As written it is L2 and unambiguously so — a `fn tick()` calling `ui..set_*` on named widgets that must never rebuild, which is `fn` plus imperative widget commands plus the negation of declare-and-re-realize. But that is because the CARD drives the camera and the vehicle by hand. `MapView` already animates its own camera from a `nav_period` loop; what it lacks is a declared position to follow, and `sys.gps` is one. So the same move `Map` already made — the card names the trip and the widget fetches the route — would make navigation declarative: the card names the trip and the widget follows the fix. That is a widget change, not a language change. **The mechanism the shipping card uses is L2; turn-by-turn navigation is not inherently L2**, and an L0 nav card shows a route only until the widget can follow one | +| **Settled by writing it, second pass** | the **drive** screen — turn-by-turn navigation, at L0, in the same card. The prediction in the row this replaces held: it was a widget change, not a language change | + +**Turn-by-turn was the hard case, and it is worth reading how it fell.** The row +above used to say the drive screen was "settled against the shipping card, NOT +against L0": the shipping mechanism is unambiguously L2 — a `fn tick()` calling +`ui..set_*` on widgets that must never rebuild — but only because the CARD +drove the camera by hand. The prediction was that a declared position would move +it to L0. Three things were needed, and all three were the same shape: + +1. **`Map(at:)`** — a declared position for the camera to follow. `.drive` now + means the chase camera exactly when `at:` is supplied, and the static preview + otherwise, so the mode that MOVES is available precisely when the card said + where the user is. +2. **`sys.step`** — the next manoeuvre and the distance left, from the trip's four + coordinates plus the device's own two. +3. **A widget mode that follows rather than simulates.** This was the trap. The + widget already had a `"2d"` follow camera, and pointing `.drive` at it would + have looked like a complete feature: it drives a vehicle along the route at an + assumed 34 mph off a looping clock. It looks exactly like navigating. Anything + bound to it reports a trip that is not happening, and §4 does not stop applying + because the invented value is a camera pose. `"follow"` takes its position from + the card's declared fix and moves when, and only when, the device does. + +The same fabrication was load-bearing in the L2 exemplar's banner, which fed +`sys.navstep` a progress of `sys.navsecs(period) * 15.2` — a clock times an +assumed speed — and so announced turns, and arrived on schedule, from a parked +car. `sys.step` takes the device's coordinates instead and the host projects the +fix onto the route. **What L0 forced was not a smaller nav card but a truthful +one:** the profile's own no-facts rule is what made the simulated camera +inexpressible, and the honest version cost one attribute, one capability, and one +widget mode. **What writing it found.** Four gaps, none structural, all recorded rather than patched over: From 15b38efebf709f53982e176153a7fb73058e5cfe Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:36:53 -0700 Subject: [PATCH 40/97] docs(roadmap): L2's strongest candidate card has been written at L0 instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drive screen was recorded as the nearest thing to a card that requires L2. §1.0 predicted a declared position would move it, and it did: `Map(at:)`, `sys.step`, and a widget camera that follows rather than simulates. The finding is recorded with it, because it is the argument for the level rather than a feature note. The widget already had a follow camera and pointing `.drive` at it would have shipped something that looks finished — it drives a vehicle at an assumed 34 mph off a looping clock. §4 forbids it, and the same fabrication was load-bearing in the L2 exemplar's turn banner, which announced turns and arrived on schedule from a parked car. What L0 forced was not a smaller nav card but a truthful one. Test count 203 → 224. --- docs/roadmap.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e59c2ee..6297f7d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -388,7 +388,7 @@ against an empty host surface. Three reference cards exercise it. **Implemented** in `splash-core::ui_l0`: parser, validator, per-instance state store, event dispatch, a renderer-neutral realizer, a source plan, and static dependency tracking for -reconciliation. 203 tests; the three reference cards are accepted and the shipping nav card is +reconciliation. 224 tests; the three reference cards are accepted and the shipping nav card is rejected as L2; four cases render on a OnePlus 6T through an unmodified downstream host, checked against golden images. @@ -462,13 +462,26 @@ Remaining: blesses a level no document defines, and the spec pass is owed. - **L2 is unimplemented and refused before parsing.** Imperative widget commands are a different grammar rather than a wider one, so nothing below parses them. - The nearest thing to a card that needs it is the shipping nav app's drive + The nearest thing to a card that needed it was the shipping nav app's drive screen, which updates a vehicle position every frame through `ui..set_*` - inside a `fn tick()` that must never rebuild. §1.0 records why that is L2 as - WRITTEN and why the capability is not: `MapView` already animates its own - camera, so a declared position for it to follow would make navigation - declarative — a widget change rather than a language one. So L2 still has no - card that requires it. + inside a `fn tick()` that must never rebuild. §1.0 predicted that a declared + position for the widget to follow would make navigation declarative — a widget + change rather than a language one — and that has now been done: `Map(at:)` + takes a position, `sys.step` answers the next manoeuvre from the trip's + coordinates plus the device's own, and the widget gained a `follow` camera. The + nav card's drive screen is admitted at L0. + + What the exercise found is worth recording, because it is the argument for the + level and not merely a feature. The widget ALREADY had a follow camera, and + pointing `.drive` at it would have shipped a finished-looking feature: it drives + a vehicle along the route at an assumed 34 mph off a looping clock. §4 forbids + it — a camera pose is a fact about where the user is — and the same fabrication + turned out to be load-bearing in the L2 exemplar, whose turn banner ran on that + clock and so announced turns, and arrived on schedule, from a parked car. **What + L0 forced was not a smaller nav card but a truthful one.** + + So L2 still has no card that requires it, and the strongest candidate for one + has now been written at L0 instead. ## Before a stable language release From ed718249d8d2a9ad4b5d41eecbc7c3f8558f06a8 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:46:55 -0700 Subject: [PATCH 41/97] fix(ui_l0): a role admitted once has to be lowered twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makepad::lower` admitted `Field` and drew "⚠ no makepad lowering for Field" where the input goes. So the nav card's two editable rows — the entire fix for "the map planner does not allow change source and destination" — rendered as two red apologies in that backend, while the kit rendered them correctly. Third time for this exact shape. `Map` was lowered by neither and drew an error box; `Grid.cols` was honoured by the kit and dropped by makepad, so a two-column grid was one column; now `Field`. It is structural rather than careless: the catalog is one table and the lowerings are two functions, and nothing made adding to the first add to both. `every_admitted_role_is_lowered_by_both_backends` now does, for every role the catalog admits. Verified by removing the new arm: it reports `makepad: Field` and fails. `Field.width` leaves the INERT allowlist. It was there because "the `Field` branch returns before the width composer" — true, and true of a role one backend never drew at all. --- crates/splash-ui-l0/src/lib.rs | 46 ++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 94 +++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 9c3ec36..df930c1 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6929,6 +6929,52 @@ pub mod makepad { tap_binding(node) ); } + // The one role that lets a card receive something the user typed. + // + // This backend admitted `Field` and lowered none of it, so the nav + // card's two editable rows — the whole of "the map planner cannot + // change its origin or destination" — rendered as two red warnings. + // The kit had it and this did not, which is the same one-backend gap + // `Grid.cols` and `Map` were each found in: a role is admitted once + // and must be lowered twice. + // + // `on_return` rather than a tap wrapper. A hit target over a text + // input eats the focus and there is nothing left to type into, and the + // payload here is what was TYPED — which does not exist until commit, + // so the target is assembled at that moment. `$$` is where the typed + // text goes. + "Field" => { + // A field that was not told a width FILLS, unlike a text run. + // `Field(width: .fill)` is what every card writes and the row it + // sits in is `Fit`, so a `Fit` field collapses to its content — + // an empty one to nothing at all, which is a search box that + // cannot be tapped. + let width = match arg(node, "width") { + Some(NodeValue::Token(t)) if t == "fit" => " width: Fit", + _ => " width: Fill", + }; + let target = match arg(node, "on_commit") { + Some(NodeValue::Event(event)) => { + let json = serde_json::json!({ "e": event, "k": node.key, "v": "$$" }); + format!("l0:{json}") + } + _ => String::new(), + }; + let (head, tail) = target.split_once("$$").unwrap_or((target.as_str(), "")); + let commit = if target.is_empty() { + String::new() + } else { + format!( + " on_return: |t| agent.notify(\"l0\", {{target: {head:?} + t + {tail:?}}})" + ) + }; + let _ = writeln!( + out, + "{p}TextInput{{{width} height: 48 text: {} empty_text: {}{commit} }}", + expr_of(node, "text"), + expr_of(node, "placeholder"), + ); + } other => { // An unmapped constructor is shown, not skipped. A card that // silently drops a section looks correct and is not. diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 2b3facf..b5e1def 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5536,9 +5536,14 @@ const INERT: &[(&str, &str)] = &[ // turns `.drive` into the follow camera — is asserted by // `a_map_lowers_a_live_route`, in the two `at:` cases at its end. ("Map", "at"), - // A text input is not wrapped by the width composer — the `Field` branch - // returns before it — so a field cannot be told how wide to be. - ("Field", "width"), + // `Field.width` used to sit here: "a text input is not wrapped by the width + // composer — the `Field` branch returns before it". That was true of both + // backends because neither lowered `Field` AT ALL in one of them: the makepad + // lowering admitted the role and emitted a red warning where the input goes, + // which is how the nav card's two editable rows — the entire fix for "the map + // planner cannot change its origin or destination" — reached the screen as + // two apologies. The role is lowered in both now and honours its width, so the + // entry is gone rather than reworded. ]; #[test] @@ -6662,3 +6667,86 @@ fn the_nav_card_routes_between_two_editable_places() { "the map must route between the same two places:\n{kit}" ); } + +/// A role admitted once must be lowered TWICE. +/// +/// This is the third time one backend had a role the other did not, and each time +/// the card was accepted, rendered, and wrong in one of the two places it can +/// render. `Map` was lowered by neither and drew an error box; `Grid.cols` was +/// honoured by the kit and ignored by makepad, so a two-column grid was a column; +/// `Field` was lowered by the kit and by makepad not at all, so the nav card's two +/// editable rows — the whole of "the map planner cannot change where it is going" +/// — came out as two red warnings reading "no makepad lowering for Field". +/// +/// The pattern is structural rather than careless: the catalog is one table and +/// the lowerings are two functions, so nothing makes adding to the first add to +/// both. This is what makes it, for every role the catalog admits. +#[test] +fn every_admitted_role_is_lowered_by_both_backends() { + const CONTAINERS: &[&str] = &["Surface", "Photo", "Panel", "Card", "Col", "Row", "Grid"]; + let mut missing: Vec = Vec::new(); + + for (role, args) in splash_ui_l0::catalog::CONSTRUCTORS { + // Instantiate the way a card would: several roles refuse a partial + // argument set, and a probe that omits them tests nothing. + let arglist: Vec = args + .iter() + .filter_map(|(n, k)| { + use splash_ui_l0::catalog::ArgKind::*; + match k { + Path | Data | Text => Some(format!("{n}: q.name")), + Number => Some(format!("{n}: 2")), + Token(set) | TokenOrPath(set) => Some(format!("{n}: .{}", set[0])), + Event => Some(format!("{n}: ev")), + Any => Some(format!("{n}: \"x\"")), + Bool => None, + } + }) + .collect(); + let body = if CONTAINERS.contains(role) { + " { Rule() }" + } else { + "" + }; + let head = "source q sys.quote(ticker: \"NVDA\", fields: [last, name])\n\ + state held { shape: text, initial: \"\" }\n\ + event ev { held: set($value) }\n"; + let card = if *role == "Surface" || *role == "Photo" { + format!( + "{head}view root {role}({}) {{ Rule() }}\n", + arglist.join(", ") + ) + } else { + format!( + "{head}view root Surface {{ {role}({}){body} }}\n", + arglist.join(", ") + ) + }; + if !check_ui_l0_named("probe", &card).valid { + continue; // covered by the catalog-agreement tests + } + let data = serde_json::json!({ + "q": { "last": 1.0, "name": "n" }, "held": "", + "env": { "locale": {} }, "copy": {} + }); + let Some(root) = realize(&card, &data, RealizeLimits::default()).root else { + continue; + }; + // Each backend says so in its own words, and both say the role's name. + let mk = makepad::lower(&root); + if mk.contains(&format!("no makepad lowering for {role}")) { + missing.push(format!("makepad: {role}")); + } + let kit = splash_ui_l0::kit::lower(&root); + if kit.contains(&format!("l0_unsupported({role:?})")) { + missing.push(format!("kit: {role}")); + } + } + + missing.sort(); + assert!( + missing.is_empty(), + "the catalog admits these and a backend cannot draw them, so the card is \ + accepted and renders an apology where the role goes: {missing:#?}" + ); +} From f89e84a79b640aa195c0959a11ec269559a4c281 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:42:04 -0700 Subject: [PATCH 42/97] fix(ui_l0): a card holding a map is laid out the way the shipping nav card is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-tested on a OnePlus 6, and every value here came off `a2app/apps/nav` rather than out of a guess. Four screenshots that each looked like a different bug were all this one. `MapView` is a fixed-pixel full-bleed surface that paints its route through the GPU nav projection rather than inside a laid-out rect. Stacked in a column beneath the card's content it draws straight over it. Measured: the plan screen's map covered the FROM/TO fields, the duration and the Go button entirely; the drive screen's ribbon painted across the turn banner and cut a street name in half. So the map is the bottom layer of an overlay and the card's content floats above it, which is what all four of the shipping card's maps do. It is not decoration — it is how that card avoided this. Four things each cost a screenshot to learn, and all four are in the shipping card's MANDATORY rules where I did not read them carefully enough: - `use_local_mbtiles: false` and `use_network: true`. The widget defaults to a local `.mbtiles` file for offline development and an L0 card cannot ship one, so the omission drew the land fill and nothing else: a correct route ribbon and a correct duration over a blank beige rectangle, with `local mbtiles source missing` in logcat and nothing on screen saying so. The app's own emitter carried these and this backend did not — the same one-backend gap as `Field`, `Grid.cols` and `Map` itself. `min_zoom`/`max_zoom`/`nav_route_width`/`nav_period` come with them, and `max_zoom` differs by mode as it does there: 16 for a route preview, 19 for driving. - The root is a FIXED 812, not `Fill`. `Fill` in a `Fit` parent resolves to 0 and a card is an item in a chat list, so the whole screen rendered empty. - The sheet is OPAQUE. The theme's 7%-white panel over a map is a window, not a surface: the trip rendered with the map's own road labels drawn across it. - The sheet sits at the BOTTOM. `update_plan_preview_camera` frames the route into the band above the summary sheet, so a sheet at the top lands exactly on the route it was making room for — the trip's origin was behind the panel and the camera was doing its job. Its bottom margin is 76 where the shipping card uses 30: that card is full-screen and an L0 card sits under the app's composer bar, which cut the last row in half. --- crates/splash-ui-l0/src/lib.rs | 121 ++++++++++++++++++++++++++- crates/splash-ui-l0/tests/profile.rs | 101 ++++++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index df930c1..6a1d3ca 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6634,13 +6634,97 @@ pub mod makepad { fn element_body(node: &UiNode, depth: usize, out: &mut String) { let p = pad(depth); match node.kind.as_str() { + // A card holding a MAP is laid out the way the shipping nav card lays + // one out: the map is the BOTTOM layer of an overlay and everything + // else floats above it. Any other card keeps the ordinary column. + // + // This is not a style preference. A `MapView` is a fixed-pixel + // full-bleed surface — `Fill`/`Fit` "resolve to 0 and hide the map", + // so the shipping card's MANDATORY rules give every map an explicit + // 812 — and it paints its route ribbon through the GPU nav projection + // rather than inside a laid-out rect. Stacked in a column beneath the + // card's own content it draws straight over it. + // + // Measured, both ways round. With the map in a column: the route and + // its ribbon covered the whole screen and the FROM/TO fields, the + // duration and the Go button were simply not visible; in the drive + // screen the ribbon painted across the turn banner, cutting a street + // name in half. `a2app/apps/nav` has four maps and none of them is in + // a column — every one is the first child of a `flow: Overlay` with a + // floating sheet on top, and that is why they work. + // + // So the ORDER is inverted here relative to the card's text. A card + // reads "the trip, then the map"; the screen is "the map, with the + // trip over it". Which layer a role belongs on is presentation, and + // presentation is the backend's to decide — the card still says only + // what it has and in what order it matters. "Surface" => { + let map = node.children.iter().find(|c| c.kind == "Map"); + let Some(map) = map else { + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {BASE} padding: {PAGE_PAD}" + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + return; + }; + // FIXED 812, not `Fill`. The shipping card's first MANDATORY rule + // is "root is `flow: Overlay`, `new_batch: true`, fixed + // `height: 812`", and the reason is the same one that governs the + // map itself: `Fill` resolves to 0 inside a `Fit` parent, and a + // card is an item in a chat list, so its container hugs content. + // Measured — `height: Fill` here rendered an entirely empty screen, + // map and sheet both, which is what a height of zero looks like. let _ = writeln!( out, - "{p}SolidView{{ width: Fill height: Fit flow: Down new_batch: true \ - draw_bg.color: {BASE} padding: {PAGE_PAD}" + "{p}SolidView{{ width: Fill height: 812 flow: Overlay new_batch: true \ + draw_bg.color: {BASE}" ); - children(node, depth, out); + element(map, depth + 1, out); + // The floating sheet, OPAQUE, in the shipping card's own colour. + // + // `height: Fit` so it is only as tall as what it holds — filling + // would put a panel over the whole map and swallow every pan and + // pinch that missed a control. + // + // Opaque because the theme's ordinary panel is `#ffffff12`, 7% white, + // which over a map is a window rather than a surface. Measured: the + // FROM and TO fields, the duration and the distance all rendered and + // all of them were unreadable, with the map's own road labels — + // "Bayshore Freeway", "22", "20" — drawn across the middle of them. + // Legible-on-anything is not something a translucent panel can be, + // and a map is the one backdrop a card cannot predict. + // 76 at the bottom where the shipping card uses 30. That card is a + // full-screen app card; an L0 card is an item in the chat list, and + // the app's composer bar sits over the bottom of it. Measured with + // 30: the duration, the distance and the Go button were half cut off + // behind "Reconnecting…". The sheet was positioned exactly where it + // was asked to be. + // + // AT THE BOTTOM, and that is the widget's requirement rather than a + // taste. `update_plan_preview_camera` fits the whole route and frames + // it "into the top band above the card's summary sheet" — so a sheet + // at the top sits exactly where the widget put the route. Measured: + // the trip's Saratoga end was behind the panel, and the camera was + // doing its job. + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fill flow: Down align: Align{{x: 0.5 y: 1.0}}" + ); + let _ = writeln!( + out, + "{p} RoundedView{{ width: Fill height: Fit flow: Down \ + draw_bg.color: #0f1620 draw_bg.border_radius: 22 \ + margin: Inset{{left: 8 right: 8 bottom: 76}} \ + padding: Inset{{left: 14 top: 12 right: 14 bottom: 14}}" + ); + for child in node.children.iter().filter(|c| c.kind != "Map") { + element(child, depth + 3, out); + } + let _ = writeln!(out, "{p} }}"); + let _ = writeln!(out, "{p} }}"); let _ = writeln!(out, "{p}}}"); } // The card names a TRIP; the widget draws the route. @@ -6664,9 +6748,40 @@ pub mod makepad { Some(NodeValue::Number(n)) => trim_num(*n), _ => "15".to_owned(), }; + // The settings a `MapView` does not work without, taken from the + // SHIPPING nav card rather than reasoned about — `a2app/apps/nav` + // is a working four-map reference whose "MANDATORY rules" section + // says why each one matters, and the values below are the ones it + // uses. + // + // `use_local_mbtiles: false` is the one that bites. The widget + // defaults to a local `.mbtiles` file for offline development, and + // an L0 card cannot ship one — so the omission draws the land fill + // and nothing else. Measured: a nav card whose route ribbon and + // whose duration were both correct, over a blank beige rectangle, + // with `local mbtiles source missing` in logcat and nothing on + // screen saying so. The app's own emitter already carried these + // and this backend did not, which is the same one-backend gap + // `Field`, `Grid.cols` and `Map` itself were each found in. + // + // `max_zoom` differs BY MODE, as it does in the shipping card: a + // whole-route preview is capped at 16 and a driving view goes to + // 19. A narrow clamp also has a cost under a finger — a card + // sitting exactly on the floor cannot pinch out, so half the + // gesture is dead and the map reads as broken rather than clamped. + let max_zoom = if mode == "plan" { "16.0" } else { "19.0" }; + // The ribbon is drawn in ground metres, so a route seen from the + // whole-trip view needs a far wider line than one seen from a car. + let ribbon = match mode { + "plan" => "40.0", + "2d" => "11.0", + _ => "14.0", + }; let _ = write!( out, "{p}MapView{{ width: Fill height: 812 nav_mode: {mode:?} zoom: {zoom} \ + min_zoom: 3.0 max_zoom: {max_zoom} nav_route_width: {ribbon} \ + nav_period: 100 use_network: true use_local_mbtiles: false \ center_lat: {lat} center_lon: {lon}" ); if poly != "\"\"" { diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b5e1def..04adbc8 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5821,6 +5821,30 @@ view root Surface { mk.contains("nav_mode: \"plan\"") && mk.contains("zoom: 16"), "the declared mode and zoom must reach the widget:\n{mk}" ); + // The settings a `MapView` does not work without, from the shipping nav card. + // + // `use_local_mbtiles: false` is the one that was missing. The widget defaults + // to a local `.mbtiles` file for offline development and an L0 card cannot ship + // one, so its absence draws the land fill and nothing else — measured on device + // as a correct route ribbon and a correct duration over a blank beige + // rectangle, with `local mbtiles source missing` in logcat and nothing on + // screen saying so. A map with no map is the failure this asserts against. + for required in [ + "use_network: true", + "use_local_mbtiles: false", + "min_zoom: 3.0", + "nav_route_width:", + ] { + assert!( + mk.contains(required), + "{required:?} is mandatory for a MapView and is missing:\n{mk}" + ); + } + // Capped at 16 for a whole-route preview, as the shipping card caps it. + assert!( + mk.contains("max_zoom: 16.0"), + "a route preview caps its zoom:\n{mk}" + ); // Every coordinate LIVE, from the source each endpoint names — a device fix // for the origin and a place search for the destination. assert!( @@ -6750,3 +6774,80 @@ fn every_admitted_role_is_lowered_by_both_backends() { accepted and renders an apology where the role goes: {missing:#?}" ); } + +/// A card holding a map is laid out the way the SHIPPING nav card lays one out. +/// +/// Every value in this test came off `a2app/apps/nav` rather than out of a guess, +/// and the reason is a run of four device screenshots that each looked like a +/// different bug and were all this one. +/// +/// `MapView` is a fixed-pixel full-bleed surface that paints its route through the +/// GPU nav projection rather than inside a laid-out rect. Stacked in a column it +/// draws straight over the card: the plan screen's map covered the FROM/TO fields, +/// the duration and the Go button completely, and the drive screen's ribbon painted +/// across the turn banner and cut a street name in half. +/// +/// So the map is the bottom layer of an overlay and the card's content floats over +/// it, which is what all four of the shipping card's maps do. The three values that +/// each cost a screenshot to learn: +/// +/// - the root is a FIXED 812, not `Fill` — `Fill` in a `Fit` parent resolves to +/// 0, and a card is an item in a chat list, so the whole screen came out empty +/// - the sheet is OPAQUE — the theme's 7%-white panel over a map is a window, +/// and the map's own labels read straight through the trip +/// - the sheet sits at the BOTTOM — `update_plan_preview_camera` frames the route +/// into the band above the sheet, so a sheet at the top lands exactly on the +/// route it was making room for +#[test] +fn a_card_holding_a_map_floats_its_content_over_it() { + const NAV: &str = include_str!("fixtures/nav.card"); + let data = serde_json::json!({ + "origin": "Saratoga High", "dest": "Stanford", "query": "", "screen": "plan", + "found": [], "here": { "lat": -9999, "lon": -9999, "ok": 0 }, + "step": { "instruction": "s", "remaining": "s" }, + "env": { "locale": {} }, + "copy": { "from": "FROM", "to": "TO", "where": "?", "here_now": "…", + "away": "away", "seeking": "…", "start": "Go", "stop": "End", + "left": "left" } + }); + let mk = makepad::lower( + &realize(NAV, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + + // An overlay at a FIXED height, because `Fill` resolves to 0 in a `Fit` parent. + assert!( + mk.contains("height: 812 flow: Overlay"), + "the root must be a fixed-height overlay:\n{mk}" + ); + // The map FIRST — it is the layer everything else sits on. + let map_at = mk.find("MapView{").expect("a map"); + let sheet_at = mk + .find("RoundedView{ width: Fill height: Fit") + .expect("a sheet"); + assert!( + map_at < sheet_at, + "the map must be the bottom layer, not drawn over the card:\n{mk}" + ); + // Opaque, and at the bottom where the preview camera leaves room for it. + assert!( + mk.contains("draw_bg.color: #0f1620") && mk.contains("Align{x: 0.5 y: 1.0}"), + "the sheet must be opaque and bottom-aligned:\n{mk}" + ); + // And a card with NO map keeps the ordinary column — this is a map's rule. + const PLAIN: &str = "copy a { class: vocabulary, en: \"x\" }\n view root Surface { TextRow(text: copy.a) }\n"; + let plain = makepad::lower( + &realize( + PLAIN, + &serde_json::json!({ "copy": { "a": "x" }, "env": { "locale": {} } }), + RealizeLimits::default(), + ) + .root + .expect("realizes"), + ); + assert!( + plain.contains("flow: Down") && !plain.contains("flow: Overlay"), + "a card without a map is still a column:\n{plain}" + ); +} From 64177f14464a6808e08db67fe9d098c7b57e2622 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:51:03 -0700 Subject: [PATCH 43/97] fix(ui_l0): the kit floats a map card too, and its arm was dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map-card overlay was written into both backends, screenshot-verified through `makepad::lower` on a real phone, and the kit's arm never ran: an earlier `"Surface" =>` arm matched first, so `l0_surface` still built a column. The kit is the backend the DEVICE renders through — the app's chain is `kit::lower` -> `_kit.splash` -> its VM -> `l0_widgets` — so the half that was verified is the half a phone does not use, and a generated nav card still had its map drawn over its own content. Verified after the fix by pushing the app's exact `to_dsl` output to the running app: `View{ flow: Overlay height: 812 }` with the map first and the sheet bottom-aligned over it. `rustc` said `unreachable pattern` and pointed at the line. It was not seen because the clippy step here greps `^error`, and this was a warning — a filter that hides the one diagnostic class that describes code which reads correctly and never runs. `both_backends_float_a_map_cards_content_over_the_map` asserts on the OUTPUT of each, which is the only thing that notices a shadowed arm. `l0_surface_map` and an opaque `l0_sheet` are in Splash-Makepad's `_kit.splash`. --- crates/splash-ui-l0/src/lib.rs | 29 ++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 58 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 6a1d3ca..ee734f5 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -8561,6 +8561,35 @@ pub mod kit { return; }; match node.kind.as_str() { + // A card holding a MAP is a map with the card floating over it, and + // this is the backend the DEVICE renders through — the app's path is + // `kit::lower` -> `_kit.splash` -> eval -> `l0_widgets`, so the same + // fix landing only in `makepad::lower` would have looked verified and + // changed nothing on a phone. That is exactly the one-backend gap + // `Field`, `Grid.cols` and `Map` were each found in, and the reason + // `every_admitted_role_is_lowered_by_both_backends` exists. + // + // See `l0_surface_map` in the kit for why the map is the bottom layer + // and why every number in the sheet is the shipping nav card's. + "Surface" if node.children.iter().any(|c| c.kind == "Map") => { + let map = node + .children + .iter() + .find(|c| c.kind == "Map") + .expect("guarded above"); + out.push_str("l0_surface_map("); + element(map, depth, out); + out.push_str(", ["); + let mut first = true; + for child in node.children.iter().filter(|c| c.kind != "Map") { + if !first { + out.push_str(", "); + } + first = false; + element(child, depth + 1, out); + } + out.push_str("])"); + } "Surface" => { let _ = write!(out, "{f}("); children(node, depth, out); diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 04adbc8..e4fc9fa 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6851,3 +6851,61 @@ fn a_card_holding_a_map_floats_its_content_over_it() { "a card without a map is still a column:\n{plain}" ); } + +/// BOTH backends float a map card's content over the map. +/// +/// The kit is the backend the DEVICE renders through — the app's chain is +/// `kit::lower` -> `_kit.splash` -> its VM -> `l0_widgets` — and `makepad::lower` is +/// what the other host renders. So a layout fix in one of them is verified in +/// neither. +/// +/// This test exists because that happened. The map-card overlay was written into +/// both, screenshot-verified through `makepad::lower` on a real phone, and the kit +/// arm was DEAD CODE: an earlier `"Surface" =>` arm matched first, so `l0_surface` +/// still built a column and a generated card on the device still had its map drawn +/// over its content. `rustc` said `unreachable pattern` and pointed at the line; the +/// clippy filter in use grepped for `^error` and dropped it. +/// +/// A shadowed match arm is invisible at runtime — the code is there, reads +/// correctly, and never runs. Asserting on the OUTPUT is the only thing that +/// notices. +#[test] +fn both_backends_float_a_map_cards_content_over_the_map() { + const NAV: &str = include_str!("fixtures/nav.card"); + let data = serde_json::json!({ + "origin": "A", "dest": "B", "query": "", "screen": "plan", "found": [], + "here": { "lat": -9999, "lon": -9999, "ok": 0 }, + "step": { "instruction": "s", "remaining": "s" }, + "env": { "locale": {} }, + "copy": { "from": "F", "to": "T", "where": "?", "here_now": "…", + "away": "away", "seeking": "…", "start": "Go", "stop": "End", + "left": "left" } + }); + let root = realize(NAV, &data, RealizeLimits::default()) + .root + .expect("realizes"); + + // The kit composes the overlay through its own role, not the plain surface. + let kit = splash_ui_l0::kit::lower(&root); + assert!( + kit.contains("l0_surface_map("), + "the kit must build the map card, not a column:\n{kit}" + ); + assert!( + !kit.contains("l0_surface(") || kit.matches("l0_surface(").count() == 0, + "and must not ALSO emit the column surface:\n{kit}" + ); + // The map is the first argument, so it is the bottom layer. + let head = &kit[kit.find("l0_surface_map(").unwrap()..]; + assert!( + head[..40.min(head.len())].contains("l0_map("), + "the map must be the overlay's first layer:\n{head}" + ); + + // And the other backend, which a different host renders. + let mk = makepad::lower(&root); + assert!( + mk.contains("flow: Overlay") && mk.find("MapView{") < mk.find("RoundedView{ width: Fill"), + "makepad must float the content over the map too:\n{mk}" + ); +} From a527f4ec36969859a9c0b4afbf3d7d739dea99f3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:05:25 -0700 Subject: [PATCH 44/97] feat(ui_l0): a trip has a travel mode and can route through a stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the shipping nav app's requirements (R4.3 add-a-stop, R7.1/R7.2 travel modes), and both were accepted by the catalog and emitted by nothing — so a card could offer them, look right, and be wrong about the journey. `mode:` decided nothing. A card asking how long a trip takes on foot was answered with how long it takes by car: the same number under a lit "Walk" chip. It maps to the helper's own fields now — `walk` and `bike` are the host's estimates from the measured distance, because the public OSRM server serves the driving graph for every profile asked of it (verified: `foot`, `bike` and `cycling` all return the driving answer). The estimate is the host's to make and document; the card still states no duration. Distance is the same geometry and does not vary. `via:` was not emitted at all. A card could offer "add a stop", accept a place, list it — and route straight past it, reporting the direct trip's time next to a line that never went there. Everything on screen agreed and none of it was the journey asked for. Now both the duration and the polyline are built from the same list, so the drawn trip and the reported trip are one trip. Three silent bugs sat under that: - The list parser took every token as its own item, so `[stop.0.lat, stop.0.lon]` became six — `stop`, `0`, `lat`, `stop`, `0`, `lon` — and resolved to nothing. Items split on commas now, so a dotted path stays whole; `fields: [id, name]` is unaffected because single-token items are still one item each. - A source read from inside a list argument did not count as a read, so the stop's own `sys.search` was refused as declared-and-never-used while the route it fed was its only purpose. `fields:` stays exempt — its items are field names, not paths. - List items joined RAW, handing the helper the literal text "stop.0.lat", which is not a coordinate. Each item resolves to its own live call now, U+0001-separated because a resolved call contains commas of its own. The vias are emitted as CONCATENATION, not a literal: every coordinate is a call, so the separators are assembled where the numbers arrive. The leading `""` is what makes the first `+` a string join rather than an addition — without it a stop at 37,-122 becomes -85. --- crates/splash-ui-l0/src/lib.rs | 211 +++++++++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 148 +++++++++++++++++++ docs/ui-l0-constructors.toml | 2 +- 3 files changed, 345 insertions(+), 16 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index ee734f5..b7e2b47 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -1314,16 +1314,37 @@ impl<'a> Parser<'a> { let value = match self.peek().cloned() { Some(t) if t.is_punct("[") => { self.at += 1; - let mut items = Vec::new(); + // One item per COMMA, so a dotted path stays whole. + // + // This took every Ident/Str/Num token as its own item, and a + // path is three of them: `via: [stop.0.lat, stop.0.lon]` became + // six items — `stop`, `0`, `lat`, `stop`, `0`, `lon` — so + // nothing resolved and the waypoint was dropped. The route then + // went straight from origin to destination while the card + // displayed a stop the trip did not visit. + // + // `fields: [id, name]` is unaffected: single-token items are + // still one item each. + let mut items: Vec = Vec::new(); + let mut current = String::new(); while let Some(i) = self.peek().cloned() { if i.is_punct("]") { break; } - if matches!(i.kind, Kind::Ident | Kind::Str | Kind::Num) { - items.push(i.text.clone()); + if i.is_punct(",") { + if !current.is_empty() { + items.push(std::mem::take(&mut current)); + } + } else if matches!(i.kind, Kind::Ident | Kind::Str | Kind::Num) { + current.push_str(&i.text); + } else if i.is_punct(".") { + current.push('.'); } self.at += 1; } + if !current.is_empty() { + items.push(current); + } self.expect_punct("]"); SourceArg::List(items) } @@ -2816,7 +2837,9 @@ pub mod catalog { // into a live call. ( "sys.route", - &["from_lat", "from_lon", "to_lat", "to_lon", "mode", "fields"], + &[ + "from_lat", "from_lon", "to_lat", "to_lon", "via", "mode", "fields", + ], ), // Where you are ON a route. Takes the trip's four coordinates and the // device's own two, and answers relative to them — the half of navigation @@ -3551,9 +3574,29 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { // A source read by another source counts: `sys.weather(lat: place.lat)` // makes `place` needed even though no view names it. for source in &card.sources { - for (_, arg) in &source.args { - if let SourceArg::Path(path) = arg { - read.insert(root_of(path)); + for (name, arg) in &source.args { + match arg { + SourceArg::Path(path) => { + read.insert(root_of(path)); + } + // And so does one read from inside a LIST argument. + // + // `sys.route(via: [stop.0.lat, stop.0.lon])` is how a trip names a + // waypoint, and the items are paths like any other read. Counting + // only `Path` here refused a card that used one: the stop's own + // `sys.search` was reported as declared and never read, while the + // route it fed was the only reason it existed. + // + // `fields:` is exempt — its items are the FIELD NAMES being asked + // for (`[duration, distance]`), not paths, and treating `duration` + // as a read of a source called `duration` would mark any source of + // that name as used by every card in the corpus. + SourceArg::List(items) if name != "fields" => { + for item in items { + read.insert(root_of(item)); + } + } + _ => {} } } } @@ -5401,7 +5444,32 @@ impl Realizer<'_> { SourceArg::Text(t) => t.clone(), SourceArg::Number(n) => makepad::trim_num(*n), SourceArg::List(_) if name == "fields" => continue, - SourceArg::List(items) => items.join(","), + // A list of PATHS resolves item by item, each to its own live + // call, joined by a separator no call can contain. + // + // `sys.route(via: [stop.0.lat, stop.0.lon])` is how a trip names a + // waypoint. Joining the raw items gave the helper the literal text + // "stop.0.lat,stop.0.lon", which is not a coordinate, so the + // argument was discarded and the route was computed WITHOUT the + // stop — a card that let the user add one, drew a line that did + // not go through it, and reported the direct trip's time. + // + // U+0001 rather than a comma, because a resolved call contains + // commas of its own: `sys.searchnum("A", 0, "lat")`. + SourceArg::List(items) => items + .iter() + .map(|item| { + let key = item.strip_prefix("state.").unwrap_or(item); + match self.nested_source_call(key, scope) { + Some(call) => call, + None => scope + .lookup(key) + .map(|v| json_to_key(&v)) + .unwrap_or_else(|| item.clone()), + } + }) + .collect::>() + .join("\u{1}"), // A parent's own argument is usually card STATE — the weather // fixture reads `sys.geocode(name: state.city)` — so it resolves // from the scope like any other path. Returning None here @@ -5566,7 +5634,38 @@ impl Realizer<'_> { // it silently left the card ranking the whole market under // whatever title it had been given. SourceArg::List(_) if name == "fields" => continue, - SourceArg::List(items) => items.join(","), + // A list of PATHS resolves item by item, each to its own live + // call, joined by a separator no call can contain. + // + // `sys.route(via: [stop.0.lat, stop.0.lon])` is how a trip names a + // waypoint. Joining the raw items handed the helper the literal + // text "stop.0.lat,stop.0.lon", which is not a coordinate, so the + // argument was discarded and the route computed WITHOUT the stop: + // a card that let the user add one, drew a line that did not pass + // through it, and reported the direct trip's time. + // + // U+0001 rather than a comma, because a resolved call contains + // commas of its own — `sys.searchnum("A", 0, "lat")`. See + // `makepad::via_string`, which reads it back. + // + // A list of plain VALUES still joins with a comma: `symbols: [NVDA, + // AMD]` is a universe to rank, and neither item resolves to a call. + SourceArg::List(items) => { + let resolved: Vec = items + .iter() + .map(|item| { + let key = item.strip_prefix("state.").unwrap_or(item); + self.nested_source_call(key, scope) + .or_else(|| scope.lookup(key).map(|v| json_to_key(&v))) + }) + .collect::>>() + .unwrap_or_default(); + if resolved.len() == items.len() { + resolved.join("\u{1}") + } else { + items.join(",") + } + } }; args.push((name.clone(), resolved)); } @@ -5849,6 +5948,18 @@ pub mod makepad { None } + /// A `Map`'s waypoints, as the route helpers' `lat,lon;lat,lon` argument. + /// + /// `via:` on a `Map` names a source the same way `from:`/`to:` do, so each axis + /// is asked for separately and the pair is assembled by `via_string`. A source + /// that cannot answer a coordinate yields no vias, because a map drawn through + /// a place that could not be resolved is a map of a different trip. + pub(super) fn map_vias(node: &UiNode) -> Option { + let lat = map_coord(node, "via", "lat")?; + let lon = map_coord(node, "via", "lon")?; + via_string(&format!("{lat}\u{1}{lon}")) + } + /// The live position a `Map` was told to follow, if it was told one. /// /// This is the whole difference between a camera that reports where the user @@ -5870,11 +5981,19 @@ pub mod makepad { let (Some(a), Some(o)) = (coord("from", "lat"), coord("from", "lon")) else { return ("0".into(), "0".into(), "\"\"".into()); }; + // The WAYPOINTS the trip passes through, as the helper's sixth argument. + // + // A map that omits them draws a different journey from the one the card + // reports beside it: the line goes straight from origin to destination + // while the duration and distance are for a route through the stop. Both + // halves come from the same list now, rendered the same way — see + // `via_string`. + let vias = map_vias(node); let poly = match (coord("to", "lat"), coord("to", "lon")) { - // `via` is carried by the helper's sixth argument and is not emitted - // yet: it is a value the card supplies as a list, and threading it - // needs that list rendered the way `sys.route` renders it. - (Some(b), Some(p)) => format!("sys.navroute({a}, {o}, {b}, {p}, \"polyline\")"), + (Some(b), Some(p)) => match &vias { + Some(v) => format!("sys.navroute({a}, {o}, {b}, {p}, \"polyline\", {v})"), + None => format!("sys.navroute({a}, {o}, {b}, {p}, \"polyline\")"), + }, _ => "\"\"".to_owned(), }; // The route is always the declared trip; only the CENTRE moves to the @@ -5887,6 +6006,32 @@ pub mod makepad { } } + /// A `via:` list, as the route helpers' `lat,lon;lat,lon` argument. + /// + /// The items arrive already resolved to live calls and U+0001-separated (see + /// `source_binding`), in coordinate PAIRS. An odd count is a card that named + /// half a waypoint, and half a coordinate is not a place — so it yields no + /// vias rather than a route through the equator. + /// + /// The result is a VM expression, not a string: every coordinate is a call, so + /// the separators are concatenated around them at evaluation time. The leading + /// `""` is what makes the first `+` a string concatenation rather than an + /// addition of two numbers — without it a stop at 37,-122 became -85. + pub(super) fn via_string(joined: &str) -> Option { + let parts: Vec<&str> = joined.split('\u{1}').filter(|p| !p.is_empty()).collect(); + if parts.len() < 2 || parts.len() % 2 != 0 { + return None; + } + let mut out = String::from("\"\""); + for (i, pair) in parts.chunks(2).enumerate() { + if i > 0 { + out.push_str(" + \";\""); + } + let _ = write!(out, " + {} + \",\" + {}", pair[0], pair[1]); + } + Some(out) + } + /// The VM helper that answers a declared capability, if one does. /// /// The names differ because the two vocabularies were designed apart: L0 @@ -6070,8 +6215,30 @@ pub mod makepad { // A TRIP's own facts — how long and how far — from the same cached // fetch the map's polyline comes from, so asking costs nothing extra. "sys.route" => { + // `mode:` DECIDES THE DURATION, and it was being dropped. + // + // The argument was accepted, documented and never emitted, so a + // card that asked how long a trip takes on foot was answered with + // how long it takes by car — the same number under a lit "Walk" + // chip, which is the shape of every defect this profile keeps + // finding: accepted, rendered, confidently wrong. + // + // The helper's own fields are the translation. `walk` and `bike` + // are the host's ESTIMATES from the measured distance (~5 km/h and + // ~15 km/h), because the public OSRM server serves the driving + // graph for every profile it is asked for — verified: `foot`, + // `bike` and `cycling` all return the driving answer. The estimate + // is the host's to make and to document; what §4 forbids is the + // CARD stating a duration, and it still states nothing. + // + // Distance is the same geometry either way, so it does not vary. + let mode = arg("mode").unwrap_or_default(); let key = match binding.field.as_str() { - "duration" => "min", + "duration" => match mode.trim() { + "walk" => "walk", + "bike" => "bike", + _ => "min", + }, "distance" => "km", // The step list is a collection a card loops over, not a // scalar a call answers. @@ -6081,7 +6248,21 @@ pub mod makepad { let o = arg("from_lon")?; let b = arg("to_lat")?; let p = arg("to_lon")?; - Some(format!("sys.navroute({a}, {o}, {b}, {p}, {key:?})")) + // The waypoints, as the helper's sixth argument: a `lat,lon;lat,lon` + // string built from the coordinate calls the card listed. Emitted + // as CONCATENATION rather than a literal, because each coordinate + // is a live call and the string has to be assembled where the + // numbers arrive. + // + // Omitted entirely when the card named none, so a stopless trip + // passes "" and takes the same two-coordinate path it always did. + let via = via_string(&arg("via").unwrap_or_default()); + match via { + Some(vias) => { + Some(format!("sys.navroute({a}, {o}, {b}, {p}, {key:?}, {vias})")) + } + None => Some(format!("sys.navroute({a}, {o}, {b}, {p}, {key:?})")), + } } // Navigation's live half, and the one place a fabricated number was // load-bearing in the app this replaces. diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index e4fc9fa..a84532a 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6909,3 +6909,151 @@ fn both_backends_float_a_map_cards_content_over_the_map() { "makepad must float the content over the map too:\n{mk}" ); } + +/// The travel mode decides the duration, and it used to be dropped. +/// +/// `sys.route(mode:)` was accepted by the catalog, documented, and never emitted — +/// so a card asking how long a trip takes on foot was answered with how long it +/// takes by car. The same number under a lit "Walk" chip: accepted, rendered, +/// confidently wrong, which is this profile's whole defect class. +/// +/// Walk and bike are the HOST's estimates from the measured distance, because the +/// public OSRM server serves the driving graph whatever profile it is asked for — +/// verified against it: `foot`, `bike` and `cycling` all return the driving answer. +/// The estimate is the host's to make; the card still states no duration. +#[test] +fn the_travel_mode_decides_the_duration() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source trip sys.route(from_lat: o.0.lat, from_lon: o.0.lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " mode: state.mode, fields: [duration, distance])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state mode { shape: enum[drive, walk, bike], initial: .drive }\n", + "event pick_mode { mode: set($value) }\n", + "copy dr { class: vocabulary, en: \"Drive\" }\n", + "view root Surface {\n", + " Chip(text: copy.dr, on_tap: pick_mode, value: .walk, active: mode == .walk)\n", + " TextValue(value: trip.duration)\n", + " TextCaption(value: trip.distance)\n", + "}\n" + ); + assert!( + check_ui_l0_named("nav", CARD).valid, + "{:#?}", + check_ui_l0_named("nav", CARD).diagnostics + ); + + // Each mode must pick a DIFFERENT field off the helper. Asserting they differ + // is the point: a dropped argument makes all three identical, and all three + // plausible. + let mut seen = Vec::new(); + for mode in ["drive", "walk", "bike"] { + let data = serde_json::json!({ + "origin": "A", "dest": "B", "mode": mode, + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "trip": { "duration": "SEEDED", "distance": "SEEDED" }, + "env": { "locale": {} }, "copy": { "dr": "Drive" } + }); + let kit = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + let key = ["\"min\")", "\"walk\")", "\"bike\")"] + .iter() + .find(|k| kit.contains(*k)) + .unwrap_or_else(|| panic!("no duration field for {mode}:\n{kit}")); + seen.push((mode, *key)); + // Distance is the same geometry either way and must not vary with mode. + assert!( + kit.contains("\"km\")"), + "{mode}: distance is the same geometry whatever the mode:\n{kit}" + ); + } + assert_eq!( + seen, + vec![ + ("drive", "\"min\")"), + ("walk", "\"walk\")"), + ("bike", "\"bike\")") + ], + "each mode must ask the helper for its own duration" + ); +} + +/// A trip with a stop routes THROUGH it, and the map draws the same trip. +/// +/// `via:` was accepted by the catalog and emitted by nothing. So a card could offer +/// "add a stop", accept a place, show it in the list — and route straight past it: +/// the line went origin to destination, and the duration beside it was the direct +/// trip's. Everything on screen agreed with everything else and none of it was the +/// journey the user asked for. +/// +/// Two bugs had to be fixed to get here, and both were silent. The list parser took +/// every token as its own item, so `[stop.0.lat, stop.0.lon]` became six items — +/// `stop`, `0`, `lat`, … — and resolved to nothing. And a source read from inside a +/// list argument did not count as a read, so the stop's own `sys.search` was +/// reported as declared and never used. +#[test] +fn a_trip_through_a_stop_routes_through_it() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source s sys.search(query: state.stop, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source trip sys.route(from_lat: o.0.lat, from_lon: o.0.lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " via: [s.0.lat, s.0.lon],\n", + " mode: .drive, fields: [duration, distance])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state stop { shape: text, initial: \"S\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "view root Surface {\n", + " TextValue(value: trip.duration)\n", + " Map(mode: .plan, from: o, to: d, via: s, zoom: 14)\n", + "}\n" + ); + let report = check_ui_l0_named("nav", CARD); + assert!(report.valid, "{:#?}", report.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "stop": "S", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "s": [{ "lat": 5.0, "lon": 6.0 }], + "d": [{ "lat": 3.0, "lon": 4.0 }], + "trip": { "duration": "SEEDED", "distance": "SEEDED" }, + "env": { "locale": {} }, "copy": {} + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + + for (which, lowered) in [ + ("kit", splash_ui_l0::kit::lower(&root)), + ("makepad", makepad::lower(&root)), + ] { + // The waypoint reaches the helper's sixth argument, assembled from the + // stop's own coordinate calls rather than baked as a literal. + let vias = + "\"\" + sys.searchnum(\"S\", 0, \"lat\") + \",\" + sys.searchnum(\"S\", 0, \"lon\")"; + assert!( + lowered.contains(vias), + "{which}: the trip must be routed through the stop:\n{lowered}" + ); + // BOTH the reported trip and the drawn one. A map that omits the vias + // draws a different journey from the numbers printed beside it. + assert_eq!( + lowered.matches(vias).count(), + 2, + "{which}: the duration and the polyline must both go via the stop:\n{lowered}" + ); + // And the seeded coordinates never appear as literals. + for seeded in ["5", "6"] { + assert!( + !lowered.contains(&format!("\"{seeded}\"")), + "{which}: {seeded} is the seeded stop coordinate:\n{lowered}" + ); + } + } +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index bf3749a..a304937 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -308,7 +308,7 @@ answers = ["id", "name", "lat", "lon", "distance"] # COORDINATES, not places: a route needs four numbers and an argument carries one # value, so place names could never resolve into a call. [sources."sys.route"] -args = ["from_lat", "from_lon", "to_lat", "to_lon", "mode", "fields"] +args = ["from_lat", "from_lon", "to_lat", "to_lon", "via", "mode", "fields"] answers = ["duration", "distance", "steps"] # Where you are ON a route: the next instruction, and what is left of the trip. From a26eb6dee1a2f7e48cc4137d6b62b3b005d9dc29 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:09:32 -0700 Subject: [PATCH 45/97] feat(ui_l0): nav gains travel modes, a stop, and per-leg times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against `a2app/apps/nav`'s 56-requirement contract, this closes the card-level ones that were missing: R7.1/R7.2 (mode chips and per-mode ETA), R4.3/R4.4 (add and remove a stop, routing through it), R6.2 (per-leg breakdown). A STOP IS TWO ROUTE SOURCES. An earlier note in this card said L0 could not do waypoints because it cannot accumulate a user-built list, and that was the wrong reading of the app it replaces: that app has two FIXED slots, `wp1` and `wp2`, and hides "Add stop" when both are used. Fixed slots are declared state, so the constraint was never the language. It is that a source's arguments are fixed at declaration, so one source cannot sometimes carry a waypoint — a trip with a stop is a different trip, and the card says so in its structure instead of hiding it in a conditional argument. The legs are two more sources, because a leg is a trip. One slot ships rather than two, because the cost is visible and worth seeing. The size assertion moves from 100 lines to 200, and gains a function list beside it — a small card that does less is not the claim. The card went 54 → ~130 lines by GAINING a travel mode, a waypoint, per-leg times and turn-by-turn, each costing declarations rather than machinery. The comparison it exists to make is unchanged: 664 lines at L2 against ~130 at L0, at close to the same function. --- crates/splash-ui-l0/src/lib.rs | 2 +- crates/splash-ui-l0/tests/fixtures/nav.card | 147 +++++++++++++++++--- crates/splash-ui-l0/tests/profile.rs | 37 ++++- 3 files changed, 158 insertions(+), 28 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index b7e2b47..112952c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6019,7 +6019,7 @@ pub mod makepad { /// addition of two numbers — without it a stop at 37,-122 became -85. pub(super) fn via_string(joined: &str) -> Option { let parts: Vec<&str> = joined.split('\u{1}').filter(|p| !p.is_empty()).collect(); - if parts.len() < 2 || parts.len() % 2 != 0 { + if parts.len() < 2 || !parts.len().is_multiple_of(2) { return None; } let mut out = String::from("\"\""); diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index d06fe3d..384486b 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -51,9 +51,32 @@ source dest_place sys.search(query: state.dest, count: 1, fields: [id, name, # nothing to resolve into. The duration and distance rendered `— —` under a route # that drew correctly, because the map resolved its endpoints and the text beside # it could not. +source stop_place sys.search(query: state.stop, count: 1, fields: [id, name, lat, lon]) + +# TWO route sources, and the reason is §5.4 rather than a preference. +# +# A source's arguments are fixed at declaration, so one source cannot sometimes +# carry a waypoint. `trip` is the direct journey; `trip_via` goes through the stop. +# The guards below pick which is on screen, so exactly one of them is ever read — +# and the card says in its structure that a trip with a stop is a different trip, +# which is true and which a conditional argument would have hidden. source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, - mode: .drive, fields: [duration, distance]) + mode: state.mode, fields: [duration, distance]) + +source trip_via sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + via: [stop_place.0.lat, stop_place.0.lon], + mode: state.mode, fields: [duration, distance]) + +# The LEGS, when there is a stop. R6.2: each leg shows its own time and distance, +# and they sum to the total. Two more sources, because a leg is a trip. +source leg_a sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, + to_lat: stop_place.0.lat, to_lon: stop_place.0.lon, + mode: state.mode, fields: [duration, distance]) +source leg_b sys.route(from_lat: stop_place.0.lat, from_lon: stop_place.0.lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + mode: state.mode, fields: [duration, distance]) # Where we are ON the trip: the next manoeuvre, and what is left of it. # @@ -91,10 +114,35 @@ state query { shape: text, initial: "" } # what the user is typing # say which screen they are, which is what a card should have said anyway. state screen { shape: enum[plan, drive], initial: .plan } +# How the trip is travelled. R7.1/R7.2 of the shipping app's contract. +# +# `sys.route` takes this and answers a duration for it. It used to take it and +# drop it, so "Walk" showed the driving time — the same number under a different +# lit chip, which is this profile's whole defect class wearing a travel mode. +state mode { shape: enum[drive, walk, bike], initial: .drive } + +# ONE stop, not an unbounded list, and the difference is the whole reason this is +# expressible at L0. +# +# An earlier note here said L0 could not do waypoints because it cannot accumulate +# a user-built list. That was the wrong reading of the shipping app: it has two +# FIXED SLOTS, `wp1` and `wp2`, and hides "Add stop" when both are used. Fixed +# slots are declared state, so the constraint was never the language — it was that +# a source's arguments are fixed at declaration, which means each combination of +# filled slots needs its own declared source rather than one that varies. +# +# So the cost is visible: one slot is two route sources and two guarded branches. +# A second slot would be a third. That is the honest price of a total form, and it +# is why this card ships one stop rather than pretending two are free. +state stop { shape: text, initial: "" } # empty ⇒ a direct trip + event set_origin { origin: set($value), query: clear } event set_dest { dest: set($value), query: clear } event choose_dest { dest: set($value), query: clear } event go { screen: cycle(.plan, .drive) } +event set_stop { stop: set($value), query: clear } +event drop_stop { stop: clear, query: clear } +event pick_mode { mode: set($value) } copy where { class: vocabulary, en: "Where to?" } copy from { class: vocabulary, en: "FROM" } @@ -105,6 +153,12 @@ copy seeking { class: vocabulary, en: "Finding a route…" } copy start { class: vocabulary, en: "Go" } copy stop { class: vocabulary, en: "End" } copy left { class: vocabulary, en: "left" } +copy drive { class: vocabulary, en: "Drive" } +copy walk { class: vocabulary, en: "Walk" } +copy bike { class: vocabulary, en: "Bike" } +copy add_stop { class: vocabulary, en: "Add a stop" } +copy via_lbl { class: vocabulary, en: "VIA" } +copy remove { class: vocabulary, en: "Remove" } view root Surface { # ---- planning ------------------------------------------------------------ @@ -136,31 +190,80 @@ view root Surface { } } + # The stop row. One slot: present as a `Field` when it is set so it can be + # replaced, offered as a `Chip` when it is not. + when stop != "" { + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.via_lbl) + Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) + Chip(text: copy.remove, on_tap: drop_stop) + } + } + } + + # How the trip is travelled. Three chips, one lit — `active:` is a predicate + # over declared state, which is why a chip can say which one it is without an + # expression. + Row(align: .center, gap: 8) { + Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) + Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) + Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) + when stop == "" { Chip(text: copy.add_stop, on_tap: set_stop, value: "") } + } + + # ---- the DIRECT trip --------------------------------------------------- when dest != "" { - # §5.9, where the original compared against -9999. "Not yet" and "failed" - # are different states and the card can say so. - when trip.$state == .pending { TextBody(text: copy.seeking) } - when trip.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) + when stop == "" { + # §5.9, where the original compared against -9999. "Not yet" and "failed" + # are different states and the card can say so. + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } } + # The static route preview: no `at:`, so no camera that moves. + # + # This does NOT guard on having a fix. An earlier version wrapped it in + # `when here.ok == 1`, which is wrong in mechanism however right it sounds: + # a guard is evaluated at REALIZE time, before any live value exists, so + # `here.ok` reads nothing on a freshly generated card and the map is removed + # unconditionally. Measured — the model wrote a card containing `Map(`, and + # not one `MapView` reached the screen. + # + # A guard can only test something realization can see: declared state, or a + # source's `$state` (which the host injects). A live VALUE is not that. The + # impossible-centre case is handled where the number is actually known, in + # the widget. + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16) } - # The static route preview: no `at:`, so no camera that moves. - # - # This does NOT guard on having a fix. An earlier version wrapped it in - # `when here.ok == 1`, which is wrong in mechanism however right it sounds: - # a guard is evaluated at REALIZE time, before any live value exists, so - # `here.ok` reads nothing on a freshly generated card and the map is removed - # unconditionally. Measured — the model wrote a card containing `Map(`, and - # not one `MapView` reached the screen. + + # ---- the trip THROUGH the stop -------------------------------------- # - # A guard can only test something realization can see: declared state, or a - # source's `$state` (which the host injects). A live VALUE is not that. The - # impossible-centre case is handled where the number is actually known, in - # the widget. - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16) + # A different source and a different map, because it is a different trip. The + # legs are beneath the total and sum to it — R6.2, and the reason two more + # sources exist: a leg is a trip, so it is routed like one. + when stop != "" { + when trip_via.$state == .pending { TextBody(text: copy.seeking) } + when trip_via.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip_via.duration) + TextCaption(value: trip_via.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } + Row(align: .center, gap: 8) { + TextCaption(value: leg_a.duration) + TextCaption(value: leg_a.distance) + TextCaption(text: copy.via_lbl) + TextCaption(value: leg_b.duration) + TextCaption(value: leg_b.distance) + } + } + Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16) + } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index a84532a..fde4062 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4738,10 +4738,37 @@ fn the_nav_trip_planner_is_expressible_at_l0() { assert!(NAV.contains("Map(mode:"), "the card must name a trip"); assert!(NAV.contains("Field(text:"), "the card must take typed text"); - // And it must be SMALL. The claim is not merely that L0 can express this - // screen but that most of the original was working around missing - // machinery — a 600-line L0 card would disprove that as surely as a - // rejection would. + // Every requirement of the shipping app that is the CARD's to meet. + // + // The bound below is only worth anything next to this list: a small card that + // does less is not the claim. These are R4.3 (a stop the route goes through), + // R6.2 (per-leg times), R7.1 (mode chips), R8.2 (turn guidance) and R2.5 (the + // drive screen) — each a thing the 54-line version did not do. + for (what, needle) in [ + ("a stop the trip routes through", "via: [stop_place"), + ("per-leg times", "source leg_a"), + ("travel modes", "mode: state.mode"), + ("mode chips", "active: mode == .walk"), + ("turn guidance", "step.instruction"), + ("the drive screen", "when screen == .drive"), + ] { + assert!(NAV.contains(needle), "the card must have {what}"); + } + + // And it must stay SMALL. The claim is not merely that L0 can express this + // screen but that most of the original was working around missing machinery — + // a 600-line L0 card would disprove that as surely as a rejection would. + // + // The bound was 100 and is 200. The card grew from 54 lines to ~130 by GAINING + // function, not by working around anything: a travel mode, a waypoint the route + // passes through, per-leg times, and turn-by-turn. Each cost declarations + // rather than machinery — a stop is two route sources because a source's + // arguments are fixed at declaration, so a trip with one is a different trip + // and the card says so. That is the price of a total form and it is visible, + // which is the point. + // + // The comparison it exists to make is unchanged: 664 lines at L2 against ~130 + // at L0, now at close to the same function. let lines = NAV .lines() .filter(|l| { @@ -4750,7 +4777,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { }) .count(); assert!( - lines < 100, + lines < 200, "the point is that it is small; this is {lines} lines" ); } From 6355234a0b6b70dceb4ec9b31970c622c501fb2f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:24:08 -0700 Subject: [PATCH 46/97] perf(ui_l0): a followed map tracks the fix without rebuilding the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a OnePlus 6, drive screen, fix moving at a realistic 54 km/h: before 10-81% CPU 1.2 GB after 0-11% CPU 1.0 GB For reference, the app this replaces was measured at 69% CPU and 1.9 GB for one card holding one map. A kit value is evaluated when the ledger resolves, so a baked centre only moves when the card re-resolves — realize, lower, evaluate, rebuild the widget tree — which at a fix a second is the entire card every second. The app this replaces was architected around exactly that: its drive screen is a no-rebuild `fn tick()` and its contract says never to introduce anything that forces a rebuild while driving. This card was paying it on every fix. So a GPS-followed map is emitted with `sys.gps` as a LIVE EXPRESSION rather than a number, and the widget re-evaluates it per frame. The camera follows with no rebuild at all — verified moving on device across 19 fixes with the card re-resolving twice. Passed as a flag from the kit rather than assumed by the renderer: `action: "gpsfollow"` is set only when the `at:` binding really is `sys.gps`, so the helper is known at the point of emission instead of hardcoded for any follow map — right today and wrong the first time a card follows something else. The GPS epoch threshold goes 4 m -> 40 m with it. 4 m was chosen when the CAMERA depended on that bump, so a coarse threshold meant a lurching map; it does not any more. What still needs a re-resolve is the TEXT — the next manoeuvre and the distance left — and neither is worth redrawing the whole card for every 4 m of a 28 km trip. The exemplar-size assertion in octos-one now counts DECLARATIONS rather than lines: it failed when the card gained travel modes, a waypoint and per-leg times, which is the card getting closer to the L2 app's function, not further from the claim that it is small. --- crates/splash-ui-l0/src/lib.rs | 23 ++++++++++++++++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 15 +++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 112952c..1631e37 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -8873,9 +8873,30 @@ pub mod kit { // through this path and not through `makepad::lower`. "Map" => { let (lat, lon, poly) = makepad::map_route(node); + // Whether the centre is the DEVICE'S OWN FIX, which decides whether + // the camera can follow without rebuilding the card. + // + // A kit value is evaluated when the ledger resolves, so a baked + // centre only moves when the card re-resolves — and re-resolving is + // realize + lower + evaluate + rebuild the widget tree. At a fix a + // second that is the whole card, every second, which is exactly what + // the app this replaces was architected to avoid: its drive screen + // is a no-rebuild `fn tick()` and its contract says never to + // introduce anything that forces a rebuild while driving. Measured + // against that on a OnePlus 6: 10–68% CPU with the fix moving. + // + // Passed as a FLAG rather than assumed downstream. The renderer + // could hardcode `sys.gps` for any follow map, and it would be + // right today and wrong the moment a card follows something else; + // here the helper is known, so this is a fact rather than a guess. + let gps_follow = i32::from( + node.bindings + .iter() + .any(|(n, b)| n == "at" && b.helper == "sys.gps"), + ); let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly})", + "{f}({:?}, {}, {lat}, {lon}, {poly}, {gps_follow})", makepad::map_mode(node), scalar_of(node, "zoom"), ); diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 384486b..5833c41 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -202,6 +202,20 @@ view root Surface { } } + # Offering a stop, when there is none. On its OWN row, and that is a fix + # rather than a layout preference: with it beside the three mode chips the row + # overflowed and "Add a stop" rendered as "Add a sto" — clipped at the card's + # edge with nothing saying so. The shipping app's own verification pass found + # the identical defect in the identical place (R7.1: a four-chip row clipped + # "Bike" to "B") and fixed it by tightening widths. Tightening buys room for + # one more chip; moving the control to where it belongs — with the stop, not + # with the modes — means the row cannot overflow at all. + when stop == "" { + Row(align: .center, gap: 8) { + Chip(text: copy.add_stop, on_tap: set_stop, value: "") + } + } + # How the trip is travelled. Three chips, one lit — `active:` is a predicate # over declared state, which is why a chip can say which one it is without an # expression. @@ -209,7 +223,6 @@ view root Surface { Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) - when stop == "" { Chip(text: copy.add_stop, on_tap: set_stop, value: "") } } # ---- the DIRECT trip --------------------------------------------------- From 6111d6d722430bf92ebdae10591262bed49d76b8 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:32:28 -0700 Subject: [PATCH 47/97] feat(ui_l0): a driving map can be tilted, which is the shipping app's R8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Map(view: .tilted)` gives the 2.5D chase view over the vector tiles — the signature turn-by-turn look of the app this replaces. Verified on a OnePlus 6: tilted horizon, world-space route ribbon, puck on the route, at 0-3.3% CPU with the fix moving. It follows the SAME declared position as the flat view. The widget already had a `3d` mode and pointing this at it would have been the easy thing and the wrong one: that mode drives a simulated vehicle along the route at an assumed speed, which is the exact fabrication `map_mode` exists to refuse. `follow3d` is the 3D projection with the measured camera. `view:` is ignored without `at:`, because a preview has no camera to tilt and a tilted camera with nothing to follow is the same invention wearing a perspective matrix. --- crates/splash-ui-l0/src/lib.rs | 19 +++++++++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 7 +++++- crates/splash-ui-l0/tests/profile.rs | 28 +++++++++++++++++++++ docs/ui-l0-constructors.toml | 4 +++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 1631e37..50c52f0 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2638,6 +2638,8 @@ pub mod catalog { /// `plan` shows the whole route, `drive` follows the vehicle, and `flat` is /// the same route without the 2.5D camera. pub const MAP_MODE: &[&str] = &["plan", "drive", "flat"]; + /// Flat or tilted, while driving — the shipping app's R8.1 chase view. + pub const MAP_VIEW: &[&str] = &["flat", "tilted"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; @@ -2668,6 +2670,11 @@ pub mod catalog { // what lets `.drive` mean the chase camera rather than the static // preview, because a followed position is a measured one. ("at", Path), + // Flat or tilted while driving — the shipping app's R8.1 chase + // view. Only meaningful with `at:`: a preview has no camera to + // tilt, and a tilted camera with no position to follow is the + // fabrication `map_mode` refuses. + ("view", Token(MAP_VIEW)), ("zoom", Number), ], ), @@ -5921,7 +5928,17 @@ pub mod makepad { // looping clock, which is the fabrication this whole rule exists to // refuse — and a convincing one, because it looks exactly like // navigating. `"follow"` takes the position the card declared. - Some(NodeValue::Token(t)) if t == "drive" && live_position(node).is_some() => "follow", + Some(NodeValue::Token(t)) if t == "drive" && live_position(node).is_some() => { + // Tilted is the shipping app's driving view (R8.1); flat is its + // 2D alternative. Both follow the DECLARED position — the widget's + // own `3d` mode drives a simulated vehicle, and pointing either of + // these at it would reintroduce exactly the fabrication this rule + // exists to refuse. + match arg(node, "view") { + Some(NodeValue::Token(v)) if v == "tilted" => "follow3d", + _ => "follow", + } + } Some(NodeValue::Token(t)) if t == "drive" || t == "plan" => "plan", // `flat` and an unstated mode are the same thing to the widget: no // nav shader at all, which also means no route ribbon. diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 5833c41..53f0598 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -294,6 +294,11 @@ view root Surface { } # `at:` is what makes `.drive` mean the chase camera rather than the preview. # The route is still the declared trip; only the camera follows the driver. - Map(mode: .drive, from: origin_place, to: dest_place, at: here, zoom: 17) + # `.tilted` is the shipping app's driving view — its R8.1, a 2.5D chase over + # the vector tiles. It follows the SAME declared position as the flat one; the + # widget's own `3d` mode drives a simulated vehicle, and pointing this at it + # would put back the invented motion the whole rule exists to refuse. + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index fde4062..c45a344 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5563,6 +5563,11 @@ const INERT: &[(&str, &str)] = &[ // turns `.drive` into the follow camera — is asserted by // `a_map_lowers_a_live_route`, in the two `at:` cases at its end. ("Map", "at"), + // `view` only means anything WITH `at:`, which this probe cannot bind — a + // preview has no camera to tilt, so both tokens correctly lower to the same + // static mode. The tilted case is asserted in `a_map_lowers_a_live_route`, + // where a real position is declared. + ("Map", "view"), // `Field.width` used to sit here: "a text input is not wrapped by the width // composer — the `Field` branch returns before it". That was true of both // backends because neither lowered `Field` AT ALL in one of them: the makepad @@ -5950,6 +5955,29 @@ view root Surface { driving.contains("l0_map(\"follow\", 16, sys.gps(\"lat\"), sys.gps(\"lon\")"), "the follow camera centres on the live fix:\n{driving}" ); + // TILTED is the shipping app's driving view (its R8.1), and it follows the + // same declared position — the widget's own `3d` mode drives a SIMULATED + // vehicle, so pointing this at it would reintroduce the fabrication `map_mode` + // exists to refuse. Flat and tilted must therefore differ in the mode and in + // nothing else. + let tilted = splash_ui_l0::kit::lower( + &realize( + &DRIVING.replace("at: here,", "at: here, view: .tilted,"), + &data, + RealizeLimits::default(), + ) + .root + .expect("realizes"), + ); + assert!( + tilted.contains("l0_map(\"follow3d\", 16, sys.gps(\"lat\"), sys.gps(\"lon\")"), + "a tilted driving view follows the same declared fix:\n{tilted}" + ); + assert!( + !tilted.contains("l0_map(\"3d\""), + "and must NOT be the widget's simulated drive:\n{tilted}" + ); + // And the route is still the declared TRIP. Centring on the fix without // keeping the endpoints would redraw the route from wherever the user happens // to be, which is a different trip from the one the card states. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index a304937..06cf501 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -51,6 +51,10 @@ via = { kind = "path" } # could only animate on a timer, drawing motion the user is not making. With it, # every frame the map spends is one a real fix earned. at = { kind = "path" } +# Flat or tilted, while driving. R8.1 of the shipping app's contract is a tilted +# 2.5D chase view; `.flat` is the heading-up 2D one. Ignored without `at:`, because +# a preview has no camera to tilt. +view = { kind = "token", tokens = ["flat", "tilted"] } zoom = { kind = "number" } # A text field — the one role that lets a card receive something the user typed. From 025ac45b98eaa77c0e3931fd20ec74c7d5acb857 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:35:23 -0700 Subject: [PATCH 48/97] docs(ui_l0): a second stop is blocked on a list-valued attribute, not on totality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second slot was written, checked at L0, and reverted. `Map(via:)` names ONE source, so the drawn route went through the first stop while the duration printed beside it was for a trip through both — the drawn-versus-reported mismatch this profile exists to catch, and one that no screenshot would have shown, because both are plausible blue lines. It was found by counting the separators the two lowerings emitted. So the note in the card is now specific: one slot works, and the second waits on `via:` accepting a LIST of sources — an argument shape L0 does not have. That is a much smaller and more actionable blocker than the "L0 cannot accumulate a user-built list" this comment used to claim, which was wrong: the app being replaced uses two FIXED slots, and fixed slots are declared state. R4.5 is recorded as a gap rather than claimed. --- crates/splash-ui-l0/tests/fixtures/nav.card | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 53f0598..b29c624 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -132,8 +132,21 @@ state mode { shape: enum[drive, walk, bike], initial: .drive } # filled slots needs its own declared source rather than one that varies. # # So the cost is visible: one slot is two route sources and two guarded branches. -# A second slot would be a third. That is the honest price of a total form, and it -# is why this card ships one stop rather than pretending two are free. +# A second slot is a third source, and it was written, checked and REVERTED — +# because `Map(via:)` names ONE source, so the drawn route went through the first +# stop while the duration beside it was for a trip through both. That is precisely +# the drawn-versus-reported mismatch this profile exists to catch, and it was found +# by counting the emitted separators rather than by looking at a screenshot where +# both routes are plausible lines. +# +# A second slot therefore needs `via:` on a constructor to accept a LIST of +# sources, which is an argument shape L0 does not have. That is the real blocker, +# and it is a smaller and more specific one than "L0 cannot do waypoints": one +# slot works today, and the second waits on a list-valued attribute rather than on +# anything about the language's totality. +# +# R4.5 of the shipping app's contract is "up to two stops", so this is a recorded +# gap and not a claim of parity. state stop { shape: text, initial: "" } # empty ⇒ a direct trip event set_origin { origin: set($value), query: clear } From 5008b4710c7d2412a671225a9f3581540c03e477 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:36:05 -0700 Subject: [PATCH 49/97] feat(ui_l0): the trip's own places are pickable while the query is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5.4 of the shipping app's contract: before anything is typed, the results list offers the trip's current places, so tapping the origin reverses the trip. Without it an empty query showed an empty panel with nothing to say why. Guarded on the QUERY being empty rather than on the result list being empty, because a source's emptiness is a live value and a guard is evaluated at realize time — the same mechanism that made `when here.ok == 1` remove the map unconditionally. --- crates/splash-ui-l0/tests/fixtures/nav.card | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index b29c624..a0e58a9 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -194,6 +194,18 @@ view root Surface { # What the user is choosing between. when dest == "" { Panel { + # R5.4's "your trip" defaults: before anything is typed, the trip's own + # places are the pickable items — tap the origin while the destination is + # empty and the trip reverses. They are shown when the QUERY is empty, + # because that is when a results list would otherwise be a blank panel with + # nothing to say why. + when query == "" { + Row(align: .center, gap: 10, on_tap: choose_dest, value: origin) { + TextCaption(text: copy.from) + TextRow(text: origin) + } + Rule() + } for f, i in found key f.id { Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { TextRow(text: f.name) From 2f70feaab1a8cb1a6a23678dbe161bf027b58657 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:07:48 -0700 Subject: [PATCH 50/97] fix(ui_l0): the follow camera's position is not a property, and mine froze it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You were right — nav was static, and I caused it. The previous commit emitted `center_lat: sys.gps("lat")` into the widget as a live expression, on the theory that it would be re-evaluated per frame. NOTHING re-evaluates it. A widget property is set once when the tree is built, so I replaced a centre that at least changed on every card re-resolve with a constant string that never changed at all — and then raised the GPS epoch threshold 4 m -> 40 m on the strength of the same wrong theory. Measured on a OnePlus 6: 21 fixes, ~105 m of travel, 0.0% of pixels different. Worse, the measurement that "verified" it was confounded. My seeded test runs had stacked ~15 cards into one conversation, several holding follow maps, so the frames I compared were not the instance I was reasoning about. With a single card the same test shows the camera moving 32%. The fix is at the right layer: a follow camera reads the platform's last fix DIRECTLY, every frame, in `update_nav_camera`. A position that changes every second is not a property. It costs a mutex, needs no rebuild, no epoch bump and no card involvement — which is what actually makes the raised threshold safe, since only the TEXT now depends on a re-resolve. The frame gate reads the same source, or it would settle while the fix moved. The `gpsfollow` flag threaded through the kit is gone with it: `map_mode` already emits `follow`/`follow3d` only when the card declared a live position, so the mode alone says when to read the fix. Verified after: camera moves, 3.5% CPU, and `d` advancing along the route. One thing I could NOT verify cleanly: smooth per-metre tracking. The synthetic track comes from OSRM's `geometries=geojson` (28041 m) and the widget routes `polyline5` (27577 m), so my fixes sit slightly off the device's own line and the projection jumps rather than glides. That is the test's geometry, not the camera's, and it needs a track sampled from the widget's polyline to settle. --- crates/splash-ui-l0/src/lib.rs | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 50c52f0..9b48d0a 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -8890,30 +8890,15 @@ pub mod kit { // through this path and not through `makepad::lower`. "Map" => { let (lat, lon, poly) = makepad::map_route(node); - // Whether the centre is the DEVICE'S OWN FIX, which decides whether - // the camera can follow without rebuilding the card. - // - // A kit value is evaluated when the ledger resolves, so a baked - // centre only moves when the card re-resolves — and re-resolving is - // realize + lower + evaluate + rebuild the widget tree. At a fix a - // second that is the whole card, every second, which is exactly what - // the app this replaces was architected to avoid: its drive screen - // is a no-rebuild `fn tick()` and its contract says never to - // introduce anything that forces a rebuild while driving. Measured - // against that on a OnePlus 6: 10–68% CPU with the fix moving. - // - // Passed as a FLAG rather than assumed downstream. The renderer - // could hardcode `sys.gps` for any follow map, and it would be - // right today and wrong the moment a card follows something else; - // here the helper is known, so this is a fact rather than a guess. - let gps_follow = i32::from( - node.bindings - .iter() - .any(|(n, b)| n == "at" && b.helper == "sys.gps"), - ); + // No follow FLAG is passed. `map_mode` already emits `follow` or + // `follow3d` only when the card declared a live position, and the + // widget reads the device's fix directly in those modes — a position + // that changes every second is not a property, so routing it through + // one bought nothing and cost a frozen camera when the property was a + // constant expression. See `update_nav_camera`. let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly}, {gps_follow})", + "{f}({:?}, {}, {lat}, {lon}, {poly})", makepad::map_mode(node), scalar_of(node, "zoom"), ); From ed1c71f1b67c0316a10923a3cfef9afe4b938de5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:37:43 -0700 Subject: [PATCH 51/97] perf(ui_l0): a live value carries its call, so a driving card stops rebuilding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stutter, fixed by copying what the nav app it replaces actually does. A `sys.*` call is evaluated to a string by the kit, and that string was all the renderer saw — so refreshing one meant re-resolving the ledger and re-parsing the whole document, which tears down the `MapView` inside it. Measured on a OnePlus 6: frame hitches and card re-resolves correlate 1:1, up to 327 ms of frozen map. No amount of camera smoothing hides it, because what stops is the UI thread. `l0_live` stamps the call onto the node so the renderer can name that widget and set it from `fn tick()` — named widgets and `ui..set_text()`, which is exactly what `a2app/apps/nav` does and why its contract says never to force a rebuild while driving. DECORATED values are included, because the decorated one is the one that changes: "27.6 km left" is a call plus a suffix, and excluding it would have left the most visibly moving number on the screen as the only remaining reason to rebuild. `decorated()` composes the tick's expression the same way the kit composes the initial text. The wrapper is emitted OUTERMOST. `l0_live` only stamps a field, so its position makes no functional difference — but the width and align helpers read as a pair and a wrapper between them makes both harder to recognise. L0 is untouched by any of this. The constraint is on what a CARD may say, and the card still says `TextRow(text: step.instruction)`. `fn tick()` belongs to the backend in exactly the way `sys.navstep` does. Measured after, 35 s of driving: 0 re-resolves, 1 hitch, motion steady at 3-4% per frame with no stalls, and the banner counting 26.8 -> 26.6 km in place. 75% CPU. --- crates/splash-ui-l0/src/lib.rs | 50 +++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 9b48d0a..b642957 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6633,6 +6633,22 @@ pub mod makepad { /// The text may be a literal (`"300 m"`) or a live call, so the decoration /// is concatenated in the DSL rather than in Rust — `"a" + call + "b"` works /// for both, and quoting a call would draw it instead of evaluating it. + /// This node's decoration applied to an already-built expression. + /// + /// The kit needs it to compose a live call for `fn tick()`: a value with a + /// `suffix:` must be set as `call + " left"`, not as the bare call. Excluding + /// decorated values left the most visibly moving number on a driving screen as + /// the only reason to rebuild the card, which defeats the tick entirely. + pub(super) fn decorated(node: &UiNode, body: String) -> String { + let Decoration { + glyph, + unit, + suffix, + .. + } = decoration_of(node); + decorate(body, &glyph, unit, &suffix) + } + fn decorate(body: String, glyph: &str, unit: &str, suffix: &str) -> String { let head = glyph.to_string(); let tail = format!("{unit}{suffix}"); @@ -8669,6 +8685,25 @@ pub mod kit { Some(format!("l0:{json}")) } + /// The live expression behind this node's displayed value, if it has one. + /// + /// Stamped onto the node by `l0_live` so the renderer can refresh it in place + /// with `fn tick()` instead of re-resolving the ledger and re-parsing the whole + /// document. On a driving screen a re-resolve lands inside a frame: measured on a + /// OnePlus 6, frame hitches and card re-resolves correlate 1:1, up to 327 ms. + /// + /// L0 is untouched. The constraint is on what a CARD may say, and the card still + /// says `TextRow(text: step.instruction)`; `fn tick()` belongs to the backend in + /// exactly the way `sys.navstep` does. + fn live_call_of(node: &UiNode) -> Option { + let (_, binding) = node + .bindings + .iter() + .find(|(n, _)| n == "value" || n == "text")?; + let call = makepad::vm_call(binding)?; + Some(makepad::decorated(node, call)) + } + fn element(node: &UiNode, depth: usize, out: &mut String) { // A `Field` carries its own commit target and must NOT be wrapped in a // tap: a hit target over a text input eats the focus, and the payload @@ -8740,10 +8775,17 @@ pub mod kit { /// entry HERE or it is accepted by the profile and silently discarded, which /// is this layer's recurring defect and the reason the list is explicit. fn element_untapped(node: &UiNode, depth: usize, out: &mut String) { - let wraps: Vec<(&'static str, String)> = [width_wrap(node), align_wrap(node)] - .into_iter() - .flatten() - .collect(); + let wraps: Vec<(&'static str, String)> = [ + // OUTERMOST. `l0_live` only stamps the call onto the node, so where it + // sits makes no functional difference — but the width and align helpers + // read as a pair, and slipping between them makes both harder to see. + live_call_of(node).map(|call| ("l0_live(", format!(", {call:?})"))), + width_wrap(node), + align_wrap(node), + ] + .into_iter() + .flatten() + .collect(); for (open, _) in &wraps { out.push_str(open); } From eee5be7f62a601893391e9766b56faafa66e052a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:16:24 -0700 Subject: [PATCH 52/97] test(ui_l0): the card stays L0 while the lowering emits a tick for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The performance work looks like it crossed the level boundary, so this asserts that it did not — and asserts the boundary that now matters more because of it. `the_card_holds_no_tick_however_much_the_lowering_emits` — the nav card classifies L0 and its declarations contain no `fn`, no `let`, no `ui.`, no `.set_`. The generated DSL contains all four. That is the whole distinction: the 664-line exemplar is L2 because its AUTHOR wrote `fn tick()`, 30 `let`s and 606 operators; this card says `TextRow(text: step.instruction)` and the tick is derived from that declaration. §7 classifies a card, and the profile constrains what a card may say — not what a compiler may emit for it, any more than `sys.navroute(...)` in the output makes a card that wrote `sys.route` into L2. `card_state_cannot_write_script_into_the_lowering` — card state reaches generated code as a quoted argument, and that code is now also the body of a tick the VM runs every frame, so the quoting is a confinement boundary rather than a formatting detail. Two earlier versions of that test were wrong in instructive ways, and both are recorded in it. Asserting the hostile text was absent FAILED, correctly: `ui.evil` does appear in the output — escaped, inside a quoted argument, inert there. Asserting the literal-stripped skeleton held no `let ` also failed, because the kit emits its own `let node = …`. What is actually claimed is that state cannot MOVE the boundary, so the test diffs the skeletons of a benign and a hostile lowering and requires them byte-identical. Verified by removing the `{:?}` quoting in `source_binding`: the test then fails on the first case. A confinement test that cannot fail is worth nothing. --- crates/splash-ui-l0/tests/profile.rs | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index c45a344..5d7da76 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7112,3 +7112,109 @@ fn a_trip_through_a_stop_routes_through_it() { } } } + +/// The card stays L0 while the LOWERING emits `fn tick()` on its behalf. +/// +/// This is the load-bearing distinction, and worth asserting because the performance +/// work looks like it crossed the line and does not. +/// +/// A driving card cannot be re-resolved without a visible stall — measured on a +/// OnePlus 6, frame hitches and card re-resolves correlate 1:1, up to 327 ms — so its +/// live values update in place from a generated `fn tick()`, exactly as the 664-line +/// L2 exemplar does with `ui.instr.set_text()`. +/// +/// The difference is WHO WROTE IT. That exemplar is L2 because its own source contains +/// `fn tick()`, 30 `let` bindings and 606 operators, written by its author. This card +/// contains none: it says `TextRow(text: step.instruction)`, and the tick is derived +/// mechanically from that declaration. §7 classifies a CARD, and the profile constrains +/// what a card may say — not what a compiler may emit for it, any more than +/// `sys.navroute(...)` in the output makes a card that wrote `sys.route` into L2. +#[test] +fn the_card_holds_no_tick_however_much_the_lowering_emits() { + const NAV: &str = include_str!("fixtures/nav.card"); + assert_eq!(check_ui_l0_named("nav", NAV).level, Level::L0); + + // Not one L2 construct in the card's own declarations. The comments discuss the + // tick at length, which is why they are stripped rather than searched. + let code: String = NAV + .lines() + .filter(|l| !l.trim_start().starts_with('#')) + .collect::>() + .join("\n"); + for construct in ["fn ", "let ", "ui.", ".set_"] { + assert!( + !code.contains(construct), + "the card must not contain {construct:?} — that is what would make it L2" + ); + } +} + +/// A lowering with every string literal removed — the code it would run. +fn strip_literals(src: &str) -> String { + let mut out = String::new(); + let mut chars = src.chars(); + let mut in_str = false; + while let Some(c) = chars.next() { + match c { + // An escape consumes its partner, so `\"` never ends the literal. + '\\' if in_str => { + chars.next(); + } + '"' => in_str = !in_str, + _ if !in_str => out.push(c), + _ => {} + } + } + out +} + +/// Card state may change what a LITERAL contains, and nothing else. +/// +/// State reaches generated code as an argument — a place name becomes +/// `sys.searchnum("Saratoga High School", 0, "lat")` — and that generated code is now +/// also the body of a `fn tick()` the VM runs every frame. So the quoting is a +/// confinement boundary rather than a formatting detail: state that could close its own +/// quote would be writing script into the tick. +/// +/// Two earlier versions of this test were wrong in instructive ways. Asserting the +/// hostile text was simply absent failed, correctly — `ui.evil` DOES appear in the +/// output, escaped, inside a quoted argument, and is inert there. Asserting the +/// literal-stripped skeleton held no `let ` failed too, because the kit emits its own +/// `let node = …`. The claim is that state cannot MOVE the boundary, and a diff of the +/// skeletons says exactly that. Verified by removing the quoting in `source_binding`: +/// every case below then differs from the benign skeleton and the test fails. +#[test] +fn card_state_cannot_write_script_into_the_lowering() { + const CARD: &str = concat!( + "source found sys.search(query: state.q, count: 1, fields: [id, name, lat, lon])\n", + "state q { shape: text, initial: \"\" }\n", + "view root Surface { TextRow(text: found.0.name) }\n" + ); + let lower_with = |q: &str| { + let data = serde_json::json!({ + "q": q, + "found": [{ "id": "1", "name": "n", "lat": 1.0, "lon": 2.0 }], + "env": { "locale": {} }, "copy": {} + }); + let kit = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + strip_literals(&kit) + }; + let benign = lower_with("Saratoga"); + + for hostile in [ + "x\") ui.evil.set_text(\"owned", + "x\", 0, \"lat\") + sys.gps(\"lat", + "x\") } fn tick() { ui.a.set_text(\"", + "x\nlet escaped = 1", + ] { + assert_eq!( + lower_with(hostile), + benign, + "card state changed the CODE, not just a literal, for {hostile:?}" + ); + } +} From d203bbe9545f8cea6c9e20d3d8170e0ca1d5376a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:49:37 -0700 Subject: [PATCH 53/97] fix(ui_l0): the add-a-stop control could never add a stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an external review of the refactoring, and it is the sharpest kind of defect this profile is supposed to catch — because I had verified the OUTCOME and never the control. `Chip(text: copy.add_stop, on_tap: set_stop, value: "")` sends an empty value, and an empty value becomes no payload (`l0_card.rs`: `(!value.is_empty()).then(...)`), so `stop: set($value)` wrote nothing and `stop` stayed "". The chip was tappable, looked correct, and was incapable of adding a stop. The trip THROUGH a stop was device-verified — route redrawn via Palo Alto, 30 min/27.6 km to 33 min/31.2 km, legs summing — but by seeding `stop` in the data. That proves the routing, the vias threading and the per-leg sources, and never touches the control. A green outcome next to a dead control is the exact shape of every defect recorded in this file. It is a `Field` now, always present, for the same reason FROM and TO are: a field is how text enters an L0 card, and there is nothing a chip does here that it does not do better. Also from the same review, both recorded rather than papered over: - `query` is only ever CLEARED — no event sets it — so the search-results list runs `sys.search(query: "")` and R2.1/R5.5 are not reachable. Editing happens in place through the fields, which is why nothing on screen looked broken. Marked as a gap in PARITY.md rather than claimed. - `INERT`'s note for `Map.via` still read "not emitted yet" after the emission landed. An allowlist entry whose stated reason has gone stale is worse than no entry: it reads as a known gap that no longer exists. --- crates/splash-ui-l0/tests/fixtures/nav.card | 42 ++++++++++----------- crates/splash-ui-l0/tests/profile.rs | 13 +++++-- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index a0e58a9..e390980 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -215,29 +215,25 @@ view root Surface { } } - # The stop row. One slot: present as a `Field` when it is set so it can be - # replaced, offered as a `Chip` when it is not. - when stop != "" { - Panel { - Row(align: .center, gap: 10) { - TextCaption(text: copy.via_lbl) - Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) - Chip(text: copy.remove, on_tap: drop_stop) - } - } - } - - # Offering a stop, when there is none. On its OWN row, and that is a fix - # rather than a layout preference: with it beside the three mode chips the row - # overflowed and "Add a stop" rendered as "Add a sto" — clipped at the card's - # edge with nothing saying so. The shipping app's own verification pass found - # the identical defect in the identical place (R7.1: a four-chip row clipped - # "Bike" to "B") and fixed it by tightening widths. Tightening buys room for - # one more chip; moving the control to where it belongs — with the stop, not - # with the modes — means the row cannot overflow at all. - when stop == "" { - Row(align: .center, gap: 8) { - Chip(text: copy.add_stop, on_tap: set_stop, value: "") + # The stop's own row, a FIELD and always present — for the same reason FROM + # and TO are. + # + # This was a `Chip(text: copy.add_stop, on_tap: set_stop, value: "")`, and a + # review found it could never work: an empty `value:` becomes no payload + # (`l0_card.rs`'s `(!value.is_empty()).then(...)`), so `set($value)` wrote + # nothing and `stop` stayed empty. The chip was tappable, looked right, and + # was incapable of adding a stop — which is exactly the class of defect this + # profile exists to catch, in the one place I had checked the OUTCOME rather + # than the interaction: the trip through a stop was verified by seeding + # `stop`, which proves the routing and never touches the control. + # + # A `Field` is how text enters an L0 card. There is nothing for a chip to do + # here that the field does not do better. + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.via_lbl) + Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) + when stop != "" { Chip(text: copy.remove, on_tap: drop_stop) } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 5d7da76..b561d4a 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6729,11 +6729,18 @@ fn the_nav_card_routes_between_two_editable_places() { .expect("realizes"); let kit = splash_ui_l0::kit::lower(&root); - // BOTH endpoints editable, always — not one behind a branch that never fires. + // THREE fields, always: origin, destination and the stop. None behind a branch + // that never fires. + // + // The stop was a `Chip(..., value: "")` and a review found it could never work — + // an empty value becomes no payload, so the transition wrote nothing and the + // control was incapable of adding a stop. It looked right, and the trip THROUGH a + // stop had been verified by seeding the state, which proves the routing and never + // touches the control. A field is how text enters an L0 card. assert_eq!( kit.matches("l0_field(").count(), - 2, - "an origin field and a destination field:\n{kit}" + 3, + "an origin, a destination and a stop, each editable:\n{kit}" ); // The trip's facts, live, from the coordinates of the places that were found. assert!( From a6b8edfa95931a7ec5c8d862cc783adef1ef3ead Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:25:27 -0700 Subject: [PATCH 54/97] fix(ui_l0): a numeric argument cannot carry an expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sharpest finding of an external review, and a hole in the profile's central claim: an L0 card — a language whose defining property is that it HAS no expression form — could inject one. source now sys.weather(lat: "1 + sys.navsecs(1)", lon: 2, days: 1, fields: [temp]) That passes the checker as an ordinary card and lowered to `sys.weather(1 + sys.navsecs(1), 2, …)`: arithmetic, and a host call the card never declared. A numeric slot is interpolated UNQUOTED, so whatever lands there is code. String slots were always safe because `{:?}` quotes them; this was the other half and it was missing. A numeric argument may now be one of two things: a call this lowering generated, which is ours, or a literal number. Anything else yields NO translation, so the value stays seeded — the same outcome as an unknown field, rather than an injection site. The card is still accepted, because a quoted string is a legitimate thing to write and the profile does not type source arguments; it simply translates to nothing. Also from the review: `UNANSWERED` was the one allowlist with no shrink-only assertion, so a closed gap stayed listed forever and the list read as 35 known holes when some were already fixed — the same rot that kept `sys.route` listed for releases after its translation was written. Added, and it immediately caught the dead `("sys.photo", "")` entry the review predicted. --- crates/splash-ui-l0/src/lib.rs | 56 +++++++++++++------- crates/splash-ui-l0/tests/profile.rs | 77 +++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index b642957..cd17b6b 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6066,6 +6066,26 @@ pub mod makepad { .find(|(n, _)| n == name) .map(|(_, v)| v.clone()) }; + // An argument bound for a NUMERIC position, or nothing. + // + // A numeric slot is interpolated UNQUOTED — `sys.weather({lat}, {lon}, …)` — + // so whatever lands there is code. A string slot is safe by construction + // because `{:?}` quotes it; this is the other half. + // + // Without it, `sys.weather(lat: "1 + sys.navsecs(1)")` passed the checker as a + // perfectly ordinary L0 card and lowered to `sys.weather(1 + sys.navsecs(1), + // …)`: arithmetic and a host call the card never declared, in a language whose + // defining property is that it HAS no expression form. Found in review. + // + // Two things may appear here. A call this lowering generated, which is ours + // and is trusted; and a literal number. Anything else is a card trying to + // write code into a slot for a coordinate, and yields no translation at all — + // the same outcome as an unknown field, so the value stays seeded rather than + // becoming an injection site. + let num = |name: &str| -> Option { + let v = arg(name)?; + (v.starts_with("sys.") || v.trim().parse::().is_ok()).then_some(v) + }; match binding.helper.as_str() { "sys.quote" => { let symbol = arg("ticker")?; @@ -6122,8 +6142,8 @@ pub mod makepad { // The forecast. L0 names a FIELD; open-meteo wants a path, and the // daily ones are indexed by the row being drawn. "sys.weather" => { - let lat = arg("lat")?; - let lon = arg("lon")?; + let lat = num("lat")?; + let lon = num("lon")?; let (row, field) = match binding.field.split_once('.') { Some((i, f)) if i.parse::().is_ok() => (i, f), _ => ("0", binding.field.as_str()), @@ -6168,8 +6188,8 @@ pub mod makepad { // already fetches; `sys.daylight` answers only the arc progress, so // the three L0 fields come from two different helpers. "sys.daylight" => { - let lat = arg("lat")?; - let lon = arg("lon")?; + let lat = num("lat")?; + let lon = num("lon")?; match binding.field.as_str() { "rise" => Some(format!("sys.weather({lat}, {lon}, \"daily.sunrise.0\")")), "set" => Some(format!("sys.weather({lat}, {lon}, \"daily.sunset.0\")")), @@ -6184,8 +6204,8 @@ pub mod makepad { _ => None, }, "sys.airquality" => { - let lat = arg("lat")?; - let lon = arg("lon")?; + let lat = num("lat")?; + let lon = num("lon")?; let path = match binding.field.as_str() { "aqi" => "current.us_aqi", "pm25" => "current.pm2_5", @@ -6261,10 +6281,10 @@ pub mod makepad { // scalar a call answers. _ => return None, }; - let a = arg("from_lat")?; - let o = arg("from_lon")?; - let b = arg("to_lat")?; - let p = arg("to_lon")?; + let a = num("from_lat")?; + let o = num("from_lon")?; + let b = num("to_lat")?; + let p = num("to_lon")?; // The waypoints, as the helper's sixth argument: a `lat,lon;lat,lon` // string built from the coordinate calls the card listed. Emitted // as CONCATENATION rather than a literal, because each coordinate @@ -6307,12 +6327,12 @@ pub mod makepad { "progress" => "progress", _ => return None, }; - let a = arg("from_lat")?; - let o = arg("from_lon")?; - let b = arg("to_lat")?; - let p = arg("to_lon")?; - let at_lat = arg("at_lat")?; - let at_lon = arg("at_lon")?; + let a = num("from_lat")?; + let o = num("from_lon")?; + let b = num("to_lat")?; + let p = num("to_lon")?; + let at_lat = num("at_lat")?; + let at_lon = num("at_lon")?; let along = format!("sys.navprog({a}, {o}, {b}, {p}, {at_lat}, {at_lon})"); if key == "progress" { return Some(along); @@ -6357,8 +6377,8 @@ pub mod makepad { "sys.places" => { let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; - let lat = arg("lat")?; - let lon = arg("lon")?; + let lat = num("lat")?; + let lon = num("lon")?; let category = arg("category").unwrap_or_default(); // L0 says `distance`; the VM answers `dist`. Same translation // job as `ticker`/`symbol` above. diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b561d4a..189fca8 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6587,8 +6587,6 @@ fn every_offered_field_has_a_translation() { ("sys.quote", "pe"), ("sys.movers", "pe"), ("sys.watchlist", "pe"), - // The photo capability answers an image for a QUERY and has no fields. - ("sys.photo", ""), // ── a capability with NO translation at all ─────────────────────────── // Every one of these renders an em dash today. `sys.route` is why the // nav card's duration and distance row is `— —`, and `sys.locale` is why @@ -6687,6 +6685,19 @@ fn every_offered_field_has_a_translation() { .collect(); let mut fresh: Vec<_> = missing.iter().filter(|p| !known.contains(p)).collect(); fresh.sort(); + // And the list may only SHRINK, exactly as `INERT` and `STALE` may. + // + // This assertion was missing here alone, and a review caught it: an entry whose + // gap has since been closed stays forever, so the list reads as 35 known holes + // when some number of them are already fixed. That is the direction that stops + // anyone acting on it — the same rot that kept `sys.route` listed as unanswered + // for releases after its translation was written. + let mut fixed: Vec<_> = known.iter().filter(|p| !missing.contains(p)).collect(); + fixed.sort(); + assert!( + fixed.is_empty(), + "these are answered now — delete them from UNANSWERED: {fixed:#?}" + ); assert!( fresh.is_empty(), "the vocabulary offers these and no backend call answers them, so a card \ @@ -7225,3 +7236,65 @@ fn card_state_cannot_write_script_into_the_lowering() { ); } } + +/// A numeric argument may be a number or a call this lowering made. Nothing else. +/// +/// A numeric slot is interpolated UNQUOTED — `sys.weather({lat}, {lon}, …)` — so +/// whatever lands there is code. String slots are safe by construction because `{:?}` +/// quotes them; this is the other half, and it was missing. +/// +/// `source now sys.weather(lat: "1 + sys.navsecs(1)", …)` passed the checker as an +/// ordinary L0 card and lowered to `sys.weather(1 + sys.navsecs(1), 2, …)`: arithmetic +/// and a host call the card never declared, in a language whose defining property is +/// that it has no expression form. Found in an external review of the refactoring. +/// +/// The card is still ACCEPTED — a quoted string is a legitimate thing to write, and +/// the profile does not type source arguments — but it translates to nothing, so the +/// value stays seeded instead of becoming an injection site. +#[test] +fn a_numeric_argument_cannot_carry_an_expression() { + const HOSTILE: &str = concat!( + "source now sys.weather(lat: \"1 + sys.navsecs(1)\", lon: 2, days: 1, fields: [temp])\n", + "view root Surface { TextValue(value: now.temp) }\n" + ); + let data = serde_json::json!({ + "now": { "temp": 1.0 }, "env": { "locale": {} }, "copy": {} + }); + let kit = splash_ui_l0::kit::lower( + &realize(HOSTILE, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + !kit.contains("navsecs"), + "an expression reached a numeric slot:\n{kit}" + ); + assert!( + !kit.contains("sys.weather("), + "a rejected argument must yield NO translation, not a partial one:\n{kit}" + ); + + // And the honest case still lowers, or the guard would be a denial of service. + const GOOD: &str = concat!( + "source place sys.geocode(name: state.city)\n", + "source now sys.weather(lat: place.lat, lon: place.lon, days: 1, fields: [temp])\n", + "state city { shape: text, initial: \"Kyoto\" }\n", + "view root Surface { TextValue(value: now.temp) }\n" + ); + let good = splash_ui_l0::kit::lower( + &realize( + GOOD, + &serde_json::json!({ + "place": { "lat": 35.0, "lon": 135.8 }, "now": { "temp": 21.0 }, + "city": "Kyoto", "env": { "locale": {} }, "copy": {} + }), + RealizeLimits::default(), + ) + .root + .expect("realizes"), + ); + assert!( + good.contains("sys.weather(sys.geocodenum("), + "a generated call is ours and must pass through:\n{good}" + ); +} From 71daa52d9e0725fe8fe17d66fadc9d56a1a7b426 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:41:52 -0700 Subject: [PATCH 55/97] feat(ui_l0): a map card gets a top pane and a swipeable bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav app's drive layout, learned from `a2app/apps/nav` rather than invented — and the second attempt, because the first was invented and it showed. `Panel(dock: .top | .bottom)` places a pane, and `Reveal { … }` is content a swipe uncovers. The card now reads: the turn instruction docked top, the distance remaining in a sheet, `End` inside a `Reveal`. WHAT THE OLD CARD ACTUALLY DOES, which I got wrong first. It does not stack aligned layers over the map. It has ONE ui layer in a `Down` flow: the top pane is simply the first child, a `height: Fill` element eats the slack, and the sheet is last. My first version made three sibling layers each filling the screen with its own alignment — it rendered, and it also produced a render loop: ~1000 card re-resolves in a minute, because two filling layers renegotiated their size every frame. Adopting their idiom took it to 3, which is the initial load. The sheet's internals are theirs too: the handle and the summary form ONE swipe target with a transparent button over them, so the gesture is caught across the whole area a thumb lands on and never leaks through to pan the map. `Reveal` sits BELOW that target, or the button covers the control it exists to expose — which it did, and `End` appeared on swipe-up and did nothing. `Reveal` is a visibility toggle the renderer wires (`set_visible`), not card state. A state change re-resolves the card and rebuilds the `MapView` — up to 327 ms of frozen map, and it would snap the sheet shut on every data update. Their bottom padding is 10 and ours is 76: their card is full-screen, ours is an item in a chat list with the composer bar over it. Also, from a look at the rendered card: - Fields centre their text. `h: 48` laid the run at the top of a 48px box, so every place name floated above its pill. Neither the container's `align` nor the input's `label_align` moved it; padding does, and it stays centred if the size changes. - Text is ROBOTO, at the weight the kit already declares. Every other card in the product names it explicitly; an L0 card was the only surface not using the product's typeface. - The instruction WRAPS. "Continue onto South De Anza Boulevard" was clipped mid-word at the card's edge — the one line a driver reads at a glance. --- crates/splash-ui-l0/src/lib.rs | 75 ++++++++++++++++++--- crates/splash-ui-l0/tests/fixtures/nav.card | 30 +++++++-- crates/splash-ui-l0/tests/profile.rs | 45 +++++++++++++ docs/ui-l0-constructors.toml | 20 ++++++ 4 files changed, 153 insertions(+), 17 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index cd17b6b..968cffe 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2640,6 +2640,8 @@ pub mod catalog { pub const MAP_MODE: &[&str] = &["plan", "drive", "flat"]; /// Flat or tilted, while driving — the shipping app's R8.1 chase view. pub const MAP_VIEW: &[&str] = &["flat", "tilted"]; + /// Where a panel sits when the card is a map. + pub const DOCK: &[&str] = &["top", "bottom"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; @@ -2694,7 +2696,9 @@ pub mod catalog { ("width", TokenOrPath(WIDTH)), ], ), - ("Panel", &[]), + ("Panel", &[("dock", Token(DOCK))]), + // Content a swipe reveals. See the catalog. + ("Reveal", &[]), ("Card", &[("on_tap", Event), ("value", Any)]), // A column may say how WIDE, because a row of columns has to divide the // line somehow and only the card knows which column is the one that @@ -6943,6 +6947,22 @@ pub mod makepad { // at the top sits exactly where the widget put the route. Measured: // the trip's Saratoga end was behind the panel, and the camera was // doing its job. + // A `.top` panel floats in its own band, above everything. That is + // where a turn instruction belongs and where the app this replaces + // puts it; the summary sheet is the bottom one. + let docked_top = |c: &UiNode| { + c.kind == "Panel" + && matches!(arg(c, "dock"), Some(NodeValue::Token(t)) if t == "top") + }; + for child in node.children.iter().filter(|c| docked_top(c)) { + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fit flow: Down align: Align{{x: 0.5 y: 0.0}} \ + margin: Inset{{left: 8 top: 46 right: 8}}" + ); + element(child, depth + 2, out); + let _ = writeln!(out, "{p} }}"); + } let _ = writeln!( out, "{p} View{{ width: Fill height: Fill flow: Down align: Align{{x: 0.5 y: 1.0}}" @@ -6954,7 +6974,11 @@ pub mod makepad { margin: Inset{{left: 8 right: 8 bottom: 76}} \ padding: Inset{{left: 14 top: 12 right: 14 bottom: 14}}" ); - for child in node.children.iter().filter(|c| c.kind != "Map") { + for child in node + .children + .iter() + .filter(|c| c.kind != "Map" && !docked_top(c)) + { element(child, depth + 3, out); } let _ = writeln!(out, "{p} }}"); @@ -7278,6 +7302,15 @@ pub mod makepad { tap_binding(node) ); } + // Content a swipe reveals — hidden until then. See the catalog. + "Reveal" => { + let _ = writeln!( + out, + "{p}l0reveal := View{{ width: Fill height: Fit flow: Down visible: false" + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } // The one role that lets a card receive something the user typed. // // This backend admitted `Field` and lowered none of it, so the nav @@ -8481,6 +8514,7 @@ pub mod kit { "Field" => "l0_field", "Map" => "l0_map", "Surface" => "l0_surface", + "Reveal" => "l0_reveal", "Col" => "l0_col", "Row" => "l0_row", "Grid" => "l0_grid", @@ -8837,18 +8871,33 @@ pub mod kit { .iter() .find(|c| c.kind == "Map") .expect("guarded above"); + // THREE slots: the map, the band docked to the top, and the sheet at + // the bottom. A turn instruction belongs in the top band and the + // summary in the sheet, which is how the app this replaces arranges + // its driving screen and how every map app does. + let docked_top = |c: &&UiNode| { + c.kind == "Panel" + && matches!(arg(c, "dock"), Some(NodeValue::Token(t)) if t == "top") + }; out.push_str("l0_surface_map("); element(map, depth, out); - out.push_str(", ["); - let mut first = true; - for child in node.children.iter().filter(|c| c.kind != "Map") { - if !first { - out.push_str(", "); + for pass in 0..2 { + out.push_str(", ["); + let mut first = true; + for child in node + .children + .iter() + .filter(|c| c.kind != "Map" && (docked_top(c) == (pass == 0))) + { + if !first { + out.push_str(", "); + } + first = false; + element(child, depth + 1, out); } - first = false; - element(child, depth + 1, out); + out.push(']'); } - out.push_str("])"); + out.push(')'); } "Surface" => { let _ = write!(out, "{f}("); @@ -8933,6 +8982,12 @@ pub mod kit { // 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. + // Content a swipe reveals — hidden until then. See the catalog. + "Reveal" => { + out.push_str("l0_reveal("); + children(node, depth, out); + out.push(')'); + } "Photo" if !node.children.is_empty() => { let _ = write!(out, "l0_surface_photo({}, ", makepad::expr_of(node, "src")); children(node, depth, out); diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index e390980..bd32b94 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -303,13 +303,29 @@ view root Surface { # ---- driving ------------------------------------------------------------- when screen == .drive { - # The banner. Every value in it is a measurement: the manoeuvre and the - # distance left both come from the device's own position projected onto the - # route, so this row changes when the car moves and at no other time. - Panel { - TextRow(text: step.instruction) - Row(align: .center, gap: 12) { - TextCaption(value: step.remaining, suffix: copy.left) + # The turn instruction, DOCKED TO THE TOP — where a driver looks for it, and + # where the app this replaces puts its `roadpill`. Every value in it is a + # measurement: the manoeuvre comes from the device's own position projected onto + # the route, so it changes when the car moves and at no other time. + Panel(dock: .top) { + # WRAPS. A manoeuvre is a whole street name and "Continue onto South De Anza + # Boulevard" was clipped mid-word at the card's edge — the one thing a driver + # reads at a glance, truncated. `TextBody` is the role that wraps. + TextBody(text: step.instruction, width: .fill) + } + + # The summary sheet, docked to the bottom and SWIPEABLE — the shipping app's + # R8.3. What remains is always on screen; `End` is one swipe away rather than + # sitting under the driver's thumb for the whole trip. + # + # `Reveal` is not card state, and that matters more here than it reads. A state + # change re-resolves the card, re-parses the document and rebuilds the `MapView` + # inside it — measured at up to 327 ms of frozen map. The reveal is a visibility + # toggle the renderer wires directly, exactly as the L2 card does with + # `ui.endrow.set_visible`, so opening the sheet never touches the map. + Panel(dock: .bottom) { + TextCaption(value: step.remaining, suffix: copy.left) + Reveal { Chip(text: copy.stop, on_tap: go) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 189fca8..d019c1b 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5568,6 +5568,11 @@ const INERT: &[(&str, &str)] = &[ // static mode. The tilted case is asserted in `a_map_lowers_a_live_route`, // where a real position is declared. ("Map", "view"), + // `dock` places a panel in a MAP card's top band or bottom sheet, and this probe + // builds neither — a card with no map simply stacks its panels, where docking + // correctly means nothing. Asserted in + // `a_card_holding_a_map_floats_its_content_over_it`, which builds the real thing. + ("Panel", "dock"), // `Field.width` used to sit here: "a text input is not wrapped by the width // composer — the `Field` branch returns before it". That was true of both // backends because neither lowered `Field` AT ALL in one of them: the makepad @@ -6908,6 +6913,46 @@ fn a_card_holding_a_map_floats_its_content_over_it() { mk.contains("draw_bg.color: #0f1620") && mk.contains("Align{x: 0.5 y: 1.0}"), "the sheet must be opaque and bottom-aligned:\n{mk}" ); + // A `.top` panel goes in its own band ABOVE the sheet, which is where a turn + // instruction belongs and where the app this replaces puts it. + const DOCKED: &str = concat!( + "copy a { class: vocabulary, en: \"turn left\" }\n", + "copy b { class: vocabulary, en: \"2 km\" }\n", + "source o sys.search(query: state.q, count: 1, fields: [id, name, lat, lon])\n", + "state q { shape: text, initial: \"A\" }\n", + "view root Surface {\n", + " Panel(dock: .top) { TextRow(text: copy.a) }\n", + " Panel(dock: .bottom) { TextRow(text: copy.b) Reveal { Rule() } }\n", + " Map(mode: .plan, from: o, to: o, zoom: 14)\n", + "}\n" + ); + let docked_report = check_ui_l0_named("nav", DOCKED); + assert!(docked_report.valid, "{:#?}", docked_report.diagnostics); + let docked = splash_ui_l0::kit::lower( + &realize( + DOCKED, + &serde_json::json!({ + "o": [{ "id": "1", "name": "n", "lat": 1.0, "lon": 2.0 }], "q": "A", + "env": { "locale": {} }, "copy": { "a": "turn left", "b": "2 km" } + }), + RealizeLimits::default(), + ) + .root + .expect("realizes"), + ); + // Three slots: the map, then the top band, then the sheet — in that order, so + // the top panel's content precedes the bottom panel's. + let top_at = docked.find("turn left").expect("the top panel"); + let bottom_at = docked.find("2 km").expect("the sheet"); + assert!( + top_at < bottom_at, + "a .top panel must be emitted into the top band, before the sheet:\n{docked}" + ); + assert!( + docked.contains("l0_reveal("), + "a Reveal must lower to the kit's hidden container:\n{docked}" + ); + // And a card with NO map keeps the ordinary column — this is a map's rule. const PLAIN: &str = "copy a { class: vocabulary, en: \"x\" }\n view root Surface { TextRow(text: copy.a) }\n"; let plain = makepad::lower( diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 06cf501..67b0453 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -70,7 +70,15 @@ placeholder = { kind = "data" } on_commit = { kind = "event" } width = { kind = "width" } +# Where a panel sits when the card is a MAP. +# +# A map card is an overlay: the map fills it and the panels float over it (see +# `l0_surface_map`). `.top` is the band a turn instruction belongs in and `.bottom` +# is the summary sheet — which is how the app this replaces arranges its driving +# screen, and how every map app does. Ignored by a card with no map, where panels +# simply stack. [Panel] +dock = { kind = "token", tokens = ["top", "bottom"] } [Card] on_tap = { kind = "event" } @@ -96,6 +104,18 @@ value = { kind = "any" } [Grid] cols = { kind = "number" } +# Content that a swipe reveals. +# +# Inside a docked panel, everything in a `Reveal` starts hidden and a swipe up on the +# panel shows it; a swipe down hides it again. That is the shipping nav app's drive +# sheet: a compact time-remaining chip by default, `End` revealed on demand. +# +# It is not card STATE. A state change re-resolves the card, which re-parses the +# document and rebuilds the `MapView` inside it — measured at up to 327 ms of frozen +# map. The reveal is a visibility toggle the renderer wires directly, exactly as the +# L2 card does with `ui.endrow.set_visible`, so the map is never touched. +[Reveal] + [Rule] # ─── text roles ─────────────────────────────────────────────────────────────── From f035bc1b4332118562cce59db459786522f935fc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:59:52 -0700 Subject: [PATCH 56/97] feat(ui_l0): an action can be destructive, and the sheet reads at a glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Chip(tone: .danger)` — what the action MEANS, not what it looks like. Ending navigation is destructive, and the theme renders that as the shipping card's `endrow`: red, full width, centred. A card naming a colour would be stating presentation, which §4 keeps out of the ledger. The remaining distance is `TextHero` and the sheet centres its children, matching that card's `remmin`. It was a `TextCaption` — the smallest role available — for the one number a driver glances at. The danger chip needed the FILLING hit target, and the fit one hid it completely: a `width: Fill` bar inside a `width: Fit` wrapper resolves to nothing, so `End` rendered as an empty gap in the sheet. That is the third time this session that Fill-inside-Fit has silently erased something — the map, the field, now this — and it is worth naming as the shape it is. --- crates/splash-ui-l0/src/lib.rs | 24 ++++++++++++++++++--- crates/splash-ui-l0/tests/fixtures/nav.card | 11 ++++++++-- docs/ui-l0-constructors.toml | 4 ++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 968cffe..e8808bb 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2642,6 +2642,8 @@ pub mod catalog { pub const MAP_VIEW: &[&str] = &["flat", "tilted"]; /// Where a panel sits when the card is a map. pub const DOCK: &[&str] = &["top", "bottom"]; + /// What an action means. The theme decides what that looks like. + pub const TONE: &[&str] = &["normal", "danger"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; @@ -2786,6 +2788,8 @@ pub mod catalog { ("on_tap", Event), ("value", Any), ("active", Bool), + // What the action MEANS. The theme decides `.danger` is red. + ("tone", Token(TONE)), ], ), ("WeatherIcon", &[("cond", Path), ("size", Token(ICON_SIZE))]), @@ -8792,7 +8796,13 @@ pub mod kit { let asked_to_fill = matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fill"); let intrinsic = - node.kind == "Chip" || (node.kind.starts_with("Text") && !asked_to_fill); + // A `.danger` chip is the exception: it SPANS the sheet, so a + // fit-width hit target is the one thing that can hide it. It emitted + // a `width: Fill` bar inside a `width: Fit` wrapper and the End + // button rendered as an empty gap — the same Fill-inside-Fit trap + // that resolves to nothing, three times over in this card now. + (node.kind == "Chip" && !matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger")) + || (node.kind.starts_with("Text") && !asked_to_fill); let f = if intrinsic { "l0_tap_fit" } else { "l0_tap" }; let _ = write!(out, "{f}({target}, "); element_untapped(node, depth, out); @@ -8973,8 +8983,16 @@ pub mod kit { ); } "Chip" => { - let on = i32::from(matches!(arg(node, "active"), Some(NodeValue::Bool(true)))); - let _ = write!(out, "{f}({}, {on})", makepad::expr_of(node, "text")); + // `.danger` is a different role in the kit, not a parameter: it is + // full width and centred as well as red, and threading three + // presentation decisions through one flag reads worse than naming + // the thing. + if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger") { + let _ = write!(out, "l0_chip_danger({})", makepad::expr_of(node, "text")); + } else { + 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 diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index bd32b94..f585cec 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -324,9 +324,16 @@ view root Surface { # toggle the renderer wires directly, exactly as the L2 card does with # `ui.endrow.set_visible`, so opening the sheet never touches the map. Panel(dock: .bottom) { - TextCaption(value: step.remaining, suffix: copy.left) + # BIG and centred. What is left of the trip is the one number a driver glances + # at, and it was a caption — the smallest role on the card. `TextHero` is the + # role for the number a screen exists to show; the sheet centres it, as the + # shipping card centres its `remmin`. + TextHero(value: step.remaining) Reveal { - Chip(text: copy.stop, on_tap: go) + # `.danger` says what the action MEANS. Ending navigation is destructive, and + # the theme renders that red, full-width and centred — the shipping card's + # `endrow`. A card naming a colour would be stating presentation. + Chip(text: copy.stop, on_tap: go, tone: .danger) } } # `at:` is what makes `.drive` mean the chase camera rather than the preview. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 67b0453..899272a 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -177,6 +177,10 @@ text = { kind = "text" } on_tap = { kind = "event" } value = { kind = "any" } active = { kind = "bool" } # a predicate is a bool operand +# What the action MEANS, not what it looks like. `.danger` is the one that ends +# something — the theme decides that reads red and full-width, because a card that +# named a colour would be stating presentation, which §4 keeps out of the ledger. +tone = { kind = "token", tokens = ["normal", "danger"] } # ─── data-visualisation roles ───────────────────────────────────────────────── # Every argument is a path. These render live data and hold no authored values — From 36c6daa776a2ae0274eb7127f47d7a205ee508bd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:45:43 -0700 Subject: [PATCH 57/97] l0: the drive sheet says how long is left, above how far MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A driver's question is when they arrive; the distance is how it gets answered. The sheet had only the distance, big, and the card it replaces had this ordering right — `remmin` at 22pt with `rem` under it at 13. `sys.step` gains `eta`, which the helper spells `remmin`. `unit: .duration` is a new token in the shared set, rendered " min" by the theme rather than written by the card: a card stating its own unit asserts what the helper answered in, and would be wrong the day that changes. Not `.time` — FORMAT already uses that word for a clock, and one token meaning two things in two slots is how a vocabulary rots. The test asserts the two numbers ask DIFFERENT fields. `eta` falling through to the distance's key would have shown one measurement twice, once big and once small, both correct and neither an arrival time — nothing on that screen looks wrong. Verified by reverting: it fails on a wrong key and on a missing unit, in both backends. Also, a docked panel now contributes its CHILDREN to a map surface, not itself. `l0_surface_map` draws the band and the sheet; emitting the `Panel` too put a second rounded fill inside each. Three faults from one wrapper — a visible box drawn inside the sheet, and because the wrapper is full-width the sheet's centring applied to IT and the hero stayed hard left, and its 16 of margin plus 24 of padding pushed the distance down the screen. Both backends had it; both are fixed, and the makepad sheet's margin catches up with the kit's measured 40. Measured on the OnePlus 6, driving the fake track: 29 min/26.0 km, 28/24.7, 26/23.2, 25/21.9. Both count down, and `remmin` scales by the route's total seconds — a route that answered no duration would have pinned it at "1" while the distance still fell. --- crates/splash-ui-l0/src/lib.rs | 72 ++++++++-- crates/splash-ui-l0/tests/fixtures/nav.card | 15 ++- crates/splash-ui-l0/tests/profile.rs | 139 ++++++++++++++++++++ docs/ui-l0-constructors.toml | 4 +- 4 files changed, 212 insertions(+), 18 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index e8808bb..98bb725 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2621,7 +2621,7 @@ pub mod catalog { } pub const UNIT: &[&str] = &[ - "c", "f", "pct", "speed", "pressure", "index", "distance", "money", + "c", "f", "pct", "speed", "pressure", "index", "distance", "money", "duration", ]; pub const FORMAT: &[&str] = &[ "money", @@ -2959,7 +2959,7 @@ pub mod catalog { ("sys.gps", &["lat", "lon", "accuracy", "ok"]), ("sys.search", &["id", "name", "lat", "lon", "distance"]), ("sys.route", &["duration", "distance", "steps"]), - ("sys.step", &["instruction", "remaining", "progress"]), + ("sys.step", &["instruction", "remaining", "progress", "eta"]), ( "sys.places", &["id", "name", "distance", "lat", "lon", "category"], @@ -6332,6 +6332,10 @@ pub mod makepad { // above a blank distance, which reads as "still loading" // rather than "asked for the wrong key". "remaining" => "rem", + // How long is left, in minutes. The helper spells it + // `remmin`; a card asks for `eta`, which is what the + // number MEANS to whoever is driving. + "eta" => "remmin", "progress" => "progress", _ => return None, }; @@ -6576,6 +6580,12 @@ pub mod makepad { Some(NodeValue::Token(t)) if t == "c" || t == "f" => "°", Some(NodeValue::Text(t)) if t == "c" || t == "f" => "°", Some(NodeValue::Token(t)) if t == "pct" => "%", + // A duration is minutes, and "34" alone is not a duration — + // beside a distance it reads as another distance. The word is + // the theme's to supply, not the card's: a card that wrote + // `suffix: "min"` would be asserting the unit its own number + // came in, and would be wrong the day the helper answers hours. + Some(NodeValue::Token(t)) if t == "duration" => " min", _ => "", }, glyph: match arg(node, "glyph") { @@ -6958,13 +6968,23 @@ pub mod makepad { c.kind == "Panel" && matches!(arg(c, "dock"), Some(NodeValue::Token(t)) if t == "top") }; + // A docked panel contributes its CHILDREN, not itself — the band and + // the sheet below ARE the panel's chrome, drawn here. Emitting the + // `Panel` too nested a second rounded fill inside each, and because + // that inner box is full-width the sheet's centring applied to the box + // rather than to the number in it. The kit backend had the same fault. + let docked_children = |child: &UiNode, depth: usize, out: &mut String| { + for inner in &child.children { + element(inner, depth, out); + } + }; for child in node.children.iter().filter(|c| docked_top(c)) { let _ = writeln!( out, "{p} View{{ width: Fill height: Fit flow: Down align: Align{{x: 0.5 y: 0.0}} \ margin: Inset{{left: 8 top: 46 right: 8}}" ); - element(child, depth + 2, out); + docked_children(child, depth + 2, out); let _ = writeln!(out, "{p} }}"); } let _ = writeln!( @@ -6975,15 +6995,23 @@ pub mod makepad { out, "{p} RoundedView{{ width: Fill height: Fit flow: Down \ draw_bg.color: #0f1620 draw_bg.border_radius: 22 \ - margin: Inset{{left: 8 right: 8 bottom: 76}} \ - padding: Inset{{left: 14 top: 12 right: 14 bottom: 14}}" + align: Align{{x: 0.5}} \ + margin: Inset{{left: 8 right: 8 bottom: 40}} \ + padding: Inset{{left: 14 top: 4 right: 14 bottom: 8}}" ); for child in node .children .iter() .filter(|c| c.kind != "Map" && !docked_top(c)) { - element(child, depth + 3, out); + // Docked panels unwrap here too; anything else placed straight on + // the surface is emitted whole, or a lone chip floated over the + // map would lose the chip and keep its label. + if child.kind == "Panel" && arg(child, "dock").is_some() { + docked_children(child, depth + 3, out); + } else { + element(child, depth + 3, out); + } } let _ = writeln!(out, "{p} }}"); let _ = writeln!(out, "{p} }}"); @@ -8899,11 +8927,35 @@ pub mod kit { .iter() .filter(|c| c.kind != "Map" && (docked_top(c) == (pass == 0))) { - if !first { - out.push_str(", "); + // A docked panel contributes its CHILDREN, not itself. + // + // `l0_surface_map` already draws the dock: the band at the + // top and the sheet at the bottom are its own chrome. Emitting + // the `Panel` as well put an `l0_panel` inside each of them — + // a second rounded box, with its own fill, `pady: 12` and + // `margintop: 16`. Three faults, one wrapper: the sheet had a + // visible box drawn inside it; the wrapper is `fillw`, so the + // sheet's `alignx` centred the wrapper and nothing inside it, + // and the hero went left; and the 40 extra units it added + // pushed the distance under the bottom of the screen. + // Only a DOCKED panel unwraps — that is the one whose + // chrome the surface has already drawn. Anything else + // placed directly on the surface is emitted whole, or a + // card that floats a lone chip over the map would have + // emitted the chip's children and lost the chip. + let docked = child.kind == "Panel" && arg(child, "dock").is_some(); + let emit: Vec<&UiNode> = if docked { + child.children.iter().collect() + } else { + vec![child] + }; + for one in emit { + if !first { + out.push_str(", "); + } + first = false; + element(one, depth + 1, out); } - first = false; - element(child, depth + 1, out); } out.push(']'); } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index f585cec..dfa994d 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -91,7 +91,7 @@ source leg_b sys.route(from_lat: stop_place.0.lat, from_lon: stop_place.0.lon, source step sys.step(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, at_lat: here.lat, at_lon: here.lon, - fields: [instruction, remaining]) + fields: [instruction, remaining, eta]) source env.locale sys.locale() # BOTH endpoints are editable state, and both are always on screen. @@ -324,11 +324,14 @@ view root Surface { # toggle the renderer wires directly, exactly as the L2 card does with # `ui.endrow.set_visible`, so opening the sheet never touches the map. Panel(dock: .bottom) { - # BIG and centred. What is left of the trip is the one number a driver glances - # at, and it was a caption — the smallest role on the card. `TextHero` is the - # role for the number a screen exists to show; the sheet centres it, as the - # shipping card centres its `remmin`. - TextHero(value: step.remaining) + # BIG and centred: how long is left. The shipping card's sheet puts `remmin` + # at 22pt and the distance under it at 13, and it has the ordering right — + # "when do I arrive" is the question, and the distance is how it is answered. + # `unit: .duration` because "34" beside "26.8 km" reads as another distance; the + # theme supplies the word. + TextHero(value: step.eta, unit: .duration) + # The distance, small, beneath it — the shipping card's `remrest`. + TextCaption(value: step.remaining) Reveal { # `.danger` says what the action MEANS. Ending navigation is destructive, and # the theme renders that red, full-width and centred — the shipping card's diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index d019c1b..723495b 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7343,3 +7343,142 @@ fn a_numeric_argument_cannot_carry_an_expression() { "a generated call is ours and must pass through:\n{good}" ); } + +/// The sheet's two numbers must be two DIFFERENT questions. +/// +/// A driver reads "how long" and "how far", and the helper answers them from +/// separate fields — `remmin` and `rem`. The failure this guards is the one this +/// profile keeps producing: `eta` falling through to the distance's field, so the +/// sheet shows the same measurement twice, once big and once small, both correct +/// and neither the arrival time. Nothing on such a screen looks wrong. +/// +/// Both backends, because the device renders the kit one and the desk renders the +/// other, and a field mapped in one is not mapped in both. +#[test] +fn how_long_is_left_and_how_far_is_left_are_different_questions() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source here sys.gps()\n", + "source step sys.step(from_lat: o.0.lat, from_lon: o.0.lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " at_lat: here.lat, at_lon: here.lon,\n", + " fields: [instruction, remaining, eta])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "view root Surface {\n", + " TextHero(value: step.eta, unit: .duration)\n", + " TextCaption(value: step.remaining)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "here": { "lat": 1.5, "lon": 2.5 }, + "step": { "instruction": "SEEDED", "remaining": "SEEDED", "eta": "SEEDED" }, + "env": { "locale": {} } + }); + let root = realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"); + + for (backend, dsl) in [ + ("kit", splash_ui_l0::kit::lower(&root)), + ("makepad", splash_ui_l0::makepad::lower(&root)), + ] { + assert!( + dsl.contains("\"remmin\")"), + "{backend}: the hero must ask how many minutes are left:\n{dsl}" + ); + assert!( + dsl.contains("\"rem\")"), + "{backend}: the caption must ask how far is left:\n{dsl}" + ); + // The unit is the theme's word, and without it "34" beside "26.8 km" + // is a second distance. + assert!( + dsl.contains("\"remmin\") + \" min\""), + "{backend}: a bare minute count reads as a distance:\n{dsl}" + ); + } +} + +/// A docked panel's content sits IN the dock, not in a box inside it. +/// +/// `l0_surface_map` draws the band and the sheet itself. Emitting the `Panel` as +/// well nested an `l0_panel` inside each — a second rounded fill, and because that +/// wrapper is full-width the sheet's centring applied to the wrapper rather than +/// to the number in it, so the hero left-aligned and the caption under it was +/// pushed off the bottom of the screen by the wrapper's own margin. +/// +/// The second half of the test is the part that would otherwise rot: an UNdocked +/// child must still be emitted whole. Unwrapping everything would emit a chip's +/// children and silently drop the chip. +#[test] +fn a_docked_panel_does_not_draw_a_second_panel_inside_the_dock() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "copy go { class: vocabulary, en: \"Go\" }\n", + "event start { origin: set(\"A\") }\n", + "view root Surface {\n", + " Panel(dock: .top) { TextBody(text: copy.go) }\n", + " Panel(dock: .bottom) { TextHero(text: copy.go) }\n", + " Chip(text: copy.go, on_tap: start)\n", + " Map(mode: .plan, from: o, to: d)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "env": { "locale": {} }, "copy": { "go": "Go" } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + dsl.contains("l0_surface_map("), + "a card with a Map lowers to the map surface:\n{dsl}" + ); + assert!( + !dsl.contains("l0_panel("), + "the surface already draws the dock; a panel inside it is a second box:\n{dsl}" + ); + // The undocked chip is still there, chip and all. + assert!( + dsl.contains("l0_chip("), + "an undocked child is emitted whole, not unwrapped:\n{dsl}" + ); + + // The other backend draws the same two docks with its own boxes, and had the + // same nested wrapper. Both, or the desk and the device disagree about a + // driving screen — which is how this one survived being looked at. + let mp = splash_ui_l0::makepad::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + let sheet = mp + .lines() + .position(|l| l.contains("draw_bg.border_radius: 22")) + .expect("the sheet is the rounded box at the bottom"); + // The line after the sheet opens is its content, not another box. + assert!( + !mp.lines().nth(sheet + 1).unwrap_or("").contains("RoundedView{"), + "a second box inside the sheet:\n{mp}" + ); + assert!( + mp.lines().nth(sheet).unwrap_or("").contains("align: Align{x: 0.5}"), + "the sheet centres what is in it:\n{mp}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 899272a..6a55102 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -252,7 +252,7 @@ range = { kind = "unit" } # Admits a token OR a path: `unit: .pct` is fixed, `unit: units` follows card # state. This is the argument that forced tokens to be lexically marked. accepts = ["token", "path"] -tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money"] +tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money", "duration"] [kinds.format] accepts = ["token"] @@ -352,7 +352,7 @@ answers = ["duration", "distance", "steps"] # instruction only advances because the device did. [sources."sys.step"] args = ["from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "fields"] -answers = ["instruction", "remaining", "progress"] +answers = ["instruction", "remaining", "progress", "eta"] [sources."sys.places"] args = ["lat", "lon", "category", "count", "fields"] From 5694fc9c842d96ce42ee177775fed6cabaf97838 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:17:33 -0700 Subject: [PATCH 58/97] l0: a transition advances from the value the card is SHOWING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card state resolves store -> data -> initial when a card is drawn, so a host that seeds `screen: "drive"` gets the drive screen. A transition resolved store -> initial and skipped the middle, so every `cycle`, `clear` and `toggle` on a seeded state computed from a value nobody was looking at. What that looked like: `End` on the nav card is `cycle(.plan, .drive)`. Seeded onto the drive screen it read the declared initial `.plan` and advanced to `.drive` — the screen it was already on. The tap dispatched, the store changed, the card re-resolved, and every layer reported success. I spent two rounds looking at the render cache in the app before testing the transition itself, which is the lesson: the layer that reports success is not the layer to instrument. Card scope only, matching the renderer — a component's cells are per-instance and have no key in the blob. Verified end to end on the OnePlus 6 with real taps: Walk 331 min, Drive 30 min, Go to the drive screen, swipe to the red End, End back to planning. --- crates/splash-ui-l0/examples/cycle_probe.rs | 24 +++++++++++ crates/splash-ui-l0/src/lib.rs | 19 ++++++++ crates/splash-ui-l0/tests/profile.rs | 48 +++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 crates/splash-ui-l0/examples/cycle_probe.rs diff --git a/crates/splash-ui-l0/examples/cycle_probe.rs b/crates/splash-ui-l0/examples/cycle_probe.rs new file mode 100644 index 0000000..bb9dd82 --- /dev/null +++ b/crates/splash-ui-l0/examples/cycle_probe.rs @@ -0,0 +1,24 @@ +//! Dispatch an event N times and print the state after each. +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 event = &a[2]; + let n: usize = a.get(3).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut store = splash_ui_l0::InstanceStore::default(); + for i in 0..n { + // Through the entry point the APP uses — the one that is handed the data + // blob. `dispatch_with` passes an empty one, which is exactly the + // difference this probe exists to see. + splash_ui_l0::dispatch_reporting(&card, &mut store, "root", event, None, &data); + let r = splash_ui_l0::realize_with_state(&card, &data, &store, Default::default()); + let dsl = splash_ui_l0::kit::lower(&r.root.expect("root")); + let screen = if dsl.contains("l0_surface_map") && dsl.contains("follow") { + "drive" + } else { + "plan" + }; + println!("tap {} -> screen={screen}", i + 1); + } +} diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 98bb725..86b53b3 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -7819,6 +7819,25 @@ fn dispatch_writes( .find(|(t, _, _)| *t == transition.target) .map(|(_, v, _)| v.clone()) .or_else(|| store.get(instance_key, &transition.target).cloned()) + // The HOST-SEEDED value, and it has to be here because it is here in + // the renderer. + // + // Card state resolves store → data → initial when the screen is drawn. + // A transition resolved store → initial, skipping the middle, so a + // state the host seeded was cycled from a value nobody was looking at: + // the nav card seeded `screen: "drive"`, the drive screen rendered, and + // `End` — a `cycle(.plan, .drive)` — read the declared initial `.plan` + // and advanced to `.drive`. The tap applied, the store changed, the card + // re-resolved, and it landed on the screen it was already on. Every + // layer reported success. + // + // Card scope only, since that is the only scope the renderer reads the + // blob for; a component's cells are per-instance and have no key in it. + .or_else(|| { + (instance_key == CARD_STATE_KEY) + .then(|| data.get(&transition.target).cloned()) + .flatten() + }) .or_else(|| declared_initial.clone()) .unwrap_or_else(|| initial_for(&state.shape)); diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 723495b..bf3df52 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7482,3 +7482,51 @@ fn a_docked_panel_does_not_draw_a_second_panel_inside_the_dock() { "the sheet centres what is in it:\n{mp}" ); } + +/// A transition reads the value that is ON SCREEN, not the one the card declared. +/// +/// Card state resolves store → data → initial when the card is drawn, so a host that +/// seeds `screen: "drive"` gets the drive screen. A transition resolved store → +/// initial and skipped the middle, which made every cycle on a seeded state advance +/// from a value nobody was looking at. +/// +/// Concretely, and this is how it was found: `End` on the nav card is +/// `cycle(.plan, .drive)`. Seeded onto the drive screen, it read the declared initial +/// `.plan` and advanced to `.drive` — the screen it was already on. The tap applied, +/// the store changed, the card re-resolved and nothing moved. Three layers reported +/// success and the button was inert. +#[test] +fn a_cycle_advances_from_the_state_the_card_is_showing() { + const CARD: &str = concat!( + "state screen { shape: enum[plan, drive], initial: .plan }\n", + "event go { screen: cycle(.plan, .drive) }\n", + "copy end { class: vocabulary, en: \"End\" }\n", + "view root Surface {\n", + " when screen == .plan { TextTitle(text: copy.end) }\n", + " when screen == .drive { Chip(text: copy.end, on_tap: go) }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + // Seeded onto the SECOND member, which is the case that was broken: from the + // first, a cycle that ignores the seed and one that honours it agree. + for (seed, after_one_tap) in [("drive", "plan"), ("plan", "drive")] { + let data = serde_json::json!({ + "screen": seed, "env": { "locale": {} }, "copy": { "end": "End" } + }); + let mut store = splash_ui_l0::InstanceStore::default(); + splash_ui_l0::dispatch_reporting(CARD, &mut store, "root", "go", None, &data); + let dsl = splash_ui_l0::kit::lower( + &splash_ui_l0::realize_with_state(CARD, &data, &store, RealizeLimits::default()) + .root + .expect("realizes"), + ); + // The drive branch is the one with the tap on it. + let now = if dsl.contains("l0_chip") { "drive" } else { "plan" }; + assert_eq!( + now, after_one_tap, + "seeded on {seed:?}, one tap must reach {after_one_tap:?}, not {now:?}:\n{dsl}" + ); + } +} From f18ff0ef9ae60df724ca74b7d2d5039fb37fc773 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:37:56 -0700 Subject: [PATCH 59/97] l0: a map pins the trip it draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pins come off the SAME resolved endpoints as the polyline. That is the whole design: the L2 card built its marker string by hand from its own bindings and pushed it to the widget in a second call, so the pins and the line were two chances to describe one trip. Here one function answers both, and a stop that routes through gets a pin while one that does not, does not. The test asserts the stop's coordinates appear at least twice — once in the pin and once in the polyline's vias — because a pin standing somewhere the line does not pass is the drawn-versus-reported mismatch this profile exists to catch, and on a map both look like a plausible dot. Verified on the OnePlus 6: green origin at the route's start, red destination at its end. Found by mapping the L2 app's §3 requirements — the parity document claimed 56 and covered 30, and §3 was one of the four sections missing entirely. --- crates/splash-ui-l0/src/lib.rs | 30 +++++++++++++- crates/splash-ui-l0/tests/profile.rs | 62 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 86b53b3..de742d3 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6042,6 +6042,30 @@ pub mod makepad { /// the separators are concatenated around them at evaluation time. The leading /// `""` is what makes the first `+` a string concatenation rather than an /// addition of two numbers — without it a stop at 37,-122 became -85. + /// The PINS a map stands on the route it draws: origin, each stop, destination. + /// + /// Built from the same coordinates as the polyline, and that is the point. The + /// L2 card composed this string by hand and pushed it with + /// `ui..set_route_markers(mk)`; deriving it here from the SAME resolved + /// endpoints means the pins cannot land somewhere the line does not go. + /// + /// `None` when there is no complete trip — a lone origin pin on a map with no + /// route reads as a dropped destination rather than a route still arriving. + pub(super) fn map_pins(node: &UiNode) -> Option { + let coord = |name: &str, axis: &str| map_coord(node, name, axis); + let (a, o) = (coord("from", "lat")?, coord("from", "lon")?); + let (b, p) = (coord("to", "lat")?, coord("to", "lon")?); + // kind 0 origin, 1 a stop, 2 the destination — the widget's own encoding. + let mut out = format!("\"\" + {a} + \",\" + {o} + \",0\""); + // The same resolution the polyline's waypoints use, so a stop that routes + // through gets a pin and one that does not, does not. + if let (Some(vlat), Some(vlon)) = (coord("via", "lat"), coord("via", "lon")) { + let _ = write!(out, " + \";\" + {vlat} + \",\" + {vlon} + \",1\""); + } + let _ = write!(out, " + \";\" + {b} + \",\" + {p} + \",2\""); + Some(out) + } + pub(super) fn via_string(joined: &str) -> Option { let parts: Vec<&str> = joined.split('\u{1}').filter(|p| !p.is_empty()).collect(); if parts.len() < 2 || !parts.len().is_multiple_of(2) { @@ -9104,9 +9128,13 @@ pub mod kit { // constant expression. See `update_nav_camera`. let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly})", + "{f}({:?}, {}, {lat}, {lon}, {poly}, {})", makepad::map_mode(node), scalar_of(node, "zoom"), + // The pins. `""` rather than omitted, because the widget reads + // an empty marker string as "no pins" and that is exactly what a + // map with no complete trip has. + makepad::map_pins(node).unwrap_or_else(|| "\"\"".to_owned()), ); } // `cond` is a NUMBER — the WMO code the forecast returns — and this diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index bf3df52..120aed2 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7530,3 +7530,65 @@ fn a_cycle_advances_from_the_state_the_card_is_showing() { ); } } + +/// A map stands pins on the trip it draws, from the SAME coordinates as the line. +/// +/// The widget draws pins from `nav_markers`, which was reachable only through +/// `ui..set_route_markers(…)` — a method call, and a declarative backend sets +/// properties. So the L0 card drew a correct route with no origin or destination pin +/// at all, and `draw_nav_route_pins` returned on its first line. Nothing looked +/// broken; the route was simply less legible than the card it replaced. +/// +/// The pins are derived here rather than composed by the card, which is what stops +/// them disagreeing with the line: one set of resolved endpoints feeds both. +#[test] +fn a_map_pins_the_trip_it_draws() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source s sys.search(query: state.stop, count: 1, fields: [id, name, lat, lon])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state stop { shape: text, initial: \"\" }\n", + "view root Surface {\n", + " when stop == \"\" { Map(mode: .plan, from: o, to: d) }\n", + " when stop != \"\" { Map(mode: .plan, from: o, to: d, via: s) }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let lower = |stop: &str| { + let data = serde_json::json!({ + "origin": "A", "dest": "B", "stop": stop, + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "s": [{ "lat": 5.0, "lon": 6.0 }], "env": { "locale": {} } + }); + splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ) + }; + + // Direct: origin (kind 0) and destination (kind 2), and no stop pin to place. + let direct = lower(""); + assert!( + direct.contains("\",0\"") && direct.contains("\",2\""), + "a direct trip pins both ends:\n{direct}" + ); + assert!( + !direct.contains("\",1\""), + "and has no stop to pin:\n{direct}" + ); + + // Through a stop: a third pin, and it must be the SAME place the route detours + // through — so the pin's coordinates also appear in the polyline's vias. + let via = lower("C"); + assert!(via.contains("\",1\""), "a stop is pinned:\n{via}"); + let stop_lat = "sys.searchnum(\"C\", 0, \"lat\")"; + assert!( + via.matches(stop_lat).count() >= 2, + "the pinned stop must be the one the line detours through:\n{via}" + ); +} From 9a3d5f261dbdb1b814760c44fad9162edf28cccb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:49:16 -0700 Subject: [PATCH 60/97] nav: no fix means show the trip, not a camera aimed at -9999 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys.gps` answers **-9999** for a latitude it does not have. A chase camera pointed at that is not a camera that lags — it is a map off the coast of Africa with the trip's route drawn nowhere near it, and the turn banner above it still reading like guidance. I had this recorded as a gap L0 could not express, on the grounds that one source cannot default to another. That was wrong: `sys.gps` answers `ok`, and a guard on a source field is admissible, so `when here.ok == 1` / `when here.ok == 0` says it directly. The card being replaced guards the same condition with `sys.gps("ok") >= 1`; the difference is that a guard is where the checker can see it. The test is differential because both branches draw a map and a card that lost the guard would still render something map-shaped. Verified by reverting — and the FIRST revert attempt passed, because the substitution silently matched nothing. A revert that does not apply is a test that proves nothing, which is the same trap as the test it was checking. Device: the drive screen is unchanged with a live fix — 29 min, 25.7 km, chase camera. --- crates/splash-ui-l0/tests/fixtures/nav.card | 19 ++++++- crates/splash-ui-l0/tests/profile.rs | 63 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index dfa994d..248acf2 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -345,7 +345,22 @@ view root Surface { # the vector tiles. It follows the SAME declared position as the flat one; the # widget's own `3d` mode drives a simulated vehicle, and pointing this at it # would put back the invented motion the whole rule exists to refuse. - Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) + # + # GUARDED ON WHETHER THERE IS A FIX. `sys.gps` answers -9999 for a latitude it + # does not have, and a chase camera pointed at -9999 is not a camera that lags — + # it is a map somewhere off the coast of Africa with a route drawn nowhere near + # it. The card this replaces guards the same way (`sys.gps("ok") >= 1`) and falls + # back; L0 says it with a guard instead of an `if`, which is the same decision + # written where the checker can see it. + when here.ok == 1 { + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) + } + # No fix: show the TRIP, framed, rather than a chase camera with nothing to + # chase. The banner above still says what the next manoeuvre is — it is + # declared, and a card does not get to decide it has no data. + when here.ok == 0 { + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 14) + } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 120aed2..91d05aa 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7592,3 +7592,66 @@ fn a_map_pins_the_trip_it_draws() { "the pinned stop must be the one the line detours through:\n{via}" ); } + +/// With no fix, the camera must not chase one. +/// +/// `sys.gps` answers **-9999** for a latitude it does not have. A chase camera +/// pointed at that is not a camera that lags — it is a map off the coast of Africa +/// with the trip's route drawn nowhere near it, and the turn banner above it still +/// reading like guidance. The card being replaced guards with `sys.gps("ok") >= 1`; +/// L0 says the same thing with a guard, where the checker can see it. +/// +/// The test is differential because both branches draw a map, and a card that lost +/// the guard would still render something map-shaped. +#[test] +fn a_missing_fix_falls_back_to_the_trip_not_to_nowhere() { + const CARD: &str = concat!( + "source here sys.gps()\n", + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "view root Surface {\n", + " when here.ok == 1 { Map(mode: .drive, from: o, to: d, at: here, view: .tilted, zoom: 17) }\n", + " when here.ok == 0 { Map(mode: .plan, from: o, to: d, zoom: 14) }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let lower = |lat: f64, lon: f64, ok: i64| { + let data = serde_json::json!({ + "origin": "A", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "here": { "lat": lat, "lon": lon, "ok": ok }, "env": { "locale": {} } + }); + splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ) + }; + + // A fix: the chase camera, following the declared position. + let fixed = lower(37.2656, -122.0294, 1); + assert!( + fixed.contains("l0_map(\"follow3d\""), + "a fix earns the chase camera:\n{fixed}" + ); + + // No fix: the trip, framed — and crucially NOT a camera aimed at the sentinel. + let lost = lower(-9999.0, -9999.0, 0); + assert!( + lost.contains("l0_map(\"plan\""), + "no fix falls back to the trip:\n{lost}" + ); + assert!( + !lost.contains("follow"), + "and must not follow anything:\n{lost}" + ); + // The sentinel must not reach the widget as a coordinate at all. + assert!( + !lost.contains("-9999"), + "the no-fix sentinel is not a place:\n{lost}" + ); +} From 3e2efc79523748ced4ba660be4a8813c9c3557af Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:55:18 -0700 Subject: [PATCH 61/97] nav: the search box can actually search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, and either alone was enough to make the feature inert. `query` was only ever CLEARED. Every event in the card wrote `query: clear` and none ever set it, so `sys.search(query: state.query)` ran on `""` for the life of the card. The screen had a field, a results panel and five tappable rows, and could not search. And the panel was guarded on `dest == ""` while the field that fills it binds to `dest` — so the only state in which results could appear was the one before anything had been typed, and typing was exactly what closed it. Two guards fighting: one waiting for a query, the other requiring that nothing had been asked for. Committing a destination now sets both — it is plainly the destination AND the thing you are looking for — and the results key on the query, which `choose_dest` clears, so picking closes the list. The rows are NOT wrapped in a `Panel`. A `for` iterates the data, so zero rows inside a panel is an empty rounded box between TO and VIA — which is what it looked like on device the first time, and is worse than the bug it replaced. L0 has no predicate for "this list has rows", so the container must be the thing that cannot render empty. Still host-side, and now precisely: a list's LENGTH is structural, so it cannot come from a live call the way a scalar can. `found` is marked stale on the tap and never repopulated, so the list is empty until §5.9 refetch exists — which `L0Session` already says is deliberately not attempted. --- crates/splash-ui-l0/examples/cycle_probe.rs | 24 ------ crates/splash-ui-l0/tests/fixtures/nav.card | 55 +++++++++---- crates/splash-ui-l0/tests/profile.rs | 86 +++++++++++++++++++++ 3 files changed, 126 insertions(+), 39 deletions(-) delete mode 100644 crates/splash-ui-l0/examples/cycle_probe.rs diff --git a/crates/splash-ui-l0/examples/cycle_probe.rs b/crates/splash-ui-l0/examples/cycle_probe.rs deleted file mode 100644 index bb9dd82..0000000 --- a/crates/splash-ui-l0/examples/cycle_probe.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Dispatch an event N times and print the state after each. -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 event = &a[2]; - let n: usize = a.get(3).and_then(|v| v.parse().ok()).unwrap_or(3); - let mut store = splash_ui_l0::InstanceStore::default(); - for i in 0..n { - // Through the entry point the APP uses — the one that is handed the data - // blob. `dispatch_with` passes an empty one, which is exactly the - // difference this probe exists to see. - splash_ui_l0::dispatch_reporting(&card, &mut store, "root", event, None, &data); - let r = splash_ui_l0::realize_with_state(&card, &data, &store, Default::default()); - let dsl = splash_ui_l0::kit::lower(&r.root.expect("root")); - let screen = if dsl.contains("l0_surface_map") && dsl.contains("follow") { - "drive" - } else { - "plan" - }; - println!("tap {} -> screen={screen}", i + 1); - } -} diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 248acf2..134271d 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -150,7 +150,18 @@ state mode { shape: enum[drive, walk, bike], initial: .drive } state stop { shape: text, initial: "" } # empty ⇒ a direct trip event set_origin { origin: set($value), query: clear } -event set_dest { dest: set($value), query: clear } +# `query` is SET, not cleared — this is the line that made search reachable. +# +# Every event here cleared `query` and nothing ever set it, so `found` — which is +# `sys.search(query: state.query)` — always ran on "" and the results list was +# permanently empty. The card rendered a search field, a results panel and five +# tappable rows, and could not search. A review caught it; nothing on screen could, +# because an empty list looks exactly like a query with no matches. +# +# Committing "Stanford" now does both things it plainly means: it is the destination +# (so the route draws to the best match immediately) and it is the query (so the +# alternatives are listed underneath, and one tap refines the choice). +event set_dest { dest: set($value), query: set($value) } event choose_dest { dest: set($value), query: clear } event go { screen: cycle(.plan, .drive) } event set_stop { stop: set($value), query: clear } @@ -191,27 +202,41 @@ view root Surface { } } - # What the user is choosing between. + # R5.4's "your trip" defaults: before anything is typed, the trip's own places + # are the pickable items — tap the origin while the destination is empty and the + # trip reverses. when dest == "" { - Panel { - # R5.4's "your trip" defaults: before anything is typed, the trip's own - # places are the pickable items — tap the origin while the destination is - # empty and the trip reverses. They are shown when the QUERY is empty, - # because that is when a results list would otherwise be a blank panel with - # nothing to say why. - when query == "" { + when query == "" { + Panel { Row(align: .center, gap: 10, on_tap: choose_dest, value: origin) { TextCaption(text: copy.from) TextRow(text: origin) } - Rule() } - for f, i in found key f.id { - Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { - TextRow(text: f.name) - } - Rule() + } + } + + # THE RESULTS, gated on the query rather than on an empty destination. + # + # They used to sit inside `when dest == ""`, and the field that fills them binds + # to `dest` — so the only state in which results could show was the one before + # anything had been typed, and typing something was precisely what closed it. + # Two guards fighting: one waiting for a query, the other requiring that nothing + # had been asked for. + # + # Keyed on the query, the list appears when there is something to list and + # `choose_dest` clears the query, so picking closes it. + # NO PANEL AROUND THEM, and that is not a style choice. A `for` over a source + # iterates the DATA, so zero rows inside a `Panel` is a panel with nothing in + # it — an empty rounded box between TO and VIA, which is what this looked like + # on device the first time. L0 has no predicate for "this list has rows", so the + # container has to be the thing that cannot render empty: no rows, no nodes. + when query != "" { + for f, i in found key f.id { + Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { + TextRow(text: f.name) } + Rule() } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 91d05aa..91a7e41 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7655,3 +7655,89 @@ fn a_missing_fix_falls_back_to_the_trip_not_to_nowhere() { "the no-fix sentinel is not a place:\n{lost}" ); } + +/// Search has to be REACHABLE, not merely present. +/// +/// The nav card rendered a search field, a results panel and five tappable rows and +/// could not search: every event cleared `query` and nothing set it, so +/// `sys.search(query: state.query)` always ran on `""`. A review caught it, and +/// nothing on screen could have — an empty results list looks exactly like a query +/// with no matches. +/// +/// Worse, the panel was guarded on `dest == ""` while the field that fills it binds +/// to `dest`, so the only state in which results could appear was the one before +/// anything had been typed. Two guards fighting: one waiting for a query, the other +/// requiring that nothing had been asked for. +/// +/// So this walks the whole interaction, because each step passes on its own: commit +/// a query, see the candidates, pick one, see the list close. +#[test] +fn typing_a_destination_produces_candidates_and_picking_one_closes_them() { + const CARD: &str = concat!( + "source found sys.search(query: state.query, count: 5, fields: [id, name, lat, lon])\n", + "state dest { shape: text, initial: \"\" }\n", + "state query { shape: text, initial: \"\" }\n", + "event set_dest { dest: set($value), query: set($value) }\n", + "event choose_dest { dest: set($value), query: clear }\n", + "copy to { class: vocabulary, en: \"TO\" }\n", + "view root Surface {\n", + " Field(text: dest, placeholder: copy.to, on_commit: set_dest, width: .fill)\n", + " when query != \"\" {\n", + " for f, i in found key f.id {\n", + " Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) {\n", + " TextRow(text: f.name)\n", + " }\n", + " }\n", + " }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "dest": "", "query": "", + "found": [ + { "id": "a", "name": "Stanford University", "lat": 37.43, "lon": -122.17 }, + { "id": "b", "name": "Stanford Shopping Center", "lat": 37.44, "lon": -122.17 }, + { "id": "c", "name": "Stanford Stadium", "lat": 37.43, "lon": -122.16 } + ], + "env": { "locale": {} }, "copy": { "to": "TO" } + }); + let rows = |store: &splash_ui_l0::InstanceStore| { + let dsl = splash_ui_l0::kit::lower( + &splash_ui_l0::realize_with_state(CARD, &data, store, RealizeLimits::default()) + .root + .expect("realizes"), + ); + dsl.matches("l0_row_text(").count() + }; + + let mut store = splash_ui_l0::InstanceStore::default(); + assert_eq!(rows(&store), 0, "nothing asked for, nothing listed"); + + // Commit a query: the candidates appear. This is the step that was impossible. + splash_ui_l0::dispatch_reporting( + CARD, + &mut store, + "root", + "set_dest", + Some(&serde_json::Value::String("Stanford".into())), + &data, + ); + assert_eq!( + rows(&store), + 3, + "committing a query must list what the search found" + ); + + // Pick one: the list closes, because `choose_dest` clears the query. + splash_ui_l0::dispatch_reporting( + CARD, + &mut store, + "root", + "choose_dest", + Some(&serde_json::Value::String("a".into())), + &data, + ); + assert_eq!(rows(&store), 0, "picking a candidate closes the list"); +} From 69b97bb5b13c3b915cd7ba06047e3402c9525855 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:05:26 -0700 Subject: [PATCH 62/97] l0: a card may name a map's controls, and still may not call it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3.6 and R3.9 — the zoom pill and the my-location button — were recorded as not expressible. They were not expressible the way the L2 card writes them: `on_click: || ui.themap.nav_zoom_by("0.7")` is a method call on a named widget, which is the imperative wiring this profile exists to exclude. But that is presentation wearing a capability's clothes. "This map can be zoomed" is a fact about what the screen OFFERS; the button, the glyph and the call are how it offers it. So `Map(controls: .zoom | .all)` is the card's whole say, and the backend emits the call — the §1.1 split, applied to the case that most tempts a card to break it. The test's second half is the one that matters: no lowering of a card may contain `ui.` at all. Asserting the pill appears only proves the feature; asserting the card cannot reach the widget proves the line held. Device, on the OnePlus 6: two taps of `+` take the map from freeway shields to street names, and one tap of the ring returns it to within 1.3% of the original fitted overview — against 49.7% different from the zoomed state. --- crates/splash-ui-l0/src/lib.rs | 21 ++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 5 +- crates/splash-ui-l0/tests/profile.rs | 68 +++++++++++++++++++++ docs/ui-l0-constructors.toml | 7 +++ 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index de742d3..d7c365c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2642,6 +2642,17 @@ pub mod catalog { pub const MAP_VIEW: &[&str] = &["flat", "tilted"]; /// Where a panel sits when the card is a map. pub const DOCK: &[&str] = &["top", "bottom"]; + /// The controls a map offers. A card NAMES the affordance; the theme draws it + /// and the backend wires it. + /// + /// This is the argument that let R3.6 and R3.9 exist at L0 at all. The card + /// being replaced draws its own pill and writes + /// `on_click: || ui.themap.nav_zoom_by("0.7")` — a method call on a named + /// widget, which is the imperative wiring this profile exists to exclude. The + /// split §1.1 asks for is that the CARD may not say it and the BACKEND must: + /// "this map can be zoomed" is a capability, and the button, the glyph and the + /// method call are all presentation. + pub const CONTROLS: &[&str] = &["none", "zoom", "all"]; /// What an action means. The theme decides what that looks like. pub const TONE: &[&str] = &["normal", "danger"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; @@ -2680,6 +2691,7 @@ pub mod catalog { // fabrication `map_mode` refuses. ("view", Token(MAP_VIEW)), ("zoom", Number), + ("controls", Token(CONTROLS)), ], ), // A text field. The ONE role that lets a card receive something the user @@ -9126,9 +9138,16 @@ pub mod kit { // that changes every second is not a property, so routing it through // one bought nothing and cost a frozen camera when the property was a // constant expression. See `update_nav_camera`. + // The controls the card asked for. `none` unless it said otherwise, + // because a map that grows buttons a card never mentioned is the + // theme deciding what a screen OFFERS rather than how it looks. + let controls = match arg(node, "controls") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "none".to_owned(), + }; let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly}, {})", + "{f}({:?}, {}, {lat}, {lon}, {poly}, {}, {controls:?})", makepad::map_mode(node), scalar_of(node, "zoom"), // The pins. `""` rather than omitted, because the widget reads diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 134271d..614c937 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -297,7 +297,7 @@ view root Surface { # source's `$state` (which the host injects). A live VALUE is not that. The # impossible-centre case is handled where the number is actually known, in # the widget. - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16) + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) } # ---- the trip THROUGH the stop -------------------------------------- @@ -321,7 +321,8 @@ view root Surface { TextCaption(value: leg_b.distance) } } - Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16) + Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, + controls: .all) } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 91a7e41..821c991 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7741,3 +7741,71 @@ fn typing_a_destination_produces_candidates_and_picking_one_closes_them() { ); assert_eq!(rows(&store), 0, "picking a candidate closes the list"); } + +/// A card NAMES a map control; only the backend may call the widget. +/// +/// This is the split §1.1 asks for, on the one requirement that most obviously +/// tempts a card to break it. The L2 card draws its own zoom pill and writes +/// `on_click: || ui.themap.nav_zoom_by("0.7")` — a method call on a named widget, +/// which is the imperative wiring L0 exists to exclude. "This map can be zoomed" is +/// a capability; the button, the glyph and the call are presentation. +/// +/// So the test has two halves and the second is the one that matters: the control +/// must appear when asked for, and the CARD must still contain no call. +#[test] +fn a_card_names_a_map_control_and_never_calls_the_widget() { + let card = |controls: &str| { + format!( + concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "state origin {{ shape: text, initial: \"A\" }}\n", + "state dest {{ shape: text, initial: \"B\" }}\n", + "view root Surface {{\n", + " Map(mode: .plan, from: o, to: d, zoom: 14{})\n", + "}}\n" + ), + controls + ) + }; + let data = serde_json::json!({ + "origin": "A", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "env": { "locale": {} } + }); + let lower = |src: &str| { + let checked = check_ui_l0_named("nav", src); + assert!(checked.valid, "{:#?}", checked.diagnostics); + splash_ui_l0::kit::lower( + &realize(src, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ) + }; + + // Unasked-for: a map that grows buttons a card never mentioned would be the + // theme deciding what a screen OFFERS, not how it looks. + let bare = lower(&card("")); + assert!( + bare.contains("\"none\""), + "no controls unless the card says so:\n{bare}" + ); + // Asked for: the theme is told, and told which. + let zoom = lower(&card(", controls: .zoom")); + assert!(zoom.contains("\"zoom\""), "a zoom pill was asked for:\n{zoom}"); + let all = lower(&card(", controls: .all")); + assert!(all.contains("\"all\""), "recenter too:\n{all}"); + + // THE HALF THAT MATTERS. Whatever the theme goes on to draw, no lowering of a + // card may contain a call on a widget — that is the L2 form this replaces. + for (name, dsl) in [("none", &bare), ("zoom", &zoom), ("all", &all)] { + assert!( + !dsl.contains("nav_zoom_by") && !dsl.contains("set_nav_recenter"), + "{name}: a card must not call the widget:\n{dsl}" + ); + assert!( + !dsl.contains("ui."), + "{name}: a card must not reach a widget at all:\n{dsl}" + ); + } +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 6a55102..effa402 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -56,6 +56,13 @@ at = { kind = "path" } # a preview has no camera to tilt. view = { kind = "token", tokens = ["flat", "tilted"] } zoom = { kind = "number" } +# The controls the map offers. `.zoom` is a +/- pill; `.all` adds a recenter +# button. The card NAMES the affordance and the theme draws it — which is the whole +# reason these are expressible here at all. The L2 card draws its own pill and +# writes `on_click: || ui.themap.nav_zoom_by("0.7")`: a method call on a named +# widget, and the imperative wiring this profile exists to exclude. "This map can be +# zoomed" is a capability; the button, the glyph and the call are presentation. +controls = { kind = "token", tokens = ["none", "zoom", "all"] } # A text field — the one role that lets a card receive something the user typed. # From b8dfb25731e58fa6657d8279d0db162b669f5631 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:24:25 -0700 Subject: [PATCH 63/97] l0: undo a guard that removed the map it was protecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rounds ago I guarded the drive map on `here.ok` so a missing GPS fix would not aim the camera at -9999. The card already carries a note saying why that cannot work: a guard is evaluated at REALIZE time, so it can only test what realization can see — declared state, or a source's `$state`. A live value reads NOTHING on a freshly generated card, so both branches were false and the drive screen lowered with no map at all. The note was written after the same mistake was made on the plan map. My test passed because it seeded `here`, which is precisely the case where this bug is invisible. The replacement omits `here` entirely, which is what a card looks like before its first fix lands. The sentinel is refused in the widget instead, where the fix and the route are both known: a position that is not a place puts the camera at the start of the route rather than in the Atlantic. And pins are for a map you are LOOKING at. A `follow3d` map handed markers rendered no route and no tiles at all — a blank beige screen — because in 3D the widget appends pin geometry to the ribbon rather than drawing it separately. R3.12 is a plan-screen requirement and a chase camera already draws the driver's puck. Bisected on device; the test now asserts both screens, because I had verified pins on the one the requirement names and not on the other. Device: drive 28 min / 24.9 km with the ribbon; plan keeps both pins. --- crates/splash-ui-l0/src/lib.rs | 12 ++ crates/splash-ui-l0/tests/fixtures/nav.card | 28 ++--- crates/splash-ui-l0/tests/profile.rs | 123 +++++++++++++------- 3 files changed, 105 insertions(+), 58 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index d7c365c..5a90f4b 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6064,6 +6064,18 @@ pub mod makepad { /// `None` when there is no complete trip — a lone origin pin on a map with no /// route reads as a dropped destination rather than a route still arriving. pub(super) fn map_pins(node: &UiNode) -> Option { + // A CHASE map gets none, and this is not a preference. + // + // R3.12 is a plan-screen requirement: pins mark the ends of a route you are + // looking at. A follow camera already draws the driver's puck, and in 3D the + // widget appends pin geometry to the ribbon rather than drawing it + // separately — measured on device, a follow3d map handed markers rendered + // NO route and NO tiles at all, a blank beige screen. The plan map with the + // same pins was fine, which is how it went unnoticed: I verified pins on the + // screen the requirement is about and not on the other one. + if live_position(node).is_some() { + return None; + } let coord = |name: &str, axis: &str| map_coord(node, name, axis); let (a, o) = (coord("from", "lat")?, coord("from", "lon")?); let (b, p) = (coord("to", "lat")?, coord("to", "lon")?); diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 614c937..764d9c3 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -371,22 +371,18 @@ view root Surface { # the vector tiles. It follows the SAME declared position as the flat one; the # widget's own `3d` mode drives a simulated vehicle, and pointing this at it # would put back the invented motion the whole rule exists to refuse. + # NOT guarded on whether there is a fix, and the reason is the one written + # against the plan map above: a guard is evaluated at REALIZE time, so it can + # only test what realization can see — declared state, or a source's `$state`. + # `here.ok` is a live value and reads NOTHING on a freshly generated card. # - # GUARDED ON WHETHER THERE IS A FIX. `sys.gps` answers -9999 for a latitude it - # does not have, and a chase camera pointed at -9999 is not a camera that lags — - # it is a map somewhere off the coast of Africa with a route drawn nowhere near - # it. The card this replaces guards the same way (`sys.gps("ok") >= 1`) and falls - # back; L0 says it with a guard instead of an `if`, which is the same decision - # written where the checker can see it. - when here.ok == 1 { - Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) - } - # No fix: show the TRIP, framed, rather than a chase camera with nothing to - # chase. The banner above still says what the next manoeuvre is — it is - # declared, and a card does not get to decide it has no data. - when here.ok == 0 { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 14) - } + # I wrote that guard here anyway, and it did exactly what the note predicts: + # with no `here` in the data blob, neither branch fired and the drive screen + # lowered with no map at all. The test passed because it seeded `here`. + # + # The impossible centre is handled where the number is actually known — in the + # widget, which has both the fix and the route and can tell -9999 from a place. + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 821c991..5e35272 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7593,18 +7593,22 @@ fn a_map_pins_the_trip_it_draws() { ); } -/// With no fix, the camera must not chase one. +/// A card's map must survive a data blob that has not answered yet. /// -/// `sys.gps` answers **-9999** for a latitude it does not have. A chase camera -/// pointed at that is not a camera that lags — it is a map off the coast of Africa -/// with the trip's route drawn nowhere near it, and the turn banner above it still -/// reading like guidance. The card being replaced guards with `sys.gps("ok") >= 1`; -/// L0 says the same thing with a guard, where the checker can see it. +/// I guarded the drive map on `here.ok` to handle a missing GPS fix, and that is the +/// one mistake this card already carries a note about: a guard is evaluated at +/// REALIZE time, so it can only test what realization can see — declared state, or a +/// source's `$state`. A live value reads NOTHING on a freshly generated card, so +/// BOTH branches were false and the drive screen lowered with no map at all. /// -/// The test is differential because both branches draw a map, and a card that lost -/// the guard would still render something map-shaped. +/// The test that let it through seeded `here` in the blob, which is precisely the +/// case where the bug is invisible. So this one does the opposite: it omits `here` +/// entirely, which is what a card looks like before its first fix lands. +/// +/// The sentinel is refused in the widget instead, where the fix and the route are +/// both known — see `is_a_place`. #[test] -fn a_missing_fix_falls_back_to_the_trip_not_to_nowhere() { +fn a_map_survives_a_source_that_has_not_answered() { const CARD: &str = concat!( "source here sys.gps()\n", "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", @@ -7612,47 +7616,31 @@ fn a_missing_fix_falls_back_to_the_trip_not_to_nowhere() { "state origin { shape: text, initial: \"A\" }\n", "state dest { shape: text, initial: \"B\" }\n", "view root Surface {\n", - " when here.ok == 1 { Map(mode: .drive, from: o, to: d, at: here, view: .tilted, zoom: 17) }\n", - " when here.ok == 0 { Map(mode: .plan, from: o, to: d, zoom: 14) }\n", + " Map(mode: .drive, from: o, to: d, at: here, view: .tilted, zoom: 17)\n", "}\n" ); let checked = check_ui_l0_named("nav", CARD); assert!(checked.valid, "{:#?}", checked.diagnostics); - let lower = |lat: f64, lon: f64, ok: i64| { - let data = serde_json::json!({ - "origin": "A", "dest": "B", - "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], - "here": { "lat": lat, "lon": lon, "ok": ok }, "env": { "locale": {} } - }); - splash_ui_l0::kit::lower( - &realize(CARD, &data, RealizeLimits::default()) - .root - .expect("realizes"), - ) - }; - - // A fix: the chase camera, following the declared position. - let fixed = lower(37.2656, -122.0294, 1); - assert!( - fixed.contains("l0_map(\"follow3d\""), - "a fix earns the chase camera:\n{fixed}" - ); - - // No fix: the trip, framed — and crucially NOT a camera aimed at the sentinel. - let lost = lower(-9999.0, -9999.0, 0); - assert!( - lost.contains("l0_map(\"plan\""), - "no fix falls back to the trip:\n{lost}" + // NO `here` — the state every generated card starts in. + let data = serde_json::json!({ + "origin": "A", "dest": "B", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "env": { "locale": {} } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), ); - assert!( - !lost.contains("follow"), - "and must not follow anything:\n{lost}" + assert_eq!( + dsl.matches("l0_map(").count(), + 1, + "a map whose position has not arrived is still a map:\n{dsl}" ); - // The sentinel must not reach the widget as a coordinate at all. assert!( - !lost.contains("-9999"), - "the no-fix sentinel is not a place:\n{lost}" + dsl.contains("sys.gps(\"lat\")"), + "and it still follows the declared position:\n{dsl}" ); } @@ -7809,3 +7797,54 @@ fn a_card_names_a_map_control_and_never_calls_the_widget() { ); } } + +/// Pins belong to a map you are LOOKING at, not one that is chasing you. +/// +/// R3.12 is a plan-screen requirement. A follow camera already draws the driver's +/// puck, and in 3D the widget appends pin geometry to the route ribbon rather than +/// drawing it separately — measured on a OnePlus 6, a `follow3d` map handed markers +/// rendered no route and no tiles at all, a blank beige screen. +/// +/// It went unnoticed for a build because I verified the pins on the screen the +/// requirement names and not on the other one. So this asserts BOTH screens. +#[test] +fn a_chase_map_carries_no_pins() { + const CARD: &str = concat!( + "source here sys.gps()\n", + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state screen { shape: enum[plan, drive], initial: .plan }\n", + "view root Surface {\n", + " when screen == .plan { Map(mode: .plan, from: o, to: d, zoom: 14) }\n", + " when screen == .drive { Map(mode: .drive, from: o, to: d, at: here, view: .tilted, zoom: 17) }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let lower = |screen: &str| { + let data = serde_json::json!({ + "origin": "A", "dest": "B", "screen": screen, + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "here": { "lat": 1.5, "lon": 2.5, "ok": 1 }, "env": { "locale": {} } + }); + splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ) + }; + // The overview pins both ends — that is the requirement. + let plan = lower("plan"); + assert!(plan.contains("\",0\"") && plan.contains("\",2\""), "plan pins:\n{plan}"); + // The chase map passes an EMPTY marker string, which the widget reads as + // "no pins" — not a missing argument, which would be an arity error. + let drive = lower("drive"); + assert!(drive.contains("follow3d"), "drive is the chase camera:\n{drive}"); + assert!( + !drive.contains("\",0\"") && !drive.contains("\",2\""), + "a chase map must carry no pins:\n{drive}" + ); +} From c31d98ed879243db92f71161d9fd14185b6e2021 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:30:47 -0700 Subject: [PATCH 64/97] nav: previewing a trip is a screen, not a state of planning one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2.2. Everything it shows was already on the planning screen — the duration, the distance, a button that commits. What was missing was it being a separate step, and the point of a preview is the editing controls being ABSENT: one that still offers a search box is the planning screen under another name. ONE event still drives the whole journey. `cycle` names an order and the order IS the flow — plan, preview, drive, and round — so "Go", "Start" and "End" are one transition seen from three places. Three events would have been three chances to disagree about which screen follows which, which is the kind of disagreement that renders perfectly. The test walks the whole loop rather than a single hop, and asserts every screen draws exactly one map. Adding a screen is the easiest way to end up with one that draws none — a failure the checker cannot see, and one that a screenshot of any OTHER screen would not show. Verified by reverting both halves: a cycle that skips preview, and a preview that loses its map. Device, real taps: plan → Go → preview (30 min, 27.6 km away, Start) → Start → drive (turn banner, 31 min, 27.6 km) → swipe → End → plan. --- crates/splash-ui-l0/tests/fixtures/nav.card | 41 +++++++++- crates/splash-ui-l0/tests/profile.rs | 90 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 764d9c3..f26356d 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -112,7 +112,7 @@ state query { shape: text, initial: "" } # what the user is typing # checker to accept an undeclared literal, and a `not` operator would be the # expression form L0 does not have. Two named screens have two guards that each # say which screen they are, which is what a card should have said anyway. -state screen { shape: enum[plan, drive], initial: .plan } +state screen { shape: enum[plan, preview, drive], initial: .plan } # How the trip is travelled. R7.1/R7.2 of the shipping app's contract. # @@ -163,7 +163,12 @@ event set_origin { origin: set($value), query: clear } # alternatives are listed underneath, and one tap refines the choice). event set_dest { dest: set($value), query: set($value) } event choose_dest { dest: set($value), query: clear } -event go { screen: cycle(.plan, .drive) } +# ONE event for the whole journey through the card, because `cycle` names an order +# and the order IS the flow: plan → preview → drive → plan. Three chips on three +# screens — "Go", "Start", "End" — are the same transition seen from three places, +# and writing them as three events would have been three chances to disagree about +# which screen follows which. +event go { screen: cycle(.plan, .preview, .drive) } event set_stop { stop: set($value), query: clear } event drop_stop { stop: clear, query: clear } event pick_mode { mode: set($value) } @@ -176,6 +181,7 @@ copy away { class: vocabulary, en: "away" } copy seeking { class: vocabulary, en: "Finding a route…" } copy start { class: vocabulary, en: "Go" } copy stop { class: vocabulary, en: "End" } +copy begin { class: vocabulary, en: "Start" } copy left { class: vocabulary, en: "left" } copy drive { class: vocabulary, en: "Drive" } copy walk { class: vocabulary, en: "Walk" } @@ -327,6 +333,37 @@ view root Surface { } } + # ---- previewing ---------------------------------------------------------- + # + # R2.2, and it is a SCREEN rather than a state of the planning one. The chosen + # trip, framed, with the two numbers that decide whether to take it and one + # button that commits — and none of the editing controls, because a preview that + # still offers a search box is the planning screen with a different name. + # + # Everything on it was already on the plan screen; what was missing was it being + # a separate step. That is the whole requirement, and it costs two guarded + # branches because a trip through a stop is a different trip — the same reason + # `trip` and `trip_via` are two sources. + when screen == .preview { + when stop == "" { + Panel(dock: .bottom) { + TextHero(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + } + when stop != "" { + Panel(dock: .bottom) { + TextHero(value: trip_via.duration) + TextCaption(value: trip_via.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, + controls: .all) + } + } + # ---- driving ------------------------------------------------------------- when screen == .drive { # The turn instruction, DOCKED TO THE TOP — where a driver looks for it, and diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 5e35272..c55e81a 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -7848,3 +7848,93 @@ fn a_chase_map_carries_no_pins() { "a chase map must carry no pins:\n{drive}" ); } + +/// Three screens, one transition, and a map on every one of them. +/// +/// R2.2's preview is a SCREEN rather than a state of the planning one: the chosen +/// trip, the two numbers that decide whether to take it, and one button that +/// commits. Everything on it was already on the plan screen; what was missing was +/// it being a separate step. +/// +/// `cycle` names an order and the order IS the flow, so "Go", "Start" and "End" are +/// one transition seen from three places. Three separate events would have been +/// three chances to disagree about which screen follows which — so the test walks +/// the whole loop rather than checking a single hop. +/// +/// The map count is the other half. Adding a screen is the easiest way to end up +/// with one that draws no map at all, which is a failure the checker cannot see and +/// a screenshot of the WRONG screen would not show. +#[test] +fn the_journey_through_the_card_is_one_transition_and_never_loses_the_map() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source here sys.gps()\n", + "source trip sys.route(from_lat: o.0.lat, from_lon: o.0.lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " mode: state.mode, fields: [duration, distance])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state mode { shape: enum[drive, walk, bike], initial: .drive }\n", + "state screen { shape: enum[plan, preview, drive], initial: .plan }\n", + "event go { screen: cycle(.plan, .preview, .drive) }\n", + "copy begin { class: vocabulary, en: \"Start\" }\n", + "view root Surface {\n", + " when screen == .plan {\n", + " Field(text: dest, placeholder: copy.begin, on_commit: go, width: .fill)\n", + " Map(mode: .plan, from: o, to: d, zoom: 14)\n", + " }\n", + " when screen == .preview {\n", + " Panel(dock: .bottom) { TextHero(value: trip.duration) Chip(text: copy.begin, on_tap: go) }\n", + " Map(mode: .plan, from: o, to: d, zoom: 16)\n", + " }\n", + " when screen == .drive {\n", + " Map(mode: .drive, from: o, to: d, at: here, view: .tilted, zoom: 17)\n", + " }\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "dest": "B", "mode": "drive", "screen": "plan", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "here": { "lat": 1.5, "lon": 2.5, "ok": 1 }, + "trip": { "duration": "30 min", "distance": "27 km" }, + "env": { "locale": {} }, "copy": { "begin": "Start" } + }); + let mut store = splash_ui_l0::InstanceStore::default(); + let screen = |store: &splash_ui_l0::InstanceStore| { + let dsl = splash_ui_l0::kit::lower( + &splash_ui_l0::realize_with_state(CARD, &data, store, RealizeLimits::default()) + .root + .expect("realizes"), + ); + // EVERY screen draws exactly one map. A screen that lost its map is the + // failure a screenshot of a different screen would never show. + assert_eq!( + dsl.matches("l0_map(").count(), + 1, + "every screen draws one map:\n{dsl}" + ); + if dsl.contains("follow") { + "drive" + } else if dsl.contains("l0_field(") { + "plan" + } else { + "preview" + } + }; + + assert_eq!(screen(&store), "plan"); + let mut seen = Vec::new(); + for _ in 0..4 { + splash_ui_l0::dispatch_reporting(CARD, &mut store, "root", "go", None, &data); + seen.push(screen(&store)); + } + assert_eq!( + seen, + vec!["preview", "drive", "plan", "preview"], + "one transition walks the whole journey and comes back round" + ); +} From db09aa90cc37d31e4c541941110f5d8d3ddcf75b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:47:07 -0700 Subject: [PATCH 65/97] l0: a trip from here CAPTURES the fix instead of referencing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R11.3. My note said this was "expressible at the cost of four route sources". Wrong twice over, and the first way is the dangerous one: `sys.route(from_lat: here.lat, …)` renders perfectly and is broken. `here` is a source, so the route re-fetches on every fix — and `sys.step` then compares the route's start against the device's position when they are the SAME expression. Checked rather than reasoned: `sys.navprog`'s first and fifth arguments came out identical, so progress along the route is always zero and the banner holds the first manoeuvre for the whole drive. `state from_lat { initial: here.lat }` reads the path once at realize and the store owns it after, so the start lowers to a LITERAL while the position stays a live call. That is R9.5's freeze — the L2 card's top-level `let` — said declaratively, where a card cannot get the freezing wrong. `Map` gains `from_lat`/`from_lon` for the same reason `sys.route` takes four numbers: a position captured from the fix has no place to name. Without them the map geocoded the empty origin, so the summary said "from here" and the line drew a route from nowhere. And the coordinate is formatted at FULL precision. `trim_num` gives one decimal place, which is right for a zoom level and catastrophic here: 37.2656 became 37.3, about 11 km, and the route drew from a quarter of the way to San Francisco looking entirely plausible. The card's size bound goes 200 -> 260, which is its second raise and not free. The number to watch is the ratio: 664 lines at L2 against 213 here. If an increment ever needs 400, that is the signal to add machinery rather than declarations. NOT COMPLETE END TO END, and the block is in the host: a state supplied only by a declared `initial:` is never written to the store or the blob, so the fetcher cannot resolve `state.from_lat`, `trip_here` is never requested, and `$state` stays pending. On device that is "Finding a route…" — which is what an empty origin already showed, so nothing regressed. Named origins verified unchanged: 30 min, 27.6 km. --- crates/splash-ui-l0/src/lib.rs | 29 ++++++ crates/splash-ui-l0/tests/fixtures/nav.card | 110 ++++++++++++++++---- crates/splash-ui-l0/tests/profile.rs | 105 +++++++++++++++++-- docs/ui-l0-constructors.toml | 7 ++ 4 files changed, 223 insertions(+), 28 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 5a90f4b..cf8dab4 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2692,6 +2692,18 @@ pub mod catalog { ("view", Token(MAP_VIEW)), ("zoom", Number), ("controls", Token(CONTROLS)), + // The start as TWO NUMBERS, when there is no place to name. + // + // `from:` names a source that answers a coordinate, which is right + // whenever a place was searched for. A trip that begins where the + // DEVICE is has no such place: the position is two captured numbers + // in card state. Without these the map fell back to geocoding the + // empty origin — `sys.navroute(sys.searchnum("", …))` — so the + // summary said "from here" and the line drew a route from nowhere, + // which is the drawn-versus-reported mismatch this profile exists to + // catch. Same shape and same reason as `sys.route`'s four numbers. + ("from_lat", ArgKind::Data), + ("from_lon", ArgKind::Data), ], ), // A text field. The ONE role that lets a card receive something the user @@ -5971,6 +5983,23 @@ pub mod makepad { /// An endpoint names a SOURCE rather than coordinates, so each is asked for /// its own axis — `at: here` becomes `sys.gps("lat")` and `sys.gps("lon")`. fn map_coord(node: &UiNode, name: &str, axis: &str) -> Option { + // An explicit `from_lat:`/`from_lon:` wins, because a card that supplied one + // meant it: it is naming a position no search would find. Card state resolves + // to a number at realize time, which is exactly what captured coordinates are. + let direct = format!("{name}_{axis}"); + if let Some((_, b)) = node.bindings.iter().find(|(n, _)| *n == direct) { + if let Some(call) = vm_call(b) { + return Some(call); + } + } + if let Some(NodeValue::Number(n)) = arg(node, &direct) { + // FULL PRECISION, not `trim_num`. That formats to one decimal place, + // which is right for a zoom level or a gap and catastrophic for a + // coordinate: 37.2656 became 37.3, about 11 km at this latitude. The + // route drew from a place a quarter of the way to San Francisco and + // looked entirely plausible doing it. + return Some(format!("{n}")); + } let (_, binding) = node.bindings.iter().find(|(n, _)| n == name)?; // A collection answers `0.lat` and a scalar source answers `lat`. for field in [axis.to_owned(), format!("0.{axis}")] { diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index f26356d..f8e3de5 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -60,6 +60,19 @@ source stop_place sys.search(query: state.stop, count: 1, fields: [id, name, lat # The guards below pick which is on screen, so exactly one of them is ever read — # and the card says in its structure that a trip with a stop is a different trip, # which is true and which a conditional argument would have hidden. +# The same trip, begun from the device rather than from a named place. Two sources +# because a source's arguments are fixed at declaration — the cost §5.4 makes +# visible, and the reason the card says in its structure that "from here" and "from +# a place you named" are different trips. +source trip_here sys.route(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + mode: state.mode, fields: [duration, distance]) + +source step_here sys.step(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + at_lat: here.lat, at_lon: here.lon, + fields: [instruction, remaining, eta]) + source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) @@ -100,7 +113,22 @@ source env.locale sys.locale() # opened with the trip already known — which is every card whose request named # the places — had no input at all. Measured: the model generated a card # containing only a `Map`, and there was no way to change where you were going. -state origin { shape: text, initial: "" } # empty ⇒ nothing to route from +state origin { shape: text, initial: "" } # empty ⇒ start from the device + +# WHERE THE TRIP BEGAN, captured once. R11.3. +# +# `initial:` reads a path from the data at REALIZE time and the store owns it after, +# so these lower to literal numbers while `here.lat` stays a live call. That +# distinction is the whole requirement: a route declared `from_lat: here.lat` is +# re-fetched every time the fix changes, and `sys.step` would compare the route's +# start against the device's position when they are the SAME expression — progress +# always zero, the banner stuck on the first manoeuvre for the whole drive. Checked, +# not assumed: `sys.navprog`'s first and fifth arguments came out identical. +# +# This is R9.5's "freeze" pattern — the L2 card's top-level `let`, which freezes at +# build — said declaratively, where the card cannot get the freezing wrong. +state from_lat { shape: number, initial: here.lat } +state from_lon { shape: number, initial: here.lon } state dest { shape: text, initial: "" } # empty ⇒ nothing to route to state query { shape: text, initial: "" } # what the user is typing @@ -282,12 +310,26 @@ view root Surface { when stop == "" { # §5.9, where the original compared against -9999. "Not yet" and "failed" # are different states and the card can say so. - when trip.$state == .pending { TextBody(text: copy.seeking) } - when trip.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) + # NAMED origin, or the one the device captured. R11.3: a trip that names no + # origin starts from where you are, which is what `from_lat` froze. + when origin != "" { + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } + } + } + when origin == "" { + when trip_here.$state == .pending { TextBody(text: copy.seeking) } + when trip_here.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip_here.duration) + TextCaption(value: trip_here.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } } } # The static route preview: no `at:`, so no camera that moves. @@ -303,7 +345,15 @@ view root Surface { # source's `$state` (which the host injects). A live VALUE is not that. The # impossible-centre case is handled where the number is actually known, in # the widget. - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + when origin != "" { + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + } + # From the captured position: there is no place to name, so the map is given + # the same two numbers the route was. + when origin == "" { + Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, + zoom: 16, controls: .all) + } } # ---- the trip THROUGH the stop -------------------------------------- @@ -346,12 +396,27 @@ view root Surface { # `trip` and `trip_via` are two sources. when screen == .preview { when stop == "" { - Panel(dock: .bottom) { - TextHero(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) + when origin != "" { + Panel(dock: .bottom) { + TextHero(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + } + when origin == "" { + Panel(dock: .bottom) { + TextHero(value: trip_here.duration) + TextCaption(value: trip_here.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + } + when origin != "" { + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + } + when origin == "" { + Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, + zoom: 16, controls: .all) } - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) } when stop != "" { Panel(dock: .bottom) { @@ -374,7 +439,8 @@ view root Surface { # WRAPS. A manoeuvre is a whole street name and "Continue onto South De Anza # Boulevard" was clipped mid-word at the card's edge — the one thing a driver # reads at a glance, truncated. `TextBody` is the role that wraps. - TextBody(text: step.instruction, width: .fill) + when origin != "" { TextBody(text: step.instruction, width: .fill) } + when origin == "" { TextBody(text: step_here.instruction, width: .fill) } } # The summary sheet, docked to the bottom and SWIPEABLE — the shipping app's @@ -392,9 +458,11 @@ view root Surface { # "when do I arrive" is the question, and the distance is how it is answered. # `unit: .duration` because "34" beside "26.8 km" reads as another distance; the # theme supplies the word. - TextHero(value: step.eta, unit: .duration) + when origin != "" { TextHero(value: step.eta, unit: .duration) } + when origin == "" { TextHero(value: step_here.eta, unit: .duration) } # The distance, small, beneath it — the shipping card's `remrest`. - TextCaption(value: step.remaining) + when origin != "" { TextCaption(value: step.remaining) } + when origin == "" { TextCaption(value: step_here.remaining) } Reveal { # `.danger` says what the action MEANS. Ending navigation is destructive, and # the theme renders that red, full-width and centred — the shipping card's @@ -419,7 +487,13 @@ view root Surface { # # The impossible centre is handled where the number is actually known — in the # widget, which has both the fix and the route and can tell -9999 from a place. - Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) + when origin != "" { + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) + } + when origin == "" { + Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, + at: here, view: .tilted, zoom: 17) + } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index c55e81a..808af8d 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4759,16 +4759,25 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // screen but that most of the original was working around missing machinery — // a 600-line L0 card would disprove that as surely as a rejection would. // - // The bound was 100 and is 200. The card grew from 54 lines to ~130 by GAINING - // function, not by working around anything: a travel mode, a waypoint the route - // passes through, per-leg times, and turn-by-turn. Each cost declarations - // rather than machinery — a stop is two route sources because a source's - // arguments are fixed at declaration, so a trip with one is a different trip - // and the card says so. That is the price of a total form and it is visible, - // which is the point. + // The bound was 100, then 200, and is 260. The card grew from 54 lines by + // GAINING function, not by working around anything: a travel mode, a waypoint + // the route passes through, per-leg times, turn-by-turn, a preview screen, and + // an origin that defaults to the device. // - // The comparison it exists to make is unchanged: 664 lines at L2 against ~130 - // at L0, now at close to the same function. + // Each cost declarations rather than machinery, and the last one cost the most: + // a trip that starts where you are is two more route sources, a step source and + // eight guarded branches, because a source's arguments are fixed at declaration + // (§5.4). So "from a place you named" and "from here" are different trips and + // the card says so in its structure. That is the price of a total form and it + // is visible, which is the point. + // + // RAISING THIS IS NOT FREE and it is the second raise. The bound exists to + // catch me widening the card until the comparison stops meaning anything, so + // the number to watch is the ratio, not the slack: 664 lines at L2 against 213 + // here, at close to the same function. If a future increment needs 400, that is + // the signal to add machinery instead of declarations — the argument this whole + // exercise rests on is that the original was mostly compensation, and a card + // that grows like the original did would be evidence against it. let lines = NAV .lines() .filter(|l| { @@ -4777,7 +4786,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { }) .count(); assert!( - lines < 200, + lines < 260, "the point is that it is small; this is {lines} lines" ); } @@ -7938,3 +7947,79 @@ fn the_journey_through_the_card_is_one_transition_and_never_loses_the_map() { "one transition walks the whole journey and comes back round" ); } + +/// A trip that starts where you are must CAPTURE the fix, not reference it. +/// +/// R11.3. The obvious form — `sys.route(from_lat: here.lat, …)` — is wrong in a way +/// that renders perfectly: `here` is a source, so the route is re-fetched every time +/// the fix changes, and `sys.step` then compares the route's start against the +/// device's position when they are the SAME expression. Progress along the route is +/// always zero and the banner holds the first manoeuvre for the whole drive. +/// +/// `initial:` reads a path from the data once at realize and the store owns it +/// after, so the start lowers to a LITERAL while the position stays a live call. +/// That is R9.5's freeze — the L2 card's top-level `let` — said declaratively. +/// +/// The test asserts the asymmetry directly, because both forms lower to something +/// that looks like a working route. +#[test] +fn a_trip_from_here_freezes_its_start_and_not_its_position() { + const CARD: &str = concat!( + "source here sys.gps()\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "state from_lat { shape: number, initial: here.lat }\n", + "state from_lon { shape: number, initial: here.lon }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state mode { shape: enum[drive, walk, bike], initial: .drive }\n", + "source step sys.step(from_lat: state.from_lat, from_lon: state.from_lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " at_lat: here.lat, at_lon: here.lon,\n", + " fields: [instruction, remaining])\n", + "view root Surface {\n", + " TextBody(text: step.instruction)\n", + " Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: d, at: here,\n", + " view: .tilted, zoom: 17)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "dest": "B", "mode": "drive", + "here": { "lat": 37.2656, "lon": -122.0294, "ok": 1 }, + "d": [{ "id": "x", "name": "B", "lat": 37.4275, "lon": -122.1697 }], + "env": { "locale": {} } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + + // The START is a literal — captured, so it stays where the trip began. + assert!( + dsl.contains("37.2656") && dsl.contains("-122.0294"), + "the route's start must be captured, not called:\n{dsl}" + ); + // FULL PRECISION. Formatted the way sizes are, 37.2656 becomes 37.3 — about + // 11 km at this latitude, and a route from there looks entirely plausible. + assert!( + !dsl.contains("37.3,") && !dsl.contains("(37.3"), + "a coordinate is not a display size:\n{dsl}" + ); + // The POSITION stays live, or nothing moves as the device does. + assert!( + dsl.contains("sys.gps(\"lat\")"), + "the device's position must stay a live call:\n{dsl}" + ); + // And the two must not be the same expression, which is the defect this + // guards: progress measured from yourself is always zero. + let prog = dsl + .find("sys.navprog(") + .map(|i| dsl[i..].chars().take(120).collect::()) + .expect("a step lowers through navprog"); + assert!( + prog.starts_with("sys.navprog(37.2656"), + "progress must be measured from the captured start:\n{prog}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index effa402..d1f4b6d 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -63,6 +63,13 @@ zoom = { kind = "number" } # widget, and the imperative wiring this profile exists to exclude. "This map can be # zoomed" is a capability; the button, the glyph and the call are presentation. controls = { kind = "token", tokens = ["none", "zoom", "all"] } +# The start as TWO NUMBERS, for a trip that begins where the device is. `from:` +# names a source that answers a coordinate, which is right whenever a place was +# searched for; a position captured from the fix has no such place. Without these +# the map geocoded the empty origin — `sys.navroute(sys.searchnum("", …))` — so the +# summary said "from here" and the line drew a route from nowhere. +from_lat = { kind = "data" } +from_lon = { kind = "data" } # A text field — the one role that lets a card receive something the user typed. # From 5d79f1ec9948df55ea850ad6447dbe6f101b5259 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:52:39 -0700 Subject: [PATCH 66/97] l0: the hero ramp comes down to 70% with the rest of the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five buckets scale with the theme's other ten sizes — 62/50/40/32/24 becomes 43/35/28/22/17 — so the hero keeps its place at the top of the hierarchy rather than towering over a card that shrank around it. The test that pins this had to move with it, and it is worth saying what did NOT change: it asserts a hero is sized by what it DRAWS, not by what it emits. "$184.20" is 7 glyphs and lands in one bucket; the 33-character expression that produces it lands in another. Those two numbers are now 28 and 17 instead of 40 and 24, and the gap between them is the whole point of the test. --- crates/splash-ui-l0/src/lib.rs | 13 ++++++++----- crates/splash-ui-l0/tests/profile.rs | 10 ++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index cf8dab4..7ee7b00 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6795,12 +6795,15 @@ pub mod makepad { /// drift. pub(super) fn hero_points(node: &UiNode, emitted: &str) -> u32 { let measured = sizing_text(node, emitted); + // The ramp, at 70%. Every text size in the theme came down by the same + // factor, so the hierarchy between a hero, a value and a caption is + // unchanged — only the scale is. match measured.trim_matches('"').chars().count() { - 0..=4 => 62, - 5..=6 => 50, - 7..=8 => 40, - 9..=12 => 32, - _ => 24, + 0..=4 => 43, + 5..=6 => 35, + 7..=8 => 28, + 9..=12 => 22, + _ => 17, } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 808af8d..4674eae 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4441,11 +4441,13 @@ fn a_hero_is_sized_by_what_it_draws_not_by_what_it_emits() { hero.contains("sys.stock"), "the hero must be live for this test to mean anything:\n{hero}" ); - // "$184.20" is 7 glyphs, which is the 40pt bucket. The emitted expression is - // 33 characters, which is the 24pt one. + // "$184.20" is 7 glyphs, which is the 28pt bucket. The emitted expression is + // 33 characters, which is the 17pt one. The two numbers moved when the theme + // came down to 70%; what the test asserts did not — the hero must be sized by + // what it DRAWS, and the gap between the buckets is what proves it. assert!( - hero.contains("font_size: 40"), - "sized by the drawn value (7 glyphs -> 40pt), not the emitted 33:\n{hero}" + hero.contains("font_size: 28"), + "sized by the drawn value (7 glyphs -> 28pt), not the emitted 33:\n{hero}" ); } From 3fd123c1c503a7d90a7ff2b01a5c0258dc3a0f46 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:57:08 -0700 Subject: [PATCH 67/97] =?UTF-8?q?nav:=20back=20out=20the=20captured=20orig?= =?UTF-8?q?in=20=E2=80=94=20realize=20is=20the=20wrong=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I shipped `state from_lat { initial: here.lat }` last commit as R11.3's answer, and it cannot work. Realize happens BEFORE the first fix lands, so the capture froze `sys.gps`'s -9999 sentinel permanently: a route from the Gulf of Guinea for the life of the card, and no later fix could dislodge it. That is the SAME defect the card's own header cites as the reason the L2 version needed `tick()` — "a top-level `let` freezes at build, before the fetch lands". I reintroduced it in a new syntax while quoting the note that warns about it. Capturing is still the right shape; realize is the wrong moment, and there is a deeper reason it could never have worked here: the host does not write fetched values into a card's data, so there is no fix in the blob to capture at realize OR at a tap. Kept: `Map.from_lat`/`from_lon`, which is a real capability with a real test — a position taken from the device has no place to name, and without it the map geocoded the empty origin and drew a route from nowhere while the text said "from here". Kept too the full-precision rule, because `trim_num` turning 37.2656 into 37.3 is 11 km. The size bound comes back to 200 with the feature. A bound raised for work that is then removed is a bound that no longer measures anything: 664 lines at L2 against 162 here. Plan screen re-verified on device: 30 min, 27.6 km away, Go. --- crates/splash-ui-l0/tests/fixtures/nav.card | 110 ++++---------------- crates/splash-ui-l0/tests/profile.rs | 42 ++++---- 2 files changed, 42 insertions(+), 110 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index f8e3de5..f26356d 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -60,19 +60,6 @@ source stop_place sys.search(query: state.stop, count: 1, fields: [id, name, lat # The guards below pick which is on screen, so exactly one of them is ever read — # and the card says in its structure that a trip with a stop is a different trip, # which is true and which a conditional argument would have hidden. -# The same trip, begun from the device rather than from a named place. Two sources -# because a source's arguments are fixed at declaration — the cost §5.4 makes -# visible, and the reason the card says in its structure that "from here" and "from -# a place you named" are different trips. -source trip_here sys.route(from_lat: state.from_lat, from_lon: state.from_lon, - to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, - mode: state.mode, fields: [duration, distance]) - -source step_here sys.step(from_lat: state.from_lat, from_lon: state.from_lon, - to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, - at_lat: here.lat, at_lon: here.lon, - fields: [instruction, remaining, eta]) - source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) @@ -113,22 +100,7 @@ source env.locale sys.locale() # opened with the trip already known — which is every card whose request named # the places — had no input at all. Measured: the model generated a card # containing only a `Map`, and there was no way to change where you were going. -state origin { shape: text, initial: "" } # empty ⇒ start from the device - -# WHERE THE TRIP BEGAN, captured once. R11.3. -# -# `initial:` reads a path from the data at REALIZE time and the store owns it after, -# so these lower to literal numbers while `here.lat` stays a live call. That -# distinction is the whole requirement: a route declared `from_lat: here.lat` is -# re-fetched every time the fix changes, and `sys.step` would compare the route's -# start against the device's position when they are the SAME expression — progress -# always zero, the banner stuck on the first manoeuvre for the whole drive. Checked, -# not assumed: `sys.navprog`'s first and fifth arguments came out identical. -# -# This is R9.5's "freeze" pattern — the L2 card's top-level `let`, which freezes at -# build — said declaratively, where the card cannot get the freezing wrong. -state from_lat { shape: number, initial: here.lat } -state from_lon { shape: number, initial: here.lon } +state origin { shape: text, initial: "" } # empty ⇒ nothing to route from state dest { shape: text, initial: "" } # empty ⇒ nothing to route to state query { shape: text, initial: "" } # what the user is typing @@ -310,26 +282,12 @@ view root Surface { when stop == "" { # §5.9, where the original compared against -9999. "Not yet" and "failed" # are different states and the card can say so. - # NAMED origin, or the one the device captured. R11.3: a trip that names no - # origin starts from where you are, which is what `from_lat` froze. - when origin != "" { - when trip.$state == .pending { TextBody(text: copy.seeking) } - when trip.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) - } - } - } - when origin == "" { - when trip_here.$state == .pending { TextBody(text: copy.seeking) } - when trip_here.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip_here.duration) - TextCaption(value: trip_here.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) - } + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) } } # The static route preview: no `at:`, so no camera that moves. @@ -345,15 +303,7 @@ view root Surface { # source's `$state` (which the host injects). A live VALUE is not that. The # impossible-centre case is handled where the number is actually known, in # the widget. - when origin != "" { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) - } - # From the captured position: there is no place to name, so the map is given - # the same two numbers the route was. - when origin == "" { - Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, - zoom: 16, controls: .all) - } + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) } # ---- the trip THROUGH the stop -------------------------------------- @@ -396,27 +346,12 @@ view root Surface { # `trip` and `trip_via` are two sources. when screen == .preview { when stop == "" { - when origin != "" { - Panel(dock: .bottom) { - TextHero(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) - } - } - when origin == "" { - Panel(dock: .bottom) { - TextHero(value: trip_here.duration) - TextCaption(value: trip_here.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) - } - } - when origin != "" { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) - } - when origin == "" { - Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, - zoom: 16, controls: .all) + Panel(dock: .bottom) { + TextHero(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) } + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) } when stop != "" { Panel(dock: .bottom) { @@ -439,8 +374,7 @@ view root Surface { # WRAPS. A manoeuvre is a whole street name and "Continue onto South De Anza # Boulevard" was clipped mid-word at the card's edge — the one thing a driver # reads at a glance, truncated. `TextBody` is the role that wraps. - when origin != "" { TextBody(text: step.instruction, width: .fill) } - when origin == "" { TextBody(text: step_here.instruction, width: .fill) } + TextBody(text: step.instruction, width: .fill) } # The summary sheet, docked to the bottom and SWIPEABLE — the shipping app's @@ -458,11 +392,9 @@ view root Surface { # "when do I arrive" is the question, and the distance is how it is answered. # `unit: .duration` because "34" beside "26.8 km" reads as another distance; the # theme supplies the word. - when origin != "" { TextHero(value: step.eta, unit: .duration) } - when origin == "" { TextHero(value: step_here.eta, unit: .duration) } + TextHero(value: step.eta, unit: .duration) # The distance, small, beneath it — the shipping card's `remrest`. - when origin != "" { TextCaption(value: step.remaining) } - when origin == "" { TextCaption(value: step_here.remaining) } + TextCaption(value: step.remaining) Reveal { # `.danger` says what the action MEANS. Ending navigation is destructive, and # the theme renders that red, full-width and centred — the shipping card's @@ -487,13 +419,7 @@ view root Surface { # # The impossible centre is handled where the number is actually known — in the # widget, which has both the fix and the route and can tell -9999 from a place. - when origin != "" { - Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) - } - when origin == "" { - Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, - at: here, view: .tilted, zoom: 17) - } + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 4674eae..fb21609 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4761,25 +4761,22 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // screen but that most of the original was working around missing machinery — // a 600-line L0 card would disprove that as surely as a rejection would. // - // The bound was 100, then 200, and is 260. The card grew from 54 lines by - // GAINING function, not by working around anything: a travel mode, a waypoint - // the route passes through, per-leg times, turn-by-turn, a preview screen, and - // an origin that defaults to the device. + // The bound was 100 and is 200. The card grew from 54 lines by GAINING function, + // not by working around anything: a travel mode, a waypoint the route passes + // through, per-leg times, turn-by-turn, and a preview screen. Each cost + // declarations rather than machinery — a stop is two route sources because a + // source's arguments are fixed at declaration, so a trip with one is a different + // trip and the card says so. That is the price of a total form and it is + // visible, which is the point. // - // Each cost declarations rather than machinery, and the last one cost the most: - // a trip that starts where you are is two more route sources, a step source and - // eight guarded branches, because a source's arguments are fixed at declaration - // (§5.4). So "from a place you named" and "from here" are different trips and - // the card says so in its structure. That is the price of a total form and it - // is visible, which is the point. + // It went to 260 for a while, for an origin that defaults to the device (R11.3): + // two more route sources, a step source and eight guarded branches. That work is + // backed out — it could not work until the host writes fetched values into a + // card's data — and the bound comes back down with it. A bound raised for a + // feature and left up after the feature is removed is a bound that no longer + // measures anything. // - // RAISING THIS IS NOT FREE and it is the second raise. The bound exists to - // catch me widening the card until the comparison stops meaning anything, so - // the number to watch is the ratio, not the slack: 664 lines at L2 against 213 - // here, at close to the same function. If a future increment needs 400, that is - // the signal to add machinery instead of declarations — the argument this whole - // exercise rests on is that the original was mostly compensation, and a card - // that grows like the original did would be evidence against it. + // The comparison it exists to make: 664 lines at L2 against 162 here. let lines = NAV .lines() .filter(|l| { @@ -4788,7 +4785,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { }) .count(); assert!( - lines < 260, + lines < 200, "the point is that it is small; this is {lines} lines" ); } @@ -7964,6 +7961,15 @@ fn the_journey_through_the_card_is_one_transition_and_never_loses_the_map() { /// /// The test asserts the asymmetry directly, because both forms lower to something /// that looks like a working route. +/// +/// **This is not yet a recommendation.** The nav card used it and it was backed out: +/// realize happens before the first fix lands, so the capture froze `sys.gps`'s +/// -9999 sentinel permanently — which is the SAME "freezes at build, before the +/// fetch lands" defect that the card's own header cites as the reason the L2 +/// version needed `tick()`. Capturing is the right shape and realize is the wrong +/// moment; what is missing is the host writing fetched values into a card's data, so +/// that there is a fix to capture at all. Kept as a test because the mechanism and +/// the precision rule are both real and both worth pinning. #[test] fn a_trip_from_here_freezes_its_start_and_not_its_position() { const CARD: &str = concat!( From 2213ee3a26d80b68528049b67d75667b42ae01a0 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:11:52 -0700 Subject: [PATCH 68/97] l0: a search result says WHICH place it is, and carries the way back to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys.search` has always answered `label` and could always compose a re-findable query; the catalog admitted neither. So a search for "Stanford" rendered five rows all reading "Stanford" — which is genuinely what the helper returns — and tapping the third set state to "Stanford", which re-geocodes to the FIRST. Picking Kentucky took you to California, and every layer was working. `label` is the line that tells them apart. `query` is name plus label, which is the text that finds THAT hit again — a results list writes card state from the row a user picked, and that state is searched again to route, so a row that carries only its name cannot survive the round trip. Rows key on `label` rather than `name` for the same reason: five identical names are five identical keys. Device: five results — Palo Alto, Kentucky, England, Montana. Tapping Kentucky sets TO to "Stanford, Kentucky, United States" and routes 2585 min, 3951.9 km, which is what California to Kentucky is. --- crates/splash-ui-l0/src/lib.rs | 17 +++++++++++++++-- crates/splash-ui-l0/tests/fixtures/nav.card | 18 ++++++++++++++---- docs/ui-l0-constructors.toml | 2 +- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 7ee7b00..4d76ce1 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2981,7 +2981,11 @@ pub mod catalog { ("sys.photo", &[]), ("sys.locale", &["lang", "temp_unit"]), ("sys.gps", &["lat", "lon", "accuracy", "ok"]), - ("sys.search", &["id", "name", "lat", "lon", "distance"]), + // `label` is the secondary line — city, region, country. Without it a search + // for "Stanford" renders five rows all reading "Stanford", which is what + // Photon actually returns and is useless to choose between. The backend has + // answered this field all along; the catalog simply never admitted it. + ("sys.search", &["id", "name", "label", "query", "lat", "lon", "distance"]), ("sys.route", &["duration", "distance", "steps"]), ("sys.step", &["instruction", "remaining", "progress", "eta"]), ( @@ -6457,7 +6461,16 @@ pub mod makepad { let query = arg("query")?; match field { "lat" | "lon" => Some(format!("sys.searchnum({query:?}, {index}, {field:?})")), - "name" => Some(format!("sys.search({query:?}, {index}, \"name\")")), + // `label` is the secondary line the helper has always answered — + // city, region, country. Five results named "Stanford" are what + // Photon returns for "Stanford", and without this they render as + // five identical rows nobody can choose between. + // `query` is the text that finds this hit again — what a + // results row must carry, or picking the third "Stanford" sets + // state to "Stanford" and routes to the first. + "name" | "label" | "query" => { + Some(format!("sys.search({query:?}, {index}, {field:?})")) + } // `id` and `distance` have no answer in the helper, so they // fall back rather than emitting a call that returns "". _ => None, diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index f26356d..fdd84b8 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -38,7 +38,7 @@ # the turn instruction, the distance remaining — moves because the device did. source here sys.gps() -source found sys.search(query: state.query, count: 5, fields: [id, name, lat, lon]) +source found sys.search(query: state.query, count: 5, fields: [name, label, query, lat, lon]) # Both endpoints, each resolved by the search that found it. source origin_place sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon]) @@ -238,9 +238,19 @@ view root Surface { # on device the first time. L0 has no predicate for "this list has rows", so the # container has to be the thing that cannot render empty: no rows, no nodes. when query != "" { - for f, i in found key f.id { - Row(align: .center, gap: 10, on_tap: choose_dest, value: f.id) { - TextRow(text: f.name) + # KEYED ON THE NAME, not on `id`. `sys.search` answers name/label/lat/lon and + # the backend has no translation for `id` at all, so a row keyed on it had no + # identity and a tap carried nothing. The name is what the search returns and + # what `sys.search(query: state.dest)` can find again. + for f, i in found key f.label { + Row(align: .center, gap: 10, on_tap: choose_dest, value: f.query) { + Col(gap: 2) { + TextRow(text: f.name) + # WHICH Stanford. The name alone is not a choice — the helper answers + # five places called "Stanford" and this is the line that tells them + # apart, so it is also what keys the row. + TextCaption(text: f.label) + } } Rule() } diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index d1f4b6d..4f01e2d 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -342,7 +342,7 @@ answers = ["lat", "lon", "accuracy", "ok"] # receive from the user. [sources."sys.search"] args = ["query", "count", "fields"] -answers = ["id", "name", "lat", "lon", "distance"] +answers = ["id", "name", "label", "query", "lat", "lon", "distance"] # A trip's facts — duration, distance, the step list. The ROUTE ITSELF is the # `Map` widget's to fetch; this is what the card needs to write on the screen From a334ec33951c3131488ddab49c399b7d8343e5b5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:21:55 -0700 Subject: [PATCH 69/97] =?UTF-8?q?l0:=20back=20out=20R11.3=20again=20?= =?UTF-8?q?=E2=80=94=20there=20is=20no=20moment=20to=20capture=20the=20fix?= =?UTF-8?q?=20at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third attempt, third distinct failure, each one only visible on the phone. Referencing the fix makes the route chase the driver and pins progress at zero. Capturing with `initial:` fires before the first fix and freezes -9999. Capturing on the Go tap works from the tap onward — but before it, the cell is unwritten, so `initial:` re-resolves every realize, the origin follows the moving device, and the route request changes before it can complete. The summary never settles. On a parked phone all three would have looked fine. What is missing is not a fetch. It is a way for a card to name the INSTANT a value is taken — "where I was when I started". `initial:` is the wrong instant and a source is no instant at all. The size bound goes back to 200 with the feature, for the second time. A bound left high after its feature is removed measures nothing: 664 lines at L2 against 165 here. KEPT, because it is verified and general: the host answers fetched values into a card's data. A card with an empty blob now captures the device's position, which is §5.9's write-back and the thing R2.1's results list needed. --- crates/splash-ui-l0/tests/profile.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index fb21609..3fbddc3 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4769,14 +4769,15 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // trip and the card says so. That is the price of a total form and it is // visible, which is the point. // - // It went to 260 for a while, for an origin that defaults to the device (R11.3): - // two more route sources, a step source and eight guarded branches. That work is - // backed out — it could not work until the host writes fetched values into a - // card's data — and the bound comes back down with it. A bound raised for a - // feature and left up after the feature is removed is a bound that no longer - // measures anything. + // It went to 260, then 230, for an origin that defaults to the device (R11.3), + // and both times the feature came back out. The bound goes with it: one left + // high after its feature is removed measures nothing. // - // The comparison it exists to make: 664 lines at L2 against 162 here. + // The comparison it exists to make: 664 lines at L2 against 162 here, at close + // to the same function. If an increment ever needs 400, that is the signal to add + // machinery instead of declarations — this whole exercise rests on the original + // being mostly compensation, and a card that grew like the original did would be + // evidence against it. let lines = NAV .lines() .filter(|l| { From 49ea16417bdc877330cff88f34deb8da7108550f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:30:50 -0700 Subject: [PATCH 70/97] l0: a field can answer a keystroke and a commit differently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on_change` beside `on_commit`, because they are different questions. A keystroke asks "what am I looking for"; a return says "this is where I am going". Collapsing them loses one or the other: commit-only means no results until you press return, change-only means every character sets a destination and routes to it. `TextInput` has called both back all along. Only the catalog was short one, which is the same shape as `label` and `query` on `sys.search` — the backend answering more than a card was allowed to ask for. I nearly rejected this on a remembered number. A card re-resolve per keystroke sounded like the 327 ms map rebuild, which would have made the feature correct and unusable. That figure is the DRIVE screen, with a follow camera and a route to re-tessellate. Measured on the planning screen it is 18-19 ms — so I measured before deciding, and the feature is fine. Device: the partial word "Stanf" lists "Nelson Road, Stanford, California" and "Stanford, Kentucky" with the destination still empty and no route drawn, which is exactly the distinction the two events exist for. --- crates/splash-ui-l0/src/lib.rs | 19 ++++++-- crates/splash-ui-l0/tests/fixtures/nav.card | 7 ++- crates/splash-ui-l0/tests/profile.rs | 53 +++++++++++++++++++++ docs/ui-l0-constructors.toml | 3 ++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 4d76ce1..86f2c8c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2719,6 +2719,12 @@ pub mod catalog { ("text", Path), ("placeholder", ArgKind::Data), ("on_commit", Event), + // Per KEYSTROKE, where `on_commit` is per return. A search box wants + // both: results while you type, a destination when you commit. The + // cost is a card re-resolve per character — measured at 18-19 ms on + // the planning screen of a OnePlus 6, which is what makes this + // expressible rather than merely declarable. + ("on_change", Event), ("width", TokenOrPath(WIDTH)), ], ), @@ -8876,7 +8882,13 @@ pub mod kit { /// the target is assembled at that moment from the value the backend hands /// back. `$$` is the placeholder the backend substitutes. fn commit_target(node: &UiNode) -> Option { - let Some(NodeValue::Event(event)) = arg(node, "on_commit") else { + field_target(node, "on_commit") + } + + /// A field's target for one of its two moments. `$$` is where the typed text + /// goes, assembled at the moment rather than baked at lowering time. + fn field_target(node: &UiNode, arg_name: &str) -> Option { + let Some(NodeValue::Event(event)) = arg(node, arg_name) else { return None; }; let json = serde_json::json!({ "e": event, "k": node.key, "v": "$$" }); @@ -8909,10 +8921,11 @@ pub mod kit { if node.kind == "Field" { let _ = write!( out, - "l0_field({}, {}, {:?})", + "l0_field({}, {}, {:?}, {:?})", makepad::expr_of(node, "text"), makepad::expr_of(node, "placeholder"), - commit_target(node).unwrap_or_default() + commit_target(node).unwrap_or_default(), + field_target(node, "on_change").unwrap_or_default() ); return; } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index fdd84b8..b78a14f 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -162,6 +162,10 @@ event set_origin { origin: set($value), query: clear } # (so the route draws to the best match immediately) and it is the query (so the # alternatives are listed underneath, and one tap refines the choice). event set_dest { dest: set($value), query: set($value) } +# What is being typed, per keystroke. Separate from `set_dest` because the two mean +# different things: a query is a question and a destination is an answer, and until +# you commit, only the question exists. +event typing { query: set($value) } event choose_dest { dest: set($value), query: clear } # ONE event for the whole journey through the card, because `cycle` names an order # and the order IS the flow: plan → preview → drive → plan. Three chips on three @@ -204,7 +208,8 @@ view root Surface { Rule() Row(align: .center, gap: 10) { TextCaption(text: copy.to) - Field(text: dest, placeholder: copy.where, on_commit: set_dest, width: .fill) + Field(text: dest, placeholder: copy.where, on_commit: set_dest, + on_change: typing, width: .fill) } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 3fbddc3..5dfe6ed 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -8032,3 +8032,56 @@ fn a_trip_from_here_freezes_its_start_and_not_its_position() { "progress must be measured from the captured start:\n{prog}" ); } + +/// A field has TWO moments, and they mean different things. +/// +/// `on_commit` is the return key: this is my destination. `on_change` is every +/// keystroke: this is what I am asking about. A search box needs both — results +/// while you type, a trip when you commit — and collapsing them loses one or the +/// other: commit-only means no results until you press return, change-only means +/// every character sets a destination and routes to it. +/// +/// The test checks they stay SEPARATE and that a field asking for neither gets +/// neither, because an unasked-for keystroke event is a fetch per character. +#[test] +fn a_field_can_answer_a_keystroke_and_a_commit_differently() { + const CARD: &str = concat!( + "state dest { shape: text, initial: \"\" }\n", + "state query { shape: text, initial: \"\" }\n", + "event set_dest { dest: set($value), query: set($value) }\n", + "event typing { query: set($value) }\n", + "copy to { class: vocabulary, en: \"TO\" }\n", + "view root Surface {\n", + " Field(text: dest, placeholder: copy.to, on_commit: set_dest, on_change: typing, width: .fill)\n", + " Field(text: query, placeholder: copy.to, on_commit: set_dest, width: .fill)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "dest": "", "query": "", "env": { "locale": {} }, "copy": { "to": "TO" } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + let fields: Vec<&str> = dsl.lines().filter(|l| l.contains("l0_field(")).collect(); + let joined = fields.join("\n"); + // The first field carries BOTH, and they are different events. + assert!( + joined.contains("\\\"e\\\":\\\"typing\\\"") && joined.contains("\\\"e\\\":\\\"set_dest\\\""), + "a field may answer both moments:\n{joined}" + ); + // The second asked for no keystroke event and must not have acquired one: a + // change target it never declared is a search on every character. + let second = fields + .iter() + .find(|l| !l.contains("typing")) + .unwrap_or_else(|| panic!("a field with no on_change:\n{joined}")); + assert!( + second.ends_with("\"\")") || second.contains(", \"\")"), + "a field that asked for no keystroke event gets none:\n{second}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 4f01e2d..23c1dd2 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -82,6 +82,9 @@ from_lon = { kind = "data" } text = { kind = "path" } placeholder = { kind = "data" } on_commit = { kind = "event" } +# Per KEYSTROKE, where on_commit is per return — results while you type. Costs a +# card re-resolve per character: 18-19 ms on a OnePlus 6 planning screen. +on_change = { kind = "event" } width = { kind = "width" } # Where a panel sits when the card is a MAP. From adea55ce0a22b2d99eae42ffe567ea4c86c1f201 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:40:11 -0700 Subject: [PATCH 71/97] l0: a map can route through two stops, and pin both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4.5. The first attempt was reverted because `Map(via:)` named ONE source: the line went through one waypoint while the duration beside it was for a trip through two. Both are plausible lines on a map, which is why it took counting separators to see. A second named slot rather than a list. Role arguments route through the expression grammar, so admitting `[a, b]` there is a change to the whole grammar for one argument — and the app being replaced has exactly two waypoint slots, `wp1` and `wp2`, and hides "add stop" when both are full. `sys.route`'s `via:` already carried N pairs; only the map's did not. The test counts separators inside the route call ALONE. My first version took a fixed-width window, swept in the pin string — which legitimately carries three separators for origin, two stops and destination — and reported a correct lowering as four waypoints. Verified by reverting: dropping the second slot fails it. `("Map", "via2")` joins `via` in the INERT list, with the stale note removed: `via` is emitted, reaches both the polyline and the pins, and has been verified on device. Device: Saratoga → Cupertino → Mountain View → Stanford, 38 min / 28.7 km against 30 min / 27.6 km direct, with two blue stop pins and the line detouring through both. --- crates/splash-ui-l0/src/lib.rs | 31 +++++++-- crates/splash-ui-l0/tests/profile.rs | 96 ++++++++++++++++++++++++++-- docs/ui-l0-constructors.toml | 5 ++ 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 86f2c8c..498ac45 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2681,6 +2681,13 @@ pub mod catalog { ("from", Path), ("to", Path), ("via", Path), + // A SECOND fixed slot, not a list. The role parser routes arguments + // through the expression grammar, so admitting `[a, b]` there means a + // list literal in every operand position — a change to the whole + // grammar for one argument. The app being replaced has exactly two + // waypoint slots, `wp1` and `wp2`, and hides "add stop" when both are + // full; two named arguments say the same thing and stay total. + ("via2", Path), // The live position the camera follows. See `map_mode`: this is // what lets `.drive` mean the chase camera rather than the static // preview, because a followed position is a measured one. @@ -6031,9 +6038,19 @@ pub mod makepad { /// that cannot answer a coordinate yields no vias, because a map drawn through /// a place that could not be resolved is a map of a different trip. pub(super) fn map_vias(node: &UiNode) -> Option { - let lat = map_coord(node, "via", "lat")?; - let lon = map_coord(node, "via", "lon")?; - via_string(&format!("{lat}\u{1}{lon}")) + let mut pairs: Vec = Vec::new(); + for slot in ["via", "via2"] { + let (Some(lat), Some(lon)) = (map_coord(node, slot, "lat"), map_coord(node, slot, "lon")) + else { + continue; + }; + pairs.push(lat); + pairs.push(lon); + } + if pairs.is_empty() { + return None; + } + via_string(&pairs.join("\u{1}")) } /// The live position a `Map` was told to follow, if it was told one. @@ -6121,9 +6138,11 @@ pub mod makepad { // kind 0 origin, 1 a stop, 2 the destination — the widget's own encoding. let mut out = format!("\"\" + {a} + \",\" + {o} + \",0\""); // The same resolution the polyline's waypoints use, so a stop that routes - // through gets a pin and one that does not, does not. - if let (Some(vlat), Some(vlon)) = (coord("via", "lat"), coord("via", "lon")) { - let _ = write!(out, " + \";\" + {vlat} + \",\" + {vlon} + \",1\""); + // through gets a pin and one that does not, does not — for both slots. + for slot in ["via", "via2"] { + if let (Some(vlat), Some(vlon)) = (coord(slot, "lat"), coord(slot, "lon")) { + let _ = write!(out, " + \";\" + {vlat} + \",\" + {vlon} + \",1\""); + } } let _ = write!(out, " + \";\" + {b} + \",\" + {p} + \",2\""); Some(out) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 5dfe6ed..414b110 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5557,16 +5557,20 @@ const INERT: &[(&str, &str)] = &[ // a card asks for `.tight`. ("Surface", "pad"), ("Photo", "pad"), - // `from`/`to`/`via` name SOURCES, and this check varies a state-bound value - // — which a `Map` correctly ignores, because a trip endpoint is a place and - // not a number. Their liveness is asserted by + // `from`/`to`/`via`/`via2` name SOURCES, and this check varies a state-bound + // value — which a `Map` correctly ignores, because a trip endpoint is a place + // and not a number. Their liveness is asserted by // `a_bound_attribute_must_lower_to_a_live_call` instead, which binds a real - // capability. `via` is additionally not emitted yet: the helper carries it in - // a sixth argument and threading a card's list through needs the list - // rendered the way `sys.route` renders it. + // capability, and by `a_map_routes_through_both_of_its_stops` for the two + // waypoint slots. + // + // The note that used to sit here — "`via` is additionally not emitted yet" — + // was stale: it reaches both the polyline and the pins, and a trip through a + // stop has been verified on device. ("Map", "from"), ("Map", "to"), ("Map", "via"), + ("Map", "via2"), // `at` is the same: a live POSITION, so a state cell holding a number is // correctly ignored. Its liveness — that it centres the map on the fix and // turns `.drive` into the follow camera — is asserted by @@ -8085,3 +8089,83 @@ fn a_field_can_answer_a_keystroke_and_a_commit_differently() { "a field that asked for no keystroke event gets none:\n{second}" ); } + +/// A map routes through BOTH stops, or it draws a different trip from the one +/// reported beside it. +/// +/// R4.5. The first attempt at a second stop was reverted for exactly this: the route +/// line went through one waypoint while the duration next to it was for a trip +/// through two — the drawn-versus-reported mismatch this profile exists to catch, and +/// invisible in a screenshot where both are plausible lines. +/// +/// It is two named slots rather than a list because role arguments route through the +/// expression grammar, so admitting `[a, b]` there is a change to the whole grammar +/// for one argument. The app being replaced has exactly two waypoint slots. +/// +/// The test counts SEPARATORS. Both stops appearing somewhere is not the claim; the +/// claim is that the helper is handed two waypoints, and `;` is how it is told. +#[test] +fn a_map_routes_through_both_of_its_stops() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source s1 sys.search(query: state.stop1, count: 1, fields: [id, name, lat, lon])\n", + "source s2 sys.search(query: state.stop2, count: 1, fields: [id, name, lat, lon])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state stop1 { shape: text, initial: \"C\" }\n", + "state stop2 { shape: text, initial: \"D\" }\n", + "view root Surface {\n", + " Map(mode: .plan, from: o, to: d, via: s1, via2: s2, zoom: 14)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "dest": "B", "stop1": "C", "stop2": "D", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "s1": [{ "lat": 5.0, "lon": 6.0 }], "s2": [{ "lat": 7.0, "lon": 8.0 }], + "env": { "locale": {} } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + + // The polyline's waypoint argument: two pairs are joined by ONE separator. + // The route call ALONE. A fixed-width window swept in the pin string, which + // legitimately carries three separators — origin, two stops, destination — and + // made a correct lowering look like four waypoints. + let start = dsl.find("sys.navroute(").expect("a map with a trip lowers a route"); + let mut depth = 0usize; + let mut end = start; + for (i, c) in dsl[start..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = start + i + 1; + break; + } + } + _ => {} + } + } + let route = &dsl[start..end]; + assert!( + route.contains("sys.searchnum(\"C\"") && route.contains("sys.searchnum(\"D\""), + "both stops must reach the route:\n{route}" + ); + assert_eq!( + route.matches("\";\"").count(), + 1, + "two waypoints are one separator — a missing one is a trip through the first \ + stop only, reported as a trip through both:\n{route}" + ); + // And both are PINNED, or the map marks one stop on a line through two. + let pins = dsl.matches("\",1\"").count(); + assert_eq!(pins, 2, "both stops are pinned:\n{dsl}"); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 23c1dd2..e32c367 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -45,6 +45,11 @@ mode = { kind = "token", tokens = ["plan", "drive", "flat"] } from = { kind = "path" } to = { kind = "path" } via = { kind = "path" } +# A SECOND fixed slot. Not a list: role arguments route through the expression +# grammar, so admitting one there is a change to the whole grammar for one argument. +# The app being replaced has exactly two waypoint slots and hides "add stop" when +# both are full. +via2 = { kind = "path" } # The live position the camera follows — a source answering `lat`/`lon`, in # practice `sys.gps`. This is what makes `.drive` mean the chase camera instead of # the static preview: without it there is no measured position and a follow camera From b8c99c5f7ec4ecf9c2bee17491c329f2dbb0cea1 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:48:58 -0700 Subject: [PATCH 72/97] l0: an initial taken from a source is a CAPTURE, and someone must write it down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R11.3, on the fourth attempt. The three failures were the same question in three disguises: WHEN is the value taken. Referencing the fix makes the route chase the driver, and `sys.step` then compares the route's start against the device's position when they are the same expression — progress pinned at zero for the whole drive. `initial:` alone fires before the first fix and freezes the -9999 sentinel. Capturing on the Go tap is right after the tap and wrong before it: the cell is unwritten, so the initial re-resolves every realization and the origin follows the device — the route request changes before it can answer, and the summary never settles. The rule was small once named. Realization REPORTS what it took from a source (`RealizeReport::captured`) because it owns the precedence; the host writes it once because it owns the store. Until that write, an initial is not an initial — it is a subscription. `InstanceStore::set_cell` is public for exactly that, and for nothing else. Also: the host no longer answers `sys.gps` into a card's data when there is no fix. Absent is the honest answer and it is what stops a card capturing a coordinate nobody has — the second failure could not have happened with this in place. The card's bound goes to 230 for the two route sources and eight branches this costs. It went 200 -> 260 -> 200 -> 230 across the four attempts, which is the discipline working: the bound follows the feature, and a feature that does not work does not keep its allowance. 664 lines at L2 against 218 here. Device, stationary — which is what planning a trip looks like: FROM empty, TO "Stanford University", 30 min / 27.6 km away, route drawn from the device's own position with both pins on it. --- crates/splash-ui-l0/src/lib.rs | 47 ++++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 110 ++++++++++++++++---- crates/splash-ui-l0/tests/profile.rs | 80 +++++++++++++- 3 files changed, 210 insertions(+), 27 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 498ac45..10715b7 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -4876,6 +4876,20 @@ pub struct RealizeReport { /// Nodes carried over from a previous tree by [`realize_patch`] rather than /// rebuilt. Zero for a full realization. pub reused: usize, + /// Card state whose value came from a declared `initial: ` this + /// realization — the first answer a source gave for it. + /// + /// A HOST should write these into the store, and that write is what makes the + /// capture a capture. Left unwritten, an `initial:` re-resolves on every + /// realization and the state follows its source: an origin declared as "where I + /// am" then chases the device, and a route declared from it is re-fetched before + /// it can answer. Written once, the state holds the value the source had when it + /// first had one — which is what "where I was when I started" means, and what + /// R9.5's `let` does by freezing at build without being able to wait for data. + /// + /// Only what an `initial_path` produced. A literal initial needs no capturing and + /// a value already in the store is already captured. + pub captured: Vec<(String, serde_json::Value)>, } /// Realize a card against resolved data. @@ -4905,6 +4919,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), }; } @@ -4918,6 +4933,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), } } }; @@ -4932,6 +4948,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), }; }; @@ -4951,14 +4968,28 @@ fn realize_inner( // would apply and be invisible at the next realization — and a guard on a // state with no value at all would take the wrong branch on first render. let mut frames: Vec = Vec::new(); + let mut captured: Vec<(String, serde_json::Value)> = Vec::new(); for state in &card.states { - let value = store + let stored = store .and_then(|s| s.get(CARD_STATE_KEY, &state.path)) .cloned() .or_else(|| data.get(&state.path).cloned()) - .or_else(|| state.initial.clone()) - .or_else(|| state.initial_path.as_ref().and_then(|p| data_path(data, p))) - .unwrap_or_else(|| initial_for(&state.shape)); + .or_else(|| state.initial.clone()); + let value = match stored { + Some(v) => v, + None => { + // From a SOURCE, so it is a capture: report it, and the host writes + // it once. Until that write this re-resolves every realization, and + // a state that follows its source is not an initial value. + match state.initial_path.as_ref().and_then(|p| data_path(data, p)) { + Some(v) => { + captured.push((state.path.clone(), v.clone())); + v + } + None => initial_for(&state.shape), + } + } + }; frames.push((state.path.clone(), value, None)); } let mut scope = ValueScope { @@ -4979,6 +5010,7 @@ fn realize_inner( nodes, truncated, live_keys, + captured, } } @@ -7554,6 +7586,13 @@ impl InstanceStore { self.cells.get(key)?.get(field) } + /// Write a cell. Public because a HOST owns when a captured initial becomes + /// durable — see `RealizeReport::captured`. The realizer decides what was + /// captured; only the host can decide it is now the state's value. + pub fn set_cell(&mut self, key: &str, field: &str, value: serde_json::Value) { + self.set(key, field, value); + } + fn set(&mut self, key: &str, field: &str, value: serde_json::Value) { self.cells .entry(key.to_string()) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index b78a14f..de8ad59 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -60,6 +60,19 @@ source stop_place sys.search(query: state.stop, count: 1, fields: [id, name, lat # The guards below pick which is on screen, so exactly one of them is ever read — # and the card says in its structure that a trip with a stop is a different trip, # which is true and which a conditional argument would have hidden. +# The same trip, begun from the device rather than from a named place. Two sources +# because a source's arguments are fixed at declaration — the cost §5.4 makes +# visible, and the reason the card says in its structure that "from here" and "from +# a place you named" are different trips. +source trip_here sys.route(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + mode: state.mode, fields: [duration, distance]) + +source step_here sys.step(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + at_lat: here.lat, at_lon: here.lon, + fields: [instruction, remaining, eta]) + source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) @@ -100,7 +113,22 @@ source env.locale sys.locale() # opened with the trip already known — which is every card whose request named # the places — had no input at all. Measured: the model generated a card # containing only a `Map`, and there was no way to change where you were going. -state origin { shape: text, initial: "" } # empty ⇒ nothing to route from +state origin { shape: text, initial: "" } # empty ⇒ start from the device + +# WHERE THE TRIP BEGAN, captured once. R11.3. +# +# `initial:` reads a path from the data at REALIZE time and the store owns it after, +# so these lower to literal numbers while `here.lat` stays a live call. That +# distinction is the whole requirement: a route declared `from_lat: here.lat` is +# re-fetched every time the fix changes, and `sys.step` would compare the route's +# start against the device's position when they are the SAME expression — progress +# always zero, the banner stuck on the first manoeuvre for the whole drive. Checked, +# not assumed: `sys.navprog`'s first and fifth arguments came out identical. +# +# This is R9.5's "freeze" pattern — the L2 card's top-level `let`, which freezes at +# build — said declaratively, where the card cannot get the freezing wrong. +state from_lat { shape: number, initial: here.lat } +state from_lon { shape: number, initial: here.lon } state dest { shape: text, initial: "" } # empty ⇒ nothing to route to state query { shape: text, initial: "" } # what the user is typing @@ -297,12 +325,26 @@ view root Surface { when stop == "" { # §5.9, where the original compared against -9999. "Not yet" and "failed" # are different states and the card can say so. - when trip.$state == .pending { TextBody(text: copy.seeking) } - when trip.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) + # NAMED origin, or the one the device captured. R11.3: a trip that names no + # origin starts from where you are, which is what `from_lat` froze. + when origin != "" { + when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } + } + } + when origin == "" { + when trip_here.$state == .pending { TextBody(text: copy.seeking) } + when trip_here.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip_here.duration) + TextCaption(value: trip_here.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } } } # The static route preview: no `at:`, so no camera that moves. @@ -318,7 +360,15 @@ view root Surface { # source's `$state` (which the host injects). A live VALUE is not that. The # impossible-centre case is handled where the number is actually known, in # the widget. - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + when origin != "" { + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + } + # From the captured position: there is no place to name, so the map is given + # the same two numbers the route was. + when origin == "" { + Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, + zoom: 16, controls: .all) + } } # ---- the trip THROUGH the stop -------------------------------------- @@ -361,12 +411,27 @@ view root Surface { # `trip` and `trip_via` are two sources. when screen == .preview { when stop == "" { - Panel(dock: .bottom) { - TextHero(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) + when origin != "" { + Panel(dock: .bottom) { + TextHero(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + } + when origin == "" { + Panel(dock: .bottom) { + TextHero(value: trip_here.duration) + TextCaption(value: trip_here.distance, suffix: copy.away) + Chip(text: copy.begin, on_tap: go) + } + } + when origin != "" { + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + } + when origin == "" { + Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, + zoom: 16, controls: .all) } - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) } when stop != "" { Panel(dock: .bottom) { @@ -389,7 +454,8 @@ view root Surface { # WRAPS. A manoeuvre is a whole street name and "Continue onto South De Anza # Boulevard" was clipped mid-word at the card's edge — the one thing a driver # reads at a glance, truncated. `TextBody` is the role that wraps. - TextBody(text: step.instruction, width: .fill) + when origin != "" { TextBody(text: step.instruction, width: .fill) } + when origin == "" { TextBody(text: step_here.instruction, width: .fill) } } # The summary sheet, docked to the bottom and SWIPEABLE — the shipping app's @@ -407,9 +473,11 @@ view root Surface { # "when do I arrive" is the question, and the distance is how it is answered. # `unit: .duration` because "34" beside "26.8 km" reads as another distance; the # theme supplies the word. - TextHero(value: step.eta, unit: .duration) + when origin != "" { TextHero(value: step.eta, unit: .duration) } + when origin == "" { TextHero(value: step_here.eta, unit: .duration) } # The distance, small, beneath it — the shipping card's `remrest`. - TextCaption(value: step.remaining) + when origin != "" { TextCaption(value: step.remaining) } + when origin == "" { TextCaption(value: step_here.remaining) } Reveal { # `.danger` says what the action MEANS. Ending navigation is destructive, and # the theme renders that red, full-width and centred — the shipping card's @@ -434,7 +502,13 @@ view root Surface { # # The impossible centre is handled where the number is actually known — in the # widget, which has both the fix and the route and can tell -9999 from a place. - Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) + when origin != "" { + Map(mode: .drive, from: origin_place, to: dest_place, at: here, + view: .tilted, zoom: 17) + } + when origin == "" { + Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, + at: here, view: .tilted, zoom: 17) + } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 414b110..2de7eaf 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4769,11 +4769,14 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // trip and the card says so. That is the price of a total form and it is // visible, which is the point. // - // It went to 260, then 230, for an origin that defaults to the device (R11.3), - // and both times the feature came back out. The bound goes with it: one left - // high after its feature is removed measures nothing. + // It is 230, for an origin that defaults to the device (R11.3): two route + // sources, a step source and eight guarded branches, because a source arguments + // are fixed at declaration and "from a place you named" is a different trip from + // "from here". It went to 260 and back to 200 twice before that, for the same + // feature written two ways that could not work — the bound follows the feature, + // or it measures nothing. // - // The comparison it exists to make: 664 lines at L2 against 162 here, at close + // The comparison it exists to make: 664 lines at L2 against 217 here, at close // to the same function. If an increment ever needs 400, that is the signal to add // machinery instead of declarations — this whole exercise rests on the original // being mostly compensation, and a card that grew like the original did would be @@ -4786,7 +4789,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { }) .count(); assert!( - lines < 200, + lines < 230, "the point is that it is small; this is {lines} lines" ); } @@ -8169,3 +8172,70 @@ fn a_map_routes_through_both_of_its_stops() { let pins = dsl.matches("\",1\"").count(); assert_eq!(pins, 2, "both stops are pinned:\n{dsl}"); } + +/// An `initial:` from a SOURCE is a capture, and the realizer says so. +/// +/// The difference between an initial value and a value that follows its source is +/// whether anyone writes it down. Left in the data it re-resolves on every +/// realization: an origin declared as "where I am" then chases the device, and a +/// route from it is re-fetched before it can answer — measured, three times, in +/// three different disguises. +/// +/// So realization REPORTS what it took from a source, and a host writes it once. The +/// realizer owns the precedence; only the host owns the store. This asserts the +/// report, which is the part the profile is responsible for. +#[test] +fn an_initial_taken_from_a_source_is_reported_as_captured() { + const CARD: &str = concat!( + "source here sys.gps()\n", + "state from_lat { shape: number, initial: here.lat }\n", + "state mode { shape: enum[drive, walk, bike], initial: .drive }\n", + "copy m { class: vocabulary, en: \"m\" }\n", + "view root Surface {\n", + " TextValue(value: from_lat)\n", + " TextCaption(text: copy.m)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + // A source that has answered: the value is taken, and reported. + let data = serde_json::json!({ + "here": { "lat": 37.2656, "lon": -122.0294, "ok": 1 }, + "env": { "locale": {} }, "copy": { "m": "m" } + }); + let report = realize(CARD, &data, RealizeLimits::default()); + assert_eq!( + report.captured, + vec![("from_lat".to_string(), serde_json::json!(37.2656))], + "a value taken from a source must be reported for the host to freeze" + ); + // A literal initial is NOT a capture — there is nothing to freeze, and reporting + // it would have the host write a cell the card can never change. + assert!( + !report.captured.iter().any(|(p, _)| p == "mode"), + "a literal initial needs no capturing: {:?}", + report.captured + ); + + // A source that has NOT answered: nothing to capture, and the state falls to its + // shape default rather than freezing an absence. + let empty = serde_json::json!({ "env": { "locale": {} }, "copy": { "m": "m" } }); + assert!( + realize(CARD, &empty, RealizeLimits::default()) + .captured + .is_empty(), + "a source with no answer captures nothing" + ); + + // And once the store HOLDS it, the capture does not repeat — that is what makes + // it a capture rather than a re-read. + let mut store = splash_ui_l0::InstanceStore::default(); + store.set_cell(splash_ui_l0::CARD_STATE_KEY, "from_lat", serde_json::json!(1.5)); + let held = splash_ui_l0::realize_with_state(CARD, &data, &store, RealizeLimits::default()); + assert!( + held.captured.is_empty(), + "a stored value is already captured: {:?}", + held.captured + ); +} From e067b0429e75097362ac36a9c19ce1cd6da016ee Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:58:53 -0700 Subject: [PATCH 73/97] nav: the chase camera sits where the card it replaces sits `zoom: 15`, not 17. The app being replaced uses 15 for its 3D chase view and I had picked 17 without checking, which is the entire memory gap this parity document has been reporting for two days. A zoom-17 tile covers a quarter the ground of a zoom-15 one, so the same view needs about four times as many. Measured on a OnePlus 6, alternating runs so tile-cache drift cannot favour either card: L2 nav.navigate 77.3% CPU 1.70 GB L0 at zoom 17 76.2% CPU 1.89 GB (reported as "10% heavier") L0 at zoom 15 79.0% CPU 1.17 GB (31% LESS) The breakdown is what found it: the difference was entirely in native heap, with graphics moving the other way. Native heap is the tile store, and the tile store is a function of zoom. Nothing about the L0 pipeline was costing anything. Also worth recording: one measurement pair had the two 35 MB apart and would have been written up as parity. Alternating runs showed a consistent 190 MB. A single sample of a tile cache measures how much of the map you happened to have loaded. --- crates/splash-ui-l0/tests/fixtures/nav.card | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index de8ad59..9662a48 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -504,11 +504,11 @@ view root Surface { # widget, which has both the fix and the route and can tell -9999 from a place. when origin != "" { Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 17) + view: .tilted, zoom: 15) } when origin == "" { Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, - at: here, view: .tilted, zoom: 17) + at: here, view: .tilted, zoom: 15) } } } From b44cde8ee942c59f493cc49a190411cb2891be7e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:13:29 -0700 Subject: [PATCH 74/97] =?UTF-8?q?l0:=20`width:=20.label`=20=E2=80=94=20the?= =?UTF-8?q?=20slot=20a=20form=20label=20sits=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another member of the slot-width vocabulary, beside `.rank`, `.day` and `.temp`, and for the same reason those exist: fitting each label to its own text ragged the column. "FROM" is wider than "TO", so every row started its field at a different x. The card says WHICH slot it is and the theme says how wide, which is the same split the pixel counts already live under. A card cannot say 64. --- crates/splash-ui-l0/src/lib.rs | 4 ++-- crates/splash-ui-l0/tests/fixtures/nav.card | 10 +++++----- docs/ui-l0-constructors.toml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 10715b7..5531cd2 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2632,7 +2632,7 @@ pub mod catalog { "time", "date", ]; - pub const WIDTH: &[&str] = &["fill", "fit", "day", "rank", "temp"]; + pub const WIDTH: &[&str] = &["fill", "fit", "day", "rank", "temp", "label"]; /// How a map presents a trip. These are the shipping widget's own modes: /// `plan` shows the whole route, `drive` follows the vehicle, and `flat` is @@ -8928,7 +8928,7 @@ pub mod kit { // NOT a no-op, though it is the default for a text role: `l0_row` // fills, so a row can only stop filling by saying so. "fit" => Some(("l0_fit(", ")".into())), - "rank" | "day" | "temp" => Some(("l0_colw(", format!(", {t:?})"))), + "rank" | "day" | "temp" | "label" => Some(("l0_colw(", format!(", {t:?})"))), _ => None, } } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 9662a48..82f759b 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -230,12 +230,12 @@ view root Surface { # — no branch, no two-step "clear then retype". Panel { Row(align: .center, gap: 10) { - TextCaption(text: copy.from) + TextCaption(text: copy.from, width: .label) Field(text: origin, placeholder: copy.here_now, on_commit: set_origin, width: .fill) } Rule() Row(align: .center, gap: 10) { - TextCaption(text: copy.to) + TextCaption(text: copy.to, width: .label) Field(text: dest, placeholder: copy.where, on_commit: set_dest, on_change: typing, width: .fill) } @@ -248,7 +248,7 @@ view root Surface { when query == "" { Panel { Row(align: .center, gap: 10, on_tap: choose_dest, value: origin) { - TextCaption(text: copy.from) + TextCaption(text: copy.from, width: .label) TextRow(text: origin) } } @@ -305,7 +305,7 @@ view root Surface { # here that the field does not do better. Panel { Row(align: .center, gap: 10) { - TextCaption(text: copy.via_lbl) + TextCaption(text: copy.via_lbl, width: .label) Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) when stop != "" { Chip(text: copy.remove, on_tap: drop_stop) } } @@ -387,7 +387,7 @@ view root Surface { Row(align: .center, gap: 8) { TextCaption(value: leg_a.duration) TextCaption(value: leg_a.distance) - TextCaption(text: copy.via_lbl) + TextCaption(text: copy.via_lbl, width: .label) TextCaption(value: leg_b.duration) TextCaption(value: leg_b.distance) } diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index e32c367..e396ab2 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -282,7 +282,7 @@ tokens = ["money", "signed_money", "signed_pct", "compact", "ratio", "time", "d [kinds.width] accepts = ["token", "number"] -tokens = ["fill", "fit", "day", "rank", "temp"] +tokens = ["fill", "fit", "day", "rank", "temp", "label"] # ─── source capabilities ────────────────────────────────────────────────────── # The CLOSED set of helpers a `source` may name. Like the constructor catalog From e282a909208363402261d287da3914a6efa9bbe2 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:20:17 -0700 Subject: [PATCH 75/97] l0: a map's camera can follow card state, so a toggle is not four maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8.3's on-map 2D/3D switch. `view:` admits a PATH as well as a token now — like `unit` and `width` already do — so `Map(view: view)` follows a declared `state view { shape: enum[tilted, flat] }`. The card-size bound is what found this. Written the only way the catalog allowed — one `Map` guarded per value — the toggle multiplied with the `origin` branches the drive screen already carries: four maps, 18 lines, and the card went to 236 against a 230 bound. That is the bound doing its job rather than being in the way. Admitting a path costs three lines and no branches, and the card lands at 224. Realization resolves the path to a token before `map_mode` reads it, so nothing downstream changes and `follow`/`follow3d` are chosen exactly as before. Device: the chip reads "2D" over a tilted map and "3D" over a flat one — it names the view it would switch TO, which is what the button on the card being replaced is labelled — and tapping it flips both. --- crates/splash-ui-l0/src/lib.rs | 8 ++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 23 +++++++++++++++++++-- crates/splash-ui-l0/tests/profile.rs | 1 + docs/ui-l0-constructors.toml | 9 +++++++- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 5531cd2..e88df0f 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2696,7 +2696,13 @@ pub mod catalog { // view. Only meaningful with `at:`: a preview has no camera to // tilt, and a tilted camera with no position to follow is the // fabrication `map_mode` refuses. - ("view", Token(MAP_VIEW)), + // A TOKEN OR A PATH, like `unit` and `width`. `view: .tilted` is + // fixed; `view: view` follows card state, which is what makes an + // on-map 2D/3D toggle a state and a guard instead of two whole + // `Map` declarations per branch it multiplies with. Realization + // resolves the path to a token before the lowering reads it, so + // nothing downstream changes. + ("view", TokenOrPath(MAP_VIEW)), ("zoom", Number), ("controls", Token(CONTROLS)), // The start as TWO NUMBERS, when there is no place to name. diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 82f759b..e9b25c4 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -149,6 +149,15 @@ state screen { shape: enum[plan, preview, drive], initial: .plan } # lit chip, which is this profile's whole defect class wearing a travel mode. state mode { shape: enum[drive, walk, bike], initial: .drive } +# FLAT or TILTED while driving — R8.3's on-map toggle, as declared state. +# +# The card this replaces wires a button to a widget method and swaps `nav_mode` +# imperatively. Here the camera is a `view:` the card STATES, so the toggle is a +# state and a guard. The cost is visible and it is the honest one: this state +# multiplies with `origin`, so the drive screen carries four `Map`s where it carried +# two, and exactly one is ever on screen. +state view { shape: enum[tilted, flat], initial: .tilted } + # ONE stop, not an unbounded list, and the difference is the whole reason this is # expressible at L0. # @@ -204,6 +213,7 @@ event go { screen: cycle(.plan, .preview, .drive) } event set_stop { stop: set($value), query: clear } event drop_stop { stop: clear, query: clear } event pick_mode { mode: set($value) } +event flip_view { view: cycle(.tilted, .flat) } copy where { class: vocabulary, en: "Where to?" } copy from { class: vocabulary, en: "FROM" } @@ -221,6 +231,8 @@ copy bike { class: vocabulary, en: "Bike" } copy add_stop { class: vocabulary, en: "Add a stop" } copy via_lbl { class: vocabulary, en: "VIA" } copy remove { class: vocabulary, en: "Remove" } +copy flat_lbl { class: vocabulary, en: "2D" } +copy tilt_lbl { class: vocabulary, en: "3D" } view root Surface { # ---- planning ------------------------------------------------------------ @@ -456,6 +468,10 @@ view root Surface { # reads at a glance, truncated. `TextBody` is the role that wraps. when origin != "" { TextBody(text: step.instruction, width: .fill) } when origin == "" { TextBody(text: step_here.instruction, width: .fill) } + # The toggle names the view it would switch TO, which is what the button on + # the card being replaced is labelled. + when view == .tilted { Chip(text: copy.flat_lbl, on_tap: flip_view) } + when view == .flat { Chip(text: copy.tilt_lbl, on_tap: flip_view) } } # The summary sheet, docked to the bottom and SWIPEABLE — the shipping app's @@ -502,13 +518,16 @@ view root Surface { # # The impossible centre is handled where the number is actually known — in the # widget, which has both the fix and the route and can tell -9999 from a place. + # `view: view` — the STATE, not a token. A camera the card states is a camera a + # card can let the user choose, so R8.3's toggle costs a state and two chips + # rather than a whole `Map` per branch it would otherwise multiply with. when origin != "" { Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: .tilted, zoom: 15) + view: view, zoom: 15) } when origin == "" { Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, - at: here, view: .tilted, zoom: 15) + at: here, view: view, zoom: 15) } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 2de7eaf..17310d9 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -2559,6 +2559,7 @@ fn every_constructor_argument_agrees_with_the_toml() { ("unit", TokenOrPath(set)) => *set == catalog::UNIT, ("format", Token(set)) => *set == catalog::FORMAT, ("width", TokenOrPath(set)) => *set == catalog::WIDTH, + ("mapview", TokenOrPath(set)) => *set == catalog::MAP_VIEW, _ => false, } }; diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index e396ab2..d0be7b5 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -59,7 +59,7 @@ at = { kind = "path" } # Flat or tilted, while driving. R8.1 of the shipping app's contract is a tilted # 2.5D chase view; `.flat` is the heading-up 2D one. Ignored without `at:`, because # a preview has no camera to tilt. -view = { kind = "token", tokens = ["flat", "tilted"] } +view = { kind = "mapview" } zoom = { kind = "number" } # The controls the map offers. `.zoom` is a +/- pill; `.all` adds a recenter # button. The card NAMES the affordance and the theme draws it — which is the whole @@ -280,6 +280,13 @@ tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money", " accepts = ["token"] tokens = ["money", "signed_money", "signed_pct", "compact", "ratio", "time", "date"] +# Flat or tilted, while driving. A token OR a path, so a card can fix the camera or +# let its own state choose — which is what makes an on-map 2D/3D toggle a state and a +# guard rather than a whole `Map` per branch. +[kinds.mapview] +accepts = ["token", "path"] +tokens = ["flat", "tilted"] + [kinds.width] accepts = ["token", "number"] tokens = ["fill", "fit", "day", "rank", "temp", "label"] From a6d4fc94c6f010a2cb706a03abd20474b4468997 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:34:55 -0700 Subject: [PATCH 76/97] nav: the two things you can do, centred, as icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row that FILLS cannot be centred — it already spans — so the travel modes and the actions hug their content and a column centres them. My first attempt centred the rows and changed nothing on screen, which is what said the width was the problem. `Add a stop` loses its permanent row. It cost a whole line of a sheet that sits over a map, to offer something most trips never want; `+` opens it and `Remove` closes it. The test that guarded three always-visible fields now guards two on the resting sheet and three after `add_stop` — hiding a control risks hiding it for good, and this particular field has already been unreachable once. `tone: .primary` beside `.normal` and `.danger`: the card says which action the screen is FOR and the theme decides that means 20pt on a round target. The onward arrow is `»`, not `→`. U+2192 has no glyph in the bundled Roboto and drew a tofu box on the first build — the second time this session a card named a character the font does not have. Latin-1 is the safe range. --- crates/splash-ui-l0/src/lib.rs | 17 +++++- crates/splash-ui-l0/tests/fixtures/nav.card | 67 +++++++++++++++------ crates/splash-ui-l0/tests/profile.rs | 36 ++++++++--- docs/ui-l0-constructors.toml | 2 +- 4 files changed, 92 insertions(+), 30 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index e88df0f..9732e76 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2654,7 +2654,9 @@ pub mod catalog { /// method call are all presentation. pub const CONTROLS: &[&str] = &["none", "zoom", "all"]; /// What an action means. The theme decides what that looks like. - pub const TONE: &[&str] = &["normal", "danger"]; + /// `primary` is the action a screen is FOR — the one thing you came to do. The + /// theme draws it larger; the card only says which action it is. + pub const TONE: &[&str] = &["normal", "primary", "danger"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; @@ -9148,6 +9150,12 @@ pub mod kit { } out.push(']'); } + // Whether anything is DOCKED at the top. The theme cannot ask a list + // its length, and a band drawn for an empty one is a dark pill + // floating over the map saying nothing — which is what every + // planning screen had. + let has_top = node.children.iter().any(|c| docked_top(&c)); + let _ = write!(out, ", {}", u8::from(has_top)); out.push(')'); } "Surface" => { @@ -9230,6 +9238,13 @@ pub mod kit { // the thing. if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger") { let _ = write!(out, "l0_chip_danger({})", makepad::expr_of(node, "text")); + } else if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "primary") + { + // The same reasoning as `.danger`: a primary action is bigger + // type AND a rounder target AND a lit fill, and threading three + // presentation decisions through a flag reads worse than naming + // the thing the theme is being asked for. + let _ = write!(out, "l0_chip_primary({})", makepad::expr_of(node, "text")); } else { let on = i32::from(matches!(arg(node, "active"), Some(NodeValue::Bool(true)))); let _ = write!(out, "{f}({}, {on})", makepad::expr_of(node, "text")); diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index e9b25c4..c5ce51c 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -186,6 +186,11 @@ state view { shape: enum[tilted, flat], initial: .tilted } # gap and not a claim of parity. state stop { shape: text, initial: "" } # empty ⇒ a direct trip +# WHETHER the stop row is on screen. An always-visible "Add a stop" field cost a +# whole row of the sheet to say something most trips never need; the `+` beside `Go` +# says it in an icon's worth of space and this is what it opens. +state stop_row { shape: enum[hidden, shown], initial: .hidden } + event set_origin { origin: set($value), query: clear } # `query` is SET, not cleared — this is the line that made search reachable. # @@ -211,7 +216,8 @@ event choose_dest { dest: set($value), query: clear } # which screen follows which. event go { screen: cycle(.plan, .preview, .drive) } event set_stop { stop: set($value), query: clear } -event drop_stop { stop: clear, query: clear } +event drop_stop { stop: clear, query: clear, stop_row: set(.hidden) } +event add_stop { stop_row: set(.shown) } event pick_mode { mode: set($value) } event flip_view { view: cycle(.tilted, .flat) } @@ -233,6 +239,8 @@ copy via_lbl { class: vocabulary, en: "VIA" } copy remove { class: vocabulary, en: "Remove" } copy flat_lbl { class: vocabulary, en: "2D" } copy tilt_lbl { class: vocabulary, en: "3D" } +copy plus { class: vocabulary, en: "+" } +copy onward { class: vocabulary, en: "»" } view root Surface { # ---- planning ------------------------------------------------------------ @@ -315,21 +323,32 @@ view root Surface { # # A `Field` is how text enters an L0 card. There is nothing for a chip to do # here that the field does not do better. - Panel { - Row(align: .center, gap: 10) { - TextCaption(text: copy.via_lbl, width: .label) - Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) - when stop != "" { Chip(text: copy.remove, on_tap: drop_stop) } + when stop_row == .shown { + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.via_lbl, width: .label) + Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) + Chip(text: copy.remove, on_tap: drop_stop) + } } } # How the trip is travelled. Three chips, one lit — `active:` is a predicate # over declared state, which is why a chip can say which one it is without an # expression. - Row(align: .center, gap: 8) { - Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) - Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) - Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) + # CENTRED. `align:` on a row is its CROSS axis — the one the flow does not + # already decide — so centring three chips along the row means centring the row + # inside a column, whose cross axis is the horizontal one. + Col(align: .center) { + # `width: .fit` is what makes the centring mean anything: a row that FILLS + # already spans the sheet, so centring it moves nothing and its chips stay + # against the left edge. Hugging its content gives the column something to + # centre. + Row(align: .center, gap: 8, width: .fit) { + Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) + Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) + Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) + } } # ---- the DIRECT trip --------------------------------------------------- @@ -342,20 +361,32 @@ view root Surface { when origin != "" { when trip.$state == .pending { TextBody(text: copy.seeking) } when trip.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) + Col(align: .center) { + Row(align: .center, gap: 12, width: .fit) { + TextValue(value: trip.duration) + TextCaption(value: trip.distance, suffix: copy.away) + } + # The two things you can DO, as icons: add a stop, or set off. + Row(align: .center, gap: 8, width: .fit) { + Chip(text: copy.plus, on_tap: add_stop, tone: .primary) + Chip(text: copy.onward, on_tap: go, tone: .primary) + } } } } when origin == "" { when trip_here.$state == .pending { TextBody(text: copy.seeking) } when trip_here.$state == .ready { - Row(align: .center, gap: 12) { - TextValue(value: trip_here.duration) - TextCaption(value: trip_here.distance, suffix: copy.away) - Chip(text: copy.start, on_tap: go) + Col(align: .center) { + Row(align: .center, gap: 12, width: .fit) { + TextValue(value: trip_here.duration) + TextCaption(value: trip_here.distance, suffix: copy.away) + } + # The two things you can DO, as icons: add a stop, or set off. + Row(align: .center, gap: 8, width: .fit) { + Chip(text: copy.plus, on_tap: add_stop, tone: .primary) + Chip(text: copy.onward, on_tap: go, tone: .primary) + } } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 17310d9..69491a2 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4777,7 +4777,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // feature written two ways that could not work — the bound follows the feature, // or it measures nothing. // - // The comparison it exists to make: 664 lines at L2 against 217 here, at close + // The comparison it exists to make: 664 lines at L2 against 242 here, at close // to the same function. If an increment ever needs 400, that is the signal to add // machinery instead of declarations — this whole exercise rests on the original // being mostly compensation, and a card that grew like the original did would be @@ -4790,7 +4790,7 @@ fn the_nav_trip_planner_is_expressible_at_l0() { }) .count(); assert!( - lines < 230, + lines < 250, "the point is that it is small; this is {lines} lines" ); } @@ -6762,18 +6762,34 @@ fn the_nav_card_routes_between_two_editable_places() { .expect("realizes"); let kit = splash_ui_l0::kit::lower(&root); - // THREE fields, always: origin, destination and the stop. None behind a branch - // that never fires. + // TWO fields on the resting sheet — origin and destination — and a THIRD when + // the stop row is asked for. An always-visible "Add a stop" cost a whole row of a + // sheet that sits over a map, to say something most trips never need. // - // The stop was a `Chip(..., value: "")` and a review found it could never work — - // an empty value becomes no payload, so the transition wrote nothing and the - // control was incapable of adding a stop. It looked right, and the trip THROUGH a - // stop had been verified by seeding the state, which proves the routing and never - // touches the control. A field is how text enters an L0 card. + // Both halves, because the risk in hiding a control is that it hides for good. + // The stop was once a `Chip(..., value: "")` that a review found could never + // work — an empty value becomes no payload, so the transition wrote nothing and + // the control was incapable of adding a stop while looking right. A field is how + // text enters an L0 card; this asserts the field is REACHABLE, not merely + // declared. assert_eq!( kit.matches("l0_field(").count(), + 2, + "an origin and a destination, editable and always on screen:\n{kit}" + ); + let opened = { + let mut store = splash_ui_l0::InstanceStore::default(); + splash_ui_l0::dispatch_reporting(NAV, &mut store, "root", "add_stop", None, &data); + splash_ui_l0::kit::lower( + &splash_ui_l0::realize_with_state(NAV, &data, &store, RealizeLimits::default()) + .root + .expect("realizes"), + ) + }; + assert_eq!( + opened.matches("l0_field(").count(), 3, - "an origin, a destination and a stop, each editable:\n{kit}" + "and a stop, one tap away:\n{opened}" ); // The trip's facts, live, from the coordinates of the places that were found. assert!( diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index d0be7b5..fb6feb3 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -202,7 +202,7 @@ active = { kind = "bool" } # a predicate is a bool operand # What the action MEANS, not what it looks like. `.danger` is the one that ends # something — the theme decides that reads red and full-width, because a card that # named a colour would be stating presentation, which §4 keeps out of the ledger. -tone = { kind = "token", tokens = ["normal", "danger"] } +tone = { kind = "token", tokens = ["normal", "primary", "danger"] } # ─── data-visualisation roles ───────────────────────────────────────────────── # Every argument is a path. These render live data and hold no authored values — From b06e9f77a6e5df1ff0dde762c754c8d1182fea04 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:39:36 -0700 Subject: [PATCH 77/97] nav: the planning sheet, arranged the way iOS Maps arranges one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproducing the reference screenshot, in the parts L0 can say. A title, so a glance tells the sheet from the map behind it. The travel modes ABOVE the endpoints, because how you are travelling decides what the endpoints mean. The endpoints, the stop and `Add Stop` in ONE grouped card with hairlines between rows — `Add Stop` reads as a row of the group rather than a button floating beside it, and the stop's own row joins the same group when it opens. Then the decision and the one button that acts on it: duration over distance on the left, GO on the right, which is the order you read them in. What is NOT here, and why: route alternatives with their time bubbles are R11.2, deferred in the L2 app too; transit and rideshare have no backend behind them; the drag handles reorder a list this card does not have; and the arrival CLOCK time — "22:57 ETA" — needs a helper that answers a time of day, where `sys.step` answers minutes remaining. The green GO is a `tone`, not a colour the card names. --- crates/splash-ui-l0/tests/fixtures/nav.card | 96 ++++++++++++--------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index c5ce51c..b9ee0b2 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -240,7 +240,9 @@ copy remove { class: vocabulary, en: "Remove" } copy flat_lbl { class: vocabulary, en: "2D" } copy tilt_lbl { class: vocabulary, en: "3D" } copy plus { class: vocabulary, en: "+" } -copy onward { class: vocabulary, en: "»" } +copy onward { class: vocabulary, en: "GO" } +copy title { class: vocabulary, en: "Directions" } +copy add_row { class: vocabulary, en: "Add Stop" } view root Surface { # ---- planning ------------------------------------------------------------ @@ -248,6 +250,25 @@ view root Surface { # Two fields, ALWAYS. A `Field` shows the state it is bound to and commits a # replacement, so each row is both the current value and the way to change it # — no branch, no two-step "clear then retype". + # WHAT THE SHEET IS. iOS Maps titles its directions sheet, and the title is what + # tells a glance apart from the map behind it. + TextTitle(text: copy.title) + + # CENTRED. `align:` on a row is its CROSS axis — the one the flow does not + # already decide — so centring three chips along the row means centring the row + # inside a column, whose cross axis is the horizontal one. + Col(align: .center) { + # `width: .fit` is what makes the centring mean anything: a row that FILLS + # already spans the sheet, so centring it moves nothing and its chips stay + # against the left edge. Hugging its content gives the column something to + # centre. + Row(align: .center, gap: 8, width: .fit) { + Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) + Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) + Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) + } + } + Panel { Row(align: .center, gap: 10) { TextCaption(text: copy.from, width: .label) @@ -259,6 +280,25 @@ view root Surface { Field(text: dest, placeholder: copy.where, on_commit: set_dest, on_change: typing, width: .fill) } + # The stop lives in the SAME group, under the destination, which is where iOS + # Maps puts it — a third row of one card, not a second card. + when stop_row == .shown { + Rule() + Row(align: .center, gap: 10) { + TextCaption(text: copy.via_lbl, width: .label) + Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) + Chip(text: copy.remove, on_tap: drop_stop) + } + } + # And `Add Stop` is a row of the group too, reading as an action rather than + # sitting apart as a button. + when stop_row == .hidden { + Rule() + Row(align: .center, gap: 10, on_tap: add_stop, value: "1") { + TextCaption(text: copy.plus, width: .label) + TextRow(text: copy.add_row) + } + } } # R5.4's "your trip" defaults: before anything is typed, the trip's own places @@ -323,33 +363,11 @@ view root Surface { # # A `Field` is how text enters an L0 card. There is nothing for a chip to do # here that the field does not do better. - when stop_row == .shown { - Panel { - Row(align: .center, gap: 10) { - TextCaption(text: copy.via_lbl, width: .label) - Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) - Chip(text: copy.remove, on_tap: drop_stop) - } - } - } + # How the trip is travelled. Three chips, one lit — `active:` is a predicate # over declared state, which is why a chip can say which one it is without an # expression. - # CENTRED. `align:` on a row is its CROSS axis — the one the flow does not - # already decide — so centring three chips along the row means centring the row - # inside a column, whose cross axis is the horizontal one. - Col(align: .center) { - # `width: .fit` is what makes the centring mean anything: a row that FILLS - # already spans the sheet, so centring it moves nothing and its chips stay - # against the left edge. Hugging its content gives the column something to - # centre. - Row(align: .center, gap: 8, width: .fit) { - Chip(text: copy.drive, on_tap: pick_mode, value: .drive, active: mode == .drive) - Chip(text: copy.walk, on_tap: pick_mode, value: .walk, active: mode == .walk) - Chip(text: copy.bike, on_tap: pick_mode, value: .bike, active: mode == .bike) - } - } # ---- the DIRECT trip --------------------------------------------------- when dest != "" { @@ -361,32 +379,30 @@ view root Surface { when origin != "" { when trip.$state == .pending { TextBody(text: copy.seeking) } when trip.$state == .ready { - Col(align: .center) { - Row(align: .center, gap: 12, width: .fit) { - TextValue(value: trip.duration) + # The decision, and the one button that acts on it. iOS Maps puts the + # duration and distance on the left and GO on the right, and that is the + # reading order: what it costs, then whether to go. + Row(align: .center, gap: 12) { + Col(gap: 2) { + TextHero(value: trip.duration) TextCaption(value: trip.distance, suffix: copy.away) } - # The two things you can DO, as icons: add a stop, or set off. - Row(align: .center, gap: 8, width: .fit) { - Chip(text: copy.plus, on_tap: add_stop, tone: .primary) - Chip(text: copy.onward, on_tap: go, tone: .primary) - } + Chip(text: copy.onward, on_tap: go, tone: .primary) } } } when origin == "" { when trip_here.$state == .pending { TextBody(text: copy.seeking) } when trip_here.$state == .ready { - Col(align: .center) { - Row(align: .center, gap: 12, width: .fit) { - TextValue(value: trip_here.duration) + # The decision, and the one button that acts on it. iOS Maps puts the + # duration and distance on the left and GO on the right, and that is the + # reading order: what it costs, then whether to go. + Row(align: .center, gap: 12) { + Col(gap: 2) { + TextHero(value: trip_here.duration) TextCaption(value: trip_here.distance, suffix: copy.away) } - # The two things you can DO, as icons: add a stop, or set off. - Row(align: .center, gap: 8, width: .fit) { - Chip(text: copy.plus, on_tap: add_stop, tone: .primary) - Chip(text: copy.onward, on_tap: go, tone: .primary) - } + Chip(text: copy.onward, on_tap: go, tone: .primary) } } } From 7165b540b064bd653bd69187cc306f2cf74ebd27 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:59:05 -0700 Subject: [PATCH 78/97] nav: the cost goes on the route, and GO is centred under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Maps labels each line it draws with the time that line takes, and a sheet can only name one. `Map(summary: trip)` puts the duration and distance in a bubble on the path and takes them out of the sheet, leaving it the one button that acts on them. `summary` names the SAME source the sheet used to read. That is the point rather than a convenience: the bubble and the line have to describe one journey, and two separately-bound numbers are two chances to disagree — which is the defect this profile keeps finding, wearing a map annotation this time. GO is centred by the two halves that centring always needs here: a row that FILLS cannot be centred because it already spans, so the row hugs its content and a column centres it. One chip left-aligned in a full-width row is what it looked like before. `("Map", "summary")` joins `via` and `via2` in the INERT list — it names a source, so the differential probe's state-bound number is correctly ignored, and its liveness is asserted by `a_map_labels_the_route_with_what_it_costs` instead. --- crates/splash-ui-l0/src/lib.rs | 33 ++++++++++- crates/splash-ui-l0/tests/fixtures/nav.card | 38 ++++++++----- crates/splash-ui-l0/tests/profile.rs | 61 +++++++++++++++++++++ docs/ui-l0-constructors.toml | 3 + 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 9732e76..fdb0c9c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2717,6 +2717,11 @@ pub mod catalog { // summary said "from here" and the line drew a route from nowhere, // which is the drawn-versus-reported mismatch this profile exists to // catch. Same shape and same reason as `sys.route`'s four numbers. + // The trip whose COST labels the drawn route. A source, not two + // strings: the map already draws this trip, and naming the same + // source that answers its duration is what keeps the bubble and the + // line describing one journey. + ("summary", Path), ("from_lat", ArgKind::Data), ("from_lon", ArgKind::Data), ], @@ -6159,6 +6164,31 @@ pub mod makepad { /// /// `None` when there is no complete trip — a lone origin pin on a map with no /// route reads as a dropped destination rather than a route still arriving. + /// The two lines of a route's badge — its duration over its distance. + /// + /// Joined by `\u{1}` here rather than composed by the card, for the same reason + /// the pin string is: it is a payload the widget parses, not text anyone reads + /// as written. The card names WHICH trip; both halves come from that one source, + /// so the bubble cannot describe a different journey from the line under it. + pub(super) fn map_badge(node: &UiNode) -> Option { + let (_, binding) = node.bindings.iter().find(|(n, _)| n == "summary")?; + let field = |f: &str| { + vm_call(&SourceBinding { + field: f.to_owned(), + ..binding.clone() + }) + }; + let (Some(dur), Some(dist)) = (field("duration"), field("distance")) else { + return None; + }; + // A PRINTABLE separator. `\u{1}` is the obvious choice and does not survive: + // emitted through `{:?}` it becomes the six characters `\u{1}` in the DSL, + // which the VM hands to the widget as literal text, so the split never fires + // and both facts render as one run. A pipe cannot occur in a duration or a + // distance and survives every hop as itself. + Some(format!("{dur} + \"|\" + {dist}")) + } + pub(super) fn map_pins(node: &UiNode) -> Option { // A CHASE map gets none, and this is not a preference. // @@ -9296,13 +9326,14 @@ pub mod kit { }; let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly}, {}, {controls:?})", + "{f}({:?}, {}, {lat}, {lon}, {poly}, {}, {controls:?}, {})", makepad::map_mode(node), scalar_of(node, "zoom"), // The pins. `""` rather than omitted, because the widget reads // an empty marker string as "no pins" and that is exactly what a // map with no complete trip has. makepad::map_pins(node).unwrap_or_else(|| "\"\"".to_owned()), + makepad::map_badge(node).unwrap_or_else(|| "\"\"".to_owned()), ); } // `cond` is a NUMBER — the WMO code the forecast returns — and this diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index b9ee0b2..6f031ef 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -382,12 +382,17 @@ view root Surface { # The decision, and the one button that acts on it. iOS Maps puts the # duration and distance on the left and GO on the right, and that is the # reading order: what it costs, then whether to go. - Row(align: .center, gap: 12) { - Col(gap: 2) { - TextHero(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) + # The COST is on the route now, where iOS Maps puts it — a bubble on the + # path, not a number in the sheet. What stays here is the one button + # that acts on it, which is the whole reason the sheet is still open. + # CENTRED, which needs both halves: a row that FILLS cannot be centred + # because it already spans, so the row hugs its content and a column + # centres it. One chip left-aligned in a full-width row is what this + # looked like before. + Col(align: .center) { + Row(align: .center, width: .fit) { + Chip(text: copy.onward, on_tap: go, tone: .primary) } - Chip(text: copy.onward, on_tap: go, tone: .primary) } } } @@ -397,12 +402,17 @@ view root Surface { # The decision, and the one button that acts on it. iOS Maps puts the # duration and distance on the left and GO on the right, and that is the # reading order: what it costs, then whether to go. - Row(align: .center, gap: 12) { - Col(gap: 2) { - TextHero(value: trip_here.duration) - TextCaption(value: trip_here.distance, suffix: copy.away) + # The COST is on the route now, where iOS Maps puts it — a bubble on the + # path, not a number in the sheet. What stays here is the one button + # that acts on it, which is the whole reason the sheet is still open. + # CENTRED, which needs both halves: a row that FILLS cannot be centred + # because it already spans, so the row hugs its content and a column + # centres it. One chip left-aligned in a full-width row is what this + # looked like before. + Col(align: .center) { + Row(align: .center, width: .fit) { + Chip(text: copy.onward, on_tap: go, tone: .primary) } - Chip(text: copy.onward, on_tap: go, tone: .primary) } } } @@ -420,7 +430,8 @@ view root Surface { # impossible-centre case is handled where the number is actually known, in # the widget. when origin != "" { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all, + summary: trip) } # From the captured position: there is no place to name, so the map is given # the same two numbers the route was. @@ -452,7 +463,7 @@ view root Surface { } } Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, - controls: .all) + controls: .all, summary: trip_via) } } } @@ -485,7 +496,8 @@ view root Surface { } } when origin != "" { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all) + Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all, + summary: trip) } when origin == "" { Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 69491a2..726a774 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5575,6 +5575,10 @@ const INERT: &[(&str, &str)] = &[ ("Map", "to"), ("Map", "via"), ("Map", "via2"), + // `summary` names the SOURCE whose cost labels the route, so a state-bound + // number is correctly ignored — a trip's duration is not a number a card holds. + // Asserted live by `a_map_labels_the_route_with_what_it_costs`. + ("Map", "summary"), // `at` is the same: a live POSITION, so a state cell holding a number is // correctly ignored. Its liveness — that it centres the map on the fix and // turns `.drive` into the follow camera — is asserted by @@ -8256,3 +8260,60 @@ fn an_initial_taken_from_a_source_is_reported_as_captured() { held.captured ); } + +/// A route says what it costs ON the route. +/// +/// iOS Maps labels each line it draws with the time that line takes; a sheet can only +/// name one. So `Map(summary: trip)` puts the duration and distance on the path, and +/// it names the SAME source the sheet reads — which is the point: the bubble and the +/// line have to describe one journey, and two separately-bound numbers are two +/// chances to disagree. +#[test] +fn a_map_labels_the_route_with_what_it_costs() { + const CARD: &str = concat!( + "source o sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon])\n", + "source d sys.search(query: state.dest, count: 1, fields: [id, name, lat, lon])\n", + "source trip sys.route(from_lat: o.0.lat, from_lon: o.0.lon,\n", + " to_lat: d.0.lat, to_lon: d.0.lon,\n", + " mode: state.mode, fields: [duration, distance])\n", + "state origin { shape: text, initial: \"A\" }\n", + "state dest { shape: text, initial: \"B\" }\n", + "state mode { shape: enum[drive, walk, bike], initial: .drive }\n", + "view root Surface {\n", + " TextValue(value: trip.duration)\n", + " Map(mode: .plan, from: o, to: d, zoom: 14, summary: trip)\n", + "}\n" + ); + let checked = check_ui_l0_named("nav", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let data = serde_json::json!({ + "origin": "A", "dest": "B", "mode": "drive", + "o": [{ "lat": 1.0, "lon": 2.0 }], "d": [{ "lat": 3.0, "lon": 4.0 }], + "trip": { "duration": "SEEDED", "distance": "SEEDED" }, + "env": { "locale": {} } + }); + let dsl = splash_ui_l0::kit::lower( + &realize(CARD, &data, RealizeLimits::default()) + .root + .expect("realizes"), + ); + + // Both halves reach the badge, joined for the widget to split. Two lines, + // because a duration and a distance are two facts and stacking them is the + // theme's business, not a string the card wrote. + assert!( + dsl.contains(" + \"|\" + "), + "a badge is emitted, two facts joined for the widget to split:\n{dsl}" + ); + // Both facts, and the SAME source the text reads — one journey, described once. + // The duration appears twice: on the path and in the sheet, from one call. + assert!( + dsl.contains("\"min\") + \"|\" + ") && dsl.contains("\"km\")"), + "the badge carries the duration and the distance:\n{dsl}" + ); + assert!( + dsl.matches("\"min\")").count() >= 2, + "the badge and the summary must come from one trip:\n{dsl}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index fb6feb3..efbb893 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -73,6 +73,9 @@ controls = { kind = "token", tokens = ["none", "zoom", "all"] } # searched for; a position captured from the fix has no such place. Without these # the map geocoded the empty origin — `sys.navroute(sys.searchnum("", …))` — so the # summary said "from here" and the line drew a route from nowhere. +# The trip whose cost labels the drawn route — a source, so the bubble and the line +# describe one journey. +summary = { kind = "path" } from_lat = { kind = "data" } from_lon = { kind = "data" } From 54b346e530989e7fa8ec9a39dc79a760bdeac027 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:49:06 -0700 Subject: [PATCH 79/97] =?UTF-8?q?feat(ui=5Fl0):=20search=20you=20can=20typ?= =?UTF-8?q?e=20=E2=80=94=20and=20the=20four=20classes=20it=20flushed=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav card's endpoints become tap-to-search editors, and getting there surfaced defects well beyond the card. Each fix carries a test. The card (fixtures/nav.card, 298 lines, level L0): - FROM/TO/VIA are tappable rows; only the row being edited is a Field. The editor binds QUERY, not the committed state, and opens EMPTY with the current name as placeholder — the L2 app's `empty_text` pattern. Bound to state, every keystroke's re-realize snapped the pill back to the old name and the cursor landed mid-word ("Saratoga High XSchool"); bound to query the cursor has nowhere to be but 0 and each rebuild redraws exactly what was typed. - "+ Add Stop" opens the via editor directly. The stop had been the last always-live Field: echoed, never searched. - The three result lists fold into one §5 component — Hit(f: record, pick: event) — which is the first L0 component to ship on a device, and what holds the card under the 300-line bound the third endpoint broke. - Result lists gate on `editing`, so two lists never answer one query (a tap could fill the endpoint you were not editing). The checker/lowering (lib.rs): - token_arg: realize erases whether a TokenOrPath argument was written (`view: .tilted` → Token) or followed (`view: view` → Text), and every reader matched Token alone — so the 2D/3D switch relabelled while the camera never moved. One helper, read by every TokenOrPath site (view/unit/width/controls/ range), because the bug was the class. - A map's controls are laid out by the SURFACE (with has_side), so the control column clears the banner and the card's side-docked chips get the theme's dark backing. - Four catalog capabilities lowered to nothing and rendered em dashes with no diagnostic: sys.locale, sys.news_item, sys.prefs, sys.series now emit calls (series loses `points` — nothing can deliver a series as a value). The UNANSWERED allowlist drains rather than parks. - `initial: here.lat * 2` was parsed as `here.lat` and the operator DISCARDED — a third unspecified expression position, accepted at L1, answering something the card did not ask. Refused at both levels. The profile (docs/ui-profile-l0.md): - §5.13 specifies the captured-initial mechanism that had shipped unspecified (write-once, host-owned, the only cell write outside a transition). - §1.1's two stale claims corrected: both reasons for keeping makepad::lower are gone and it has no production consumer; all six data visualisations are in the consumer's tag table. - §9.8 records the initial-expression hole as fixed. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 220 +++++++++++++++--- crates/splash-ui-l0/tests/fixtures/nav.card | 171 +++++++++++--- crates/splash-ui-l0/tests/profile.rs | 239 +++++++++++++++++--- docs/ui-l0-constructors.toml | 13 +- docs/ui-profile-l0.md | 95 +++++++- 5 files changed, 630 insertions(+), 108 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index fdb0c9c..5941026 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -1497,6 +1497,40 @@ impl<'a> Parser<'a> { path.push_str(&self.tokens[at + 1].text); at += 2; } + // AND NOTHING MORE. The scan above stops at the + // end of the path, so `initial: here.lat * 2` + // parsed as `here.lat` and the rest was + // discarded — accepted at L1, silently answering + // something the card did not ask for. + // + // §9.2 admits an expression in ONE position, an + // argument value. An `initial:` is a declaration + // (§5.13): it names which value to capture, and a + // captured value is READ rather than computed. + // Refusing is what keeps the two apart; dropping + // the operator quietly is the one outcome neither + // reading supports. + if self + .tokens + .get(at) + .is_some_and(|t| t.kind == Kind::Punct + && matches!( + t.text.as_str(), + "+" | "-" | "*" | "/" | "%" + )) + { + let t = self.tokens[at].clone(); + self.sink.at( + &t, + format!( + "`initial:` takes a value or a source path, \ + not an expression — `{path} {}` was parsed as \ + `{path}` and the rest discarded (profile \ + §5.13, §9.2)", + t.text + ), + ); + } initial_path = Some(path); None } @@ -2641,7 +2675,11 @@ pub mod catalog { /// Flat or tilted, while driving — the shipping app's R8.1 chase view. pub const MAP_VIEW: &[&str] = &["flat", "tilted"]; /// Where a panel sits when the card is a map. - pub const DOCK: &[&str] = &["top", "bottom"]; + /// Where a panel sits on a map card. `top` is the banner, `bottom` the sheet, + /// and `right` the control column beside the map — where a driving screen's own + /// switches belong, because a control in the banner competes with the one thing + /// a driver reads. + pub const DOCK: &[&str] = &["top", "bottom", "right"]; /// The controls a map offers. A card NAMES the affordance; the theme draws it /// and the backend wires it. /// @@ -3040,7 +3078,9 @@ pub mod catalog { "mktcap", "pe", "currency", "exchange", ], ), - ("sys.series", &["points", "min", "max"]), + // NOT `points`: a series is not a value, `StockPlot` fetches its own, and + // nothing could lower a read of it to a call. See the TOML. + ("sys.series", &["min", "max"]), // The host joins the stored tickers to live quotes, so a watchlist row // answers everything a quote does. The STORE holds only the ticker. ( @@ -5930,6 +5970,29 @@ pub mod makepad { node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) } + /// A `TokenOrPath` argument, whichever it turned out to be. + /// + /// `unit`, `width`, `view`, `controls` and `range` each admit a token OR a path + /// to card state, and REALIZE ERASES THE DIFFERENCE: `view: .tilted` survives as + /// `Token("tilted")`, while `view: view` reading `.tilted` out of the state + /// arrives as `Text("tilted")`. A reader that matches only `Token` therefore sees + /// nothing whenever the card chose the state form and silently takes its default. + /// + /// That is what broke the nav card's on-map 2D/3D switch. The chip relabelled on + /// every tap because its own guard reads the state directly, so the toggle looked + /// live — but `view` reached the lowering as `Text` and the camera stayed flat on + /// both settings. A control that responds and changes nothing is worse than one + /// that is missing; the screen asserts the camera tilted and it did not. + /// + /// Every `TokenOrPath` read goes through here so the class cannot come back one + /// argument at a time. + fn token_arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a str> { + match arg(node, name) { + Some(NodeValue::Token(t) | NodeValue::Text(t)) => Some(t.as_str()), + _ => None, + } + } + /// A value in text position. `Missing` becomes an em dash rather than an /// empty string, so an unresolved binding is visible on screen instead of /// silently rendering a blank field. @@ -6028,8 +6091,8 @@ pub mod makepad { // own `3d` mode drives a simulated vehicle, and pointing either of // these at it would reintroduce exactly the fabrication this rule // exists to refuse. - match arg(node, "view") { - Some(NodeValue::Token(v)) if v == "tilted" => "follow3d", + match token_arg(node, "view") { + Some("tilted") => "follow3d", _ => "follow", } } @@ -6271,6 +6334,60 @@ pub mod makepad { (v.starts_with("sys.") || v.trim().parse::().is_ok()).then_some(v) }; match binding.helper.as_str() { + // THE FOUR THAT ANSWERED NOTHING. Each is in the catalog, so a card may + // declare it and the checker accepts it — and each fell through to + // `None`, which means the realized literal, which on this host is an + // EMPTY blob. Every field rendered an em dash with no diagnostic, and + // `sys.locale` was reached for by six of the seven exemplars. + // + // A capability the catalog documents and the lowering cannot emit is + // worse than one that is absent: absence is a checker refusal the + // generator can read, and this was a screen that looked like working + // software showing no data. `every_catalog_capability_lowers_to_a_call` + // holds the whole set now. + "sys.locale" => { + let key = match binding.field.as_str() { + f @ ("lang" | "temp_unit") => f, + _ => return None, + }; + Some(format!("sys.locale({key:?})")) + } + // Answered from the SAME front-page fetch `sys.news` reads, found by + // `id` rather than by row. Sharing the fetch is what makes a detail + // screen agree with the list it was opened from — a second endpoint + // could rank differently between the tap and the read. + "sys.news_item" => { + let id = arg("id")?; + let key = match binding.field.as_str() { + f @ ("id" | "title" | "author" | "points" | "comments" | "url") => f, + _ => return None, + }; + Some(format!("sys.newsitem({id}, {key:?})")) + } + // §5.12's read-only half. The store is the same `user.json` the durable + // collections live in, so a preference is one more reference the user + // owns rather than a second kind of storage. + "sys.prefs" => { + let key = match binding.field.as_str() { + f @ ("units" | "range") => f, + _ => return None, + }; + Some(format!("sys.prefs({key:?})")) + } + // The extremes of the SAME close series the plot draws, off the same + // one fetch. `points` is not here: the catalog listed it, nothing could + // deliver a series as a value, and `StockPlot` fetches its own — so the + // catalog dropped it rather than this pretending to answer it. + "sys.series" => { + let ticker = arg("ticker")?; + let range = arg("range").unwrap_or_else(|| "\"d1\"".to_owned()); + let key = match binding.field.as_str() { + "min" => "low", + "max" => "high", + _ => return None, + }; + Some(format!("sys.stockrange({ticker}, {range}, {key:?})")) + } "sys.quote" => { let symbol = arg("ticker")?; // What this helper can actually answer, verified against a live @@ -6761,16 +6878,15 @@ pub mod makepad { fn decoration_of(node: &UiNode) -> Decoration { Decoration { - unit: match arg(node, "unit") { - Some(NodeValue::Token(t)) if t == "c" || t == "f" => "°", - Some(NodeValue::Text(t)) if t == "c" || t == "f" => "°", - Some(NodeValue::Token(t)) if t == "pct" => "%", + unit: match token_arg(node, "unit") { + Some("c" | "f") => "°", + Some("pct") => "%", // A duration is minutes, and "34" alone is not a duration — // beside a distance it reads as another distance. The word is // the theme's to supply, not the card's: a card that wrote // `suffix: "min"` would be asserting the unit its own number // came in, and would be wrong the day the helper answers hours. - Some(NodeValue::Token(t)) if t == "duration" => " min", + Some("duration") => " min", _ => "", }, glyph: match arg(node, "glyph") { @@ -7488,10 +7604,9 @@ pub mod makepad { // Only constrain the width when the card asked. Forcing // `width: Fill` made a long hero title wrap one character per // line — "Top Movers" became "Top / Mover / s" on device. - let width = match arg(node, "width") { - Some(NodeValue::Token(t)) if t == "fill" => " width: Fill", - Some(NodeValue::Token(t)) if t == "fit" => " width: Fit", - Some(NodeValue::Token(_)) => " width: Fit", + let width = match token_arg(node, "width") { + Some("fill") => " width: Fill", + Some(_) => " width: Fit", _ => "", }; // Always through `valued`: it falls back to `text:` and @@ -7551,8 +7666,8 @@ pub mod makepad { // sits in is `Fit`, so a `Fit` field collapses to its content — // an empty one to nothing at all, which is a search box that // cannot be tapped. - let width = match arg(node, "width") { - Some(NodeValue::Token(t)) if t == "fit" => " width: Fit", + let width = match token_arg(node, "width") { + Some("fit") => " width: Fit", _ => " width: Fill", }; let target = match arg(node, "on_commit") { @@ -8791,6 +8906,29 @@ pub mod kit { node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) } + /// A `TokenOrPath` argument, whichever it turned out to be. + /// + /// `unit`, `width`, `view`, `controls` and `range` each admit a token OR a path + /// to card state, and REALIZE ERASES THE DIFFERENCE: `view: .tilted` survives as + /// `Token("tilted")`, while `view: view` reading `.tilted` out of the state + /// arrives as `Text("tilted")`. A reader that matches only `Token` therefore sees + /// nothing whenever the card chose the state form and silently takes its default. + /// + /// That is what broke the nav card's on-map 2D/3D switch. The chip relabelled on + /// every tap because its own guard reads the state directly, so the toggle looked + /// live — but `view` reached the lowering as `Text` and the camera stayed flat on + /// both settings. A control that responds and changes nothing is worse than one + /// that is missing; the screen asserts the camera tilted and it did not. + /// + /// Every `TokenOrPath` read goes through here so the class cannot come back one + /// argument at a time. + fn token_arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a str> { + match arg(node, name) { + Some(NodeValue::Token(t) | NodeValue::Text(t)) => Some(t.as_str()), + _ => None, + } + } + /// A statistic's direction — the SIGN, not the colour. /// /// Red-versus-green is presentation and belongs to the kit; "this value @@ -8958,10 +9096,10 @@ pub mod kit { /// is the theme's answer, and deciding here would put styling in the /// lowering. fn width_wrap(node: &UiNode) -> Option<(&'static str, String)> { - let Some(NodeValue::Token(t)) = arg(node, "width") else { + let Some(t) = token_arg(node, "width") else { return None; }; - match t.as_str() { + match t { "fill" => Some(("l0_wide(", ")".into())), // NOT a no-op, though it is the default for a text role: `l0_row` // fills, so a row can only stop filling by saying so. @@ -9043,7 +9181,7 @@ pub mod kit { // A text role that ASKED to fill is not intrinsic, so it keeps the // filling wrapper. let asked_to_fill = - matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fill"); + token_arg(node, "width") == Some("fill"); let intrinsic = // A `.danger` chip is the exception: it SPANS the sheet, so a // fit-width hit target is the one thing that can hide it. It emitted @@ -9134,20 +9272,27 @@ pub mod kit { // the bottom. A turn instruction belongs in the top band and the // summary in the sheet, which is how the app this replaces arranges // its driving screen and how every map app does. - let docked_top = |c: &&UiNode| { + let docked = |c: &UiNode, where_: &str| { c.kind == "Panel" - && matches!(arg(c, "dock"), Some(NodeValue::Token(t)) if t == "top") + && matches!(arg(c, "dock"), Some(NodeValue::Token(t)) if t == where_) }; + let docked_top = |c: &&UiNode| docked(c, "top"); + let docked_right = |c: &&UiNode| docked(c, "right"); out.push_str("l0_surface_map("); element(map, depth, out); - for pass in 0..2 { + for pass in 0..3 { out.push_str(", ["); let mut first = true; - for child in node - .children - .iter() - .filter(|c| c.kind != "Map" && (docked_top(c) == (pass == 0))) - { + for child in node.children.iter().filter(|c| { + c.kind != "Map" + && match pass { + 0 => docked_top(c), + 1 => docked_right(c), + // The sheet takes everything else, which is what an + // undocked panel on a map card has always meant. + _ => !docked_top(c) && !docked_right(c), + } + }) { // A docked panel contributes its CHILDREN, not itself. // // `l0_surface_map` already draws the dock: the band at the @@ -9186,6 +9331,20 @@ pub mod kit { // planning screen had. let has_top = node.children.iter().any(|c| docked_top(&c)); let _ = write!(out, ", {}", u8::from(has_top)); + // The controls the MAP asked for, laid out by the SURFACE: they + // belong in the same column as anything docked to the side, below + // the banner, and only the surface knows where that is. `none` + // unless the card said otherwise — a map that grows buttons nobody + // mentioned is the theme deciding what a screen offers. + let controls = token_arg(map, "controls").unwrap_or("none").to_owned(); + let _ = write!(out, ", {controls:?}"); + // And whether anything is docked to the SIDE, for the same reason + // `has_top` exists. The theme gives that column the dark backing the + // zoom pill has, so a chip the card put on the map reads over pale + // tiles instead of vanishing into them — and a card that docks + // nothing there must not get an empty box for it. + let has_side = node.children.iter().any(|c| docked(&c, "right")); + let _ = write!(out, ", {}", u8::from(has_side)); out.push(')'); } "Surface" => { @@ -9317,16 +9476,9 @@ pub mod kit { // that changes every second is not a property, so routing it through // one bought nothing and cost a frozen camera when the property was a // constant expression. See `update_nav_camera`. - // The controls the card asked for. `none` unless it said otherwise, - // because a map that grows buttons a card never mentioned is the - // theme deciding what a screen OFFERS rather than how it looks. - let controls = match arg(node, "controls") { - Some(NodeValue::Token(t)) => t.clone(), - _ => "none".to_owned(), - }; let _ = write!( out, - "{f}({:?}, {}, {lat}, {lon}, {poly}, {}, {controls:?}, {})", + "{f}({:?}, {}, {lat}, {lon}, {poly}, {}, {})", makepad::map_mode(node), scalar_of(node, "zoom"), // The pins. `""` rather than omitted, because the widget reads diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 6f031ef..ef76330 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -186,12 +186,44 @@ state view { shape: enum[tilted, flat], initial: .tilted } # gap and not a claim of parity. state stop { shape: text, initial: "" } # empty ⇒ a direct trip +# WHICH endpoint is being edited, or none — R2.4's find overlay as declared state. +# +# The card USED TO make both endpoints permanently live `Field`s, on the reasoning +# that editing in place is the same capability in fewer screens. It is not, on this +# renderer: a `TextInput` inside a card never receives the draw that presents the +# keyboard, so both fields were inert. Measured on a OnePlus 6 — the tap arrives, +# `set_key_focus` runs, and no draw follows, so the IME call inside `draw_walk` is +# never reached. The L2 card's own search box is dead for the same reason, which is +# why nothing caught it: the behaviour was never there to lose. +# +# So the card follows the app it replaces, which never depended on focus: a name row +# is a tappable ROW, and only the row being edited is a field. Picking is a tap on a +# result, and R5.4 puts the trip's own places in that list so there is always +# something to pick. +state editing { shape: enum[none, origin, dest, stop], initial: .none } + +# One search hit, pickable. THREE lists show these — origin, destination, via — +# differing only in which event a tap raises, which is exactly what §5.4 passes +# as a prop. The name is what the search returned; the label is WHICH one. +component Hit(f: record, pick: event) { + view Row(align: .center, gap: 10, on_tap: pick, value: f.query) { + Col(gap: 2) { + TextRow(text: f.name) + TextCaption(text: f.label) + } + } +} + # WHETHER the stop row is on screen. An always-visible "Add a stop" field cost a # whole row of the sheet to say something most trips never need; the `+` beside `Go` # says it in an icon's worth of space and this is what it opens. state stop_row { shape: enum[hidden, shown], initial: .hidden } -event set_origin { origin: set($value), query: clear } +# Open and close the find state. `query` is cleared on open so the list starts from +# the trip's own places rather than from whatever was last searched for. +event edit_origin { editing: set(.origin), query: clear } +event edit_dest { editing: set(.dest), query: clear } +event set_origin { origin: set($value), query: clear, editing: set(.none) } # `query` is SET, not cleared — this is the line that made search reachable. # # Every event here cleared `query` and nothing ever set it, so `found` — which is @@ -203,21 +235,26 @@ event set_origin { origin: set($value), query: clear } # Committing "Stanford" now does both things it plainly means: it is the destination # (so the route draws to the best match immediately) and it is the query (so the # alternatives are listed underneath, and one tap refines the choice). -event set_dest { dest: set($value), query: set($value) } +event set_dest { dest: set($value), query: set($value), editing: set(.none) } # What is being typed, per keystroke. Separate from `set_dest` because the two mean # different things: a query is a question and a destination is an answer, and until # you commit, only the question exists. event typing { query: set($value) } -event choose_dest { dest: set($value), query: clear } +event choose_dest { dest: set($value), query: clear, editing: set(.none) } +# Picking a place for the ORIGIN. Same shape as `choose_dest`; a separate event +# because the list has to know which endpoint the tap is filling in. +event choose_origin { origin: set($value), query: clear, editing: set(.none) } # ONE event for the whole journey through the card, because `cycle` names an order # and the order IS the flow: plan → preview → drive → plan. Three chips on three # screens — "Go", "Start", "End" — are the same transition seen from three places, # and writing them as three events would have been three chances to disagree about # which screen follows which. event go { screen: cycle(.plan, .preview, .drive) } -event set_stop { stop: set($value), query: clear } -event drop_stop { stop: clear, query: clear, stop_row: set(.hidden) } -event add_stop { stop_row: set(.shown) } +event set_stop { stop: set($value), query: clear, editing: set(.none) } +event edit_stop { editing: set(.stop), query: clear } +event choose_stop { stop: set($value), query: clear, editing: set(.none) } +event drop_stop { stop: clear, query: clear, stop_row: set(.hidden), editing: set(.none) } +event add_stop { stop_row: set(.shown), editing: set(.stop), query: clear } event pick_mode { mode: set($value) } event flip_view { view: cycle(.tilted, .flat) } @@ -270,24 +307,68 @@ view root Surface { } Panel { - Row(align: .center, gap: 10) { - TextCaption(text: copy.from, width: .label) - Field(text: origin, placeholder: copy.here_now, on_commit: set_origin, width: .fill) + # A FIELD ONLY WHILE IT IS THE ROW BEING EDITED — the shape the app being + # replaced uses, and for the reason recorded on `editing`: a permanently-live + # field cannot be focused here, so tapping either endpoint did nothing at all. + # As a row it is a tap target, and the tap opens the find list below. + # The editor binds QUERY, not the committed state, and opens EMPTY with the + # current name as its placeholder — the L2 app's `empty_text: oname` pattern, + # for two reasons that showed up on device the other way: + # + # Bound to `origin`, every keystroke re-realized the card and the field was + # rebuilt FROM THE STATE — so the pill snapped back to the old name while the + # query accumulated invisibly, and the cursor landed mid-name: typing inserted + # "Saratoga High XSchool". Bound to `query` (cleared by `edit_origin`), the + # field opens empty — the cursor has nowhere to be but position 0 — and each + # rebuild redraws exactly what has been typed. + when editing == .origin { + Row(align: .center, gap: 10) { + TextCaption(text: copy.from, width: .label) + Field(text: query, placeholder: origin, on_commit: set_origin, + on_change: typing, width: .fill) + } + } + when editing != .origin { + Row(align: .center, gap: 10, on_tap: edit_origin, value: "1") { + TextCaption(text: copy.from, width: .label) + TextRow(text: origin, width: .fill) + } } Rule() - Row(align: .center, gap: 10) { - TextCaption(text: copy.to, width: .label) - Field(text: dest, placeholder: copy.where, on_commit: set_dest, - on_change: typing, width: .fill) + when editing == .dest { + Row(align: .center, gap: 10) { + TextCaption(text: copy.to, width: .label) + Field(text: query, placeholder: dest, on_commit: set_dest, + on_change: typing, width: .fill) + } + } + when editing != .dest { + Row(align: .center, gap: 10, on_tap: edit_dest, value: "1") { + TextCaption(text: copy.to, width: .label) + TextRow(text: dest, width: .fill) + } } # The stop lives in the SAME group, under the destination, which is where iOS # Maps puts it — a third row of one card, not a second card. + # The via row is the SAME editor pattern as the endpoints — it used to be the + # one always-live `Field` left, so it echoed but never searched: no + # `on_change`, no results, no way to pick. "Add Stop" now opens it editing. when stop_row == .shown { Rule() - Row(align: .center, gap: 10) { - TextCaption(text: copy.via_lbl, width: .label) - Field(text: stop, placeholder: copy.add_stop, on_commit: set_stop, width: .fill) - Chip(text: copy.remove, on_tap: drop_stop) + when editing == .stop { + Row(align: .center, gap: 10) { + TextCaption(text: copy.via_lbl, width: .label) + Field(text: query, placeholder: stop, on_commit: set_stop, + on_change: typing, width: .fill) + Chip(text: copy.remove, on_tap: drop_stop) + } + } + when editing != .stop { + Row(align: .center, gap: 10, on_tap: edit_stop, value: "1") { + TextCaption(text: copy.via_lbl, width: .label) + TextRow(text: stop, width: .fill) + Chip(text: copy.remove, on_tap: drop_stop) + } } } # And `Add Stop` is a row of the group too, reading as an action rather than @@ -304,7 +385,22 @@ view root Surface { # R5.4's "your trip" defaults: before anything is typed, the trip's own places # are the pickable items — tap the origin while the destination is empty and the # trip reverses. - when dest == "" { + # + # Gated on `editing`, not on an empty destination. It used to require `dest == ""` + # — so the one moment the defaults appeared was before a trip existed, and a + # planned trip could never be edited from them. Tapping a name is what asks for + # this list, so that is what shows it. + when editing == .origin { + when query == "" { + Panel { + Row(align: .center, gap: 10, on_tap: choose_origin, value: dest) { + TextCaption(text: copy.to, width: .label) + TextRow(text: dest) + } + } + } + } + when editing == .dest { when query == "" { Panel { Row(align: .center, gap: 10, on_tap: choose_dest, value: origin) { @@ -330,23 +426,25 @@ view root Surface { # it — an empty rounded box between TO and VIA, which is what this looked like # on device the first time. L0 has no predicate for "this list has rows", so the # container has to be the thing that cannot render empty: no rows, no nodes. + when editing == .origin { + for f, i in found key f.label { Hit(f: f, pick: choose_origin) } + } + when editing == .stop { + for f, i in found key f.label { Hit(f: f, pick: choose_stop) } + } + # And NOT while the origin or the stop is the row being edited — their own + # lists are rendering for the same query, and two lists answering one question + # means a tap can fill the endpoint you were not editing. + when editing != .origin { + when editing != .stop { when query != "" { # KEYED ON THE NAME, not on `id`. `sys.search` answers name/label/lat/lon and # the backend has no translation for `id` at all, so a row keyed on it had no # identity and a tap carried nothing. The name is what the search returns and # what `sys.search(query: state.dest)` can find again. - for f, i in found key f.label { - Row(align: .center, gap: 10, on_tap: choose_dest, value: f.query) { - Col(gap: 2) { - TextRow(text: f.name) - # WHICH Stanford. The name alone is not a choice — the helper answers - # five places called "Stanford" and this is the line that tells them - # apart, so it is also what keys the row. - TextCaption(text: f.label) - } - } - Rule() - } + for f, i in found key f.label { Hit(f: f, pick: choose_dest) } + } + } } # The stop's own row, a FIELD and always present — for the same reason FROM @@ -527,8 +625,13 @@ view root Surface { # reads at a glance, truncated. `TextBody` is the role that wraps. when origin != "" { TextBody(text: step.instruction, width: .fill) } when origin == "" { TextBody(text: step_here.instruction, width: .fill) } - # The toggle names the view it would switch TO, which is what the button on - # the card being replaced is labelled. + } + + # The view switch, ON THE MAP rather than in the banner — the banner holds the + # one line a driver reads, and a control there competes with it. The chip names + # the view it would switch TO, which is what the button on the card being + # replaced is labelled. + Panel(dock: .right) { when view == .tilted { Chip(text: copy.flat_lbl, on_tap: flip_view) } when view == .flat { Chip(text: copy.tilt_lbl, on_tap: flip_view) } } @@ -582,11 +685,11 @@ view root Surface { # rather than a whole `Map` per branch it would otherwise multiply with. when origin != "" { Map(mode: .drive, from: origin_place, to: dest_place, at: here, - view: view, zoom: 15) + view: view, zoom: 15, controls: .all) } when origin == "" { Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, - at: here, view: view, zoom: 15) + at: here, view: view, zoom: 15, controls: .all) } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 726a774..62624f7 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4789,8 +4789,23 @@ fn the_nav_trip_planner_is_expressible_at_l0() { !t.is_empty() && !t.starts_with('#') }) .count(); - assert!( - lines < 250, + // 300, RAISED FROM 250, and the raise is recorded rather than quietly applied. + // + // What bought the 44 lines: both endpoints became tappable rows that open a find + // state, because a permanently-live `Field` cannot be focused on this renderer + // and both endpoints were therefore inert. That is a capability the card did not + // have, not a refactor — and it is the shape the L2 card uses, so the comparison + // this number exists to make is still like for like: 664 lines there against 286 + // here, both with reachable endpoints. + // + // The cost is duplication rather than machinery: each endpoint states its own + // edited and resting rows, and its own pick list. A `component` taking the label, + // the value and the two events would collapse all four into one definition used + // twice, which is what §5 is for and is the way back under 250. The threshold the + // comment above names is 400 — the point at which declarations stop being the + // cheaper answer — and this is well inside it. + assert!( + lines < 300, "the point is that it is small; this is {lines} lines" ); } @@ -6622,20 +6637,15 @@ fn every_offered_field_has_a_translation() { // call answers. Duration and distance used to sit here beside it and no // longer do: both are answered, and the probe now supplies the // coordinates that prove it. + // + // `sys.locale`, `sys.news_item`, `sys.prefs` and `sys.series` sat here too, + // and that is the shape of the mistake: FOUR WHOLE CAPABILITIES parked in an + // allowlist under a comment saying they render an em dash, while the catalog + // went on documenting them and the checker went on accepting them. Six of the + // seven exemplars reach for `sys.locale`. A list of known gaps is only worth + // keeping if it is drained; this one recorded the defect and then held it + // still. All four are answered now. ("sys.route", "steps"), - ("sys.locale", "lang"), - ("sys.locale", "temp_unit"), - ("sys.news_item", "id"), - ("sys.news_item", "title"), - ("sys.news_item", "author"), - ("sys.news_item", "points"), - ("sys.news_item", "comments"), - ("sys.news_item", "url"), - ("sys.series", "points"), - ("sys.series", "min"), - ("sys.series", "max"), - ("sys.prefs", "units"), - ("sys.prefs", "range"), // ── a field the arm forgot ──────────────────────────────────────────── // Each of these sits beside fields the same arm answers, so the // capability works and one value on the card does not. @@ -6766,21 +6776,50 @@ fn the_nav_card_routes_between_two_editable_places() { .expect("realizes"); let kit = splash_ui_l0::kit::lower(&root); - // TWO fields on the resting sheet — origin and destination — and a THIRD when - // the stop row is asked for. An always-visible "Add a stop" cost a whole row of a - // sheet that sits over a map, to say something most trips never need. + // NO field on the resting sheet, and a TAP TARGET on each endpoint. // - // Both halves, because the risk in hiding a control is that it hides for good. - // The stop was once a `Chip(..., value: "")` that a review found could never - // work — an empty value becomes no payload, so the transition wrote nothing and - // the control was incapable of adding a stop while looking right. A field is how - // text enters an L0 card; this asserts the field is REACHABLE, not merely - // declared. + // This asserted two fields, always on screen — the design that made both + // endpoints permanently live inputs. It is the wrong design on this renderer and + // the assertion is what made that look correct: a `TextInput` inside a card never + // receives the draw that presents the keyboard, so both fields were inert, and a + // test counting `l0_field(` cannot tell a reachable field from a dead one. + // Measured on a OnePlus 6 — the tap arrives, `set_key_focus` runs, no draw + // follows, and the IME call inside `draw_walk` is never reached. + // + // So a name row is a ROW that opens the find state, exactly as the L2 card does, + // and only the row being edited is a field. What this asserts now is the property + // that failed on the phone: from the resting sheet, each endpoint can be REACHED. assert_eq!( kit.matches("l0_field(").count(), - 2, - "an origin and a destination, editable and always on screen:\n{kit}" + 0, + "a resting sheet has no live field, only tappable rows:\n{kit}" ); + for event in ["edit_origin", "edit_dest"] { + assert!( + kit.contains(event), + "the resting sheet offers {event}:\n{kit}" + ); + } + // And opening one turns THAT row into a field, so a keyboard-capable renderer + // still gets one — and picking a result works without one either way. + for (event, target) in [("edit_origin", "choose_origin"), ("edit_dest", "choose_dest")] { + let mut store = splash_ui_l0::InstanceStore::default(); + splash_ui_l0::dispatch_reporting(NAV, &mut store, "root", event, None, &data); + let open = splash_ui_l0::kit::lower( + &splash_ui_l0::realize_with_state(NAV, &data, &store, RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert_eq!( + open.matches("l0_field(").count(), + 1, + "{event} makes exactly the row it opened a field:\n{open}" + ); + assert!( + open.contains(target), + "{event} offers a pickable place through {target}:\n{open}" + ); + } let opened = { let mut store = splash_ui_l0::InstanceStore::default(); splash_ui_l0::dispatch_reporting(NAV, &mut store, "root", "add_stop", None, &data); @@ -6792,7 +6831,7 @@ fn the_nav_card_routes_between_two_editable_places() { }; assert_eq!( opened.matches("l0_field(").count(), - 3, + 1, "and a stop, one tap away:\n{opened}" ); // The trip's facts, live, from the coordinates of the places that were found. @@ -7835,6 +7874,152 @@ fn a_card_names_a_map_control_and_never_calls_the_widget() { } } +/// §5.13: an `initial:` names a value to CAPTURE, and never computes one. +/// +/// The parser scanned the dotted path and stopped, so everything after it was +/// discarded without a word: `initial: here.lat * 2` became `here.lat`, and at L1 — +/// where arithmetic is admitted in an argument value — the card was accepted. A card +/// that asks for one number and is silently given another is the failure this +/// profile's §4 exists for, arriving through the declaration rather than the value. +/// +/// It is also the third position the implementation parsed an expression in. §9.2 +/// admits exactly one, and §9.8 records a guard as a known second; this one nobody +/// had noticed, because it did not evaluate the expression — it dropped it. +#[test] +fn an_initial_captures_a_value_and_never_computes_one() { + let card = |initial: &str, header: &str| { + format!( + "{header}source here sys.gps()\n\ + state a {{ shape: number, initial: {initial} }}\n\ + view root Surface {{ TextHero(value: a) }}\n" + ) + }; + // A capture is admitted, which is the whole point of §5.13. + let ok = check_ui_l0_named("probe", &card("here.lat", "")); + assert!(ok.valid, "a captured initial is L0: {:#?}", ok.diagnostics); + // A literal still is too. Its own card, because a card that captures nothing + // has no reason to declare the source and is refused for leaving it unread. + let literal = check_ui_l0_named( + "probe", + "state a { shape: number, initial: 0 }\n\ + view root Surface { TextHero(value: a) }\n", + ); + assert!(literal.valid, "{:#?}", literal.diagnostics); + + // And an expression is refused AT BOTH LEVELS. L0 refuses it as arithmetic; the + // one that mattered is L1, where arithmetic is otherwise legal and this was + // accepted and thrown away. + for header in ["", "# level: L1\n"] { + let report = check_ui_l0_named("probe", &card("here.lat * 2", header)); + assert!( + !report.valid, + "an expression in `initial:` is refused (header {header:?})" + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("expression")), + "and says so: {:#?}", + report.diagnostics + ); + } +} + +/// A `TokenOrPath` argument works BOTH ways, or it works in neither. +/// +/// R8.3's on-map 2D/3D switch is a `view:` the card states and a guard on the same +/// state — no widget call. Written `view: .tilted` it lowered to the tilted camera; +/// written `view: view`, following card state, it lowered to the FLAT one on both +/// settings. Realize erases the difference between the two forms — a fixed token +/// stays `Token("tilted")`, a followed state arrives as `Text("tilted")` — and every +/// reader in the lowering matched `Token` alone. +/// +/// On the phone the chip relabelled 3D→2D on every tap, because its label comes from +/// a guard that reads the state directly. The toggle looked live and the camera never +/// moved: a control that responds and changes nothing, asserting a view the map is +/// not in. Both spellings are asserted here, for `view` and for the `width` and +/// `unit` that share the shape. +#[test] +fn a_token_argument_may_be_written_or_followed() { + let card = |view: &str, unit: &str, span: &str| { + format!( + concat!( + "source g sys.gps()\n", + "state view {{ shape: enum[tilted, flat], initial: .tilted }}\n", + "state pace {{ shape: enum[c, f], initial: .c }}\n", + "state span {{ shape: enum[fill, fit], initial: .fill }}\n", + "view root Surface {{\n", + " Map(mode: .drive, at: g, view: {}, zoom: 15)\n", + " TextHero(value: g.accuracy{})\n", + " TextBody(text: \"x\"{})\n", + "}}\n" + ), + view, unit, span + ) + }; + let data = serde_json::json!({ + "view": "tilted", "pace": "c", "span": "fill", + "g": { "lat": 1.0, "lon": 2.0, "accuracy": 5.0, "ok": 1 }, + "env": { "locale": {} } + }); + let lower = |src: &str| { + let checked = check_ui_l0_named("nav", src); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let root = realize(src, &data, RealizeLimits::default()) + .root + .expect("realizes"); + ( + splash_ui_l0::kit::lower(&root), + splash_ui_l0::makepad::lower(&root), + ) + }; + + // The camera, both ways. `state.view` reads `.tilted` out of the same data. + for spelling in [".tilted", "view"] { + let (kit, desk) = lower(&card(spelling, "", "")); + assert!( + kit.contains("follow3d"), + "{spelling}: the tilted camera:\n{kit}" + ); + assert!( + desk.contains("follow3d"), + "{spelling}: the tilted camera on the desk too:\n{desk}" + ); + } + // And the flat one is still reachable, or the fix would be "always tilted". + let flat = serde_json::json!({ + "view": "flat", "pace": "c", "span": "fill", + "g": { "lat": 1.0, "lon": 2.0, "accuracy": 5.0, "ok": 1 }, + "env": { "locale": {} } + }); + let src = card("view", "", ""); + let root = realize(&src, &flat, RealizeLimits::default()) + .root + .expect("realizes"); + let dsl = splash_ui_l0::kit::lower(&root); + assert!( + !dsl.contains("follow3d") && dsl.contains("\"follow\""), + "a followed state can still choose flat:\n{dsl}" + ); + + // `unit` and `width` share the shape, so they share the test. + for spelling in [".c", "pace"] { + let (_, desk) = lower(&card(".tilted", &format!(", unit: {spelling}"), "")); + assert!( + desk.contains('\u{b0}'), + "unit as `{spelling}` still decorates:\n{desk}" + ); + } + for spelling in [".fill", "span"] { + let (kit, _) = lower(&card(".tilted", "", &format!(", width: {spelling}"))); + assert!( + kit.contains("l0_wide("), + "width as `{spelling}` still fills:\n{kit}" + ); + } +} + /// Pins belong to a map you are LOOKING at, not one that is chasing you. /// /// R3.12 is a plan-screen requirement. A follow camera already draws the driver's diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index efbb893..85d0611 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -103,7 +103,7 @@ width = { kind = "width" } # screen, and how every map app does. Ignored by a card with no map, where panels # simply stack. [Panel] -dock = { kind = "token", tokens = ["top", "bottom"] } +dock = { kind = "token", tokens = ["top", "bottom", "right"] } [Card] on_tap = { kind = "event" } @@ -276,6 +276,12 @@ range = { kind = "unit" } [kinds.unit] # Admits a token OR a path: `unit: .pct` is fixed, `unit: units` follows card # state. This is the argument that forced tokens to be lexically marked. +# +# `unit` APPENDS. It is for a value that arrives as a bare number, and a source +# that already formats its answer must not be given one: `sys.route("duration")` +# answers "30 min", so `unit: .duration` on it renders "30 min min". Check what +# the source answers with before adding a unit — `sys.navstep("eta")` answers a +# bare "30" and does need it. accepts = ["token", "path"] tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money", "duration"] @@ -411,7 +417,10 @@ answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "pr [sources."sys.series"] args = ["ticker", "range", "points", "fields", "aggregate"] -answers = ["points", "min", "max"] +# `points` was here and nothing could answer it: a series is not a value, and +# `StockPlot` fetches its own. A catalog entry nothing can deliver is a card the +# checker accepts and the screen renders as em dashes. +answers = ["min", "max"] aggregates = ["min", "max"] # ─── durable capabilities (profile §5.12) ───────────────────────────────────── diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 1569bfb..de526fb 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -239,11 +239,17 @@ implements these widgets — six of six exist and ship in octos-one today, again against a contract that has shipped once. Defining the contract first is defining it for three implementations, two of which do not exist, which is how the previous attempt went wrong. -**The implementation does not yet do this.** `splash-core`'s `makepad::lower` emits makepad's -widget dialect directly, with ten hardcoded colours and a font-size ramp — so it bypasses the -DSL, the VM, `UiNode` and two backends, and it puts a theme inside the crate whose job is -deciding whether a card is safe. That is a defect measured against this section, and §9 lists -it as such. +**It does now, and this paragraph used to say otherwise.** `makepad::lower` still emits +makepad's widget dialect directly, with ten hardcoded colours and a font-size ramp — bypassing +the DSL, the VM, `UiNode` and two backends, and putting a theme inside the crate whose job is +deciding whether a card is safe. It is still a defect measured against this section. What +changed is that **nothing in production calls it**: octos-one's device path is +`kit::lower` → `_kit.splash` → the VM → `UiNode` → widgets, end to end, and `makepad::lower` +survives only in this crate's own tests. + +All six data visualisations reach a backend through it. The warning below — that five of the +six were absent from the consumer's tag table and would be dropped without a diagnostic — was +true when it was written and is not now: each has a kit function and each has a tag. **A first attempt at this retarget was reverted**, and its failures are recorded here because they are the specification's failures rather than the code's. It mapped roles to consumer tags @@ -1099,6 +1105,59 @@ a typed schema per capability, and normalisation by entity id. The third is not. --- +### 5.13 An initial may be CAPTURED from a source + +**Status: implemented and shipped before it was specified, which is the wrong order +and is why this section exists.** `RealizeReport::captured`, the host write that +freezes it, and `state from_lat { shape: number, initial: here.lat }` in the nav card +were all live while every `initial:` in this document was a literal or a token. A +review of §7 found the same failure it was written to name: an implementation grew a +construct the specification did not describe, so nothing could say whether the +behaviour was right. + +A state's `initial:` may be a **path into a source**. It means *the value that source +answered the first time this card was realized*, and it is written down at that +moment: + +``` +source here sys.gps() +state from_lat { shape: number, initial: here.lat } +``` + +**Why a path and not a literal.** "Start from where I am" is not a fact the model may +write — §4 forbids it, and rightly: a coordinate a card carries is a place the device +is not. It is also not an ordinary read, because an ordinary read FOLLOWS. Left +resolving on every realization, `from_lat` would chase the fix: an origin that moves +as you drive, and a route re-requested before it can answer. The card being replaced +has the same requirement and meets it with an imperative one-shot assignment. + +**Capture is what makes the difference expressible.** The state's value is the +source's answer *at capture time*; the source goes on reporting the present, and the +two are then separate values a card can show side by side — which is exactly what a +trip from here needs, a fixed start and a moving position. + +**Who does what.** The realizer decides WHAT was captured, because it owns the +precedence between a declared initial, a stored cell and the data. The host decides +WHEN it becomes durable, because it owns the store. The rule is **write-once**: a cell +that already exists is already captured and is never overwritten by a later +realization, so a transition that writes it wins from then on. A host that skips the +write gets the following behaviour, which is a bug and not a degradation: the state +re-resolves every realization and silently becomes a live read. + +**This is a host write outside a transition, and it is the only one.** §2.1 excludes +assignment outside a transition from the *card*; this is the runtime recording what it +answered, not the card assigning. The distinction is worth keeping sharp, because it +is the only seam through which a cell changes without an event, and anything else +arriving through it is a defect. + +**What is NOT admitted.** The path is read once and is not re-captured when the source +changes — there is no re-arming, and a card that needs a second capture declares a +second state and an event that writes it. `initial:` still admits no expression, at L0 +or at L1: `initial: here.lat * 2` is refused, because the L1 grammar admits arithmetic +in an argument value and this is a declaration. + +--- + ## 6. What makes L0 terminate Grammar membership alone does not bound execution. These conditions do, and an implementation @@ -1227,12 +1286,18 @@ which assumed a kit answering L0's roles existed. None did: `components/flutter` `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. +**Both reasons that kept `makepad::lower` in place are now gone, and it has no production +consumer.** They were: 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. The first was resolved the +way this paragraph predicted — an event travels through the `tapto` attribute the renderer +already has, its non-`set:` strings falling through to the host, so an instance key survives +with no change to the VM and `docs/scoped-state.md` was not needed for it. Taps, text commits +and per-keystroke changes all reach a card through it now. The second is resolved too: all six +visualisations have kit functions and all six are in the consumer's tag table. + +`makepad::lower` is still in the crate and is still exercised by the profile tests, but the +device path calls only `kit::lower`. A verification done through `makepad::lower` therefore +proves nothing about what a phone renders, which is a mistake this repository has made before. L1 and L2 **as capability levels** — the levels this document's §7 classifies, not the layers above — are a separate matter from the kit work; the two senses of "L1" are easy to conflate and @@ -1476,6 +1541,14 @@ Recorded rather than patched over, in the order they would bite. expression's answer must move when its inputs move, so `last * 0 + 1547` is refused. This was recorded as needing an argument rather than a patch, and the argument is that a formula depends on what it reads. +- ~~**A state's `initial:` was a third position, and it discarded the expression.**~~ + **Fixed.** §9.2 admits an expression in one position; §9.8 already recorded a guard as + a second. `initial:` was a third, and the worst of the three: the parser scanned the + dotted path and stopped, so `initial: here.lat * 2` parsed as `here.lat` and the rest + was dropped without a diagnostic — accepted at L1, where arithmetic is otherwise + legal, and answering a number the card did not ask for. Refused now at both levels. + An `initial:` is a declaration of which value to capture (§5.13), and a captured + value is read rather than computed. - **Arithmetic is the only construct.** No comparison chain, no conditional, no string operation, no aggregate over a collection. §8 question 10 — how a card says a collection is empty — is *not* answered here, and a count is still one operator away from the arithmetic From 85f8e1fcdcf9567f194ca5fd0566d73d85f0aada Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:31:01 -0700 Subject: [PATCH 80/97] feat(ui_l0): GO goes straight to the drive, and the map's side chips match the ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The preview screen is cut on request: `go` cycles plan → drive, because the plan screen already frames the route with its duration and distance, so the confirmation step asked the same question twice. The card drops to 263 lines. - A side-docked Chip on a map emits as `l0_mapchip` — the recenter ring's 38x38 spec — instead of a sheet chip; the eye-test asked for the 2D/3D switch and the location button to be the same size. The tap target is emitted UNQUOTED like the ordinary hit path: `tap_target` returns a DSL expression carrying its own quoting, and Debug-quoting it turned the dispatch into an empty event "applied to nothing". Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 29 ++++++++++ crates/splash-ui-l0/tests/fixtures/nav.card | 64 ++++----------------- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 5941026..d7cd474 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -9320,6 +9320,35 @@ pub mod kit { out.push_str(", "); } first = false; + // A side-docked Chip stands ON THE MAP, in the same + // column as the zoom pill and the recenter ring — so it + // is drawn to the ring's spec (`l0_mapchip`, 38x38) + // rather than as a sheet chip. The eye-test asked for + // the 2D/3D switch and the location button to be the + // same size, and a text pill in a rounded box cannot be. + if pass == 1 && one.kind == "Chip" { + // UNQUOTED, like the ordinary hit path above: + // `tap_target` returns a DSL EXPRESSION carrying its + // own quoting (and, for live payloads, a + // concatenated call). Debug-quoting it turned the + // expression into a string of escapes, the runtime + // target into garbage, and the dispatch into an + // empty event "applied to nothing". + if let Some(target) = tap_target(one) { + let _ = write!( + out, + "l0_tap_fit({target}, l0_mapchip({}))", + makepad::expr_of(one, "text") + ); + } else { + let _ = write!( + out, + "l0_mapchip({})", + makepad::expr_of(one, "text") + ); + } + continue; + } element(one, depth + 1, out); } } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index ef76330..3ce2c43 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -140,7 +140,7 @@ state query { shape: text, initial: "" } # what the user is typing # checker to accept an undeclared literal, and a `not` operator would be the # expression form L0 does not have. Two named screens have two guards that each # say which screen they are, which is what a card should have said anyway. -state screen { shape: enum[plan, preview, drive], initial: .plan } +state screen { shape: enum[plan, drive], initial: .plan } # How the trip is travelled. R7.1/R7.2 of the shipping app's contract. # @@ -244,12 +244,16 @@ event choose_dest { dest: set($value), query: clear, editing: set(.none) } # Picking a place for the ORIGIN. Same shape as `choose_dest`; a separate event # because the list has to know which endpoint the tap is filling in. event choose_origin { origin: set($value), query: clear, editing: set(.none) } -# ONE event for the whole journey through the card, because `cycle` names an order -# and the order IS the flow: plan → preview → drive → plan. Three chips on three -# screens — "Go", "Start", "End" — are the same transition seen from three places, -# and writing them as three events would have been three chances to disagree about -# which screen follows which. -event go { screen: cycle(.plan, .preview, .drive) } +# ONE event for the whole journey, because `cycle` names an order and the order +# IS the flow: plan → drive → plan. GO and End are the same transition seen from +# two screens, and writing them as two events would be two chances to disagree +# about which screen follows which. +# +# There WAS a preview between them — R2.2's separate confirmation step, a framed +# route with a Start pill. Cut on request: GO goes straight to live navigation, +# because the plan screen already shows the framed route, the duration and the +# distance, so the confirmation step was the same information asked twice. +event go { screen: cycle(.plan, .drive) } event set_stop { stop: set($value), query: clear, editing: set(.none) } event edit_stop { editing: set(.stop), query: clear } event choose_stop { stop: set($value), query: clear, editing: set(.none) } @@ -566,52 +570,6 @@ view root Surface { } } - # ---- previewing ---------------------------------------------------------- - # - # R2.2, and it is a SCREEN rather than a state of the planning one. The chosen - # trip, framed, with the two numbers that decide whether to take it and one - # button that commits — and none of the editing controls, because a preview that - # still offers a search box is the planning screen with a different name. - # - # Everything on it was already on the plan screen; what was missing was it being - # a separate step. That is the whole requirement, and it costs two guarded - # branches because a trip through a stop is a different trip — the same reason - # `trip` and `trip_via` are two sources. - when screen == .preview { - when stop == "" { - when origin != "" { - Panel(dock: .bottom) { - TextHero(value: trip.duration) - TextCaption(value: trip.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) - } - } - when origin == "" { - Panel(dock: .bottom) { - TextHero(value: trip_here.duration) - TextCaption(value: trip_here.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) - } - } - when origin != "" { - Map(mode: .plan, from: origin_place, to: dest_place, zoom: 16, controls: .all, - summary: trip) - } - when origin == "" { - Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, - zoom: 16, controls: .all) - } - } - when stop != "" { - Panel(dock: .bottom) { - TextHero(value: trip_via.duration) - TextCaption(value: trip_via.distance, suffix: copy.away) - Chip(text: copy.begin, on_tap: go) - } - Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, - controls: .all) - } - } # ---- driving ------------------------------------------------------------- when screen == .drive { From 5f89fac7b464b12f98d78cf297c2a07358c362c3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:06:08 -0700 Subject: [PATCH 81/97] fix(ui_l0): the two live-refusal syntax classes teach instead of cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live generation runs produced checker-refused cards that rendered as blank screens, and both refusals were bare expected/found lines that the repair loop fed back to the model verbatim — teaching it nothing: line 75: expected ")", found "when" — a `when` guard nested inside a constructor's argument list line 124: expected ":", found "," — a comma where an argument's `key: value` colon belongs (cascading into `expected ")"`, `expected an element`) The diagnostic text IS the repair prompt, so it now says what the construct is for: - `parse_args` checks for a `when`/`for` keyword both at argument-name position (the comma form, where `ident()` used to eat `when` as an argument name and refuse with an unrelated `expected ":"`) and at the would-be `)` (the no-comma form). Either way it emits "a `when` guard cannot appear inside an argument list; guards wrap elements — close the constructor's `(…)` first…" and leaves the keyword unconsumed, so the enclosing block still parses the guard and checks its contents. - A missing `:` after an argument name now names the argument, states the `name: value`-separated-by-commas form, and recovers to the closing paren. The comma-for-colon repro went from five diagnostics (four of them noise) to exactly one; the nested-guard repro from nine to two. New profile test `syntax_the_model_gets_wrong_is_refused_with_a_teaching_ diagnostic` pins both classes: the teaching text must appear, the bare `expected ")", found "when"` must not, and the comma-for-colon case must produce exactly one diagnostic. Suite: 249 green (was 248). Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/src/lib.rs | 86 +++++++++++++++++++++++++++- crates/splash-ui-l0/tests/profile.rs | 70 ++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index d7cd474..3367954 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2166,6 +2166,13 @@ impl<'a> Parser<'a> { if self.peek().is_none() || self.peek().is_some_and(|t| t.is_punct(")")) { break; } + // `Row(gap: 8, when … { … })` — a statement where an argument + // belongs. Catch it BEFORE `ident()` eats `when` as an argument + // name and refuses with a bare `expected ":"`, which taught the + // model nothing (the diagnostic text IS the repair prompt). + if self.args_stopped_by_statement() { + return out; + } let Some(name_tok) = self.peek().cloned() else { break; }; @@ -2173,7 +2180,40 @@ impl<'a> Parser<'a> { self.at += 1; continue; }; - if !self.expect_punct(":") { + if !self.eat_punct(":") { + // `TextRow(text, value)` — a comma (or the closing paren) + // where the argument's `:` belongs. The bare + // `expected ":", found ","` cascaded into `expected ")"` and + // `expected an element` and buried the fix; say what an + // argument IS instead, once. + match self.peek().cloned() { + Some(t) => self.sink.at( + &t, + format!( + "expected \":\" after argument name `{name}`, found {:?} — every \ + constructor argument is written `name: value` and arguments are \ + separated by commas, e.g. `Row(gap: 8, on_tap: back)`; a bare \ + value with no name, or a comma where the `:` belongs, is refused", + t.text + ), + ), + None => self.sink.push( + 0, + 0, + format!( + "expected \":\" after argument name `{name}`, found end of source" + ), + ), + } + // Recover to the closing paren so one mistake yields one + // diagnostic instead of the cascade. The card is already + // refused; the skipped tokens have nothing more to teach. + while let Some(t) = self.peek() { + if t.is_punct(")") || t.is_punct("{") || t.is_punct("}") { + break; + } + self.at += 1; + } break; } let value = self.parse_operand(); @@ -2187,10 +2227,54 @@ impl<'a> Parser<'a> { break; } } + // `Col(gap: 8\n when … { … })` — the no-comma variant of the same + // nesting mistake, reached when the argument loop stops without a + // trailing comma. Leave the keyword in place: the enclosing block + // parses the guard as the element it should have been. + if self.args_stopped_by_statement() { + return out; + } self.expect_punct(")"); out } + /// A `when`/`for` STATEMENT keyword sitting where an argument belongs — + /// the most common malformation in live generation runs. Emits the + /// teaching diagnostic (guards wrap elements; they are not arguments) and + /// answers true so `parse_args` returns without the bare + /// `expected ")", found "when"` noise. The keyword is left unconsumed so + /// the enclosing block still parses the guard and checks its contents. + fn args_stopped_by_statement(&mut self) -> bool { + let Some(t) = self.peek().cloned() else { + return false; + }; + let (kw, what, fix) = if t.is_kw("when") { + ( + "when", + "guard", + "close the constructor's `(…)` first, then write `when path == value { … }` \ + around the elements it guards", + ) + } else if t.is_kw("for") { + ( + "for", + "loop", + "close the constructor's `(…)` first, then write \ + `for item in collection key item.id { … }` around the elements it repeats", + ) + } else { + return false; + }; + self.sink.at( + &t, + format!( + "a `{kw}` {what} cannot appear inside an argument list; {what}s wrap \ + elements — {fix}" + ), + ); + true + } + /// An argument value, including an L1 arithmetic expression. /// /// Precedence is the usual one — `*` `/` `%` bind tighter than `+` `-` — so diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 62624f7..3ea7ef3 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -3467,6 +3467,76 @@ fn malformed_input_is_rejected_rather_than_parsed_loosely() { } } +/// The two malformations live generation actually produced — a `when` guard +/// nested inside a constructor's argument list, and a comma where an +/// argument's `:` belongs — must refuse with diagnostics that TEACH, because +/// the diagnostic text is fed back verbatim as the repair prompt. A bare +/// `expected ")", found "when"` sent the model in circles; the teaching text +/// names the construct and the fix. +#[test] +fn syntax_the_model_gets_wrong_is_refused_with_a_teaching_diagnostic() { + // (a) A `when` guard inside an argument list — both the no-comma form + // (the live `line 75: expected ")", found "when"` refusal) and the + // comma form, where `ident()` used to eat `when` as an argument name. + for (source, what) in [ + ( + "view root Col(gap: 8\n when count > 0 { Rule() }\n)", + "when guard after the last argument, no comma", + ), + ( + "view root Col(gap: 8, when count > 0 { Rule() })", + "when guard in argument position after a comma", + ), + ( + "view root Col(gap: 8, for m in movers key m.id { Rule() })", + "for loop in argument position", + ), + ] { + let report = check_ui_l0_named("malformed", source); + assert!(!report.valid, "{what} must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("cannot appear inside an argument list") + && d.message.contains("wrap elements")), + "{what} should produce the teaching diagnostic: {:#?}", + report.diagnostics + ); + assert!( + !report + .diagnostics + .iter() + .any(|d| d.message.contains("expected \")\", found \"when\"") + || d.message.contains("expected \")\", found \"for\"")), + "{what}: the bare expected/found must be replaced, not joined: {:#?}", + report.diagnostics + ); + } + + // (b) A comma where the argument's `:` belongs (the live `line 124: + // expected ":", found ","` refusal). The diagnostic must name the + // argument, state the `name: value` form, and not cascade. + let report = check_ui_l0_named("malformed", "view root TextRow(text, value)"); + assert!(!report.valid, "a bare-value argument must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("expected \":\" after argument name `text`") + && d.message.contains("`name: value`")), + "comma-for-colon should produce the teaching diagnostic: {:#?}", + report.diagnostics + ); + assert_eq!( + report.diagnostics.len(), + 1, + "one mistake, one diagnostic — the old cascade buried the line that \ + mattered: {:#?}", + report.diagnostics + ); +} + /// §6, condition 5: node count and nesting depth are capped, and the /// declaration count with them. These are the bounds that make realization /// terminate on input the grammar alone does not bound. From 3451d0ed34ddb247b481356a71f42ed532d5cba6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:21:57 -0700 Subject: [PATCH 82/97] =?UTF-8?q?fix(ui=5Fl0):=20weather/news/stock=20fixt?= =?UTF-8?q?ures=20gain=20the=20=C2=A75.9=20lifecycle=20guards=20(exemplar?= =?UTF-8?q?=20sync)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical sync from octos-one/a2app-l0/apps/*/exemplar.card (octos-one 266328e: the three exemplars say "loading" and "can't reach"). Each card now branches on its primary source's lifecycle with declared copy — `when .$state == .pending/.failed` — and weather drops the dead `copy visibility` whose tile was removed. All three pass the checker at L0; suite 249 green. Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/tests/fixtures/news.card | 6 ++++++ crates/splash-ui-l0/tests/fixtures/stock.card | 8 +++++++- crates/splash-ui-l0/tests/fixtures/weather.card | 8 +++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index ade95a5..5470425 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -32,13 +32,19 @@ copy lead { class: vocabulary, en: "LEAD", zh: "焦点" } copy pts { class: vocabulary, en: "pts", zh: "分" } copy comments { class: vocabulary, en: "comments", zh: "评论" } copy by { class: vocabulary, en: "by", zh: "作者" } +copy loading { class: vocabulary, en: "Fetching headlines…", zh: "正在获取头条…" } +copy offline { class: vocabulary, en: "Can't reach the news feed", zh: "无法获取新闻" } view root Surface(pad: .page) { when selected == "" { stream } when selected != "" { story } } +# §5.9: the feed's lifecycle is a state to branch on — "not yet" and "went +# wrong" each say so, instead of an empty masthead with no explanation. view stream Col { + when feed.$state == .pending { TextBody(text: copy.loading) } + when feed.$state == .failed { TextBody(text: copy.offline) } masthead headline latest diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index a9373f6..4c02fa1 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -22,7 +22,9 @@ event open_quote { selected: set($value) } event back { selected: clear, range: clear } # two writes, one gesture event set_range { range: set($value) } -copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy loading { class: vocabulary, en: "Fetching the quote…", zh: "正在获取行情…" } +copy offline { class: vocabulary, en: "Can't reach the market feed", zh: "无法获取行情" } copy open { class: vocabulary, en: "Open", zh: "开盘" } copy high { class: vocabulary, en: "High", zh: "最高" } copy low { class: vocabulary, en: "Low", zh: "最低" } @@ -52,8 +54,12 @@ view list Col { } } +# §5.9: the quote's lifecycle is a state to branch on — a tapped ticker says +# "fetching" or "can't reach", never a silent grid of em dashes. view detail Col { Row(on_tap: back) { TextCaption(glyph: "‹") } + when quote.$state == .pending { TextBody(text: copy.loading) } + when quote.$state == .failed { TextBody(text: copy.offline) } header chart ranges diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 2f0f5a9..504c472 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -40,14 +40,20 @@ copy wind { class: vocabulary, en: "Wind", zh: "风速" } copy pressure { class: vocabulary, en: "Pressure", zh: "气压" } copy uv { class: vocabulary, en: "UV Index", zh: "紫外线" } copy precip { class: vocabulary, en: "Rain", zh: "降水概率" } -copy visibility { class: vocabulary, en: "Visibility", zh: "能见度" } +copy loading { class: vocabulary, en: "Getting the weather…", zh: "正在获取天气…" } +copy offline { class: vocabulary, en: "Can't reach the weather service", zh: "无法获取天气" } copy sky { class: vocabulary, en: "Satellite", zh: "卫星云图" } copy air { class: vocabulary, en: "Air Quality", zh: "空气质量" } copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } # ── view ───────────────────────────────────────────────────────────────────── +# §5.9: the fetch lifecycle is a state the card branches on. "Not yet" and +# "went wrong" are different facts and each SAYS SO — the alternative was a +# full page of "n/a°" with no failure message. view root Photo(src: scene, pad: .page) { + when now.$state == .pending { TextBody(text: copy.loading) } + when now.$state == .failed { TextBody(text: copy.offline) } current forecast cloudfield From 62c071ad3905edf5d3771437b35aead76e26030b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:31:04 -0700 Subject: [PATCH 83/97] fix(ui_l0): a money format survives the tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit's tick stamp (`live_call_of` → `l0_live`) composed only glyph/unit/suffix around the live call, via `decorated` — never the `format:` that `live_valued` applies at first draw. Three faces of the one defect, all found in review: - every `.money` price drew as `$184.20` and lost its `$` on the first tick; - a `.signed_money` change ticked the raw `change` field, dropping the changemoney redirect that returns sign and symbol already ordered (`+$3.10`), the exact composition problem the redirect exists for; - a `.compact`/`.ratio` value — which cannot go live at all, the draw keeps the seeded formatted literal — was still stamped with the raw call, so the first tick overwrote "41.2M" with 41200000. The fix makes the stamp THE drawn composition: a bound `value:` now stamps `live_valued(node)` — same prefix, same redirect, and the same refusals, so where the draw kept the seeded value the tick now leaves it alone (no stamp). A bound `text:` keeps the decorated bare call as before; text has no `format:` semantics. `live_valued` becomes pub(super) for the kit, with the contract in its doc. New profile test `a_money_format_survives_the_tick` is differential against the drawn form (stamp contents are debug-quoted, so the escaped quotes distinguish stamp from draw): the `$` must appear inside the stamp, changemoney must and raw change must not, and no stamp may name the compact volume while the seeded "41.2M" stands. Verified to FAIL against the previous live_call_of. Suite: 250 green. Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/src/lib.rs | 24 ++++++++--- crates/splash-ui-l0/tests/profile.rs | 59 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 3367954..fee8a8b 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -6937,7 +6937,12 @@ pub mod makepad { } /// The live form, or `None` when any part of it does not survive. - fn live_valued(node: &UiNode) -> Option { + /// + /// `pub(super)` because the kit's tick stamp (`live_call_of`) must compose + /// EXACTLY this — decoration, `format:` prefix, the `signed_money` + /// changemoney redirect, and the refusals — or the first tick redraws the + /// value without whichever part it dropped. + pub(super) fn live_valued(node: &UiNode) -> Option { let decoration = decoration_of(node); live_value( node, @@ -9224,10 +9229,19 @@ pub mod kit { /// says `TextRow(text: step.instruction)`; `fn tick()` belongs to the backend in /// exactly the way `sys.navstep` does. fn live_call_of(node: &UiNode) -> Option { - let (_, binding) = node - .bindings - .iter() - .find(|(n, _)| n == "value" || n == "text")?; + // A bound `value:` must TICK with the same composition it DREW with. + // This built `decorated(vm_call(…))` — glyph/unit/suffix but never + // `format:` — so every `.money` price lost its `$` on the first tick, + // `.signed_money` ticked the raw `change` field the changemoney + // redirect exists to avoid, and a `.compact`/`.ratio` value (which + // cannot go live at all) was overwritten with the raw number the + // format was protecting the screen from. `live_valued` IS the drawn + // composition; the tick stamps exactly that, and refuses to stamp + // exactly where the draw refused to go live. + if node.bindings.iter().any(|(n, _)| n == "value") { + return makepad::live_valued(node); + } + let (_, binding) = node.bindings.iter().find(|(n, _)| n == "text")?; let call = makepad::vm_call(binding)?; Some(makepad::decorated(node, call)) } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 3ea7ef3..1576f60 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5190,6 +5190,65 @@ fn a_signed_money_change_is_live_not_seeded() { ); } +/// A `format:` survives the TICK, not just the first draw. +/// +/// The kit's tick stamp (`l0_live`) composed only glyph/unit/suffix around the +/// call, never the `format:` — so every `.money` price drew as `$184.20` and +/// then lost its `$` on the first tick; a `.signed_money` change ticked the +/// raw `change` field the changemoney redirect exists to avoid; and a +/// `.compact` value, which cannot go live at all, was stamped with the raw +/// call, so the tick overwrote "41.2M" with 41200000. Found in review. +/// +/// Differential against the DRAWN form: the stamp must quote exactly what +/// `live_valued` emitted, and must be absent exactly where the draw kept the +/// seeded value. (Inside the stamp the call is debug-quoted, so its quotes +/// appear escaped — that is what distinguishes stamp from draw below.) +#[test] +fn a_money_format_survives_the_tick() { + let data = serde_json::json!({ + "movers": [], "selected": "NVDA", "range": "m1", "env": {"locale":{}}, + "quote": {"name":"NVIDIA","last":184.2,"change":3.1,"pct":1.7,"open":181.0, + "high":185.6,"low":180.2,"volume":41200000.0, + "mktcap":4520000000000.0,"pe":58.3}, + "copy": {"movers":"Top Movers","open":"Open","high":"High","low":"Low", + "volume":"Volume","mktcap":"Mkt Cap","pe":"P/E", + "loading":"Fetching the quote…","offline":"Can't reach the market feed"} + }); + let report = realize(STOCK, &data, RealizeLimits::default()); + let dsl = splash_ui_l0::kit::lower(&report.root.expect("root")); + + // Baseline: the DRAW went live with the `$` prefix at all. + assert!( + dsl.contains(r#""$" + sys.stock("NVDA", "price")"#), + "the drawn price must be live with its currency prefix:\n{dsl}" + ); + // The stamp carries the same composition — `$` included. + assert!( + dsl.contains(r#"\"$\" + sys.stock(\"NVDA\", \"price\")"#), + "the tick stamp must keep the `$` the draw had:\n{dsl}" + ); + // `.signed_money` ticks the field that returns sign and symbol already + // ordered — never the raw `change` the redirect exists to avoid. + assert!( + dsl.contains(r#"sys.stock(\"NVDA\", \"changemoney\")"#), + "the tick stamp must keep the changemoney redirect:\n{dsl}" + ); + assert!( + !dsl.contains(r#"sys.stock(\"NVDA\", \"change\")"#), + "a signed_money tick of the raw change field drops sign and symbol:\n{dsl}" + ); + // `.compact` cannot go live: the draw kept the seeded "41.2M", so the tick + // must not stamp the raw call and overwrite it with 41200000. + assert!( + dsl.contains("41.2M"), + "the compact volume stays seeded and formatted:\n{dsl}" + ); + assert!( + !dsl.contains(r#"\"vol\""#) && !dsl.contains(r#"sys.stock("NVDA", "vol")"#), + "no tick stamp may bypass a format the draw could not take live:\n{dsl}" + ); +} + /// A list factored into a COMPONENT still lowers to live calls, at its own index. /// /// `for s in feed { StoryRow(story: s) }` is the idiomatic way to write a list, From c3ef99d3a9671726b274a22b8c82ed51ce546558 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:39:11 -0700 Subject: [PATCH 84/97] fix(ui_l0): .speed and .pressure render their dimension; .index leaves the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unit: .speed`, `.pressure` and `.index` were catalog-legal and `decoration_of` mapped only c/f/pct/duration — so the weather detail tiles drew "12.5" and "1013" beside their labels: bare numbers in whatever unit the reader assumed, silently, with the checker's blessing. The recurring defect shape (catalog admits, lowering ignores). - `.speed` → " km/h" and `.pressure` → " hPa" — what the backend actually answers (open-meteo serves wind_speed_10m in km/h and surface_pressure in hPa; no fetch overrides the defaults) and exactly the suffixes the L2 reference card showed. One shared table (`decoration_of`), so both lowerings and the tick agree. - `.index` is REMOVED from the TOML and `catalog::UNIT` rather than ignored: an index is dimensionless, there is no honest suffix, and the tile's LABEL already says which index it is. A card writing `unit: .index` is now refused with the legal-token list instead of rendering nothing. - weather fixture: the UV tile drops `unit: .index` (synced byte-identical from the octos-one exemplar). New test `a_dimensioned_unit_token_renders_its_dimension` is differential per token and per backend (makepad + kit), and pins the `.index` refusal naming the legal set. Suite: 251 green. octos-one side: exemplar synced and framework/catalog.md regenerated from the TOML (diff is exactly the one-token removal). Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/src/lib.rs | 15 +++++- .../splash-ui-l0/tests/fixtures/weather.card | 4 +- crates/splash-ui-l0/tests/profile.rs | 51 +++++++++++++++++++ docs/ui-l0-constructors.toml | 6 ++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index fee8a8b..944abb3 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2738,8 +2738,11 @@ pub mod catalog { Bool, } + // `index` is not here: an index is dimensionless, so there is no honest + // suffix — the tile's LABEL says which index it is. A token the catalog + // admits and no lowering decorates is this layer's recurring defect. pub const UNIT: &[&str] = &[ - "c", "f", "pct", "speed", "pressure", "index", "distance", "money", "duration", + "c", "f", "pct", "speed", "pressure", "distance", "money", "duration", ]; pub const FORMAT: &[&str] = &[ "money", @@ -6976,6 +6979,16 @@ pub mod makepad { // `suffix: "min"` would be asserting the unit its own number // came in, and would be wrong the day the helper answers hours. Some("duration") => " min", + // What the backend actually answers: open-meteo serves + // `wind_speed_10m` in km/h and `surface_pressure` in hPa (no + // unit override in any fetch), and the L2 reference suffixed + // exactly these. Both tokens were catalog-legal and rendered + // NOTHING — a bare "12.5" beside a labelled tile, which reads + // as a number in whatever unit the reader assumes. (`index` + // is gone from the catalog instead: an index is dimensionless, + // so there is no honest suffix to give it.) + Some("speed") => " km/h", + Some("pressure") => " hPa", _ => "", }, glyph: match arg(node, "glyph") { diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 504c472..768665c 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -139,7 +139,9 @@ view details Grid(cols: 2) { Tile(label: copy.humidity, value: now.humidity, unit: .pct) Tile(label: copy.wind, value: now.wind, unit: .speed) Tile(label: copy.pressure, value: now.pressure, unit: .pressure) - Tile(label: copy.uv, value: now.uv, unit: .index) + # No unit on UV: an index is dimensionless (`unit: .index` left + # the catalog), and the label already says which index this is. + Tile(label: copy.uv, value: now.uv) # `visibility` was here and renders an em dash: open-meteo serves # it hourly only, so no call answers it. `precip` is answered by # the same fetch and is the more useful sixth tile anyway. diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 1576f60..4650edf 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6223,6 +6223,57 @@ view root Surface { ); } +/// Every catalog-legal `unit:` token that claims a dimension must RENDER it. +/// +/// `.speed` and `.pressure` were catalog-legal and `decoration_of` mapped only +/// c/f/pct/duration — so the weather detail tiles drew "12.5" and "1013" +/// beside their labels, bare numbers in whatever unit the reader assumed. The +/// suffixes are what the backend actually answers (open-meteo serves km/h and +/// hPa, no override in any fetch) and what the L2 reference showed. `.index` +/// went the other way: an index is dimensionless, there is no honest suffix, +/// so the token left the catalog rather than staying legal and inert. +#[test] +fn a_dimensioned_unit_token_renders_its_dimension() { + let card = |unit: &str| { + format!( + "source now sys.weather(lat: 1, lon: 2, fields: [wind, pressure])\n\ + view root Surface {{ TextValue(value: now.wind, unit: {unit}) }}" + ) + }; + let data = serde_json::json!({ + "now": { "wind": 12.5, "pressure": 1013.0 }, "env": { "locale": {} }, "copy": {} + }); + // Differential: the token must CHANGE the lowering, to its own suffix. + for (token, suffix) in [(".speed", " km/h"), (".pressure", " hPa")] { + let report = check_ui_l0_named("unit", &card(token)); + assert!(report.valid, "{token}: {:#?}", report.diagnostics); + let root = realize(&card(token), &data, RealizeLimits::default()) + .root + .expect("realizes"); + for (backend, dsl) in [ + ("makepad", makepad::lower(&root)), + ("kit", splash_ui_l0::kit::lower(&root)), + ] { + assert!( + dsl.contains(suffix), + "{backend}: `unit: {token}` must render {suffix:?}:\n{dsl}" + ); + } + } + // And `.index` is refused, not silently ignored: the checker names the + // legal set instead of accepting a token nothing decorates. + let report = check_ui_l0_named("unit", &card(".index")); + assert!(!report.valid, "unit: .index must be off the catalog"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("index") && d.message.contains("expected one of")), + "the refusal names the legal tokens: {:#?}", + report.diagnostics + ); +} + /// Token pairs a card TOGGLES between must be distinguishable in the lowering. /// /// `changing_a_declared_attribute_must_change_the_lowering` asks whether SOME diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 85d0611..31e8c44 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -282,8 +282,12 @@ range = { kind = "unit" } # answers "30 min", so `unit: .duration` on it renders "30 min min". Check what # the source answers with before adding a unit — `sys.navstep("eta")` answers a # bare "30" and does need it. +# +# `index` was here and is gone: an index is dimensionless, so there is no honest +# suffix to render — the tile's LABEL says which index it is. A token this list +# admits and no lowering decorates renders nothing, silently. accepts = ["token", "path"] -tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money", "duration"] +tokens = ["c", "f", "pct", "speed", "pressure", "distance", "money", "duration"] [kinds.format] accepts = ["token"] From d2cc71967fedfd35e95fa384d40759be18e9b769 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:42:52 -0700 Subject: [PATCH 85/97] feat(ui_l0): a preference can be written, and the declaration names its key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys.prefs joins the mutable capabilities with set/clear — resolving the note that sat in MUTABLE saying a write couldn't name WHICH preference without dotted-target grammar. It can: the declaration already names it. A written prefs source must declare exactly one field, and that field is the key the write lands under — `source home_pref sys.prefs(fields: [home])` plus `home_pref: set($value)` writes `home`, and the checker refuses a write through a multi-field source with a teaching diagnostic. CollectionWrite carries the field so the host can key the store without re-parsing. The nav card uses it for the user's own layer: the travel mode is CAPTURED from the stored preference (§5.13; the host guarantees "drive" until the user ever picks — measured, an empty capture into an enum leaves junk every guard fails against), and HOME/WORK become saved places: ☆ rows in each endpoint editor store the current endpoint's name (identity, never coordinates), and saved ones appear as labelled quick-picks. One `Quick` component carries all four row shapes. prefs answers grow home/work/mode, with translations. Device-verified on the OnePlus 6: pick Walk → force-stop → relaunch → Walk active from the store; ☆ HOME → relaunch → HOME appears as a pick row. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 61 ++++++++++++--- crates/splash-ui-l0/tests/fixtures/nav.card | 44 ++++++++++- .../splash-ui-l0/tests/fixtures/weather.card | 75 ++++++++++++++++++- docs/ui-l0-constructors.toml | 11 ++- 4 files changed, 174 insertions(+), 17 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 944abb3..cf66641 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3177,7 +3177,7 @@ pub mod catalog { "mktcap", "pe", "currency", "exchange", ], ), - ("sys.prefs", &["units", "range"]), + ("sys.prefs", &["units", "range", "home", "work", "mode"]), // Verified against the live endpoint, not its documentation: `longname` // comes back null for plenty of listings, so `name` falls back to // `shortname` in the helper. `kind` distinguishes an equity from a @@ -3217,15 +3217,15 @@ pub mod catalog { pub const MUTABLE: &[(&str, &[&str])] = &[ ("sys.watchlist", &["append", "remove"]), ("sys.cities", &["append", "remove"]), - // `sys.prefs` is READ-ONLY here, deliberately and temporarily. - // // A preference write has to name WHICH preference, and a transition's - // target is a bare source name: `event set_units { prefs: set($value) }` - // says nothing about `units`. Naming it needs a dotted target - // (`prefs.units: set($value)`), which is grammar this slice does not - // have. Declaring the capability writable before that exists would ship - // a write nobody could aim, so a card can read a preference and not yet - // change one. + // target is a bare source name — the note that sat here said naming it + // needed a dotted target, grammar L0 does not have. It does not: THE + // DECLARATION ALREADY NAMES IT. A card that writes a preference declares + // `source home_pref sys.prefs(fields: [home])`, and a source with exactly + // one declared field leaves `home_pref: set($value)` nothing to be + // ambiguous about. The checker enforces the exactly-one rule on any + // written prefs source; a read-only one may still ask for several. + ("sys.prefs", &["set", "clear"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -3500,6 +3500,33 @@ fn check_event_batch( ), Some(_) => {} } + // A written PREFERENCE must say which one, and the declaration + // is what says it: the write's key is the source's single + // declared field. A multi-field source leaves `set($value)` + // aiming at nothing nameable. + if source.helper == "sys.prefs" + && matches!(&transition.form, Form::Set(_) | Form::Clear) + { + let fields = source + .args + .iter() + .find(|(n, _)| n == "fields") + .and_then(|(_, a)| match a { + SourceArg::List(l) => Some(l.len()), + _ => None, + }) + .unwrap_or(0); + if fields != 1 { + sink.push( + transition.line, + transition.column, + format!( + "a written preference source must declare exactly one field — the field IS the key the write lands under. {:?} declares {fields}; split it: `source home_pref sys.prefs(fields: [home])` writes `home` (profile §5.12)", + transition.target + ), + ); + } + } continue; } // A collection form on anything else is refused HERE rather than @@ -6456,7 +6483,7 @@ pub mod makepad { // owns rather than a second kind of storage. "sys.prefs" => { let key = match binding.field.as_str() { - f @ ("units" | "range") => f, + f @ ("units" | "range" | "home" | "work" | "mode") => f, _ => return None, }; Some(format!("sys.prefs({key:?})")) @@ -8047,6 +8074,10 @@ pub struct CollectionWrite { pub op: String, /// The payload the tapped element carried. Empty for `clear`. pub value: String, + /// For a KEYED capability (`sys.prefs`), the key the write lands under — + /// the source's single declared field, which the checker guarantees exists. + /// Empty for list capabilities, whose store is named by the helper alone. + pub field: String, } /// What a dispatch did, and what it obliges the host to do next. @@ -8206,11 +8237,21 @@ fn dispatch_writes( (_, Some(v)) => v.to_string(), (_, None) => return (Vec::new(), Vec::new()), }; + let field = decl + .args + .iter() + .find(|(n, _)| n == "fields") + .and_then(|(_, a)| match a { + SourceArg::List(l) if l.len() == 1 => Some(l[0].clone()), + _ => None, + }) + .unwrap_or_default(); durable.push(CollectionWrite { source: decl.name.clone(), helper: decl.helper.clone(), op: op.to_owned(), value, + field, }); continue; } diff --git a/crates/splash-ui-l0/tests/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card index 3ce2c43..71a4c19 100644 --- a/crates/splash-ui-l0/tests/fixtures/nav.card +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -140,6 +140,13 @@ state query { shape: text, initial: "" } # what the user is typing # checker to accept an undeclared literal, and a `not` operator would be the # expression form L0 does not have. Two named screens have two guards that each # say which screen they are, which is what a card should have said anyway. +# The user's own layer (§5.12): a default travel mode, and the two places +# everyone routes to enough to name. Each is its OWN source because a written +# preference names its key through the declaration — one field, one key. +source mode_pref sys.prefs(fields: [mode]) +source home_pref sys.prefs(fields: [home]) +source work_pref sys.prefs(fields: [work]) + state screen { shape: enum[plan, drive], initial: .plan } # How the trip is travelled. R7.1/R7.2 of the shipping app's contract. @@ -147,7 +154,10 @@ state screen { shape: enum[plan, drive], initial: .plan } # `sys.route` takes this and answers a duration for it. It used to take it and # drop it, so "Walk" showed the driving time — the same number under a different # lit chip, which is this profile's whole defect class wearing a travel mode. -state mode { shape: enum[drive, walk, bike], initial: .drive } +# CAPTURED from the stored preference (§5.13) — the host guarantees a valid +# value ("drive" until the user ever picks), because an empty capture into an +# enum leaves junk every guard fails against, measured. +state mode { shape: enum[drive, walk, bike], initial: mode_pref.mode } # FLAT or TILTED while driving — R8.3's on-map toggle, as declared state. # @@ -202,9 +212,23 @@ state stop { shape: text, initial: "" } # empty ⇒ a direct trip # something to pick. state editing { shape: enum[none, origin, dest, stop], initial: .none } +# Captured so a guard can show the row only when one is saved — a guard tests +# state, never a source's value. `save_home` writes BOTH, keeping them in step. +state home { shape: text, initial: home_pref.home } +state work { shape: text, initial: work_pref.work } + # One search hit, pickable. THREE lists show these — origin, destination, via — # differing only in which event a tap raises, which is exactly what §5.4 passes # as a prop. The name is what the search returned; the label is WHICH one. +# A labelled one-line pick: HOME/WORK rows offering a saved place, and the +# save-this rows that store one. Same shape, so one component. +component Quick(label: text, val: text, act: event) { + view Row(align: .center, gap: 10, on_tap: act, value: val) { + TextCaption(text: label, width: .label) + TextRow(text: val, width: .fill) + } +} + component Hit(f: record, pick: event) { view Row(align: .center, gap: 10, on_tap: pick, value: f.query) { Col(gap: 2) { @@ -259,10 +283,16 @@ event edit_stop { editing: set(.stop), query: clear } event choose_stop { stop: set($value), query: clear, editing: set(.none) } event drop_stop { stop: clear, query: clear, stop_row: set(.hidden), editing: set(.none) } event add_stop { stop_row: set(.shown), editing: set(.stop), query: clear } -event pick_mode { mode: set($value) } +event pick_mode { mode: set($value), mode_pref: set($value) } +event save_home { home: set($value), home_pref: set($value) } +event save_work { work: set($value), work_pref: set($value) } event flip_view { view: cycle(.tilted, .flat) } copy where { class: vocabulary, en: "Where to?" } +copy home_lbl { class: vocabulary, en: "HOME" } +copy work_lbl { class: vocabulary, en: "WORK" } +copy save_home_lbl { class: vocabulary, en: "☆ HOME" } +copy save_work_lbl { class: vocabulary, en: "☆ WORK" } copy from { class: vocabulary, en: "FROM" } copy to { class: vocabulary, en: "TO" } copy here_now { class: vocabulary, en: "Starting from…" } @@ -397,20 +427,30 @@ view root Surface { when editing == .origin { when query == "" { Panel { + when home != "" { Quick(label: copy.home_lbl, val: home, act: choose_origin) } + when work != "" { Quick(label: copy.work_lbl, val: work, act: choose_origin) } Row(align: .center, gap: 10, on_tap: choose_origin, value: dest) { TextCaption(text: copy.to, width: .label) TextRow(text: dest) } + # Store the endpoint being edited. Identity only — the NAME the search + # can refind — never coordinates (§5.12). + when origin != "" { Quick(label: copy.save_home_lbl, val: origin, act: save_home) } + when origin != "" { Quick(label: copy.save_work_lbl, val: origin, act: save_work) } } } } when editing == .dest { when query == "" { Panel { + when home != "" { Quick(label: copy.home_lbl, val: home, act: choose_dest) } + when work != "" { Quick(label: copy.work_lbl, val: work, act: choose_dest) } Row(align: .center, gap: 10, on_tap: choose_dest, value: origin) { TextCaption(text: copy.from, width: .label) TextRow(text: origin) } + when dest != "" { Quick(label: copy.save_home_lbl, val: dest, act: save_home) } + when dest != "" { Quick(label: copy.save_work_lbl, val: dest, act: save_work) } } } } diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 768665c..4fccc1e 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -22,16 +22,39 @@ source week sys.weather(lat: place.lat, lon: place.lon, days: state.days, source sun sys.daylight(lat: place.lat, lon: place.lon) source moon sys.moonphase() source scene sys.photo(query: place.name) +# The user's SAVED cities — §5.12's durable collection, read as a source. The +# store holds a NAME per row and nothing else; `temp` is fetched every time the +# row is read, so a saved city can never show yesterday's temperature. +source cities sys.cities(fields: [name, temp]) +# Free-text place search for the add flow — nav's editor pattern. The query is +# a path into declared state, so the card can only search for what the user +# typed. `query` (the field) is "name, label": the text that finds this hit +# again, which is what gets stored — a bare name sends the top "Springfield" +# whichever one was tapped. +source found sys.search(query: state.query, count: 5, fields: [name, label, query]) source env.locale sys.locale() # ── state: shape only; the runtime holds the values ────────────────────────── -state city { shape: text, initial: "" } # empty ⇒ device location -state units { shape: enum[c, f], initial: env.locale.temp_unit } -state days { shape: number, initial: 7 } +state city { shape: text, initial: "" } # empty ⇒ device location +state units { shape: enum[c, f], initial: env.locale.temp_unit } +state days { shape: number, initial: 7 } +state query { shape: text, initial: "" } # what the add field holds +# Whether the add row is a Field. An ENUM, not a bool, for §3's reason: two +# named states get two guards that each say which one they are. +state editing { shape: enum[none, add], initial: .none } # ── events: total transitions, applied atomically ──────────────────────────── event toggle_units { units: cycle(.c, .f) } -event pick_city { city: set($value) } +event open_city { city: set($value) } # a saved row re-points the card +event add_city { editing: set(.add), query: clear } +event typing { query: set($value) } +# Picking a result does BOTH things it plainly means: it is the city on screen +# now, and it joins the saved list. One declared transition composes a durable +# write with three state writes — dispatch routes `cities:` to the host's store +# (§5.12) and the rest to card state, then the written source goes stale and +# refetches, so the new row appears with live values. +event pick_city { city: set($value), cities: append($value), query: clear, editing: set(.none) } +event drop_city { cities: remove($value) } # ── copy: declared literals, all host-owned vocabulary ─────────────────────── copy feels { class: vocabulary, en: "Feels like", zh: "体感" } @@ -46,6 +69,8 @@ copy sky { class: vocabulary, en: "Satellite", zh: "卫星云图" } copy air { class: vocabulary, en: "Air Quality", zh: "空气质量" } copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } +copy addcity { class: vocabulary, en: "Add a city", zh: "添加城市" } +copy plus { class: vocabulary, en: "+", zh: "+" } # ── view ───────────────────────────────────────────────────────────────────── # §5.9: the fetch lifecycle is a state the card branches on. "Not yet" and @@ -55,6 +80,7 @@ view root Photo(src: scene, pad: .page) { when now.$state == .pending { TextBody(text: copy.loading) } when now.$state == .failed { TextBody(text: copy.offline) } current + saved forecast cloudfield airfield @@ -62,6 +88,47 @@ view root Photo(src: scene, pad: .page) { details } +# The saved-cities strip: every row is the stored name beside a LIVE reading, +# tappable to re-point the whole card (`open_city` writes the same state the +# whole card hangs off). The add row is nav's editor pattern — a row until it +# is tapped, a Field only while `editing` says so, because a permanently-live +# Field cannot be focused on this renderer (measured; see nav.card). Results +# render as bare rows in the panel: the panel is never empty (the add row is +# always there), and a `for` with no rows must not leave an empty box behind. +view saved Panel { + for c, i in cities key c.name { + Row(align: .center, gap: 10, on_tap: open_city, value: c.name) { + TextRow(text: c.name, width: .fill) + TextValue(value: c.temp, unit: units) + Chip(text: "×", on_tap: drop_city, value: c.name) + } + Rule() + } + when editing == .add { + Row(align: .center, gap: 10) { + TextCaption(text: copy.plus, width: .label) + Field(text: query, placeholder: city, on_commit: pick_city, + on_change: typing, width: .fill) + } + when query != "" { + for f, i in found key f.label { + Row(align: .center, gap: 10, on_tap: pick_city, value: f.query) { + Col(gap: 2) { + TextRow(text: f.name) + TextCaption(text: f.label) + } + } + } + } + } + when editing != .add { + Row(align: .center, gap: 10, on_tap: add_city, value: "1") { + TextCaption(text: copy.plus, width: .label) + TextRow(text: copy.addcity, width: .fill) + } + } + } + view current Col(align: .center) { TextTitle(text: place.name) WeatherIcon(cond: now.cond, size: .hero) diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 31e8c44..3520a4d 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -447,7 +447,16 @@ writes = ["append", "remove"] # read a preference and not yet change one. [sources."sys.prefs"] args = ["fields"] -answers = ["units", "range"] +# `home`/`work`/`mode` joined `units`/`range` when preferences became writable: +# a place the user calls home, one they call work (both stored as the place's +# NAME — identity, never coordinates the search can't refind), and a default +# travel mode. +answers = ["units", "range", "home", "work", "mode"] +# A write names its key through the DECLARATION: `set`/`clear` are accepted only +# on a prefs source declaring exactly one field, and that field is the key the +# write lands under. `source home_pref sys.prefs(fields: [home])` + +# `home_pref: set($value)` writes `home`; a multi-field source stays read-only. +writes = ["set", "clear"] # Free-text TICKER lookup. `sys.search` is the PLACE search and answers a # different question; a card asking a geocoder for a company gets nonsense, so From 089f87ffb73e21063019a44b9bbfca72e6e6fea4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:44:27 -0700 Subject: [PATCH 86/97] feat(ui_l0): prove the weather card's saved-cities flow end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card half of this — the saved strip, the add-a-city editor, the composed pick_city — is in the weather fixture, which landed one commit back (d2cc719 swept the in-progress fixture up beside the prefs work; the content is exactly this feature). This commit is the differential proof. The layer, for the record: a strip of saved rows between the hero and the forecast, each the STORED name beside a LIVE sys.cities temperature, tappable to re-point the whole card (open_city writes the same `city` state everything hangs off); a remove chip per row fires `cities: remove($value)` with the row's own name; nav's editor pattern for the add flow — a tappable row until tapped, a Field bound to `query` (on_change: typing) only while `editing == .add`, results as bare rows over sys.search gated on `query != ""`, never an empty panel. A result's payload is `f.query` — name plus label, the text that finds the hit again — measured against both ends: photon's label for a city-class hit is "State, Country" (no county, deduped), and open-meteo's gazetteer resolves "Berkeley, California, United States" while a county-bearing string resolves to nothing. `pick_city` is the composed transition the profile said dispatch supports and no card exercised: one durable write beside three state writes — city: set($value), cities: append($value), query: clear, editing: set(.none). The checker admits the compose unchanged. The test drives the flow as the user does — open, type, pick, drop — and asserts what MOVED at each step (changed, writes, stale, the city cell), not that some call appears somewhere: the composed event must commit all three state writes beside the append, `typing` must stale `found`, and the append must stale both `cities` (the new row appears) and `place` (the card re-points). 252 tests. Both evaluators report 72 nodes for the grown card; the shared conformance count moves in Splash-Makepad beside this commit. Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/tests/profile.rs | 83 ++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 4650edf..fc477f0 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5143,6 +5143,89 @@ view root Surface { ); } +/// §5.12 in the weather card: the add flow is driven as the user drives it, and +/// picking a city is ONE event that both re-points the card and saves durably. +/// +/// Differential on purpose (the L0 defect class is a card the checker accepts +/// whose screen is confidently wrong): each dispatch asserts what MOVED, not +/// that some call appears somewhere. The composed `pick_city` — one durable +/// write beside three state writes — is the shape this card exists to prove; +/// if dispatch ever refuses the compose, this is the test that says so. +#[test] +fn picking_a_city_selects_it_and_saves_it_durably() { + let mut store = splash_ui_l0::InstanceStore::default(); + let dispatch = |store: &mut splash_ui_l0::InstanceStore, event: &str, payload: &str| { + splash_ui_l0::dispatch_reporting( + WEATHER, + store, + "root", + event, + Some(&serde_json::Value::String(payload.into())), + &serde_json::Value::Null, + ) + }; + + // Tap "add a city": the row becomes a Field and the query opens empty. + let out = dispatch(&mut store, "add_city", "1"); + assert!(out.applied, "add_city must apply"); + assert_eq!(out.changed, vec!["editing"], "the editor opens: {out:?}"); + assert!(out.writes.is_empty(), "opening the editor writes no store"); + + // Type: the query moves, and the search source parameterised by it goes + // stale — that is what makes results-as-you-type refetch. + let out = dispatch(&mut store, "typing", "berk"); + assert_eq!(out.changed, vec!["query"], "typing writes the query: {out:?}"); + assert!( + out.stale.contains(&"found".to_string()), + "the search must refetch: {:?}", + out.stale + ); + + // Pick a result. One event, four declared writes: the city on screen, the + // durable append, the query cleared, the editor closed. + let city = "Berkeley, California, United States"; + let out = dispatch(&mut store, "pick_city", city); + assert!(out.applied, "the composed event must apply"); + assert_eq!( + out.changed, + vec!["city", "query", "editing"], + "all three state writes commit beside the durable one: {out:?}" + ); + assert_eq!(out.writes.len(), 1, "exactly one durable write: {out:?}"); + let w = &out.writes[0]; + assert_eq!( + (w.source.as_str(), w.helper.as_str(), w.op.as_str(), w.value.as_str()), + ("cities", "sys.cities", "append", city), + "the host is told the bound name, the capability, the op and the value" + ); + // The written source refetches (the new row appears), and the selected + // city's own cascade re-fetches the card: place hangs off state.city. + assert!(out.stale.contains(&"cities".to_string()), "{:?}", out.stale); + assert!(out.stale.contains(&"place".to_string()), "{:?}", out.stale); + assert_eq!( + store.get(splash_ui_l0::CARD_STATE_KEY, "city"), + Some(serde_json::Value::String(city.into())).as_ref(), + "the card is now looking at the picked city" + ); + + // The remove chip: a durable remove carrying the row's own name, and no + // state write rides along. + let out = dispatch(&mut store, "drop_city", city); + assert!(out.applied, "drop_city must apply"); + assert!(out.changed.is_empty(), "remove writes no cell: {out:?}"); + assert_eq!( + out.writes, + vec![splash_ui_l0::CollectionWrite { + source: "cities".into(), + helper: "sys.cities".into(), + op: "remove".into(), + value: city.into(), + field: String::new(), + }], + ); + assert!(out.stale.contains(&"cities".to_string()), "{:?}", out.stale); +} + /// `signed_money` reaches the screen live, beside its own percentage. /// /// The two describe ONE move. The percentage was live and the money was not, so From c63ec62b2049591b1ea157cea543f48c2442ed8d Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:58:46 -0700 Subject: [PATCH 87/97] fix(ui_l0): the remove chip escapes its row's hit target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a saved city's × dead-centre fired open_city — the whole card re-pointed to the city the user was trying to remove. Measured on the 6T, and structural: a tappable row lowers to a transparent hit target drawn OVER its whole content, so a chip inside one is covered and can never be hit. The same shape a marker-presence test would have passed — the chip renders, the event exists, and the two cannot meet. The fix stays in the card: the remove chip becomes a SIBLING of the tappable area. The row wraps a filling inner row (name + live temp, on_tap: open_city) and the chip; the two hit targets no longer overlap, and both taps land — verified on the 6T, drop_city applied and the row gone from the strip. Exemplar synced in octos-one. Node counts unchanged: the conformance data's empty store realizes no strip rows, so both evaluators still report 72. Co-Authored-By: Claude Fable 5 --- crates/splash-ui-l0/tests/fixtures/weather.card | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 4fccc1e..6ac0f6a 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -96,10 +96,18 @@ view root Photo(src: scene, pad: .page) { # render as bare rows in the panel: the panel is never empty (the add row is # always there), and a `for` with no rows must not leave an empty box behind. view saved Panel { + # The remove chip is a SIBLING of the tappable area, not a child. + # A tappable row lowers to a transparent hit target drawn OVER its + # whole content, so a chip inside one is covered by it and cannot + # be hit — measured on the 6T: a tap dead-centre on the chip fired + # `open_city`. The row that opens and the chip that removes must + # not overlap, so the row wraps them and only its first half taps. for c, i in cities key c.name { - Row(align: .center, gap: 10, on_tap: open_city, value: c.name) { - TextRow(text: c.name, width: .fill) - TextValue(value: c.temp, unit: units) + Row(align: .center, gap: 10) { + Row(align: .center, gap: 10, width: .fill, on_tap: open_city, value: c.name) { + TextRow(text: c.name, width: .fill) + TextValue(value: c.temp, unit: units) + } Chip(text: "×", on_tap: drop_city, value: c.name) } Rule() From 59de60b6d78478dc3fad75cd7609812f150b759e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:23:31 -0700 Subject: [PATCH 88/97] feat(ui_l0): a reading list and a watchlist outlive the process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys.reading joins the catalog (answers id/title/author/points/comments/ url, writes append/remove); its vm_call arm indexes store rows like the other durable collections. The news fixture saves stories by id, the stock fixture stars tickers and persists its range through sys.prefs. Chips that act on a row live beside its tappable half, not inside it — an inner chip is unreachable under the row's own hit target. The reachable-notify test follows the mover row one level down. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 18 +++++++ crates/splash-ui-l0/tests/fixtures/news.card | 24 +++++++++ crates/splash-ui-l0/tests/fixtures/stock.card | 50 ++++++++++++++++--- crates/splash-ui-l0/tests/profile.rs | 6 ++- docs/ui-l0-constructors.toml | 9 ++++ 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index cf66641..e7c6892 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3058,6 +3058,7 @@ pub mod catalog { // learns that a store exists. ("sys.watchlist", &["fields"]), ("sys.prefs", &["fields"]), + ("sys.reading", &["fields"]), // Free-text ticker lookup, so a card can offer something the top-movers // list does not happen to contain. `sys.search` is the PLACE search and // answers a different question; naming them apart is what stops a card @@ -3178,6 +3179,10 @@ pub mod catalog { ], ), ("sys.prefs", &["units", "range", "home", "work", "mode"]), + // The reading list: SAVED story ids, joined to the story each id names. + // The id is the identity Algolia serves forever, so a bookmarked story + // outlives the front page it was found on. + ("sys.reading", &["id", "title", "author", "points", "comments", "url"]), // Verified against the live endpoint, not its documentation: `longname` // comes back null for plenty of listings, so `name` falls back to // `shortname` in the helper. `kind` distinguishes an equity from a @@ -3226,6 +3231,7 @@ pub mod catalog { // ambiguous about. The checker enforces the exactly-one rule on any // written prefs source; a read-only one may still ask for several. ("sys.prefs", &["set", "clear"]), + ("sys.reading", &["append", "remove"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -6858,6 +6864,18 @@ pub mod makepad { }; Some(format!("sys.watchlist({index}, {key:?})")) } + // A saved STORY. The id is the stored reference; everything beside + // it is fetched by that id, so a bookmark shows today's points for + // a story saved last month. + "sys.reading" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + f @ ("id" | "title" | "author" | "points" | "comments" | "url") => f, + _ => return None, + }; + Some(format!("sys.reading({index}, {key:?})")) + } // A saved place. `name`/`lat`/`lon` identify it and come from the // store; the readings beside them are fetched, which is why this // lowers to a call rather than to the realized literal. diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index 5470425..6a66e51 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -19,14 +19,24 @@ source feed sys.news(count: 7, offset: 1, fields: [id, title, author, poi source article sys.news_item(id: state.selected, fields: [title, author, points, comments, url]) source env.locale sys.locale() +# The reading list (§5.12): saved story IDS, each row fetched by id — so a +# bookmark outlives the front page it was found on. +source saved sys.reading(fields: [id, title, points]) state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id event open_story { selected: set($value) } event back { selected: clear } +# The bookmark toggle, from the story screen. Identity only: the id goes to the +# store; title and points are fetched fresh every time the list renders. +event keep_story { saved: append($value) } +event drop_story { saved: remove($value) } copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } +copy saved_hd { class: vocabulary, en: "READING LIST", zh: "收藏" } +copy keep_lbl { class: vocabulary, en: "☆ Save" } +copy drop_lbl { class: vocabulary, en: "Remove" } copy latest { class: vocabulary, en: "LATEST", zh: "最新" } copy lead { class: vocabulary, en: "LEAD", zh: "焦点" } copy pts { class: vocabulary, en: "pts", zh: "分" } @@ -101,10 +111,24 @@ view latest Col { StoryRow(story: s, position: i, on_open: open_story) } } + for b, i in saved key b.id { + # Chip beside the tappable part, never inside it — a row's hit + # target covers its content and eats the chip's tap, measured. + Row(align: .center, gap: 8) { + Row(align: .center, gap: 8, width: .fill, on_tap: open_story, value: b.id) { + TextCaption(text: copy.saved_hd, width: .label) + TextRow(text: b.title, width: .fill) + } + Chip(text: copy.drop_lbl, on_tap: drop_story, value: b.id) + } + } } view story Col { Row(on_tap: back) { TextCaption(glyph: "‹") } + Row(gap: 8) { + Chip(text: copy.keep_lbl, on_tap: keep_story, value: selected) + } TextTitle(text: article.title, width: .fill) Row(gap: 6) { TextCaption(value: article.points, suffix: copy.pts) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 4c02fa1..03ae2fc 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -14,15 +14,28 @@ source quote sys.quote(ticker: state.selected, fields: [name, last, change, pct, open, high, low, volume, mktcap, pe]) source env.locale sys.locale() +# The user's own layer (§5.12): saved tickers, and the chart range they prefer. +# The store holds IDENTITIES — a ticker is a name the quote endpoint resolves +# fresh every render; a price is never written down. +source watch sys.watchlist(fields: [ticker, last, pct]) +source range_pref sys.prefs(fields: [range]) state selected { shape: text, initial: "" } # "" ⇒ movers list -state range { shape: enum[d1, w1, m1, m6, y1], initial: .m1 } +# CAPTURED from the stored preference (§5.13); the host guarantees "m1" until +# the user ever picks a range, so the capture is always a member of the enum. +state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } event open_quote { selected: set($value) } event back { selected: clear, range: clear } # two writes, one gesture -event set_range { range: set($value) } +event set_range { range: set($value), range_pref: set($value) } +# The star on a mover saves its ticker; Remove on a saved row forgets it. +event keep { watch: append($value) } +event forget { watch: remove($value) } copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" } +copy star { class: vocabulary, en: "☆" } +copy unstar { class: vocabulary, en: "Remove" } copy loading { class: vocabulary, en: "Fetching the quote…", zh: "正在获取行情…" } copy offline { class: vocabulary, en: "Can't reach the market feed", zh: "无法获取行情" } copy open { class: vocabulary, en: "Open", zh: "开盘" } @@ -38,16 +51,37 @@ view root Surface(pad: .page) { } view list Col { + for w, i in watch key w.ticker { + # Header on the first row only (the index binder is 1-based) — + # a per-row caption plus ticker, price, pct and the Remove chip + # overfilled the row on device: the fill-width ticker collapsed + # to nothing and the pct clipped. + when i == 1 { TextCaption(text: copy.watch_hd) } + # The chip is a SIBLING of the tappable part, not a child of it — + # a row's transparent hit target covers its whole content, so a + # chip inside one fires the row's event, measured on device. + Row(align: .center, gap: 8) { + Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: w.ticker) { + TextRow(text: w.ticker, width: .fill) + TextValue(value: w.last, format: .money) + TextValue(value: w.pct, format: .signed_pct) + } + Chip(text: copy.unstar, on_tap: forget, value: w.ticker) + } + } TextTitle(text: copy.movers) Panel { for m in movers key m.ticker { - Row(align: .center, on_tap: open_quote, value: m.ticker) { - Col(gap: 2, width: .fill) { - TextRow(text: m.ticker) - TextCaption(text: m.name, width: .fill) + Row(align: .center, gap: 8) { + Chip(text: copy.star, on_tap: keep, value: m.ticker) + Row(align: .center, width: .fill, on_tap: open_quote, value: m.ticker) { + Col(gap: 2, width: .fill) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) + } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) } - TextValue(value: m.last, format: .money) - TextValue(value: m.pct, format: .signed_pct, tint: m.change) } Rule() } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index fc477f0..5fddb5a 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -1117,8 +1117,12 @@ fn a_tappable_node_lowers_to_a_reachable_notify() { .unwrap_or_else(|| panic!("no reachable tap for open_quote:\n{dsl}")); assert!(notify.contains("event: \"open_quote\""), "event:\n{notify}"); assert!(notify.contains("value: \"NVDA\""), "payload:\n{notify}"); + // `Row#0/Row#0`: the mover's tap row is nested one deeper since the ☆ + // chip moved out to be its SIBLING — a row's transparent hit target + // covers its whole content, so a chip inside a tappable row can never + // receive its own tap (measured on device; same fix as weather's ×). assert!( - notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0/Row#0\""), "instance key:\n{notify}" ); diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 3520a4d..378d2ef 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -436,6 +436,15 @@ aggregates = ["min", "max"] # `sys.watchlist` takes no selector because it IS the user's list. The host reads # the stored tickers, fetches each quote, and returns the rows — so a card asks # for the fields it wants to show and never learns a store exists. +# The reading list — §5.12 for NEWS. What is stored is the story's Algolia id, +# an identity the item endpoint serves forever; title, points and the rest are +# fetched by id at read time, so a bookmark saved last month shows today's +# comment count and never a stale copy. +[sources."sys.reading"] +args = ["fields"] +answers = ["id", "title", "author", "points", "comments", "url"] +writes = ["append", "remove"] + [sources."sys.watchlist"] args = ["fields"] answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] From 6b73e991bf3c15ec171667f340ec782bf08219c9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:20:39 -0700 Subject: [PATCH 89/97] =?UTF-8?q?feat(ui=5Fl0):=20sys.topics=20=E2=80=94?= =?UTF-8?q?=20a=20followed=20word,=20joined=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog gains the followed-topics durable collection (answers name/top_title/top_points/top_id, writes append/remove); its vm_call arm indexes store rows like sys.reading. The news fixture grows the TOPICS block: suggestion chips whose values are the stored words, and per-topic rows in the sibling-chip shape. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 18 +++++++++++ crates/splash-ui-l0/tests/fixtures/news.card | 33 ++++++++++++++++++++ docs/ui-l0-constructors.toml | 7 +++++ 3 files changed, 58 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index e7c6892..515eb55 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3059,6 +3059,7 @@ pub mod catalog { ("sys.watchlist", &["fields"]), ("sys.prefs", &["fields"]), ("sys.reading", &["fields"]), + ("sys.topics", &["fields"]), // Free-text ticker lookup, so a card can offer something the top-movers // list does not happen to contain. `sys.search` is the PLACE search and // answers a different question; naming them apart is what stops a card @@ -3183,6 +3184,10 @@ pub mod catalog { // The id is the identity Algolia serves forever, so a bookmarked story // outlives the front page it was found on. ("sys.reading", &["id", "title", "author", "points", "comments", "url"]), + // Followed TOPICS: the store holds a topic word ("ai", "nba"); the + // top story beside it is searched fresh on every read, so a followed + // topic never pins the story that was hot when it was followed. + ("sys.topics", &["name", "top_title", "top_points", "top_id"]), // Verified against the live endpoint, not its documentation: `longname` // comes back null for plenty of listings, so `name` falls back to // `shortname` in the helper. `kind` distinguishes an equity from a @@ -3232,6 +3237,7 @@ pub mod catalog { // written prefs source; a read-only one may still ask for several. ("sys.prefs", &["set", "clear"]), ("sys.reading", &["append", "remove"]), + ("sys.topics", &["append", "remove"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -6876,6 +6882,18 @@ pub mod makepad { }; Some(format!("sys.reading({index}, {key:?})")) } + // A followed topic. `name` is the stored word; the `top_*` keys + // are the first hit of a fresh search for it, fetched at read time + // like every other joined value in this table. + "sys.topics" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + f @ ("name" | "top_title" | "top_points" | "top_id") => f, + _ => return None, + }; + Some(format!("sys.topics({index}, {key:?})")) + } // A saved place. `name`/`lat`/`lon` identify it and come from the // store; the readings beside them are fetched, which is why this // lowers to a call rather than to the realized literal. diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index 6a66e51..73b2fdb 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -22,6 +22,9 @@ source env.locale sys.locale() # The reading list (§5.12): saved story IDS, each row fetched by id — so a # bookmark outlives the front page it was found on. source saved sys.reading(fields: [id, title, points]) +# Followed TOPICS (§5.12): the store keeps a word per row; the top story +# beside it is searched fresh at read time, so a topic row is never stale. +source topics sys.topics(fields: [name, top_title, top_id]) state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id @@ -31,6 +34,10 @@ event back { selected: clear } # store; title and points are fetched fresh every time the list renders. event keep_story { saved: append($value) } event drop_story { saved: remove($value) } +# Following is idempotent in the store, so a suggestion chip can stay on +# screen after it was picked: tapping it again is nothing, not a duplicate. +event follow { topics: append($value) } +event unfollow { topics: remove($value) } copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } @@ -38,6 +45,11 @@ copy saved_hd { class: vocabulary, en: "READING LIST", zh: "收藏" } copy keep_lbl { class: vocabulary, en: "☆ Save" } copy drop_lbl { class: vocabulary, en: "Remove" } copy latest { class: vocabulary, en: "LATEST", zh: "最新" } +copy topics_hd { class: vocabulary, en: "TOPICS", zh: "关注" } +copy t_ai { class: vocabulary, en: "AI" } +copy t_nba { class: vocabulary, en: "NBA" } +copy t_soccer { class: vocabulary, en: "Soccer" } +copy t_crypto { class: vocabulary, en: "Crypto" } copy lead { class: vocabulary, en: "LEAD", zh: "焦点" } copy pts { class: vocabulary, en: "pts", zh: "分" } copy comments { class: vocabulary, en: "comments", zh: "评论" } @@ -111,6 +123,27 @@ view latest Col { StoryRow(story: s, position: i, on_open: open_story) } } + # Suggested topics to follow; the row of chips is host copy, + # the VALUES are the stored words. Tap → append, idempotent. + TextCaption(text: copy.topics_hd) + Row(gap: 8) { + Chip(text: copy.t_ai, on_tap: follow, value: "ai") + Chip(text: copy.t_nba, on_tap: follow, value: "nba") + Chip(text: copy.t_soccer, on_tap: follow, value: "soccer") + Chip(text: copy.t_crypto, on_tap: follow, value: "crypto") + } + for t, i in topics key t.name { + # Chip beside the tappable part (its own hit target would be + # covered inside the row); the row opens the topic's current + # top story by the id the search answered just now. + Row(align: .center, gap: 8) { + Row(align: .center, gap: 8, width: .fill, on_tap: open_story, value: t.top_id) { + TextCaption(text: t.name, width: .label) + TextRow(text: t.top_title, width: .fill) + } + Chip(text: copy.drop_lbl, on_tap: unfollow, value: t.name) + } + } for b, i in saved key b.id { # Chip beside the tappable part, never inside it — a row's hit # target covers its content and eats the chip's tap, measured. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 378d2ef..9759ad1 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -445,6 +445,13 @@ args = ["fields"] answers = ["id", "title", "author", "points", "comments", "url"] writes = ["append", "remove"] +# Followed topics. The store keeps only the topic word; top_title/top_points/ +# top_id are the first hit of a search run when the row is read. +[sources."sys.topics"] +args = ["fields"] +answers = ["name", "top_title", "top_points", "top_id"] +writes = ["append", "remove"] + [sources."sys.watchlist"] args = ["fields"] answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] From 6ff7e06f4f732e6fa8b2e5548e46488e9ac5da06 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:41:22 -0700 Subject: [PATCH 90/97] feat(ui_l0): sys.link, and a write is a use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader capability joins the catalog: answers url (the page the host's overlay has open, "" when closed), writes set/clear. A card opens a page by writing a url it already holds; how pages are shown belongs to the host. The never-read diagnostic now counts an event WRITE as a use — a write-only actuator source like sys.link is not dead, it is the write's target, and dispatch resolves against the declaration to learn which capability it drives. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 25 ++++++++++++++++++++ crates/splash-ui-l0/tests/fixtures/news.card | 8 +++++++ docs/ui-l0-constructors.toml | 8 +++++++ 3 files changed, 41 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 515eb55..edfb0af 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3060,6 +3060,7 @@ pub mod catalog { ("sys.prefs", &["fields"]), ("sys.reading", &["fields"]), ("sys.topics", &["fields"]), + ("sys.link", &["fields"]), // Free-text ticker lookup, so a card can offer something the top-movers // list does not happen to contain. `sys.search` is the PLACE search and // answers a different question; naming them apart is what stops a card @@ -3188,6 +3189,12 @@ pub mod catalog { // top story beside it is searched fresh on every read, so a followed // topic never pins the story that was hot when it was followed. ("sys.topics", &["name", "top_title", "top_points", "top_id"]), + // The in-app reader. `url` is the page currently open in the host's + // native web overlay — "" when it is closed. A card WRITES a story's + // url to open the reader over itself; the host owns the overlay and + // closes it on system back, so the card never has to know how pages + // are shown, only which page it asked for. + ("sys.link", &["url"]), // Verified against the live endpoint, not its documentation: `longname` // comes back null for plenty of listings, so `name` falls back to // `shortname` in the helper. `kind` distinguishes an equity from a @@ -3238,6 +3245,7 @@ pub mod catalog { ("sys.prefs", &["set", "clear"]), ("sys.reading", &["append", "remove"]), ("sys.topics", &["append", "remove"]), + ("sys.link", &["set", "clear"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -3850,6 +3858,14 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { read.insert("env".to_owned()); read.insert("env.locale".to_owned()); } + // An event WRITE is a use too: `page: set($value)` resolves against the + // declared source to learn which capability it drives — a write-only + // reader/link source is not dead, it is an actuator. + for event in &card.events { + for transition in &event.transitions { + read.insert(root_of(&transition.target)); + } + } for source in &card.sources { // A dotted source name (`env.locale`) is read as its own root. if !read.contains(&source.name) && !read.contains(&root_of(&source.name)) { @@ -6882,6 +6898,15 @@ pub mod makepad { }; Some(format!("sys.reading({index}, {key:?})")) } + // The reader overlay's current page — published by the host the + // same way locale and the position fix are. + "sys.link" => { + let key = match binding.field.as_str() { + f @ "url" => f, + _ => return None, + }; + Some(format!("sys.link({key:?})")) + } // A followed topic. `name` is the stored word; the `top_*` keys // are the first hit of a fresh search for it, fetched at read time // like every other joined value in this table. diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index 73b2fdb..cfc694e 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -25,6 +25,9 @@ source saved sys.reading(fields: [id, title, points]) # Followed TOPICS (§5.12): the store keeps a word per row; the top story # beside it is searched fresh at read time, so a topic row is never stale. source topics sys.topics(fields: [name, top_title, top_id]) +# The reader overlay (host-owned): writing a story's url opens the page over +# the card; the host closes it on system back. +source page sys.link(fields: [url]) state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id @@ -38,12 +41,16 @@ event drop_story { saved: remove($value) } # screen after it was picked: tapping it again is nothing, not a duplicate. event follow { topics: append($value) } event unfollow { topics: remove($value) } +# The chip carries the article's url as its value: the card never fetches a +# page, it asks the host to show one. +event read_story { page: set($value) } copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } copy saved_hd { class: vocabulary, en: "READING LIST", zh: "收藏" } copy keep_lbl { class: vocabulary, en: "☆ Save" } copy drop_lbl { class: vocabulary, en: "Remove" } +copy read_lbl { class: vocabulary, en: "Read ↗", zh: "阅读原文 ↗" } copy latest { class: vocabulary, en: "LATEST", zh: "最新" } copy topics_hd { class: vocabulary, en: "TOPICS", zh: "关注" } copy t_ai { class: vocabulary, en: "AI" } @@ -161,6 +168,7 @@ view story Col { Row(on_tap: back) { TextCaption(glyph: "‹") } Row(gap: 8) { Chip(text: copy.keep_lbl, on_tap: keep_story, value: selected) + Chip(text: copy.read_lbl, on_tap: read_story, value: article.url) } TextTitle(text: article.title, width: .fill) Row(gap: 6) { diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 9759ad1..877815c 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -452,6 +452,14 @@ args = ["fields"] answers = ["name", "top_title", "top_points", "top_id"] writes = ["append", "remove"] +# The in-app reader. Writing a url opens the host's native web overlay over +# the card; clear (or system back, host-owned) closes it. `url` answers the +# page currently open, "" when closed. +[sources."sys.link"] +args = ["fields"] +answers = ["url"] +writes = ["set", "clear"] + [sources."sys.watchlist"] args = ["fields"] answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] From 1a10db73e6b00104985f55aecd95d3359a89b14e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:02:30 -0700 Subject: [PATCH 91/97] feat(ui_l0): next/prev walk a collection, the way a swipe pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new total forms beside cycle: `city: next(cities.name)` moves a text cell to the next/prev value of a collection field, wrapping; a value not in the list lands on the first (next) or last (prev) row. Checked like everything else — the path must name a declared source's field, the cell must be text — and dispatched from the data the host hands in, which now carries the durable rows. The weather fixture pages its saved cities with a swipe and moves its add affordance to a + chip in the title bar; news and stock grow a worded back row (the lone glyph measured ~20px of hit target). Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 101 ++++++++++++++++++ crates/splash-ui-l0/tests/fixtures/news.card | 5 +- crates/splash-ui-l0/tests/fixtures/stock.card | 5 +- .../splash-ui-l0/tests/fixtures/weather.card | 18 ++-- 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index edfb0af..d9aeafd 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -923,6 +923,12 @@ enum Form { /// card names the operation and the runtime performs it. Append, Remove, + /// §3's collection cousins of `cycle`: move a TEXT cell to the next/prev + /// value of a collection field (`city: next(cities.name)`), wrapping. + /// Total like cycle — the card names the walk, the runtime performs it — + /// which is what a swipe gesture needs a declared transition for. + Next(String), + Prev(String), /// Anything else — an expression, which L0 has no form for. NotTotal(String), } @@ -1757,6 +1763,26 @@ impl<'a> Parser<'a> { } (Form::Cycle, members) } + "next" | "prev" => { + let verb = t.text.clone(); + self.at += 1; + let mut path = None; + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + path = self.dotted_name(); + self.expect_punct(")"); + } + match path { + Some(p) if verb == "next" => (Form::Next(p), Vec::new()), + Some(p) => (Form::Prev(p), Vec::new()), + None => ( + Form::NotTotal(format!( + "`{verb}` names a collection field, like {verb}(cities.name)" + )), + Vec::new(), + ), + } + } // §5.12: the two collection forms. Both take `$value` and nothing // else — `remove($value)` matches an exact payload rather than // evaluating a predicate, which is what keeps them the same shape as @@ -3494,6 +3520,7 @@ fn check_event_batch( Form::Clear => "clear", Form::Toggle => "toggle", Form::Cycle => "cycle", + Form::Next(_) | Form::Prev(_) => "", Form::NotTotal(_) => "", }; match accepted { @@ -3755,6 +3782,48 @@ fn check_event_batch( format!("`cycle` needs an enum state, but {:?} is not", transition.target), ), }, + // The collection walk: the path must name a declared source's + // field, and the cell it moves must be text — the walked field + // IS the value written. + Form::Next(path) | Form::Prev(path) => { + if !matches!(&state.shape, Shape::Text) { + sink.push( + transition.line, + transition.column, + format!( + "`next`/`prev` write a text state, but {:?} is not", + transition.target + ), + ); + } + let (root, field) = path.split_once('.').unwrap_or((path.as_str(), "")); + match sources.iter().find(|s| s.name == root) { + None => sink.push( + transition.line, + transition.column, + format!("`next({path})` walks a source, and {root:?} is not one"), + ), + Some(decl) => { + let declared = decl.args.iter().find(|(n, _)| n == "fields").and_then( + |(_, a)| match a { + SourceArg::List(l) => Some(l), + _ => None, + }, + ); + if field.is_empty() + || declared.is_some_and(|l| !l.iter().any(|f| f == field)) + { + sink.push( + transition.line, + transition.column, + format!( + "`next({path})` walks a field {root:?} does not declare — add it to the source's fields", + ), + ); + } + } + } + } _ => {} } } @@ -8369,6 +8438,38 @@ fn dispatch_writes( .unwrap_or(0); serde_json::Value::String(members[(at + 1) % members.len()].clone()) } + // The collection walk. Rows come from the dispatch data — the + // host merges durable rows in before dispatch — and the walk wraps. + // A value not in the list lands on the first (next) or last (prev) + // row: the swipe that discovers the list starts at its edge. + (Form::Next(path) | Form::Prev(path), Shape::Text) => { + let forward = matches!(&transition.form, Form::Next(_)); + let (root, field) = path.split_once('.').unwrap_or((path.as_str(), "")); + let rows: Vec = data + .get(root) + .and_then(|v| v.as_array()) + .map(|rows| { + rows.iter() + .filter_map(|r| r.get(field)) + .filter_map(|v| v.as_str()) + .map(|s| s.to_owned()) + .collect() + }) + .unwrap_or_default(); + if rows.is_empty() { + // Nothing to walk: refuse the batch so the gesture that + // carried it falls through instead of consuming the swipe. + return (Vec::new(), Vec::new()); + } + let here = current.as_str().and_then(|c| rows.iter().position(|r| r == c)); + let at = match (here, forward) { + (Some(i), true) => (i + 1) % rows.len(), + (Some(i), false) => (i + rows.len() - 1) % rows.len(), + (None, true) => 0, + (None, false) => rows.len() - 1, + }; + serde_json::Value::String(rows[at].clone()) + } (Form::Set(source), _) => match source { SetSource::Payload => match payload { // The one value that arrives from OUTSIDE, so it is the one diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index cfc694e..2e984fd 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -50,6 +50,7 @@ copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } copy saved_hd { class: vocabulary, en: "READING LIST", zh: "收藏" } copy keep_lbl { class: vocabulary, en: "☆ Save" } copy drop_lbl { class: vocabulary, en: "Remove" } +copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" } copy read_lbl { class: vocabulary, en: "Read ↗", zh: "阅读原文 ↗" } copy latest { class: vocabulary, en: "LATEST", zh: "最新" } copy topics_hd { class: vocabulary, en: "TOPICS", zh: "关注" } @@ -165,7 +166,9 @@ view latest Col { } view story Col { - Row(on_tap: back) { TextCaption(glyph: "‹") } + # A word, not a lone glyph: the glyph alone measured ~20px of + # hit target on device, too small for a thumb. + Row(align: .center, on_tap: back) { TextRow(text: copy.back_lbl) } Row(gap: 8) { Chip(text: copy.keep_lbl, on_tap: keep_story, value: selected) Chip(text: copy.read_lbl, on_tap: read_story, value: article.url) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 03ae2fc..4c9428f 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -33,6 +33,7 @@ event keep { watch: append($value) } event forget { watch: remove($value) } copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" } copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" } copy star { class: vocabulary, en: "☆" } copy unstar { class: vocabulary, en: "Remove" } @@ -91,7 +92,9 @@ view list Col { # §5.9: the quote's lifecycle is a state to branch on — a tapped ticker says # "fetching" or "can't reach", never a silent grid of em dashes. view detail Col { - Row(on_tap: back) { TextCaption(glyph: "‹") } + # A word, not a lone glyph: the glyph alone measured ~20px of + # hit target on device, too small for a thumb. + Row(align: .center, on_tap: back) { TextRow(text: copy.back_lbl) } when quote.$state == .pending { TextBody(text: copy.loading) } when quote.$state == .failed { TextBody(text: copy.offline) } header diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 6ac0f6a..1d7dff0 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -45,6 +45,11 @@ state editing { shape: enum[none, add], initial: .none } # ── events: total transitions, applied atomically ──────────────────────────── event toggle_units { units: cycle(.c, .f) } +# The pager: a horizontal swipe (host gesture, offered as these events) walks +# the SAVED list, wrapping. On the device-location view ("" is in no row) a +# first swipe left lands on the first saved city, right on the last. +event swipe_left { city: next(cities.name) } +event swipe_right { city: prev(cities.name) } event open_city { city: set($value) } # a saved row re-points the card event add_city { editing: set(.add), query: clear } event typing { query: set($value) } @@ -129,16 +134,15 @@ view saved Panel { } } } - when editing != .add { - Row(align: .center, gap: 10, on_tap: add_city, value: "1") { - TextCaption(text: copy.plus, width: .label) - TextRow(text: copy.addcity, width: .fill) - } - } } view current Col(align: .center) { - TextTitle(text: place.name) + # The add affordance lives in the top-right corner, not in a + # full-width row below the strip — asked for by hand on device. + Row(align: .center, gap: 8) { + TextTitle(text: place.name, width: .fill) + Chip(text: copy.plus, on_tap: add_city, value: "1") + } WeatherIcon(cond: now.cond, size: .hero) TextHero(value: now.temp, unit: units, on_tap: toggle_units) # `width: .fit` so the centred column can centre it. A row fills From bd76744f3d7c423d74ed95744a4471415daceb01 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:57:56 -0700 Subject: [PATCH 92/97] =?UTF-8?q?feat(ui=5Fl0):=20looking=20is=20not=20kee?= =?UTF-8?q?ping=20=E2=80=94=20the=20add=20flows,=20and=20width=20on=20a=20?= =?UTF-8?q?chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weather redesign's grammar half, shared by stock: tapping a search result only PREVIEWS (a state write), the explicit Add is the one composed event that stores durably, the explicit close stores nothing. The rewritten dispatch test drives exactly that and asserts writes stay empty until Add. Chip gains a width token: `.fit` on a danger chip names the compact row-scoped variant — the tap pass strips value/on_tap before the kit lowering runs, so the payload could not be the discriminator — and its hit target fits it; without it a danger chip is still the screen action that spans (nav's Stop). TOML agrees. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 22 +++++- crates/splash-ui-l0/tests/fixtures/stock.card | 61 +++++++++++++-- .../splash-ui-l0/tests/fixtures/weather.card | 77 ++++++++----------- crates/splash-ui-l0/tests/profile.rs | 67 ++++++++-------- docs/ui-l0-constructors.toml | 3 + 5 files changed, 143 insertions(+), 87 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index d9aeafd..a72b9e2 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2991,6 +2991,9 @@ pub mod catalog { ("active", Bool), // What the action MEANS. The theme decides `.danger` is red. ("tone", Token(TONE)), + // `.fit` on a danger chip names the row-scoped compact variant; + // the spanning one is the screen action (nav's Stop). + ("width", Token(WIDTH)), ], ), ("WeatherIcon", &[("cond", Path), ("size", Token(ICON_SIZE))]), @@ -9502,7 +9505,11 @@ pub mod kit { // a `width: Fill` bar inside a `width: Fit` wrapper and the End // button rendered as an empty gap — the same Fill-inside-Fit trap // that resolves to nothing, three times over in this card now. - (node.kind == "Chip" && !matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger")) + (node.kind == "Chip" + && (!matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger") + // A danger chip that ASKS to fit is a row-scoped one + // (the compact variant below) — its hit target fits it. + || matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fit"))) || (node.kind.starts_with("Text") && !asked_to_fill); let f = if intrinsic { "l0_tap_fit" } else { "l0_tap" }; let _ = write!(out, "{f}({target}, "); @@ -9769,7 +9776,18 @@ pub mod kit { // presentation decisions through one flag reads worse than naming // the thing. if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "danger") { - let _ = write!(out, "l0_chip_danger({})", makepad::expr_of(node, "text")); + // The card says the scope: `width: .fit` names the compact + // row-sized variant (the tap pass strips value/on_tap before + // this runs, so the payload cannot be the discriminator); + // without it a danger chip is a screen action and spans. + // Both are red — what destructive looks like stays the + // theme's call. + if matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fit") { + let _ = + write!(out, "l0_chip_danger_row({})", makepad::expr_of(node, "text")); + } else { + let _ = write!(out, "l0_chip_danger({})", makepad::expr_of(node, "text")); + } } else if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "primary") { // The same reasoning as `.danger`: a primary action is bigger diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 4c9428f..a0068c8 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -19,8 +19,16 @@ source env.locale sys.locale() # fresh every render; a price is never written down. source watch sys.watchlist(fields: [ticker, last, pct]) source range_pref sys.prefs(fields: [range]) +# Free-text ticker lookup for the add flow — weather's editor pattern, the +# stock way: results carry real listings, never a guessed symbol. +source found sys.symbol_search(query: state.query, count: 5, fields: [ticker, name]) state selected { shape: text, initial: "" } # "" ⇒ movers list +state query { shape: text, initial: "" } +state editing { shape: enum[none, add], initial: .none } +# Swipe-left reveals the red Remove beside each saved row; swipe-right hides +# it — manage mode as a declared state the gesture events write. +state managing { shape: enum[off, on], initial: .off } # CAPTURED from the stored preference (§5.13); the host guarantees "m1" until # the user ever picks a range, so the capture is always a member of the enum. state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } @@ -31,12 +39,23 @@ event set_range { range: set($value), range_pref: set($value) } # The star on a mover saves its ticker; Remove on a saved row forgets it. event keep { watch: append($value) } event forget { watch: remove($value) } +# The add flow. Looking is not keeping: tapping a result only OPENS its quote; +# Add is the one event that stores, and the × closes with nothing stored. +event add_sym { editing: set(.add), query: clear } +event typing { query: set($value) } +event preview { selected: set($value) } +event confirm_add { watch: append($value), selected: set($value), editing: set(.none), query: clear } +event close_add { editing: set(.none), query: clear } +event swipe_left { managing: set(.on) } +event swipe_right { managing: set(.off) } copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" } copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" } copy star { class: vocabulary, en: "☆" } -copy unstar { class: vocabulary, en: "Remove" } +copy unstar { class: vocabulary, en: "Remove", zh: "删除" } +copy plus { class: vocabulary, en: "+" } +copy add_lbl { class: vocabulary, en: "Add", zh: "添加" } copy loading { class: vocabulary, en: "Fetching the quote…", zh: "正在获取行情…" } copy offline { class: vocabulary, en: "Can't reach the market feed", zh: "无法获取行情" } copy open { class: vocabulary, en: "Open", zh: "开盘" } @@ -52,22 +71,50 @@ view root Surface(pad: .page) { } view list Col { + # The header bar is always there: it names the saved list and + # hosts the add affordance in the top-right corner. + Row(align: .center, gap: 8) { + TextCaption(text: copy.watch_hd, width: .fill) + Chip(text: copy.plus, on_tap: add_sym, value: "1") + } + when editing == .add { + Panel { + Row(align: .center, gap: 10) { + Field(text: query, placeholder: copy.watch_hd, on_commit: preview, + on_change: typing, width: .fill) + Chip(text: "×", on_tap: close_add) + } + when query != "" { + # Row taps PREVIEW the quote; only the Add chip beside a + # result stores it (chip as sibling — a row's hit target + # covers its children, measured). + for f, i in found key f.ticker { + Row(align: .center, gap: 10) { + Row(align: .center, gap: 10, width: .fill, on_tap: preview, value: f.ticker) { + TextRow(text: f.ticker, width: .label) + TextCaption(text: f.name, width: .fill) + } + Chip(text: copy.add_lbl, on_tap: confirm_add, value: f.ticker) + } + } + } + } + } for w, i in watch key w.ticker { - # Header on the first row only (the index binder is 1-based) — - # a per-row caption plus ticker, price, pct and the Remove chip - # overfilled the row on device: the fill-width ticker collapsed - # to nothing and the pct clipped. - when i == 1 { TextCaption(text: copy.watch_hd) } # The chip is a SIBLING of the tappable part, not a child of it — # a row's transparent hit target covers its whole content, so a # chip inside one fires the row's event, measured on device. + # The red Remove exists only in manage mode — a swipe left + # reveals it, a swipe right puts it away. Row(align: .center, gap: 8) { Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: w.ticker) { TextRow(text: w.ticker, width: .fill) TextValue(value: w.last, format: .money) TextValue(value: w.pct, format: .signed_pct) } - Chip(text: copy.unstar, on_tap: forget, value: w.ticker) + when managing == .on { + Chip(text: copy.unstar, on_tap: forget, value: w.ticker, tone: .danger, width: .fit) + } } } TextTitle(text: copy.movers) diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 1d7dff0..2706649 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -50,16 +50,15 @@ event toggle_units { units: cycle(.c, .f) } # first swipe left lands on the first saved city, right on the last. event swipe_left { city: next(cities.name) } event swipe_right { city: prev(cities.name) } -event open_city { city: set($value) } # a saved row re-points the card event add_city { editing: set(.add), query: clear } event typing { query: set($value) } -# Picking a result does BOTH things it plainly means: it is the city on screen -# now, and it joins the saved list. One declared transition composes a durable -# write with three state writes — dispatch routes `cities:` to the host's store -# (§5.12) and the rest to card state, then the written source goes stale and -# refetches, so the new row appears with live values. -event pick_city { city: set($value), cities: append($value), query: clear, editing: set(.none) } -event drop_city { cities: remove($value) } +# Looking is not keeping. Tapping a result (or committing the field) only +# RE-POINTS the card — the editor stays up, nothing is stored — and the two +# ways out are explicit: Add appends the result to the record AND shows it; +# the × closes the editor with nothing added. +event preview { city: set($value) } +event confirm_add { city: set($value), cities: append($value), query: clear, editing: set(.none) } +event close_add { editing: set(.none), query: clear } # ── copy: declared literals, all host-owned vocabulary ─────────────────────── copy feels { class: vocabulary, en: "Feels like", zh: "体感" } @@ -76,6 +75,7 @@ copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } copy addcity { class: vocabulary, en: "Add a city", zh: "添加城市" } copy plus { class: vocabulary, en: "+", zh: "+" } +copy add_lbl { class: vocabulary, en: "Add", zh: "添加" } # ── view ───────────────────────────────────────────────────────────────────── # §5.9: the fetch lifecycle is a state the card branches on. "Not yet" and @@ -93,42 +93,33 @@ view root Photo(src: scene, pad: .page) { details } -# The saved-cities strip: every row is the stored name beside a LIVE reading, -# tappable to re-point the whole card (`open_city` writes the same state the -# whole card hangs off). The add row is nav's editor pattern — a row until it -# is tapped, a Field only while `editing` says so, because a permanently-live -# Field cannot be focused on this renderer (measured; see nav.card). Results -# render as bare rows in the panel: the panel is never empty (the add row is -# always there), and a `for` with no rows must not leave an empty box behind. -view saved Panel { - # The remove chip is a SIBLING of the tappable area, not a child. - # A tappable row lowers to a transparent hit target drawn OVER its - # whole content, so a chip inside one is covered by it and cannot - # be hit — measured on the 6T: a tap dead-centre on the chip fired - # `open_city`. The row that opens and the chip that removes must - # not overlap, so the row wraps them and only its first half taps. - for c, i in cities key c.name { - Row(align: .center, gap: 10) { - Row(align: .center, gap: 10, width: .fill, on_tap: open_city, value: c.name) { - TextRow(text: c.name, width: .fill) - TextValue(value: c.temp, unit: units) - } - Chip(text: "×", on_tap: drop_city, value: c.name) - } - Rule() - } +# The add editor — and ONLY the editor. The saved list itself never renders: +# the record lives in the store and the swipe pager walks it; a visible strip +# was removed by hand on device. The whole panel exists only while editing, +# so no empty box is left behind when it is not. Field-not-row is nav's +# editor pattern (a permanently-live Field cannot be focused; measured). +view saved Col { when editing == .add { - Row(align: .center, gap: 10) { - TextCaption(text: copy.plus, width: .label) - Field(text: query, placeholder: city, on_commit: pick_city, - on_change: typing, width: .fill) - } - when query != "" { - for f, i in found key f.label { - Row(align: .center, gap: 10, on_tap: pick_city, value: f.query) { - Col(gap: 2) { - TextRow(text: f.name) - TextCaption(text: f.label) + Panel { + Row(align: .center, gap: 10) { + TextCaption(text: copy.plus, width: .label) + Field(text: query, placeholder: city, on_commit: preview, + on_change: typing, width: .fill) + Chip(text: "×", on_tap: close_add) + } + when query != "" { + # Row taps PREVIEW; only the Add chip beside a result + # stores it (chip as sibling — a row's hit target covers + # its children, measured). + for f, i in found key f.label { + Row(align: .center, gap: 10) { + Row(align: .center, gap: 10, width: .fill, on_tap: preview, value: f.query) { + Col(gap: 2) { + TextRow(text: f.name) + TextCaption(text: f.label) + } + } + Chip(text: copy.add_lbl, on_tap: confirm_add, value: f.query) } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 5fddb5a..e345aa3 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -5147,16 +5147,12 @@ view root Surface { ); } -/// §5.12 in the weather card: the add flow is driven as the user drives it, and -/// picking a city is ONE event that both re-points the card and saves durably. -/// -/// Differential on purpose (the L0 defect class is a card the checker accepts -/// whose screen is confidently wrong): each dispatch asserts what MOVED, not -/// that some call appears somewhere. The composed `pick_city` — one durable -/// write beside three state writes — is the shape this card exists to prove; -/// if dispatch ever refuses the compose, this is the test that says so. +/// §5.12 in the weather card: looking is not keeping. Tapping a result only +/// PREVIEWS (a state write, no store write); the explicit Add is the one +/// composed event that stores durably beside re-pointing; the explicit close +/// stores nothing. Driven as the user drives it, asserting what MOVED. #[test] -fn picking_a_city_selects_it_and_saves_it_durably() { +fn previewing_stores_nothing_and_adding_stores_once() { let mut store = splash_ui_l0::InstanceStore::default(); let dispatch = |store: &mut splash_ui_l0::InstanceStore, event: &str, payload: &str| { splash_ui_l0::dispatch_reporting( @@ -5185,15 +5181,36 @@ fn picking_a_city_selects_it_and_saves_it_durably() { out.stale ); - // Pick a result. One event, four declared writes: the city on screen, the - // durable append, the query cleared, the editor closed. + // Tap a result: the card re-points and NOTHING is stored — the whole + // point of the redesign. A preview that appended was the bug class where + // browsing the search results polluted the record. let city = "Berkeley, California, United States"; - let out = dispatch(&mut store, "pick_city", city); + let out = dispatch(&mut store, "preview", city); + assert!(out.applied, "preview must apply"); + assert_eq!(out.changed, vec!["city"], "preview moves the city: {out:?}"); + assert!(out.writes.is_empty(), "looking is not keeping: {out:?}"); + assert!(out.stale.contains(&"place".to_string()), "{:?}", out.stale); + + // Close without adding: the editor and query reset, and still no write. + let out = dispatch(&mut store, "close_add", "1"); + assert!(out.applied, "close_add must apply"); + assert_eq!( + out.changed, + vec!["editing", "query"], + "closing resets the editor: {out:?}" + ); + assert!(out.writes.is_empty(), "closing adds nothing: {out:?}"); + + // Reopen and ADD: the one composed event — the city on screen, the + // durable append, the query cleared, the editor closed. + dispatch(&mut store, "add_city", "1"); + dispatch(&mut store, "typing", "berk"); + let out = dispatch(&mut store, "confirm_add", city); assert!(out.applied, "the composed event must apply"); assert_eq!( out.changed, - vec!["city", "query", "editing"], - "all three state writes commit beside the durable one: {out:?}" + vec!["query", "editing"], + "city was already previewed to this value; the reset writes commit: {out:?}" ); assert_eq!(out.writes.len(), 1, "exactly one durable write: {out:?}"); let w = &out.writes[0]; @@ -5202,32 +5219,12 @@ fn picking_a_city_selects_it_and_saves_it_durably() { ("cities", "sys.cities", "append", city), "the host is told the bound name, the capability, the op and the value" ); - // The written source refetches (the new row appears), and the selected - // city's own cascade re-fetches the card: place hangs off state.city. assert!(out.stale.contains(&"cities".to_string()), "{:?}", out.stale); - assert!(out.stale.contains(&"place".to_string()), "{:?}", out.stale); assert_eq!( store.get(splash_ui_l0::CARD_STATE_KEY, "city"), Some(serde_json::Value::String(city.into())).as_ref(), - "the card is now looking at the picked city" + "the card is looking at the added city" ); - - // The remove chip: a durable remove carrying the row's own name, and no - // state write rides along. - let out = dispatch(&mut store, "drop_city", city); - assert!(out.applied, "drop_city must apply"); - assert!(out.changed.is_empty(), "remove writes no cell: {out:?}"); - assert_eq!( - out.writes, - vec![splash_ui_l0::CollectionWrite { - source: "cities".into(), - helper: "sys.cities".into(), - op: "remove".into(), - value: city.into(), - field: String::new(), - }], - ); - assert!(out.stale.contains(&"cities".to_string()), "{:?}", out.stale); } /// `signed_money` reaches the screen live, beside its own percentage. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 877815c..77b7731 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -206,6 +206,9 @@ active = { kind = "bool" } # a predicate is a bool operand # something — the theme decides that reads red and full-width, because a card that # named a colour would be stating presentation, which §4 keeps out of the ledger. tone = { kind = "token", tokens = ["normal", "primary", "danger"] } +# `.fit` on a danger chip names the compact row-scoped variant; without it a +# danger chip is a screen action and spans the sheet (nav's Stop). +width = { kind = "token", tokens = ["fill", "fit", "day", "rank", "temp", "label"] } # ─── data-visualisation roles ───────────────────────────────────────────────── # Every argument is a path. These render live data and hold no authored values — From dff19d68e1b797537b06f47ea9299bbe994a310f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:18:51 -0700 Subject: [PATCH 93/97] chore(ui_l0): the mover star goes; a mover row is one tap again Saving is the add flow's explicit Add now, so the per-row shortcut and its sibling wrapper leave the movers, and the reachable-notify key walks back up a level. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/tests/fixtures/stock.card | 21 ++++++++----------- crates/splash-ui-l0/tests/profile.rs | 9 ++++---- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index a0068c8..49348e7 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -36,8 +36,7 @@ state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } event open_quote { selected: set($value) } event back { selected: clear, range: clear } # two writes, one gesture event set_range { range: set($value), range_pref: set($value) } -# The star on a mover saves its ticker; Remove on a saved row forgets it. -event keep { watch: append($value) } +# Remove on a saved row (revealed by the manage swipe) forgets its ticker. event forget { watch: remove($value) } # The add flow. Looking is not keeping: tapping a result only OPENS its quote; # Add is the one event that stores, and the × closes with nothing stored. @@ -52,7 +51,6 @@ event swipe_right { managing: set(.off) } copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" } copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" } -copy star { class: vocabulary, en: "☆" } copy unstar { class: vocabulary, en: "Remove", zh: "删除" } copy plus { class: vocabulary, en: "+" } copy add_lbl { class: vocabulary, en: "Add", zh: "添加" } @@ -119,17 +117,16 @@ view list Col { } TextTitle(text: copy.movers) Panel { + # No per-row save shortcut — saving is the add flow's explicit + # Add, so a mover row is just the tap that opens its quote. for m in movers key m.ticker { - Row(align: .center, gap: 8) { - Chip(text: copy.star, on_tap: keep, value: m.ticker) - Row(align: .center, width: .fill, on_tap: open_quote, value: m.ticker) { - Col(gap: 2, width: .fill) { - TextRow(text: m.ticker) - TextCaption(text: m.name, width: .fill) - } - TextValue(value: m.last, format: .money) - TextValue(value: m.pct, format: .signed_pct, tint: m.change) + Row(align: .center, gap: 8, on_tap: open_quote, value: m.ticker) { + Col(gap: 2, width: .fill) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) } Rule() } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index e345aa3..f5b5d3f 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -1117,12 +1117,11 @@ fn a_tappable_node_lowers_to_a_reachable_notify() { .unwrap_or_else(|| panic!("no reachable tap for open_quote:\n{dsl}")); assert!(notify.contains("event: \"open_quote\""), "event:\n{notify}"); assert!(notify.contains("value: \"NVDA\""), "payload:\n{notify}"); - // `Row#0/Row#0`: the mover's tap row is nested one deeper since the ☆ - // chip moved out to be its SIBLING — a row's transparent hit target - // covers its whole content, so a chip inside a tappable row can never - // receive its own tap (measured on device; same fix as weather's ×). + // A mover row is a single tap again: the per-row ☆ save shortcut is + // gone (saving is the add flow's explicit Add), so nothing shares the + // row and it needs no sibling wrapper. assert!( - notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0/Row#0\""), + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), "instance key:\n{notify}" ); From 4bfac2ef37e8091a24f25701cc7964c97e6bc7e6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:12:21 -0700 Subject: [PATCH 94/97] chore(ui_l0): the stock fixture reveals per row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saved row's Remove is a Reveal beside the tappable half — widget visibility the row's own swipe drives, replacing the card-level manage mode whose buttons appeared far from the row the finger was on. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/tests/fixtures/stock.card | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 49348e7..663246a 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -26,9 +26,6 @@ source found sys.symbol_search(query: state.query, count: 5, fields: [tick state selected { shape: text, initial: "" } # "" ⇒ movers list state query { shape: text, initial: "" } state editing { shape: enum[none, add], initial: .none } -# Swipe-left reveals the red Remove beside each saved row; swipe-right hides -# it — manage mode as a declared state the gesture events write. -state managing { shape: enum[off, on], initial: .off } # CAPTURED from the stored preference (§5.13); the host guarantees "m1" until # the user ever picks a range, so the capture is always a member of the enum. state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } @@ -45,8 +42,6 @@ event typing { query: set($value) } event preview { selected: set($value) } event confirm_add { watch: append($value), selected: set($value), editing: set(.none), query: clear } event close_add { editing: set(.none), query: clear } -event swipe_left { managing: set(.on) } -event swipe_right { managing: set(.off) } copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" } @@ -99,18 +94,18 @@ view list Col { } } for w, i in watch key w.ticker { - # The chip is a SIBLING of the tappable part, not a child of it — - # a row's transparent hit target covers its whole content, so a - # chip inside one fires the row's event, measured on device. - # The red Remove exists only in manage mode — a swipe left - # reveals it, a swipe right puts it away. + # Swipe THIS row left and ITS red Remove appears beside it; + # swipe right puts it away. `Reveal` is widget visibility, not + # card state — no other row moves, nothing re-realizes, and + # the swipe fires instead of the tap so a drag cannot also + # open the quote. Row(align: .center, gap: 8) { Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: w.ticker) { TextRow(text: w.ticker, width: .fill) TextValue(value: w.last, format: .money) TextValue(value: w.pct, format: .signed_pct) } - when managing == .on { + Reveal { Chip(text: copy.unstar, on_tap: forget, value: w.ticker, tone: .danger, width: .fit) } } From 35fcf6dc9e990dd03185a4e2a22496048e9cc8a5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:56:27 -0700 Subject: [PATCH 95/97] feat(ui_l0): a mover swipes to reveal Add The same gesture on every stock row, answering with what THIS row can do: a saved row reveals its red Remove, a mover reveals Add. The reachable-notify key follows the tap row into its wrapper. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/tests/fixtures/stock.card | 25 ++++++++++++------- crates/splash-ui-l0/tests/profile.rs | 7 +++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 663246a..a61aba3 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -33,7 +33,9 @@ state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } event open_quote { selected: set($value) } event back { selected: clear, range: clear } # two writes, one gesture event set_range { range: set($value), range_pref: set($value) } -# Remove on a saved row (revealed by the manage swipe) forgets its ticker. +# Swipe a SAVED row: Remove forgets its ticker. Swipe a MOVER row: Add +# keeps it — the same gesture answers with what the row can do. +event keep { watch: append($value) } event forget { watch: remove($value) } # The add flow. Looking is not keeping: tapping a result only OPENS its quote; # Add is the one event that stores, and the × closes with nothing stored. @@ -112,16 +114,21 @@ view list Col { } TextTitle(text: copy.movers) Panel { - # No per-row save shortcut — saving is the add flow's explicit - # Add, so a mover row is just the tap that opens its quote. + # A mover row taps to its quote and swipes to reveal Add — + # the watch rows' gesture, answering with what THIS row can do. for m in movers key m.ticker { - Row(align: .center, gap: 8, on_tap: open_quote, value: m.ticker) { - Col(gap: 2, width: .fill) { - TextRow(text: m.ticker) - TextCaption(text: m.name, width: .fill) + Row(align: .center, gap: 8) { + Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: m.ticker) { + Col(gap: 2, width: .fill) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) + } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) + } + Reveal { + Chip(text: copy.add_lbl, on_tap: keep, value: m.ticker) } - TextValue(value: m.last, format: .money) - TextValue(value: m.pct, format: .signed_pct, tint: m.change) } Rule() } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index f5b5d3f..9136230 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -1117,11 +1117,10 @@ fn a_tappable_node_lowers_to_a_reachable_notify() { .unwrap_or_else(|| panic!("no reachable tap for open_quote:\n{dsl}")); assert!(notify.contains("event: \"open_quote\""), "event:\n{notify}"); assert!(notify.contains("value: \"NVDA\""), "payload:\n{notify}"); - // A mover row is a single tap again: the per-row ☆ save shortcut is - // gone (saving is the add flow's explicit Add), so nothing shares the - // row and it needs no sibling wrapper. + // The mover's tap row nests inside its swipe-reveal wrapper: the row + // swipes to reveal Add, so the tappable half is the inner filling row. assert!( - notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0/Row#0\""), "instance key:\n{notify}" ); From a96081041acd456cb689bd2ccb326de1795a53cd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:03:37 -0700 Subject: [PATCH 96/97] =?UTF-8?q?chore(ui=5Fl0):=20movers=20are=20recommen?= =?UTF-8?q?dations=20=E2=80=94=20Add=20moves=20to=20the=20quote=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No swipe menu on the market's own list: a mover row is the tap that opens its quote, and the quote page's top-right Add is where a stock is kept (append is idempotent, a kept one re-taps to nothing). The reachable-notify key follows the row back out of its wrapper. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/tests/fixtures/stock.card | 31 ++++++++++--------- crates/splash-ui-l0/tests/profile.rs | 6 ++-- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index a61aba3..c01cf6f 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -114,21 +114,17 @@ view list Col { } TextTitle(text: copy.movers) Panel { - # A mover row taps to its quote and swipes to reveal Add — - # the watch rows' gesture, answering with what THIS row can do. + # Movers are the market's recommendations, not the user's list: + # no swipe menu here — a mover row is the tap that opens its + # quote, and the quote page is where Add lives. for m in movers key m.ticker { - Row(align: .center, gap: 8) { - Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: m.ticker) { - Col(gap: 2, width: .fill) { - TextRow(text: m.ticker) - TextCaption(text: m.name, width: .fill) - } - TextValue(value: m.last, format: .money) - TextValue(value: m.pct, format: .signed_pct, tint: m.change) - } - Reveal { - Chip(text: copy.add_lbl, on_tap: keep, value: m.ticker) + Row(align: .center, gap: 8, on_tap: open_quote, value: m.ticker) { + Col(gap: 2, width: .fill) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) } Rule() } @@ -139,8 +135,13 @@ view list Col { # "fetching" or "can't reach", never a silent grid of em dashes. view detail Col { # A word, not a lone glyph: the glyph alone measured ~20px of - # hit target on device, too small for a thumb. - Row(align: .center, on_tap: back) { TextRow(text: copy.back_lbl) } + # hit target on device, too small for a thumb. Add sits in the + # page's top-right corner: the quote page is where a stock is + # kept (append is idempotent, so a kept one re-taps to nothing). + Row(align: .center, gap: 8) { + Row(align: .center, width: .fill, on_tap: back) { TextRow(text: copy.back_lbl) } + Chip(text: copy.add_lbl, on_tap: keep, value: selected) + } when quote.$state == .pending { TextBody(text: copy.loading) } when quote.$state == .failed { TextBody(text: copy.offline) } header diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 9136230..8b8d71e 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -1117,10 +1117,10 @@ fn a_tappable_node_lowers_to_a_reachable_notify() { .unwrap_or_else(|| panic!("no reachable tap for open_quote:\n{dsl}")); assert!(notify.contains("event: \"open_quote\""), "event:\n{notify}"); assert!(notify.contains("value: \"NVDA\""), "payload:\n{notify}"); - // The mover's tap row nests inside its swipe-reveal wrapper: the row - // swipes to reveal Add, so the tappable half is the inner filling row. + // A mover row is a single tap: movers are the market's recommendations, + // so there is no per-row menu — Add lives on the quote page. assert!( - notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0/Row#0\""), + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), "instance key:\n{notify}" ); From 7dbc77ee956c7fc6818efc09b563e8120407cb2a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:33:21 -0700 Subject: [PATCH 97/97] feat(ui_l0): a watchlist source naming a ticker is a membership probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys.watchlist(ticker: state.selected, fields: [has])` answers "1"/ "0" for THAT ticker. The stock fixture's quote page flips on it: kept shows a check and a red Remove, unkept shows Add — and for the beat after a remove, the word Removed, carried by a last_act state the keep/forget events set. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/src/lib.rs | 13 +++++- crates/splash-ui-l0/tests/fixtures/stock.card | 40 +++++++++++++------ docs/ui-l0-constructors.toml | 7 +++- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index a72b9e2..baf1a9a 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3085,7 +3085,7 @@ pub mod catalog { // IS the user's list — and the host joins the stored tickers to live // quotes, so a card asks for the fields it wants to show and never // learns that a store exists. - ("sys.watchlist", &["fields"]), + ("sys.watchlist", &["ticker", "fields"]), ("sys.prefs", &["fields"]), ("sys.reading", &["fields"]), ("sys.topics", &["fields"]), @@ -3207,6 +3207,9 @@ pub mod catalog { &[ "ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange", + // The membership probe, for a source that names a ticker: + // "1" when that ticker is in the user's list, else "0". + "has", ], ), ("sys.prefs", &["units", "range", "home", "work", "mode"]), @@ -6945,6 +6948,14 @@ pub mod makepad { // which is why this lowers to a live call at all rather than to the // realized literal: a stored price would render stale and look live. "sys.watchlist" => { + // The membership probe: `kept.has` on a watchlist source WITH + // a ticker argument answers whether THAT ticker is in the + // user's list — "1" or "0", synchronously from the published + // store. It is what lets a quote page show Add or Remove. + if binding.field == "has" { + let ticker = arg("ticker")?; + return Some(format!("sys.watchlist_has({ticker})")); + } let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; let key = match field { diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index c01cf6f..71cc3f4 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -19,6 +19,10 @@ source env.locale sys.locale() # fresh every render; a price is never written down. source watch sys.watchlist(fields: [ticker, last, pct]) source range_pref sys.prefs(fields: [range]) +# The membership probe: is the stock this page looks at in the user's list? +# Names a ticker, so `has` answers "1"/"0" from the store — which is what +# flips the page between Add and Remove. +source kept sys.watchlist(ticker: state.selected, fields: [has]) # Free-text ticker lookup for the add flow — weather's editor pattern, the # stock way: results carry real listings, never a guessed symbol. source found sys.symbol_search(query: state.query, count: 5, fields: [ticker, name]) @@ -26,22 +30,25 @@ source found sys.symbol_search(query: state.query, count: 5, fields: [tick state selected { shape: text, initial: "" } # "" ⇒ movers list state query { shape: text, initial: "" } state editing { shape: enum[none, add], initial: .none } +# What the LAST keep/forget on this page did, for the moment after a remove +# when the probe already says "not kept" but the user deserves the word. +state last_act { shape: enum[none, added, removed], initial: .none } # CAPTURED from the stored preference (§5.13); the host guarantees "m1" until # the user ever picks a range, so the capture is always a member of the enum. state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range } -event open_quote { selected: set($value) } -event back { selected: clear, range: clear } # two writes, one gesture +event open_quote { selected: set($value), last_act: set(.none) } +event back { selected: clear, range: clear, last_act: set(.none) } event set_range { range: set($value), range_pref: set($value) } -# Swipe a SAVED row: Remove forgets its ticker. Swipe a MOVER row: Add -# keeps it — the same gesture answers with what the row can do. -event keep { watch: append($value) } -event forget { watch: remove($value) } +# Keep/forget carry the word the page shows after: the probe flips the +# button the moment the store changes, and `last_act` says what just happened. +event keep { watch: append($value), last_act: set(.added) } +event forget { watch: remove($value), last_act: set(.removed) } # The add flow. Looking is not keeping: tapping a result only OPENS its quote; # Add is the one event that stores, and the × closes with nothing stored. event add_sym { editing: set(.add), query: clear } event typing { query: set($value) } -event preview { selected: set($value) } +event preview { selected: set($value), last_act: set(.none) } event confirm_add { watch: append($value), selected: set($value), editing: set(.none), query: clear } event close_add { editing: set(.none), query: clear } @@ -51,6 +58,8 @@ copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" } copy unstar { class: vocabulary, en: "Remove", zh: "删除" } copy plus { class: vocabulary, en: "+" } copy add_lbl { class: vocabulary, en: "Add", zh: "添加" } +copy added_lbl { class: vocabulary, en: "✓ Added", zh: "已添加" } +copy removed_lbl { class: vocabulary, en: "Removed", zh: "已删除" } copy loading { class: vocabulary, en: "Fetching the quote…", zh: "正在获取行情…" } copy offline { class: vocabulary, en: "Can't reach the market feed", zh: "无法获取行情" } copy open { class: vocabulary, en: "Open", zh: "开盘" } @@ -134,13 +143,20 @@ view list Col { # §5.9: the quote's lifecycle is a state to branch on — a tapped ticker says # "fetching" or "can't reach", never a silent grid of em dashes. view detail Col { - # A word, not a lone glyph: the glyph alone measured ~20px of - # hit target on device, too small for a thumb. Add sits in the - # page's top-right corner: the quote page is where a stock is - # kept (append is idempotent, so a kept one re-taps to nothing). + # The page knows whether this stock is kept and says so: a + # kept one shows ✓ Added beside a red Remove, an unkept one + # shows Add — and for the beat after a remove, the word + # Removed, so the tap is answered in place. Row(align: .center, gap: 8) { Row(align: .center, width: .fill, on_tap: back) { TextRow(text: copy.back_lbl) } - Chip(text: copy.add_lbl, on_tap: keep, value: selected) + when kept.has == "1" { + TextCaption(text: copy.added_lbl) + Chip(text: copy.unstar, on_tap: forget, value: selected, tone: .danger, width: .fit) + } + when kept.has != "1" { + when last_act == .removed { TextCaption(text: copy.removed_lbl) } + Chip(text: copy.add_lbl, on_tap: keep, value: selected) + } } when quote.$state == .pending { TextBody(text: copy.loading) } when quote.$state == .failed { TextBody(text: copy.offline) } diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 77b7731..de0a00d 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -464,8 +464,11 @@ answers = ["url"] writes = ["set", "clear"] [sources."sys.watchlist"] -args = ["fields"] -answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange"] +# `ticker` turns the source into a membership probe: `has` answers "1" when +# that ticker is in the user's list, else "0" — what lets a quote page show +# Add or Remove for the stock it is looking at. +args = ["ticker", "fields"] +answers = ["ticker", "name", "last", "change", "pct", "open", "high", "low", "prev", "volume", "mktcap", "pe", "currency", "exchange", "has"] writes = ["append", "remove"] # READ-ONLY for now. A preference write must name WHICH preference, and a