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 cfc523b..baf1a9a 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, @@ -895,10 +917,29 @@ 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, + /// §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), } +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)] @@ -974,6 +1015,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, @@ -1004,6 +1055,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() { @@ -1263,16 +1320,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) } @@ -1425,6 +1503,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 } @@ -1651,6 +1763,62 @@ 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 + // `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()) @@ -2024,6 +2192,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; }; @@ -2031,7 +2206,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(); @@ -2045,14 +2253,159 @@ 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 + /// `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()); }; + // 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; @@ -2077,6 +2430,17 @@ impl<'a> Parser<'a> { } self.at += 1; self.depth += 1; + // 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 { @@ -2114,6 +2478,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); + } + _ => {} } } @@ -2138,7 +2520,108 @@ 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()?; + 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, + // 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, + }; + 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, + } +} + +/// 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 @@ -2150,6 +2633,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, @@ -2259,8 +2764,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", + "c", "f", "pct", "speed", "pressure", "distance", "money", "duration", ]; pub const FORMAT: &[&str] = &[ "money", @@ -2271,7 +2779,35 @@ 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 + /// 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"]; + /// Where a panel sits when the card is a map. + /// 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. + /// + /// 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. + /// `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"]; @@ -2284,12 +2820,111 @@ pub mod catalog { pub const CONSTRUCTORS: &[(&str, Args)] = &[ ("Surface", &[("pad", Token(PAD))]), ("Photo", &[("src", Path), ("pad", Token(PAD))]), - ("Panel", &[]), + // 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), + // 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. + ("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. + // 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. + // + // `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. + // 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), + ], + ), + // 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), + // 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)), + ], + ), + ("Panel", &[("dock", Token(DOCK))]), + // Content a swipe reveals. See the catalog. + ("Reveal", &[]), ("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)), + ], + ), + // `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), @@ -2354,6 +2989,11 @@ pub mod catalog { ("on_tap", Event), ("value", Any), ("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))]), @@ -2367,6 +3007,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))], @@ -2400,19 +3045,255 @@ pub mod catalog { ("sys.moonphase", &["lat", "lon"]), ("sys.photo", &["query"]), ("sys.locale", &[]), + ("sys.gps", &[]), + ("sys.search", &["query", "count", "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", "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 + // 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"]), - ("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", &["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", &["ticker", "fields"]), + ("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 + // 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]> { 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"]), + // `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"]), + ( + "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", + ], + ), + // 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. + ( + "sys.watchlist", + &[ + "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"]), + // 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"]), + // 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"]), + // 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 + // 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 + /// 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"]), + ]; + + /// 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"]), + // A preference write has to name WHICH preference, and a transition's + // 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"]), + ("sys.reading", &["append", "remove"]), + ("sys.topics", &["append", "remove"]), + ("sys.link", &["set", "clear"]), + ]; + + 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) + } + + pub fn aggregates(name: &str) -> &'static [&'static str] { + AGGREGATES + .iter() + .find(|(n, _)| *n == name) + .map_or(&[], |(_, a)| *a) + } } // ──────────────────────────────────────────────────────────────────── validation ── @@ -2528,6 +3409,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &card_readable, &card_events, &card.copies, + &card.sources, sink, ); for component in &card.components { @@ -2559,6 +3441,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, ); } @@ -2622,10 +3508,100 @@ 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::Next(_) | Form::Prev(_) => "", + 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(_) => {} + } + // 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 + // 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, @@ -2812,6 +3788,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", + ), + ); + } + } + } + } _ => {} } } @@ -2874,9 +3892,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)); + } + } + _ => {} } } } @@ -2895,6 +3933,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)) { @@ -2930,6 +3976,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 @@ -3070,6 +4151,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 { @@ -3092,6 +4212,7 @@ impl Scope { copies: card.copies.clone(), events, forbidden_state: Vec::new(), + fields: declared_fields(card), } } @@ -3119,6 +4240,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()); + } } } @@ -3185,7 +4335,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" => { @@ -3200,8 +4364,42 @@ 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 { .. }) { + 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); + } } } } @@ -3488,10 +4686,69 @@ 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 { .. }) { + 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); + } + } + // §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(), + ); + } 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); } } _ => {} @@ -3701,6 +4958,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) { @@ -3718,6 +5008,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 { @@ -3739,6 +5046,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 @@ -3837,6 +5146,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. @@ -3866,6 +5189,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), }; } @@ -3879,6 +5203,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), } } }; @@ -3893,6 +5218,7 @@ fn realize_inner( truncated: false, live_keys: Vec::new(), reused: 0, + captured: Vec::new(), }; }; @@ -3911,16 +5237,30 @@ 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 = 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)); - frames.push((state.path.clone(), value)); + .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 { frames, @@ -3940,12 +5280,38 @@ fn realize_inner( nodes, truncated, live_keys, + captured, } } /// 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> { - 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, data: &'a serde_json::Value, copies: &'a [CopyDecl], } @@ -4002,8 +5368,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 { @@ -4136,6 +5502,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); @@ -4144,6 +5511,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) { @@ -4198,7 +5570,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)); + // 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); @@ -4230,6 +5624,7 @@ impl Realizer<'_> { live.or_else(|| state.initial.clone()) .or(from_path) .unwrap_or_else(|| initial_for(&state.shape)), + None, )); bound += 1; } @@ -4255,10 +5650,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( @@ -4316,13 +5726,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; } @@ -4362,7 +5781,168 @@ 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. + /// 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, + // 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 + // 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(), + }) + } + + /// 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 { + // 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())? + } + _ => return None, + }; + (n >= 1.0).then(|| (n as usize).min(self.limits.max_collection)) + } + + /// 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 + // 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 @@ -4378,24 +5958,107 @@ 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); + // 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 `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)?), + } } 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, + // 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)); } + // 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, }) } @@ -4429,6 +6092,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), @@ -4463,7 +6133,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 @@ -4517,6 +6187,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. @@ -4533,6 +6226,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; @@ -4541,67 +6239,816 @@ pub mod makepad { text_of(arg(node, arg_name)) } - /// The VM helper that answers a declared capability, if one does. + /// 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()), + // 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)?, + render_expr(rhs)? + )), + } + } + + /// Which member of the map family a `Map` is, in the WIDGET's vocabulary. /// - /// The names differ because the two vocabularies were designed apart: L0 - /// 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 { - let arg = |name: &str| { - binding - .args - .iter() - .find(|(n, _)| n == name) - .map(|(_, v)| v.clone()) - }; - match binding.helper.as_str() { - "sys.quote" => { - let symbol = arg("ticker")?; - // 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. - // - // 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. - let key = match binding.field.as_str() { - "last" => "price", - "pct" => "changepct", - "volume" => "vol", - f @ ("name" | "change" | "high" | "low") => f, - _ => return None, - }; - Some(format!("sys.stock({symbol:?}, {key:?})")) + /// 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") { + // `.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. + // + // 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 settle gate + // exists because a map that keeps asking for frames "was pinning the + // 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. + // + // `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() => { + // 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 token_arg(node, "view") { + Some("tilted") => "follow3d", + _ => "follow", + } } - _ => None, + 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. + _ => "", } } - fn text_of(value: Option<&NodeValue>) -> String { - match value { - Some(NodeValue::Text(s)) => format!("{s:?}"), - Some(NodeValue::Number(n)) => format!("{:?}", trim_num(*n)), - Some(NodeValue::Bool(b)) => format!("{b:?}"), - Some(NodeValue::Token(t)) => format!("{t:?}"), - Some(NodeValue::Event(_)) | None => "\"\"".into(), - Some(NodeValue::Missing) => "\"—\"".into(), - Some(NodeValue::Status(st)) => format!("{:?}", st.as_token()), + /// 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 { + // 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}")] { + let probe = SourceBinding { + field, + ..binding.clone() + }; + if let Some(call) = vm_call(&probe) { + return Some(call); + } } + None } - fn num_of(value: Option<&NodeValue>) -> f64 { - match value { - Some(NodeValue::Number(n)) => *n, - Some(NodeValue::Text(s)) => s.parse().unwrap_or(0.0), - _ => 0.0, + /// 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 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. + /// + /// 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)`. + /// + /// 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| map_coord(node, name, axis); + 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")) { + (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 + // 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), + } + } + + /// 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. + /// 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. + /// 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. + // + // 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")?); + // 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 — 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) + } + + 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) { + 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 + /// 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. + /// 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 + .iter() + .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() { + // 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" | "home" | "work" | "mode") => 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 + // response rather than against its documentation. + // + // `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. + // + // 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" | "changemoney" | "high" | "low" | "open") => f, + _ => return None, + }; + 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. + // 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 = 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()), + }; + 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}") + )) + } + // `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:?})")) + } + // 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 = 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\")")), + "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 = num("lat")?; + let lon = num("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 "—". + // 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. + // 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" => 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. + _ => return None, + }; + 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 + // 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. + // + // `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", + // 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, + }; + 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); + } + Some(format!("sys.navstep({a}, {o}, {b}, {p}, {along}, {key:?})")) + } + "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" => { + // 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:?})")), + // `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, + } + } + "sys.places" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + 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. + 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()?; + let key = match field { + "ticker" => "symbol", + "last" => "price", + "pct" => "changepct", + "volume" => "vol", + "mktcap" => "marketcap", + f @ ("name" | "change" | "changemoney" | "high" | "low" | "open") => f, + _ => return None, + }; + let universe = binding + .args + .iter() + .find(|(n, _)| n == "symbols") + .map(|(_, v)| v.clone()) + .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" => { + // 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 { + "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 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:?})")) + } + // 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. + "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. + "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, + } + } + + fn text_of(value: Option<&NodeValue>) -> String { + match value { + Some(NodeValue::Text(s)) => format!("{s:?}"), + Some(NodeValue::Number(n)) => format!("{:?}", trim_num(*n)), + Some(NodeValue::Bool(b)) => format!("{b:?}"), + Some(NodeValue::Token(t)) => format!("{t:?}"), + Some(NodeValue::Event(_)) | None => "\"\"".into(), + Some(NodeValue::Missing) => "\"—\"".into(), + Some(NodeValue::Status(st)) => format!("{:?}", st.as_token()), + } + } + + fn num_of(value: Option<&NodeValue>) -> f64 { + match value { + Some(NodeValue::Number(n)) => *n, + Some(NodeValue::Text(s)) => s.parse().unwrap_or(0.0), + _ => 0.0, } } @@ -4664,7 +7111,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, @@ -4689,10 +7141,25 @@ 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("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") { @@ -4721,6 +7188,38 @@ 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() { + // 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); + } + } + } match value { Some(NodeValue::Missing) => "\"—\"".into(), Some(v) => { @@ -4732,8 +7231,57 @@ 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. + /// 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}"); + 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. @@ -4744,12 +7292,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, } } @@ -4814,9 +7365,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, }; @@ -4896,15 +7463,199 @@ 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. + // 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") + }; + // 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}}" + ); + docked_children(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}}" + ); + let _ = writeln!( + out, + "{p} RoundedView{{ width: Fill height: Fit flow: Down \ + draw_bg.color: #0f1620 draw_bg.border_radius: 22 \ + 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)) + { + // 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} }}"); 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" => { + 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(), + }; + // 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 != "\"\"" { + let _ = write!(out, " nav_polyline: {poly}"); + } + 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 @@ -4966,13 +7717,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" @@ -5045,6 +7808,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 @@ -5102,17 +7877,14 @@ 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", _ => "", }; - 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 @@ -5138,6 +7910,61 @@ 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 + // 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 token_arg(node, "width") { + Some("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. @@ -5185,6 +8012,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()) @@ -5364,7 +8198,30 @@ 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, + /// 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. @@ -5381,11 +8238,17 @@ 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. 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. @@ -5401,18 +8264,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, } } @@ -5423,10 +8297,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(); @@ -5463,11 +8337,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()), }, }; @@ -5479,11 +8353,54 @@ 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. + 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()), + }; + 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; + } 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 @@ -5496,9 +8413,28 @@ 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()) + // 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)); @@ -5516,16 +8452,48 @@ 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 // 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 @@ -5534,28 +8502,47 @@ 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)); + 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 + (written, durable) } // ───────────────────────────────────────────────────────────────── source plan ── @@ -5948,10 +8935,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() } @@ -5972,19 +8978,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 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() { @@ -6172,7 +9191,10 @@ 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", + "Map" => "l0_map", "Surface" => "l0_surface", + "Reveal" => "l0_reveal", "Col" => "l0_col", "Row" => "l0_row", "Grid" => "l0_grid", @@ -6182,6 +9204,12 @@ pub mod kit { "Chip" => "l0_chip", "Photo" => "l0_photo", "WeatherIcon" => "l0_weathericon", + "TempBar" => "l0_tempbar", + "SunArc" => "l0_sunarc", + "MoonPhase" => "l0_moonphase", + "AqiContour" => "l0_aqicontour", + "Satellite" => "l0_satellite", + "StockPlot" => "l0_stockplot", "TextHero" => "l0_hero", "TextTitle" => "l0_title", "TextBody" => "l0_body", @@ -6197,6 +9225,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 @@ -6213,6 +9264,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() { @@ -6240,6 +9321,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), @@ -6247,23 +9347,183 @@ pub mod kit { _ => String::new(), }; let json = serde_json::json!({ "e": event, "k": node.key, "v": value }); + Some(format!("{:?}", 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. + /// 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 { + 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 if numeric { + format!("sys.num({call})") + } else { + call + }; + } + } + 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(), + } + } + + /// 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(t) = token_arg(node, "width") else { + return None; + }; + 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. + "fit" => Some(("l0_fit(", ")".into())), + "rank" | "day" | "temp" | "label" => 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 { + 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": "$$" }); 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 { + // 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)) + } + 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(), + field_target(node, "on_change").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 = + 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 + // 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") + // 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}, "); element_untapped(node, depth, out); out.push(')'); return; @@ -6271,12 +9531,183 @@ 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)> = [ + // 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); + } + 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; }; 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"); + // 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 = |c: &UiNode, where_: &str| { + c.kind == "Panel" + && 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..3 { + out.push_str(", ["); + let mut first = true; + 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 + // 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; + // 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); + } + } + 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)); + // 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" => { let _ = write!(out, "{f}("); children(node, depth, out); @@ -6296,7 +9727,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(')'); @@ -6313,8 +9782,34 @@ 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") { + // 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 + // 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")); + } } // 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 @@ -6322,6 +9817,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); @@ -6330,16 +9831,87 @@ 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); + // 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}, {}, {})", + 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 + // 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)); + // `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 + // 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 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 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 + // range nobody supplied is a bar drawn against zero, and it looks + // like data. + "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"], + }; + // 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 // what will be DRAWN — a live value's text is not in the DSL. @@ -6349,11 +9921,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/fixtures/nav.card b/crates/splash-ui-l0/tests/fixtures/nav.card new file mode 100644 index 0000000..71a4c19 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/nav.card @@ -0,0 +1,693 @@ +# 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, see the route, then drive it — 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. +# - 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. + +# 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: [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]) +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 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. +# 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]) + +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. +# +# `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, eta]) +source env.locale sys.locale() + +# 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 ⇒ 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 + +# 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. +# 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. +# +# `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. +# 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. +# +# 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. +# +# 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 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 + +# 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 } + +# 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) { + 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 } + +# 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 +# `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), 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, 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, 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) } +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), 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…" } +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" } +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" } +copy plus { 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 ------------------------------------------------------------ + 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". + # 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 { + # 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() + 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() + 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 + # 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 + # are the pickable items — tap the origin while the destination is empty and the + # trip reverses. + # + # 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 { + 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) } + } + } + } + + # 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 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 { Hit(f: f, pick: choose_dest) } + } + } + } + + # 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. + + + # 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. + + # ---- the DIRECT trip --------------------------------------------------- + when dest != "" { + 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 { + # 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. + # 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) + } + } + } + } + when origin == "" { + when trip_here.$state == .pending { TextBody(text: copy.seeking) } + when trip_here.$state == .ready { + # 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. + # 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) + } + } + } + } + # 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. + when origin != "" { + 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. + 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 -------------------------------------- + # + # 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, width: .label) + TextCaption(value: leg_b.duration) + TextCaption(value: leg_b.distance) + } + } + Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, + controls: .all, summary: trip_via) + } + } + } + + + # ---- driving ------------------------------------------------------------- + when screen == .drive { + # 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. + when origin != "" { TextBody(text: step.instruction, width: .fill) } + when origin == "" { TextBody(text: step_here.instruction, width: .fill) } + } + + # 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) } + } + + # 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) { + # 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. + 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`. + 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 + # `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. + # The route is still the declared trip; only the camera follows the driver. + # `.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. + # 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. + # + # 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. + # `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: 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, controls: .all) + } + } +} diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card index ade95a5..2e984fd 100644 --- a/crates/splash-ui-l0/tests/fixtures/news.card +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -19,26 +19,62 @@ 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]) +# 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 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) } +# 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) } +# 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 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: "关注" } +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: "评论" } 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 @@ -95,10 +131,48 @@ 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. + 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: "‹") } + # 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) + } 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 c9d3d23..71cc3f4 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -14,15 +14,54 @@ 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]) +# 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]) state selected { shape: text, initial: "" } # "" ⇒ movers list -state range { shape: enum[d1, w1, m1, m6, y1], initial: .m1 } +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 set_range { range: set($value) } +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) } +# 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), 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 } -copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +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 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: "开盘" } copy high { class: vocabulary, en: "High", zh: "最高" } copy low { class: vocabulary, en: "Low", zh: "最低" } @@ -36,11 +75,60 @@ 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 { + # 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) + } + Reveal { + Chip(text: copy.unstar, on_tap: forget, value: w.ticker, tone: .danger, width: .fit) + } + } + } TextTitle(text: copy.movers) Panel { + # 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, on_tap: open_quote, value: m.ticker) { - Col(gap: 2) { + 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) } @@ -52,8 +140,26 @@ 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: "‹") } + # 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) } + 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) } header chart ranges diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 59461c1..2706649 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]) @@ -22,16 +22,43 @@ 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) } +# 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 add_city { editing: set(.add), query: clear } +event typing { query: set($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: "体感" } @@ -39,25 +66,79 @@ 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 visibility { class: vocabulary, en: "Visibility", zh: "能见度" } +copy precip { class: vocabulary, en: "Rain", 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: "日落" } +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 +# "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 + saved forecast + cloudfield airfield sunmoon details } +# 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 { + 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) + } + } + } + } + } + } + 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) - 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 +179,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) @@ -120,6 +209,11 @@ 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) - Tile(label: copy.visibility, value: now.visibility, unit: .distance) + # 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. + 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 aecc59f..8b8d71e 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}" ); } @@ -683,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}"); @@ -960,24 +966,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}" @@ -1108,6 +1117,8 @@ 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: 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\""), "instance key:\n{notify}" @@ -2083,6 +2094,93 @@ 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"), + ("writes = [", "writes"), + ] { + 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")), + // 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!( + 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" + ); + } + // 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 ────── // // Every one of these parsed cleanly and then lost the value. That is the single @@ -2463,6 +2561,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, } }; @@ -3370,6 +3469,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. @@ -4345,10 +4514,4253 @@ 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: 28"), + "sized by the drawn value (7 glyphs -> 28pt), 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)), + ] { + // `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(r#"+ " 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); +} + +/// `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}" + ); +} + +/// 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}" + ); + // 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 + // arrives" would have passed on it. + // + // 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. +/// +/// 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"); + + // 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 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. + // + // 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 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 + // evidence against it. + let lines = NAV + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !t.starts_with('#') + }) + .count(); + // 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" + ); +} + +/// 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}" + ); +} + +/// 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) + ); +} + +/// §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 + ); +} + +/// §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 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( + 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 + ); + + // 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, "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!["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]; + 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" + ); + assert!(out.stale.contains(&"cities".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 looking at the added city" + ); +} + +/// `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 `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, +/// 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}" + ); +} + +// ─────────────────────────────────────────────────────────────────────── 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}" + ); +} + +/// 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:?}" + ); +} + +/// 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)); +} + +/// 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}" + ); +} + +/// 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}" + ); +} + +// ─── 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. + +/// 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 { + Token(set) | TokenOrPath(set) => { + if set.len() < 2 { + return None; + } + 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 => 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 => 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, + }) +} + +/// 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"), + // `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, 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"), + // `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 + // `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"), + // `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 + // 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] +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(candidates) = probe_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)| { + 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}")]; + 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) + )) + }; + // 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 outputs.iter().all(|o| *o == outputs[0]) { + 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"]; + // `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(); + 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)] = &[]; + +/// 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 — 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: \"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!( + 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}" + ); + // 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(\"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. + 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}" + ); + } + + // ── `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}" + ); + // 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. + 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}" + ); +} + +/// 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 +/// 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:#?}" + ); +} + +/// 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) + ); +} + +/// 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" + ); +} + +/// 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`" + ); + } +} + +/// 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}" + ); +} + +/// 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}" + ); +} + +/// 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. + // Coerced, because `sys.satellite` takes coordinates as NUMBERS and every + // helper answers with a string. + assert!( + kit.contains("l0_satellite(sys.num(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}" + ); +} + +/// 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}" + ); +} + +/// 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"), + // ── 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. + // 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.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"), + // ── 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()), + // 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(), + }) + .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(); + // 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 \ + 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); + + // NO field on the resting sheet, and a TAP TARGET on each endpoint. + // + // 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(), + 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); + 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(), + 1, + "and a stop, one tap away:\n{opened}" + ); + // 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}" + ); +} + +/// 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:#?}" + ); +} + +/// 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}" + ); + // 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( + &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}" + ); +} + +/// 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}" + ); +} + +/// 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}" + ); + } + } +} + +/// 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:?}" + ); + } +} + +/// 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}" + ); +} + +/// 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}" + ); +} + +/// 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}" + ); + } +} + +/// 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}" + ); +} + +/// A card's map must survive a data blob that has not answered yet. +/// +/// 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 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_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", + "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: .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); + + // 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_eq!( + dsl.matches("l0_map(").count(), + 1, + "a map whose position has not arrived is still a map:\n{dsl}" + ); + assert!( + dsl.contains("sys.gps(\"lat\")"), + "and it still follows the declared position:\n{dsl}" + ); +} + +/// 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"); +} + +/// 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}" + ); + } +} + +/// §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 +/// 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}" + ); +} + +/// 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" + ); +} + +/// 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. +/// +/// **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!( + "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}" + ); +} + +/// 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}" + ); +} + +/// 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}"); +} + +/// 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 + ); +} + +/// 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!( - hero.contains("font_size: 40"), - "sized by the drawn value (7 glyphs -> 40pt), not the emitted 33:\n{hero}" + dsl.matches("\"min\")").count() >= 2, + "the badge and the summary must come from one trip:\n{dsl}" ); } diff --git a/docs/roadmap.md b/docs/roadmap.md index f676002..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. 85 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. @@ -430,15 +430,58 @@ 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. -- **`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. + 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 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 diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index f8d4271..de0a00d 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -33,17 +33,94 @@ 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" } +# 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 +# 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 = "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 +# 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"] } +# 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. +# 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" } + +# 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" } +# 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. +# +# 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", "right"] } [Card] 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" } +# `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" } @@ -52,6 +129,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 ─────────────────────────────────────────────────────────────── @@ -113,6 +202,13 @@ 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", "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 — @@ -158,6 +254,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" } @@ -177,16 +279,33 @@ 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. +# +# `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"] +tokens = ["c", "f", "pct", "speed", "pressure", "distance", "money", "duration"] [kinds.format] 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"] +tokens = ["fill", "fit", "day", "rank", "temp", "label"] # ─── source capabilities ────────────────────────────────────────────────────── # The CLOSED set of helpers a `source` may name. Like the constructor catalog @@ -208,36 +327,191 @@ 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. +# +# `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. +# The device's own position. No arguments: a card does not get to ask where +# 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", "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 +# 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_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. +# +# 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", "eta"] + +[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"] -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"] +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"] +# `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) ───────────────────────────────────── +# 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. +# 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"] + +# 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"] + +# 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"] +# `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 +# 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"] +# `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 +# 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 cb92c31..de526fb 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -26,6 +26,154 @@ 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, +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. + +**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 +`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 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 or a commit | +| **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 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: + +- **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 +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 @@ -91,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 @@ -627,6 +781,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 @@ -705,6 +862,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` @@ -794,6 +955,207 @@ 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. + +--- + +### 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 @@ -898,7 +1260,8 @@ 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 | Questions 1–7 are settled; 8 and 9 were opened by working through where state lives. What @@ -923,17 +1286,24 @@ 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 — 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 @@ -978,3 +1348,210 @@ 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). + +**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: + +``` +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. + +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 + +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.**~~ **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.**~~ **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.**~~ **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. +- ~~**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 + 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.