diff --git a/Cargo.lock b/Cargo.lock index 2694b69..4e15d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,6 +734,7 @@ version = "0.1.0" dependencies = [ "makepad-script", "serde_json", + "splash-ui-l0", ] [[package]] @@ -812,6 +813,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "splash-ui-l0" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "splash-worker" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8b28598..9b244fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/splash-core", + "crates/splash-ui-l0", "crates/splash-linux-seccomp", "crates/splash-linux-project-quota", "crates/splash-capabilities", diff --git a/crates/splash-core/Cargo.toml b/crates/splash-core/Cargo.toml index 9e6ec54..8ef596c 100644 --- a/crates/splash-core/Cargo.toml +++ b/crates/splash-core/Cargo.toml @@ -10,5 +10,6 @@ description = "Bounded, host-neutral Splash VM runtime" workspace = true [dependencies] +splash-ui-l0 = { path = "../splash-ui-l0" } makepad-script = { path = "../../vendor/makepad/platform/script" } serde_json = "1.0.150" diff --git a/crates/splash-core/src/lib.rs b/crates/splash-core/src/lib.rs index aa2e8d4..c8fedf5 100644 --- a/crates/splash-core/src/lib.rs +++ b/crates/splash-core/src/lib.rs @@ -8,6 +8,14 @@ //! Rust code. mod profile; +/// The UI profile, re-exported so existing paths keep working. +/// +/// It lives in `splash-ui-l0` now: it depends on `serde_json` alone, and an +/// application carrying a different makepad lineage cannot depend on THIS crate +/// at all — Cargo refuses the lockfile, since both ship `makepad-error-log +/// v1.0.0` at different paths. L0 never needed the vendored VM; it inherited it +/// by living here. +pub use splash_ui_l0 as ui_l0; use std::any::Any; use std::cell::Cell; diff --git a/crates/splash-ui-l0/Cargo.toml b/crates/splash-ui-l0/Cargo.toml new file mode 100644 index 0000000..9bc3a25 --- /dev/null +++ b/crates/splash-ui-l0/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "splash-ui-l0" +version = "0.1.0" +edition = "2021" +description = "UI Profile Level 0: check, realize and dispatch LLM-authored Splash cards" +license = "MIT OR Apache-2.0" + +# serde_json and nothing else — deliberately. +# +# L0 has no expression form, so there is nothing to evaluate and no VM in the +# path. Keeping the dependency list at one crate is what makes that claim +# checkable rather than asserted, and what lets a host adopt L0 without adopting +# a runtime: `splash-core` depends on a vendored makepad-script, and any app +# already carrying a different makepad lineage cannot depend on it at all — +# Cargo rejects the lockfile, because both ship `makepad-error-log v1.0.0` at +# different paths. That collision is the reason this crate exists. +[dependencies] +serde_json = "1" + +[dev-dependencies] +serde_json = "1" diff --git a/crates/splash-ui-l0/examples/dirty_set.rs b/crates/splash-ui-l0/examples/dirty_set.rs new file mode 100644 index 0000000..e56475d --- /dev/null +++ b/crates/splash-ui-l0/examples/dirty_set.rs @@ -0,0 +1,38 @@ +//! How much of a card a state write actually touches. +//! +//! Usage: dirty_set … + +use splash_ui_l0::{dirty_records, patch_points, record_dependencies}; + +fn main() { + let mut a = std::env::args().skip(1); + let path = a.next().expect("usage: dirty_set …"); + let changed: Vec = a.collect(); + let card = std::fs::read_to_string(&path).expect("card"); + + let all = record_dependencies(&card); + println!("{} records", all.len()); + for d in &all { + println!(" {:<14} reads {}", d.record, d.reads.join(", ")); + } + for c in &changed { + let dirty = dirty_records(&card, &[c.as_str()]); + println!( + "\nwriting {c:?} dirties {}/{} records: {}", + dirty.len(), + all.len(), + dirty.join(", ") + ); + let minimal = patch_points(&card, &[c.as_str()]); + println!( + " minimal patch points: {}/{} -> {}", + minimal.len(), + all.len(), + if minimal.is_empty() { + "(none)".into() + } else { + minimal.join(", ") + } + ); + } +} diff --git a/crates/splash-ui-l0/examples/lower_l0.rs b/crates/splash-ui-l0/examples/lower_l0.rs new file mode 100644 index 0000000..01c8b00 --- /dev/null +++ b/crates/splash-ui-l0/examples/lower_l0.rs @@ -0,0 +1,34 @@ +//! Realize an L0 card against a data snapshot and print the Makepad DSL. +//! +//! Usage: cargo run -p splash-core --example lower_l0 -- +//! +//! The data stands in for what the runtime would have fetched. Realization is a +//! tree walk, so this never evaluates anything and reaches nothing. + +use splash_ui_l0::{makepad, realize, RealizeLimits}; + +fn main() { + let mut args = std::env::args().skip(1); + let card_path = args.next().expect("usage: lower_l0 "); + let data_path = args.next().expect("usage: lower_l0 "); + + let card = std::fs::read_to_string(&card_path).expect("card"); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&data_path).expect("data")).expect("json"); + + let report = realize(&card, &data, RealizeLimits::default()); + for d in &report.diagnostics { + eprintln!("{}:{}:{}: {}", card_path, d.line, d.column, d.message); + } + match report.root { + Some(root) => { + eprintln!( + "realized {} nodes{}", + report.nodes, + if report.truncated { " (truncated)" } else { "" } + ); + print!("{}", makepad::lower(&root)); + } + None => std::process::exit(1), + } +} diff --git a/crates/splash-ui-l0/examples/source_plan.rs b/crates/splash-ui-l0/examples/source_plan.rs new file mode 100644 index 0000000..a993c92 --- /dev/null +++ b/crates/splash-ui-l0/examples/source_plan.rs @@ -0,0 +1,61 @@ +//! Print a card's source plan as JSON, for a host to fulfil. +//! +//! Usage: source_plan +//! +//! splash-core says WHAT to fetch and in what order; it never fetches. Only a +//! host knows what answers `sys.weather`. + +use splash_ui_l0::{source_plan, SourceArg}; + +fn arg_json(a: &SourceArg) -> String { + match a { + SourceArg::Path(p) => format!(r#"{{"kind":"path","value":{p:?}}}"#), + SourceArg::Text(t) => format!(r#"{{"kind":"text","value":{t:?}}}"#), + SourceArg::Number(n) => format!(r#"{{"kind":"number","value":{n}}}"#), + SourceArg::List(items) => format!( + r#"{{"kind":"list","value":[{}]}}"#, + items + .iter() + .map(|i| format!("{i:?}")) + .collect::>() + .join(",") + ), + } +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: source_plan "); + let plan = source_plan(&std::fs::read_to_string(&path).expect("card")); + + let requests: Vec = plan + .requests + .iter() + .map(|r| { + let args: Vec = r + .args + .iter() + .map(|(n, a)| format!("[{n:?},{}]", arg_json(a))) + .collect(); + let deps: Vec = r.depends_on.iter().map(|d| format!("{d:?}")).collect(); + format!( + r#"{{"name":{:?},"helper":{:?},"args":[{}],"depends_on":[{}]}}"#, + r.name, + r.helper, + args.join(","), + deps.join(",") + ) + }) + .collect(); + + let diags: Vec = plan + .diagnostics + .iter() + .map(|d| format!(r#"{{"line":{},"message":{:?}}}"#, d.line, d.message)) + .collect(); + + println!( + r#"{{"requests":[{}],"diagnostics":[{}]}}"#, + requests.join(","), + diags.join(",") + ); +} diff --git a/crates/splash-ui-l0/examples/tap_l0.rs b/crates/splash-ui-l0/examples/tap_l0.rs new file mode 100644 index 0000000..b47328d --- /dev/null +++ b/crates/splash-ui-l0/examples/tap_l0.rs @@ -0,0 +1,33 @@ +//! Realize a card, dispatch one event, realize again — the interaction loop. +//! +//! Usage: tap_l0 [payload] +//! +//! Proves the loop end to end without a UI: state lives in the store, the event +//! is a declared name, and the second realization reflects the write. + +use splash_ui_l0::{dispatch_with, makepad, realize_with_state, InstanceStore, RealizeLimits}; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let (card_path, data_path, event) = (&a[0], &a[1], &a[2]); + let payload = a.get(3).map(|v| serde_json::Value::String(v.clone())); + + let card = std::fs::read_to_string(card_path).expect("card"); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(data_path).expect("data")).expect("json"); + let mut store = InstanceStore::default(); + + let before = realize_with_state(&card, &data, &store, RealizeLimits::default()); + eprintln!("before: {} nodes", before.nodes); + + let applied = dispatch_with(&card, &mut store, "root", event, payload.as_ref()); + eprintln!("dispatch {event:?} -> {applied}"); + + let after = realize_with_state(&card, &data, &store, RealizeLimits::default()); + eprintln!("after: {} nodes", after.nodes); + // Forget instances that went away, so the store tracks the live tree. + store.prune(&after.live_keys); + eprintln!("store: {} live cells", store.len()); + + print!("{}", makepad::lower(&after.root.expect("root"))); +} diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs new file mode 100644 index 0000000..7a446bc --- /dev/null +++ b/crates/splash-ui-l0/src/lib.rs @@ -0,0 +1,5685 @@ +//! **UI Profile Level 0** — a canonical Splash profile for LLM-authored UI. +//! +//! A generated card is untrusted source. L0 admits UI constructs while admitting +//! nothing that can reach a capability: constructors, bindings, keyed loops, +//! guarded branches, and components with declared local state, and **no +//! expression form at all**. With nothing to evaluate, realization is a walk +//! over the parsed tree with data substituted, and this crate contains no +//! reference to a VM. Confinement is structural rather than sandboxed. +//! +//! The normative profile is `docs/ui-profile-l0.md`; the closed constructor and +//! capability catalog is `docs/ui-l0-constructors.toml`. Where this code and +//! those documents disagree, the documents win — and a test asserts the catalog +//! agrees with the Rust table in both directions. +//! +//! # Why this is its own crate +//! +//! It depends on `serde_json` and nothing else, so any host can adopt L0 without +//! adopting a runtime. That is not a stylistic preference: `splash-core` carries +//! a vendored `makepad-script`, and an application already using a different +//! makepad lineage cannot depend on it — Cargo refuses the lockfile, because +//! both ship `makepad-error-log v1.0.0` at different paths. L0 never needed +//! that dependency; it inherited it by living in the same crate. +//! +//! Level 0 of the UI profile — parser and validator for generated card source. +//! +//! See [`docs/ui-profile-l0.md`](../../../docs/ui-profile-l0.md) for the normative +//! grammar and [`docs/ui-l0-constructors.toml`](../../../docs/ui-l0-constructors.toml) +//! for the argument contract. The catalog in [`catalog`] is the implementation's +//! copy of that TOML; a test asserts the two agree. +//! +//! ## What this establishes, and what it does not +//! +//! Two properties are decided here: **membership in the L0 grammar**, and **absence +//! of any authority-bearing operation**. The second is structural rather than +//! enforced — the grammar has no syntax for a module access or a call, so there is +//! nothing to reject; the checks for `mod.` and `ui..` exist to classify +//! such source as a higher level with a useful diagnostic, not to hold a boundary. +//! +//! Termination is **not** established. It needs the five conditions in §6 of the +//! profile, of which this checks two: the component graph is acyclic, and iteration +//! is over a bound path rather than an expression. Collection length, node count and +//! nesting depth are the runtime's to bound. +//! +//! Everything is effect-free and bounded: no evaluation, no imports, no host access. + +/// One one-based location. Mirrors `splash_core::SyntaxDiagnostic` in shape; +/// separate so this crate stands alone. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxDiagnostic { + pub line: usize, + pub column: usize, + pub message: String, +} +/// Largest source this profile will read. Matches `splash-core`'s bound, so a +/// card refused there is refused here for the same reason. +pub const DEFAULT_MAX_SOURCE_BYTES: usize = 256 * 1024; +/// Maximum delimiter nesting. Bounds parse recursion — see the deep-path tests, +/// which exist because unbounded recursion aborted the process on a 60 KB card. +pub const DEFAULT_MAX_SYNTAX_NESTING: usize = 128; + +/// The narrowest profile a source belongs to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Level { + /// Constructors, bindings, keyed loops, guarded branches, components with + /// declared local state. No expression form, no reachable capability. + L0, + /// Adds pure expressions. Not accepted by this checker. + L1, + /// Full Splash, including imperative widget commands. Not accepted here. + L2, +} + +/// Result of checking source against the L0 profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiL0Report { + pub valid: bool, + /// The narrowest level the source belongs to. `valid` is true only for + /// [`Level::L0`]. + pub level: Level, + pub diagnostics: Vec, + pub diagnostics_truncated: bool, + /// The header a card declares — profile §2. `None` when it has none. + pub header: Option, + /// Profile §7: every component the card depends on, with a digest of its + /// definition, sorted by name. + /// + /// This is what a host pins. `level` is a judgment over the transitive + /// component closure, so it is only as durable as the definitions it was + /// computed from — replace one and an approved L0 card can silently become + /// L2. Storing this alongside the approved level makes that detectable: + /// a digest that moved means the level must be re-derived before the card + /// is accepted, rather than inherited from the record it no longer matches. + pub closure: Vec<(String, u64)>, +} + +/// What a card's header declares — ledger name, version, level and profile. +/// +/// §7 says "level is declared in the header and checked at append", and nothing +/// read it: every `#` line lexed as a comment, so a card could declare +/// `# level: L2` and be accepted and realized as L0. A declared level that is +/// never compared to the derived one is not a check, it is a comment. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CardHeader { + pub ledger: Option, + pub version: Option, + pub level: Option, + pub profile: Option, + pub model: Option, + /// Fields declared more than once with different values. Last-wins made + /// `# level: L2` followed by `# level: L0` accepted, so a card could + /// declare whatever it needed and then declare the truth after it. + pub contradictions: Vec, +} + +/// Read the header from the leading `#` lines, which the lexer discards. +pub fn parse_header(source: &str) -> Option { + let mut header = CardHeader::default(); + let mut saw_any = false; + for line in source.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix('#') else { + if line.is_empty() { + continue; + } + break; // The header is the leading comment block only. + }; + let rest = rest.trim(); + if let Some(decl) = rest.strip_prefix("ledger ") { + saw_any = true; + let (name, version) = match decl.split_once('@') { + Some((n, v)) => (n.trim().to_string(), Some(v.trim().to_string())), + None => (decl.trim().to_string(), None), + }; + // A card has ONE identity. Last-wins let a record declare two. + match (&header.ledger, &header.version) { + (Some(first), v) if *first != name || *v != version => { + header.contradictions.push(format!( + "the header declares the ledger twice, as {first:?} and {name:?}" + )); + } + _ => { + header.ledger = Some(name); + header.version = version; + } + } + } else if let Some((key, value)) = rest.split_once(':') { + let value = value.trim().to_string(); + match key.trim() { + "level" => { + saw_any = true; + match &header.level { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares level twice, as {first:?} and {value:?}" + )); + } + _ => header.level = Some(value), + } + } + "profile" => { + saw_any = true; + match &header.profile { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares profile twice, as {first:?} and {value:?}" + )); + } + _ => header.profile = Some(value), + } + } + "model" => { + // Presence counts: a header of only `model:` lines used to + // return None, taking any contradiction in it along too. + saw_any = true; + match &header.model { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares the model twice, as {first:?} and {value:?}" + )); + } + _ => header.model = Some(value), + } + } + _ => {} + } + } + } + saw_any.then_some(header) +} + +/// Diagnostics retained before truncation, so a pathological source cannot grow +/// the report without bound. +pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; + +/// Maximum declarations retained from one card. +pub const MAX_UI_L0_DECLARATIONS: usize = 1_024; + +/// Check source against the L0 profile. +pub fn check_ui_l0(source: &str) -> UiL0Report { + check_ui_l0_named("card.l0", source) +} + +/// Check source against the L0 profile, naming it for diagnostics. +pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { + let mut sink = Diagnostics::default(); + let header = parse_header(source); + + if source.len() > DEFAULT_MAX_SOURCE_BYTES { + sink.push( + 1, + 1, + format!( + "source is {} bytes, over the {DEFAULT_MAX_SOURCE_BYTES}-byte limit", + source.len() + ), + ); + return sink.into_report(Level::L0); + } + + let tokens = match lex(source, &mut sink) { + Some(tokens) => tokens, + None => return sink.into_report(Level::L0), + }; + + // A construct outside the grammar tells us the level before parsing can + // finish, and a level diagnostic is far more useful than "unexpected token". + 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; + } + + let mut parser = Parser::new(&tokens, &mut sink); + let card = parser.parse_card(); + if sink.any() { + return sink.into_report(Level::L0); + } + + validate(&card, &mut sink); + let mut report = sink.into_report(Level::L0); + report.closure = component_closure(&card); + report.header = header.clone(); + check_header(&header, report.level, &mut report); + report +} + +/// §7: a declared level must match the derived one, and escalation is never +/// silent. Reading the header without comparing it would leave the declaration +/// decorative — which is what it was. +fn check_header(header: &Option, derived: Level, report: &mut UiL0Report) { + let Some(header) = header else { return }; + + for message in &header.contradictions { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: message.clone(), + }); + } + + if let Some(declared) = &header.level { + let matches = match declared.trim() { + "L0" => derived == Level::L0, + "L1" => derived == Level::L1, + "L2" => derived == Level::L2, + _ => false, + }; + if !matches { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "the header declares level {declared}, but the card requires {derived:?} \ + — a record needing a wider grammar is rejected until the level is \ + explicitly raised (profile §7)" + ), + }); + } + } + + // The profile names which grammar the card was written against. A card + // claiming a profile this checker does not implement must not be silently + // accepted as though it had claimed this one. + if let Some(profile) = &header.profile { + if profile.trim() != "ui/l0" { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "the header declares profile {profile:?}, but this checker implements \ + \"ui/l0\"" + ), + }); + } + } +} + +/// A digest per component definition, sorted by name — profile §7. +/// +/// The digest covers everything that can move a card's level or change what it +/// renders: params, state, events and the whole view body. It deliberately does +/// NOT cover the component's position in the file, so reordering declarations is +/// not a version change. +fn component_closure(card: &Card) -> Vec<(String, u64)> { + let mut out: Vec<(String, u64)> = card + .components + .iter() + .map(|c| (c.name.clone(), definition_digest(c))) + .collect(); + out.sort(); + out +} + +fn definition_digest(component: &Component) -> u64 { + fn element(e: &Element, into: &mut String) { + into.push_str(&e.name); + into.push('('); + for b in &e.binders { + into.push_str(b); + into.push(','); + } + for a in &e.args { + into.push_str(&a.name); + into.push(':'); + into.push_str(&format!("{:?}", a.value)); + into.push(','); + } + if let Some(cmp) = &e.cmp { + into.push_str(cmp); + } + if let Some(rhs) = &e.rhs { + into.push_str(&format!("{rhs:?}")); + } + into.push('|'); + for child in &e.children { + element(child, into); + } + into.push(')'); + } + + let mut acc = String::new(); + acc.push_str(&component.name); + for p in &component.params { + acc.push_str(&format!("{}:{:?}:{:?}", p.name, p.shape, p.default)); + acc.push(','); + } + for st in &component.states { + acc.push_str(&format!( + "{}:{:?}:{:?}:{:?}:{}", + st.path, st.shape, st.initial, st.initial_path, st.keep + )); + } + for ev in &component.events { + acc.push_str(&ev.name); + for t in &ev.transitions { + acc.push_str(&format!("{}={:?}{:?}", t.target, t.form, t.tokens)); + } + } + element(&component.body, &mut acc); + + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in acc.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + hash +} + +// ─────────────────────────────────────────────────────────────────── diagnostics ── + +#[derive(Default)] +struct Diagnostics { + items: Vec, + truncated: bool, +} + +impl Diagnostics { + fn push(&mut self, line: usize, column: usize, message: String) { + if self.items.len() >= MAX_UI_L0_DIAGNOSTICS { + self.truncated = true; + return; + } + self.items.push(SyntaxDiagnostic { + line, + column, + message, + }); + } + + fn at(&mut self, t: &Token, message: String) { + self.push(t.line, t.column, message); + } + + fn any(&self) -> bool { + !self.items.is_empty() + } + + fn into_report(self, level: Level) -> UiL0Report { + UiL0Report { + header: None, + closure: Vec::new(), + valid: self.items.is_empty() && level == Level::L0, + level, + diagnostics: self.items, + diagnostics_truncated: self.truncated, + } + } +} + +// ──────────────────────────────────────────────────────────────────────── lexer ── + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Kind { + Ident, + /// A dotted token: `.fill`. Tokens are lexically marked so a bare identifier + /// is always a path — see profile §2.2. + Token, + Str, + Num, + Punct, + Cmp, +} + +#[derive(Clone, Debug)] +struct Token { + kind: Kind, + text: String, + line: usize, + column: usize, +} + +impl Token { + fn is(&self, kind: Kind, text: &str) -> bool { + self.kind == kind && self.text == text + } + fn is_kw(&self, kw: &str) -> bool { + self.is(Kind::Ident, kw) + } + fn is_punct(&self, p: &str) -> bool { + self.is(Kind::Punct, p) + } +} + +fn lex(source: &str, sink: &mut Diagnostics) -> Option> { + let bytes: Vec = source.chars().collect(); + let mut out = Vec::new(); + let (mut i, mut line, mut col) = (0usize, 1usize, 1usize); + + while i < bytes.len() { + let c = bytes[i]; + + if c == '\n' { + line += 1; + col = 1; + i += 1; + continue; + } + if c.is_whitespace() { + i += 1; + col += 1; + continue; + } + // Comments: `#` to end of line. The profile's cards use `#`; `//` is + // rejected so a Splash-syntax card cannot be mistaken for an L0 one. + if c == '#' || (c == '/' && i + 1 < bytes.len() && bytes[i + 1] == '/') { + while i < bytes.len() && bytes[i] != '\n' { + i += 1; + } + continue; + } + + let (start_line, start_col) = (line, col); + + // String literal. + if c == '"' { + let mut text = String::new(); + i += 1; + col += 1; + let mut closed = false; + while i < bytes.len() { + if bytes[i] == '"' { + i += 1; + col += 1; + closed = true; + break; + } + if bytes[i] == '\n' { + break; + } + text.push(bytes[i]); + i += 1; + col += 1; + } + if !closed { + sink.push(start_line, start_col, "unterminated string".into()); + return None; + } + out.push(Token { + kind: Kind::Str, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Dotted token, but only when a name follows — `.` between path segments + // is punctuation and is handled below. + if c == '.' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) { + // A `.` directly after an identifier, token, or `]` is a path + // separator, not a token marker. + let after_value = out + .last() + .map(|t: &Token| { + matches!(t.kind, Kind::Ident | Kind::Token | Kind::Num) || t.is_punct("]") + }) + .unwrap_or(false); + if !after_value { + let mut text = String::from("."); + i += 1; + col += 1; + while i < bytes.len() && is_ident_continue(bytes[i]) { + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Token, + text, + line: start_line, + column: start_col, + }); + continue; + } + } + + // Comparators. + if matches!(c, '=' | '!' | '<' | '>') { + let two = i + 1 < bytes.len() && bytes[i + 1] == '='; + let text: String = if two { + let s: String = bytes[i..i + 2].iter().collect(); + i += 2; + col += 2; + s + } else { + i += 1; + col += 1; + c.to_string() + }; + // A lone `=` or `!` is punctuation; only the two-character forms are + // comparators. Without this `!x` lexes as Cmp and the level + // classifier's punctuation check never fires. + if !two { + out.push(Token { + kind: Kind::Punct, + text, + line: start_line, + column: start_col, + }); + } else { + out.push(Token { + kind: Kind::Cmp, + text, + line: start_line, + column: start_col, + }); + } + continue; + } + + // Number. + if c.is_ascii_digit() { + let mut text = String::new(); + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { + // Stop at a `.` that starts a name — `7.foo` is a path, not a float. + if bytes[i] == '.' && (i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_digit()) { + break; + } + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Num, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Identifier. + if is_ident_start(c) { + let mut text = String::new(); + while i < bytes.len() && is_ident_continue(bytes[i]) { + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Ident, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Punctuation, including operators the grammar does not admit. Those are + // kept as tokens so `classify_beyond_l0` can report them as L1 rather + // than as a lex failure. + if "{}()[]:,.$+-*/%?|&".contains(c) { + i += 1; + col += 1; + out.push(Token { + kind: Kind::Punct, + text: c.to_string(), + line: start_line, + column: start_col, + }); + continue; + } + + sink.push(start_line, start_col, format!("unexpected character {c:?}")); + return None; + } + + Some(out) +} + +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' +} + +fn is_ident_continue(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +// ───────────────────────────────────────────────────────────── level classifier ── + +/// Report the level implied by any construct outside L0, before parsing. +/// +/// Level is a property of the whole card: the maximum any record requires +/// (profile §7). Component-closure resolution happens in [`validate`]; this is +/// the lexical half. +fn classify_beyond_l0(tokens: &[Token]) -> Option<(Level, SyntaxDiagnostic)> { + let mut worst: Option<(Level, SyntaxDiagnostic)> = None; + let mut keep = |found: Option<(Level, SyntaxDiagnostic)>| { + if let Some((level, diag)) = found { + let better = match &worst { + Some((Level::L2, _)) => false, + Some((Level::L1, _)) => level == Level::L2, + _ => true, + }; + if better { + worst = Some((level, diag)); + } + } + }; + for (i, t) in tokens.iter().enumerate() { + let bad = |level: Level, msg: &str| { + Some(( + level, + SyntaxDiagnostic { + line: t.line, + column: t.column, + message: msg.into(), + }, + )) + }; + + if t.is_kw("fn") { + keep(bad( + Level::L2, + "`fn` is not in L0: a function reintroduces unbounded work", + )); + } + if t.is_kw("while") { + keep(bad( + Level::L2, + "`while` is not in L0: iteration must be over a declared collection", + )); + } + if t.is_kw("let") || t.is_kw("var") { + keep(bad( + Level::L2, + "`let` is not in L0: state is declared, not bound imperatively", + )); + } + if t.is_kw("mod") && tokens.get(i + 1).is_some_and(|n| n.is_punct(".")) { + keep(bad( + Level::L2, + "module access is not in L0: the host surface is empty", + )); + } + // `ui..` — an imperative widget command. + if t.is_kw("ui") + && tokens.get(i + 1).is_some_and(|n| n.is_punct(".")) + && tokens.get(i + 3).is_some_and(|n| n.is_punct(".")) + { + keep(bad( + Level::L2, + "imperative widget commands are L2: L0 declares a tree", + )); + } + if t.kind == Kind::Punct && matches!(t.text.as_str(), "+" | "*" | "/" | "%" | "?") { + keep(bad( + Level::L1, + "arithmetic and conditional expressions are not in L0: an expression can hide a fabricated fact", + )); + } + // A copy class is a hyphenated NAME, not a subtraction: `class: + // user-copy` lexes as `user`, `-`, `copy`, and reading that as + // arithmetic made the two spellings §4 depends on unusable — the + // provenance rule could not be written in a card at all. + let is_class_name = tokens + .get(i.wrapping_sub(3)) + .is_some_and(|k| k.text == "class") + && tokens.get(i.wrapping_sub(1)).is_some_and(|p| { + p.kind == Kind::Ident && matches!(p.text.as_str(), "user" | "model") + }); + + // `-` is only rejected between values; a negative literal is fine. + if t.is_punct("-") + && !is_class_name + && tokens + .get(i.wrapping_sub(1)) + .is_some_and(|p| matches!(p.kind, Kind::Ident | Kind::Num | Kind::Token)) + { + keep(bad(Level::L1, "arithmetic is not in L0")); + } + if t.is_punct("!") { + keep(bad( + Level::L1, + "negation is computation: use the `toggle` transition form", + )); + } + } + worst +} + +// ────────────────────────────────────────────────────────────────────────── ast ── + +#[derive(Debug, Default)] +struct Card { + sources: Vec, + states: Vec, + events: Vec, + /// `copy` declarations: name -> locale -> text. Authored in the ledger, so + /// they resolve from the CARD rather than the injected data. Carrying them + /// in the data blob duplicated what the card already said, and the two + /// silently drifted. + copies: Vec, + components: Vec, + views: Vec, +} + +/// A declared capability dependency: the name it binds, the helper that answers +/// it, and the arguments. Parsed rather than skipped — a card that declares what +/// data it needs is useless if nothing can read the declaration. +#[derive(Clone, Debug)] +struct SourceDecl { + name: String, + helper: String, + args: Vec<(String, SourceArg)>, + line: usize, +} + +/// One argument to a capability query. None of these is computed. +#[derive(Clone, Debug, PartialEq)] +pub enum SourceArg { + /// A path into another source, card state, or a declared value. + Path(String), + Text(String), + Number(f64), + /// `fields: [temp, hi, lo]` — which fields the card needs back. + List(Vec), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Shape { + Text, + Number, + Bool, + Enum(Vec), + /// A structured value a source returns — §5.2, a component prop shape. + Record, + /// An iterable a `for` walks. + Collection, + /// An event passed in, so a component can report outward (§5.4). + Event, + /// A shape the declaration did not name. + /// + /// `Text` and `Number` used to land here too, which made them + /// indistinguishable: retyping a state between them changed neither the + /// schema id nor the closure digest, so live values survived a change that + /// should have reset them. + Other, +} + +/// Authored text, with the provenance that decides whether it may be rendered. +/// +/// The provenance was parsed and thrown away — and worse, `parse_locale_map` +/// only kept entries whose value was a STRING, so `class: vocabulary` (an +/// identifier) was dropped before anything could read it. §4's "a literal in a +/// data position must be vocabulary or source-derived" was therefore not merely +/// unenforced but undecidable: nothing recorded which kind a string was. +#[derive(Clone, Debug)] +struct CopyDecl { + name: String, + provenance: Provenance, + locales: Vec<(String, String)>, +} + +/// Who wrote a piece of text, and therefore whether it can be trusted on screen. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Provenance { + /// Fixed interface language — labels, units, headings. Translatable, and + /// carries no claim about the world. + #[default] + Vocabulary, + /// Text the user wrote. Theirs to be wrong about. + UserCopy, + /// Text the MODEL wrote. May not appear in a rendering position: this is + /// the class §4 exists to keep off the screen, because a model-authored + /// string is indistinguishable from a fact it invented. + ModelCopy, +} + +/// A component's declared input — §5.2. +/// +/// The shape was parsed and dropped, which cost three separate defects: an +/// unknown argument at a call site could not be told from a known one, an event +/// prop could not be told from a value, and every param had to be treated as a +/// possible event name because none could be ruled out. +#[derive(Clone, Debug)] +struct Param { + name: String, + shape: Shape, + /// `title: text = "Safe"`. Dropped, an omitted argument created no binding + /// at all and lookup fell through to top-level injected data — so an + /// unfilled prop could capture whatever the host happened to supply under + /// the same name. + default: Option, +} + +#[derive(Debug)] +struct StateDecl { + path: String, + shape: Shape, + /// The declared `initial`. Parsed rather than discarded: without it a guard + /// on a freshly-mounted state compares against null and takes the wrong + /// branch on first render. + initial: Option, + /// A path-valued `initial`, e.g. `initial: env.locale.temp_unit`. Held + /// separately because it resolves against injected data at realization, + /// not at parse. Dropping it made weather.card fall back to the enum's + /// first member, so a device set to Fahrenheit still rendered Celsius. + initial_path: Option, + /// `keep: true` — profile §5.8. A schema change resets local state by + /// default; this opts one cell out, and even then only while its own shape + /// is unchanged. Preservation is never inferred from a shape that merely + /// looks compatible. + keep: bool, +} + +/// An event and the transition batch it applies. Profile §3: an event names +/// several transitions, applied atomically. +#[derive(Debug)] +struct EventDecl { + name: String, + transitions: Vec, +} + +#[derive(Debug)] +struct Transition { + target: String, + form: Form, + /// `cycle` members, which must be declared in the target's enum shape. + tokens: Vec, + line: usize, + column: usize, +} + +#[derive(Clone, Debug, PartialEq)] +enum Form { + Set(SetSource), + Toggle, + Cycle, + Clear, + /// Anything else — an expression, which L0 has no form for. + NotTotal(String), +} + +/// What `set(…)` assigns. Each is a value the runtime already holds; none is +/// computed, so the transition stays total. +#[derive(Clone, Debug, PartialEq)] +enum SetSource { + /// `$value` — the payload from the element that raised the event. + Payload, + /// A `.token`, e.g. an enum member. + Token(String), + /// A string literal. + Text(String), + /// A bound path, resolved against the data the runtime injected. + Path(String), + /// A numeric literal. Without this `set(1)` parsed as `Payload` and did + /// nothing at all when no payload was present. + Num(f64), + /// A boolean literal. + Bool(bool), +} + +#[derive(Debug)] +struct Component { + name: String, + params: Vec, + states: Vec, + events: Vec, + body: Element, + line: usize, + /// Components this one instantiates, for the acyclicity check. + instantiates: Vec, +} + +#[derive(Debug)] +struct View { + name: String, + body: Element, +} + +#[derive(Clone, Debug, Default)] +struct Element { + /// Constructor name, a referenced view/component name, or empty for a group. + name: String, + args: Vec, + children: Vec, + /// Bindings a `for` introduces: the item and the optional index. + binders: Vec, + line: usize, + column: usize, + is_reference: bool, + /// `when` guard: comparator and right-hand operand, absent for a bare bool. + cmp: Option, + rhs: Option, + /// `for` loop: the per-item key expression. Identity never comes from position. + key_path: Option, +} + +#[derive(Clone, Debug)] +struct Arg { + name: String, + value: Operand, + line: usize, + column: usize, +} + +#[derive(Clone, Debug)] +enum Operand { + Path(String), + Token(String), + Str(String), + Num(f64), + /// A comparison in argument position: `active: range == .d1`. + /// + /// Previously this kept only the left path and discarded the comparator and + /// 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. + Predicate { + path: String, + cmp: String, + rhs: Box, + }, +} + +// ─────────────────────────────────────────────────────────────────────── parser ── + +struct Parser<'a> { + tokens: &'a [Token], + at: usize, + sink: &'a mut Diagnostics, + depth: usize, +} + +impl<'a> Parser<'a> { + fn new(tokens: &'a [Token], sink: &'a mut Diagnostics) -> Self { + Self { + tokens, + at: 0, + sink, + depth: 0, + } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.at) + } + + fn next(&mut self) -> Option { + let t = self.tokens.get(self.at).cloned(); + if t.is_some() { + self.at += 1; + } + t + } + + fn eat_punct(&mut self, p: &str) -> bool { + if self.peek().is_some_and(|t| t.is_punct(p)) { + self.at += 1; + return true; + } + false + } + + fn expect_punct(&mut self, p: &str) -> bool { + if self.eat_punct(p) { + return true; + } + match self.peek().cloned() { + Some(t) => self + .sink + .at(&t, format!("expected {p:?}, found {:?}", t.text)), + None => self + .sink + .push(0, 0, format!("expected {p:?}, found end of source")), + } + false + } + + fn ident(&mut self) -> Option { + match self.peek().cloned() { + Some(t) if t.kind == Kind::Ident => { + self.at += 1; + Some(t.text) + } + Some(t) => { + self.sink + .at(&t, format!("expected a name, found {:?}", t.text)); + None + } + None => { + self.sink + .push(0, 0, "expected a name, found end of source".into()); + None + } + } + } + + fn parse_card(&mut self) -> Card { + let mut card = Card::default(); + let mut guard = 0usize; + + while self.peek().is_some() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS { + self.sink.push(0, 0, "too many declarations".into()); + break; + } + let Some(t) = self.peek().cloned() else { break }; + let before = self.at; + + match t.text.as_str() { + "source" => { + self.at += 1; + let line = t.line; + if let Some(name) = self.dotted_name() { + let (helper, args) = self.parse_source_query(); + card.sources.push(SourceDecl { + name, + helper, + args, + line, + }); + } + } + "state" => { + self.at += 1; + if let Some(d) = self.parse_state() { + card.states.push(d); + } + } + "event" => { + self.at += 1; + if let Some(e) = self.parse_event() { + card.events.push(e); + } + } + "copy" => { + self.at += 1; + if let Some(n) = self.ident() { + let (provenance, locales) = self.parse_locale_map(); + // Absent `class` is NOT vocabulary. Defaulting to + // trusted meant the rule only bit when a card + // volunteered the label, which a model with something to + // hide would simply not do. + let provenance = match provenance { + Some(p) => p, + None => { + self.sink.at( + &t, + format!( + "copy {n:?} declares no `class`; L0 needs one of \ + `vocabulary`, `user-copy` or `model-copy` \ + (profile §4)" + ), + ); + Provenance::ModelCopy + } + }; + card.copies.push(CopyDecl { + name: n, + provenance, + locales, + }); + } + } + "component" => { + self.at += 1; + if let Some(c) = self.parse_component() { + card.components.push(c); + } + } + "view" => { + self.at += 1; + let Some(name) = self.ident() else { break }; + let body = self.parse_element(); + card.views.push(View { name, body }); + } + _ => { + self.sink.at( + &t, + format!( + "expected a declaration (source, state, event, copy, component, view), found {:?}", + t.text + ), + ); + self.at += 1; + } + } + + // Never loop without consuming; a malformed card must terminate. + if self.at == before { + self.at += 1; + } + } + card + } + + /// `a.b.c`, `a.0.c`, `a[b]` — amendments #3 and #4. A constant index and an + /// index by a bound path are both total lookups, so neither is an expression. + fn dotted_name(&mut self) -> Option { + let mut name = self.ident()?; + let mut guard = 0usize; + loop { + guard += 1; + if guard > 32 { + break; + } + // `.$state` — the source lifecycle suffix (§5.9). It is the one + // segment that starts with a sigil, which is what keeps it from + // colliding with a field a source might actually return. + let next_is_status = self.peek().is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(self.at + 1) + .is_some_and(|t| t.is_punct("$")) + && self + .tokens + .get(self.at + 2) + .is_some_and(|t| t.kind == Kind::Ident && t.text == "state"); + if next_is_status { + self.at += 3; + name.push_str(".$state"); + continue; + } + + let next_is_segment = self.peek().is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(self.at + 1) + .is_some_and(|t| matches!(t.kind, Kind::Ident | Kind::Num)); + if next_is_segment { + self.at += 1; + let Some(seg) = self.next() else { break }; + name.push('.'); + name.push_str(&seg.text); + continue; + } + if self.peek().is_some_and(|t| t.is_punct("[")) { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + if let Some(t) = self.peek().cloned() { + self.sink.at(&t, "nesting is too deep".into()); + } + return Some(name); + } + self.at += 1; + self.depth += 1; + // The index is itself a path and must be RETAINED, not just + // recognised: dropping it turned `stories[selected].title` into + // `stories.title`, which resolved to nothing and rendered an + // em dash. Held as a `[…]` segment so lookup can resolve the + // inner path and use its value as the key. + let index = self.dotted_name(); + self.depth -= 1; + if let Some(index) = index { + name.push_str(".["); + name.push_str(&index); + name.push(']'); + } + self.expect_punct("]"); + continue; + } + break; + } + Some(name) + } + + /// A source body is a capability query the runtime executes. It is not + /// evaluated here and its shape is the runtime's contract, so it is skipped + /// to the end of its balanced parentheses. + /// `sys.weather(lat: place.lat, fields: [temp, hi])` — the helper and its + /// arguments. The body is a query the RUNTIME executes; the view can only + /// bind to its result, which is what keeps the host surface empty. + fn parse_source_query(&mut self) -> (String, Vec<(String, SourceArg)>) { + let mut helper = String::new(); + while let Some(t) = self.peek().cloned() { + if t.kind == Kind::Ident || t.is_punct(".") { + helper.push_str(&t.text); + self.at += 1; + } else { + break; + } + } + + let mut args = Vec::new(); + if !self.eat_punct("(") { + return (helper, args); + } + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 || self.peek().is_none() { + break; + } + if self.eat_punct(")") { + break; + } + let Some(name) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + break; + } + let value = match self.peek().cloned() { + Some(t) if t.is_punct("[") => { + self.at += 1; + let mut items = Vec::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()); + } + self.at += 1; + } + self.expect_punct("]"); + SourceArg::List(items) + } + Some(t) if t.kind == Kind::Str => { + self.at += 1; + SourceArg::Text(t.text) + } + Some(t) if t.kind == Kind::Num => { + self.at += 1; + SourceArg::Number(t.text.parse().unwrap_or(0.0)) + } + Some(t) if t.kind == Kind::Token => { + self.at += 1; + SourceArg::Text(t.text.trim_start_matches('.').to_string()) + } + Some(t) if t.kind == Kind::Ident => { + SourceArg::Path(self.dotted_name().unwrap_or(t.text)) + } + _ => { + self.at += 1; + continue; + } + }; + args.push((name, value)); + if !self.eat_punct(",") { + self.expect_punct(")"); + break; + } + } + (helper, args) + } + + fn skip_braced(&mut self) { + if self.peek().is_some_and(|t| t.is_punct("{")) { + self.skip_balanced("{", "}"); + } + } + + fn skip_balanced(&mut self, open: &str, close: &str) { + let mut depth = 0usize; + while let Some(t) = self.peek().cloned() { + if t.is_punct(open) { + depth += 1; + } else if t.is_punct(close) { + depth -= 1; + self.at += 1; + if depth == 0 { + return; + } + continue; + } + self.at += 1; + } + } + + fn parse_state(&mut self) -> Option { + let path = self.dotted_name()?; + let mut shape = Shape::Other; + let mut initial = None; + let mut initial_path = None; + let mut keep = false; + // `{ shape: enum[a, b], initial: .a, keep: true }`. + // + // Read the value AFTER each key rather than scanning the body for shape + // words. Scanning meant a member of an enum could retype the state it + // belonged to: `shape: enum[a, text]` parsed as the enum and then the + // member `text` overwrote it, so the field accepted any string. + if self.peek().is_some_and(|t| t.is_punct("{")) { + let mut scan = self.at + 1; + let mut saw_shape = false; + while let Some(t) = self.tokens.get(scan) { + if t.is_punct("}") { + break; + } + let is_key = t.kind == Kind::Ident + && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")); + if !is_key { + scan += 1; + continue; + } + let key = t.text.clone(); + let value = self.tokens.get(scan + 2); + match key.as_str() { + "shape" => { + saw_shape = true; + shape = match value.map(|v| v.text.as_str()) { + Some("text") => Shape::Text, + Some("number") => Shape::Number, + Some("bool") => Shape::Bool, + Some("record") => Shape::Record, + Some("collection") => Shape::Collection, + Some("event") => Shape::Event, + Some("enum") => { + let mut members = Vec::new(); + let mut at = scan + 4; // past `enum` and `[` + while let Some(m) = self.tokens.get(at) { + if m.is_punct("]") { + break; + } + if m.kind == Kind::Ident { + members.push(m.text.clone()); + } + at += 1; + } + Shape::Enum(members) + } + Some(other) => { + // A misspelling used to become `Other`, which + // the `set` checks treat as "accepts anything" — + // so a typo silently disabled them. + let other = other.to_string(); + if let Some(v) = value { + self.sink.at( + v, + format!( + "{other:?} is not an L0 shape; L0 has text, \ + number, bool, enum[…], record, collection \ + and event" + ), + ); + } + Shape::Other + } + None => Shape::Other, + }; + } + "initial" => { + if let Some(v) = value { + initial = match v.kind { + Kind::Str => Some(serde_json::Value::String(v.text.clone())), + Kind::Token => Some(serde_json::Value::String( + v.text.trim_start_matches('.').to_string(), + )), + Kind::Num => { + v.text.parse::().ok().map(serde_json::Value::from) + } + Kind::Ident if v.text == "true" || v.text == "false" => { + Some(serde_json::Value::Bool(v.text == "true")) + } + Kind::Ident => { + let mut path = v.text.clone(); + let mut at = scan + 3; + while self.tokens.get(at).is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(at + 1) + .is_some_and(|t| t.kind == Kind::Ident) + { + path.push('.'); + path.push_str(&self.tokens[at + 1].text); + at += 2; + } + initial_path = Some(path); + None + } + _ => None, + }; + } + } + "keep" => { + keep = value.is_some_and(|v| v.kind == Kind::Ident && v.text == "true"); + } + _ => {} + } + scan += 1; + } + + if !saw_shape { + if let Some(t) = self.peek().cloned() { + self.sink + .at(&t, format!("state {path:?} declares no `shape`")); + } + } + self.skip_braced(); + } + + Some(StateDecl { + path, + shape, + initial, + initial_path, + keep, + }) + } + + /// `{ class: vocabulary, en: "…", zh: "…" }` — the text per locale. + fn parse_locale_map(&mut self) -> (Option, Vec<(String, String)>) { + let mut out = Vec::new(); + let mut provenance = None; + if !self.eat_punct("{") { + return (provenance, out); + } + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > 64 || t.is_punct("}") { + break; + } + if t.kind == Kind::Ident + && self + .tokens + .get(self.at + 1) + .is_some_and(|n| n.is_punct(":")) + { + if let Some(v) = self.tokens.get(self.at + 2) { + // `class:` names a provenance and its value is an + // IDENTIFIER, which the string-only filter below discarded. + if t.text == "class" { + // `-` lexes as punctuation, so `user-copy` arrives as + // the identifier `user`. Match the head rather than + // reassembling: the three classes differ there. + provenance = Some(match v.text.as_str() { + "user" => Provenance::UserCopy, + "model" => Provenance::ModelCopy, + "vocabulary" => Provenance::Vocabulary, + other => { + self.sink.at( + v, + format!( + "{other:?} is not a copy class; L0 has \ + `vocabulary`, `user-copy` and `model-copy`" + ), + ); + Provenance::Vocabulary + } + }); + } else if v.kind == Kind::Str { + out.push((t.text.clone(), v.text.clone())); + } + } + } + self.at += 1; + } + self.expect_punct("}"); + (provenance, out) + } + + /// `event name { path: total-form, … }` + fn parse_event(&mut self) -> Option { + let name = self.ident()?; + let mut transitions = Vec::new(); + + if !self.eat_punct("{") { + return Some(EventDecl { name, transitions }); + } + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 || self.peek().is_none() { + break; + } + if self.eat_punct("}") { + break; + } + let Some(head) = self.peek().cloned() else { + break; + }; + let Some(target) = self.dotted_name() else { + self.at += 1; + continue; + }; + if !self.expect_punct(":") { + break; + } + + let (form, tokens) = self.parse_total_form(); + transitions.push(Transition { + target, + form, + tokens, + line: head.line, + column: head.column, + }); + + if !self.eat_punct(",") { + self.expect_punct("}"); + break; + } + } + Some(EventDecl { name, transitions }) + } + + /// One of `set(v)`, `toggle`, `cycle(.a, .b)`, `clear` — profile §3. Anything + /// else is recorded as not-total so validation can name it. + fn parse_total_form(&mut self) -> (Form, Vec) { + let Some(t) = self.peek().cloned() else { + return (Form::NotTotal(String::new()), Vec::new()); + }; + match t.text.as_str() { + "toggle" => { + self.at += 1; + (Form::Toggle, Vec::new()) + } + "clear" => { + self.at += 1; + (Form::Clear, Vec::new()) + } + "set" => { + self.at += 1; + let mut source = SetSource::Payload; + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + if let Some(inner) = self.peek().cloned() { + match inner.kind { + Kind::Token => { + source = SetSource::Token( + inner.text.trim_start_matches('.').to_string(), + ); + self.at += 1; + } + Kind::Str => { + source = SetSource::Text(inner.text.clone()); + self.at += 1; + } + Kind::Punct if inner.text == "$" => { + // `$value` — the payload. The name is checked: + // `$anything` used to be accepted as a synonym, + // so a typo silently became the payload. + self.at += 1; + match self.ident() { + Some(name) if name == "value" => {} + Some(other) => { + self.sink.at( + &inner, + format!( + "`${other}` is not a payload; L0 names it `$value`" + ), + ); + } + None => {} + } + } + Kind::Num => { + source = SetSource::Num(inner.text.parse().unwrap_or(0.0)); + self.at += 1; + } + Kind::Ident if inner.text == "true" || inner.text == "false" => { + source = SetSource::Bool(inner.text == "true"); + self.at += 1; + } + Kind::Ident => { + source = self + .dotted_name() + .map(SetSource::Path) + .unwrap_or(SetSource::Payload); + } + _ => { + self.at += 1; + } + } + } + self.expect_punct(")"); + } + (Form::Set(source), Vec::new()) + } + "cycle" => { + self.at += 1; + let mut members = Vec::new(); + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + let mut guard = 0usize; + while let Some(m) = self.peek().cloned() { + guard += 1; + if guard > 64 || m.is_punct(")") { + break; + } + if m.kind == Kind::Token { + members.push(m.text.trim_start_matches('.').to_string()); + } else if m.kind == Kind::Ident { + // A bare name here is a path, and a path is not a token. + members.push(format!("!{}", m.text)); + } + self.at += 1; + } + self.expect_punct(")"); + } + (Form::Cycle, members) + } + other => { + self.at += 1; + (Form::NotTotal(other.to_string()), Vec::new()) + } + } + } + + fn parse_component(&mut self) -> Option { + let line = self.peek().map(|t| t.line).unwrap_or(0); + let name = self.ident()?; + let mut params = Vec::new(); + + if self.eat_punct("(") { + let mut guard = 0usize; + while self.peek().is_some_and(|t| !t.is_punct(")")) { + guard += 1; + if guard > 64 { + break; + } + if let Some(t) = self.peek().cloned() { + if t.kind == Kind::Ident + && self + .tokens + .get(self.at + 1) + .is_some_and(|n| n.is_punct(":")) + { + // The declared shape follows the colon. `enum[a, b]` + // carries its members, which is what lets a token + // argument be checked against the prop it fills. + let shape = match self.tokens.get(self.at + 2) { + Some(k) if k.text == "text" => Shape::Text, + Some(k) if k.text == "number" => Shape::Number, + Some(k) if k.text == "bool" => Shape::Bool, + Some(k) if k.text == "record" => Shape::Record, + Some(k) if k.text == "collection" => Shape::Collection, + Some(k) if k.text == "event" => Shape::Event, + Some(k) if k.text == "enum" => { + let mut members = Vec::new(); + let mut at = self.at + 4; // past `enum` and `[` + while let Some(m) = self.tokens.get(at) { + if m.is_punct("]") { + break; + } + if m.kind == Kind::Ident { + members.push(m.text.clone()); + } + at += 1; + } + Shape::Enum(members) + } + _ => Shape::Other, + }; + // `= ` after the shape. + let mut default = None; + let mut at = self.at + 3; + if matches!(shape, Shape::Enum(_)) { + // Skip `[…]`, BALANCED. Seeking the first `]` put + // the cursor wherever a bracket happened to be. + let mut depth = 0usize; + while let Some(t) = self.tokens.get(at) { + if t.is_punct("[") { + depth += 1; + } else if t.is_punct("]") { + depth -= 1; + if depth == 0 { + at += 1; + break; + } + } + at += 1; + } + } + if self.tokens.get(at).is_some_and(|t| t.is_punct("=")) { + if let Some(v) = self.tokens.get(at + 1) { + default = match v.kind { + Kind::Str => Some(serde_json::Value::String(v.text.clone())), + Kind::Token => Some(serde_json::Value::String( + v.text.trim_start_matches('.').to_string(), + )), + Kind::Num => { + v.text.parse::().ok().map(serde_json::Value::from) + } + Kind::Ident if v.text == "true" || v.text == "false" => { + Some(serde_json::Value::Bool(v.text == "true")) + } + // A bare enum member: `unit: enum[c, f] = c`, + // which is how §5.2 spells it and which + // produced no default at all. + Kind::Ident if matches!(shape, Shape::Enum(_)) => { + Some(serde_json::Value::String(v.text.clone())) + } + _ => None, + }; + } + } + params.push(Param { + name: t.text.clone(), + shape, + default, + }); + } + } + self.at += 1; + } + self.expect_punct(")"); + } + + self.expect_punct("{"); + let mut states = Vec::new(); + let mut events = Vec::new(); + let mut body = Element::default(); + + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS || t.is_punct("}") { + break; + } + match t.text.as_str() { + "state" => { + self.at += 1; + if let Some(d) = self.parse_state() { + states.push(d); + } + } + "event" => { + self.at += 1; + if let Some(e) = self.parse_event() { + events.push(e); + } + } + "view" => { + self.at += 1; + body = self.parse_element(); + } + _ => { + self.sink.at( + &t, + format!( + "expected state, event or view in a component, found {:?}", + t.text + ), + ); + self.at += 1; + } + } + } + self.expect_punct("}"); + + let mut instantiates = Vec::new(); + collect_constructors(&body, &mut instantiates); + Some(Component { + name, + params, + states, + events, + body, + line, + instantiates, + }) + } + + fn parse_element(&mut self) -> Element { + self.depth += 1; + let element = self.parse_element_inner(); + self.depth -= 1; + element + } + + fn parse_element_inner(&mut self) -> Element { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + if let Some(t) = self.peek().cloned() { + self.sink.at(&t, "nesting is too deep".into()); + } + return Element::default(); + } + + let Some(t) = self.peek().cloned() else { + self.sink + .push(0, 0, "expected an element, found end of source".into()); + return Element::default(); + }; + + // `for item[, index] in path key path { … }` + if t.is_kw("for") { + self.at += 1; + let mut binders = Vec::new(); + if let Some(b) = self.ident() { + binders.push(b); + } + if self.eat_punct(",") { + if let Some(b) = self.ident() { + binders.push(b); + } + } + if !self.peek().is_some_and(|t| t.is_kw("in")) { + if let Some(t) = self.peek().cloned() { + self.sink + .at(&t, "expected `in` after the loop binder".into()); + } + } else { + self.at += 1; + } + let collection = self.dotted_name().unwrap_or_default(); + let key_path = if self.peek().is_some_and(|t| t.is_kw("key")) { + self.at += 1; + self.dotted_name() + } else { + if let Some(t) = self.peek().cloned() { + self.sink.at( + &t, + "a loop must declare `key`: identity may not come from position".into(), + ); + } + None + }; + let mut element = Element { + name: "for".into(), + binders, + line: t.line, + column: t.column, + key_path, + ..Default::default() + }; + element.args.push(Arg { + name: "in".into(), + value: Operand::Path(collection), + line: t.line, + column: t.column, + }); + element.children = self.parse_block(); + return element; + } + + // `when predicate { … }` + if t.is_kw("when") { + self.at += 1; + let left = self.dotted_name().unwrap_or_default(); + // A comparison, or a bare path when the state is bool-shaped — + // `when expanded { … }`. Neither form computes. + let mut cmp = None; + let mut rhs = None; + if let Some(op) = self.peek().cloned() { + if op.kind == Kind::Cmp { + self.at += 1; + cmp = Some(op.text.clone()); + rhs = Some(self.parse_operand()); + } + } + let mut element = Element { + name: "when".into(), + line: t.line, + column: t.column, + cmp, + rhs, + ..Default::default() + }; + element.args.push(Arg { + name: "predicate".into(), + value: Operand::Path(left), + line: t.line, + column: t.column, + }); + element.children = self.parse_block(); + return element; + } + + // `into name { … }` — a call site directing children at a named slot. + if t.is_kw("into") { + self.at += 1; + let mut element = Element { + name: "into".into(), + line: t.line, + column: t.column, + ..Default::default() + }; + if let Some(slot_name) = self.ident() { + element.binders.push(slot_name); + } + element.children = self.parse_block(); + return element; + } + + // `slot` or `slot name` — §5.5. The anonymous form is the common case; + // a name lets a component place two groups of children. + if t.is_kw("slot") { + self.at += 1; + let mut element = Element { + name: "slot".into(), + line: t.line, + column: t.column, + ..Default::default() + }; + if self.peek().is_some_and(|n| n.kind == Kind::Ident) { + if let Some(slot_name) = self.ident() { + element.binders.push(slot_name); + } + } + return element; + } + + let Some(name_tok) = self.next() else { + return Element::default(); + }; + if name_tok.kind != Kind::Ident { + self.sink.at( + &name_tok, + format!("expected an element, found {:?}", name_tok.text), + ); + return Element::default(); + } + + // A lowercase name with no arguments references another view record. + let is_constructor = name_tok + .text + .chars() + .next() + .is_some_and(|c| c.is_uppercase()); + + let mut element = Element { + name: name_tok.text.clone(), + line: name_tok.line, + column: name_tok.column, + is_reference: !is_constructor, + ..Default::default() + }; + + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + element.args = self.parse_args(); + } + if self.peek().is_some_and(|t| t.is_punct("{")) { + element.children = self.parse_block(); + } + element + } + + fn parse_block(&mut self) -> Vec { + let mut out = Vec::new(); + if !self.expect_punct("{") { + return out; + } + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS { + self.sink.at(&t, "too many elements in one block".into()); + break; + } + if t.is_punct("}") { + break; + } + let before = self.at; + out.push(self.parse_element()); + if self.at == before { + self.at += 1; + } + } + self.expect_punct("}"); + out + } + + fn parse_args(&mut self) -> Vec { + let mut out = Vec::new(); + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 { + break; + } + if self.peek().is_none() || self.peek().is_some_and(|t| t.is_punct(")")) { + break; + } + let Some(name_tok) = self.peek().cloned() else { + break; + }; + let Some(name) = self.ident() else { + self.at += 1; + continue; + }; + if !self.expect_punct(":") { + break; + } + let value = self.parse_operand(); + out.push(Arg { + name, + value, + line: name_tok.line, + column: name_tok.column, + }); + if !self.eat_punct(",") { + break; + } + } + self.expect_punct(")"); + out + } + + fn parse_operand(&mut self) -> Operand { + let Some(t) = self.peek().cloned() else { + return Operand::Str(String::new()); + }; + match t.kind { + Kind::Token => { + self.at += 1; + Operand::Token(t.text) + } + Kind::Str => { + self.at += 1; + Operand::Str(t.text.clone()) + } + Kind::Num => { + self.at += 1; + Operand::Num(t.text.parse().unwrap_or(0.0)) + } + Kind::Ident => { + let path = self.dotted_name().unwrap_or_default(); + // A comparison in argument position is a predicate operand. + if let Some(op) = self.peek().cloned() { + if op.kind == Kind::Cmp { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&op, "nesting is too deep".into()); + return Operand::Path(path); + } + self.at += 1; + self.depth += 1; + let rhs = self.parse_operand(); + self.depth -= 1; + return Operand::Predicate { + path, + cmp: op.text, + rhs: Box::new(rhs), + }; + } + } + Operand::Path(path) + } + _ => { + self.at += 1; + Operand::Str(String::new()) + } + } + } +} + +/// Split a call site's children by the slot each targets. +/// +/// `into header { … }` groups the elements that follow into the named slot; +/// anything outside a group goes to the anonymous one. Keeping the default +/// unnamed means a component with one slot needs no ceremony. +/// The slot names a component declares. An anonymous `slot` is `""`. +/// Resolve an operand in a comparison's right-hand position. +/// Follow a dotted path into injected data. Used for a path-valued `initial`, +/// which resolves against what the host supplied rather than at parse time. +/// A literal operand's value. +fn literal_of(operand: &Operand) -> serde_json::Value { + match operand { + Operand::Str(t) => serde_json::Value::String(t.clone()), + Operand::Num(n) => serde_json::Value::from(*n), + Operand::Token(t) => serde_json::Value::String(t.trim_start_matches('.').to_string()), + Operand::Path(p) | Operand::Predicate { path: p, .. } => { + serde_json::Value::String(p.clone()) + } + } +} + +fn data_path(data: &serde_json::Value, path: &str) -> Option { + let mut current = data.clone(); + for segment in path.split('.') { + current = match segment.parse::() { + Ok(i) => current.get(i)?.clone(), + Err(_) => current.get(segment)?.clone(), + }; + } + Some(current) +} + +fn scope_value(scope: &ValueScope, operand: &Operand) -> Option { + match operand { + Operand::Path(p) => scope.lookup(p), + Operand::Token(t) => Some(serde_json::Value::String( + t.trim_start_matches('.').to_string(), + )), + Operand::Str(s) => Some(serde_json::Value::String(s.clone())), + Operand::Num(n) => Some(serde_json::Value::from(*n)), + // A nested comparison is not in the grammar. + Operand::Predicate { .. } => None, + } +} + +/// Compare two resolved values. Shared by guards (`when a == b`) and by +/// predicate arguments (`active: range == .d1`), so the two cannot drift. +fn compare(left: Option, cmp: &str, right: Option) -> bool { + let (Some(left), Some(right)) = (left, right) else { + // An operand that did not resolve cannot be compared. `!=` used to + // return true here, so a guard against an UNDECLARED name took its + // branch; an unresolvable comparison is now simply false. + return false; + }; + match cmp { + "==" => left == right, + "!=" => left != right, + _ => match (left.as_f64(), right.as_f64()) { + (Some(a), Some(b)) => match cmp { + "<" => a < b, + "<=" => a <= b, + ">" => a > b, + ">=" => a >= b, + _ => false, + }, + _ => false, + }, + } +} + +fn slot_names(component: &Component) -> Vec { + fn walk(elements: &[Element], out: &mut Vec) { + for e in elements { + if e.name == "slot" { + out.push(e.binders.first().cloned().unwrap_or_default()); + } + walk(&e.children, out); + } + } + let mut out = Vec::new(); + walk(std::slice::from_ref(&component.body), &mut out); + out +} + +/// Path segments for a list of siblings, stable against insertion. +/// +/// Profile §5.1 identifies an instance by its *instantiation site*, not its +/// position, "so that identity survives a ledger edit that inserts an element +/// above". A raw child index does not: inserting a `Rule()` renumbers every +/// sibling after it, and each one loses its local state — which is React's rule, +/// and precisely the rule §5.1 says L0 does not use. +/// +/// Numbering within each element NAME fixes that. Two sibling `Row`s are still +/// distinct (`Row#0`, `Row#1`), so keys stay unique for hit-testing, but a +/// `Rule()` inserted above a `Toggly` leaves `Toggly#0` untouched. +fn sibling_segments(children: &[Element]) -> Vec<(String, &Element)> { + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + children + .iter() + .map(|child| { + let n = seen.entry(child.name.as_str()).or_insert(0); + let segment = format!("{}#{}", child.name, *n); + *n += 1; + (segment, child) + }) + .collect() +} + +fn split_slots(children: &[Element]) -> Vec<(String, Vec)> { + let mut groups: Vec<(String, Vec)> = vec![(String::new(), Vec::new())]; + for child in children { + if child.name == "into" { + let name = child.binders.first().cloned().unwrap_or_default(); + groups.push((name, child.children.clone())); + } else { + groups[0].1.push(child.clone()); + } + } + groups +} + +fn collect_constructors(e: &Element, out: &mut Vec) { + if !e.name.is_empty() && e.name != "for" && e.name != "when" && e.name != "slot" { + out.push(e.name.clone()); + } + for c in &e.children { + collect_constructors(c, out); + } +} + +// ─────────────────────────────────────────────────────────────────────── catalog ── + +/// The argument contract. Mirrors `docs/ui-l0-constructors.toml`, which is the +/// human-readable spec; a test asserts the two agree. +pub mod catalog { + /// What an argument admits. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum ArgKind { + /// One of a closed token set, written with a leading dot. + Token(&'static [&'static str]), + /// A token from the set, or a bound path. + TokenOrPath(&'static [&'static str]), + /// A bound path only. + Path, + /// A literal or a path. + Any, + /// A declared event name. + Event, + /// A number literal or a path; also admits a width token. Layout, not + /// data — `gap: 6` and `cols: 2` are structure the card chooses. + Number, + /// A bound path that renders a FACT. Distinct from `Number` because + /// `gap: 6` is layout the card is entitled to choose and + /// `value: 41.2` is a measurement it is not (§4). Without the + /// distinction the no-facts rule cannot be stated in the catalog at + /// all, so both were `Number` and neither was checked. + Data, + /// Text literal or path. + Text, + /// A predicate or a bool path. + Bool, + } + + pub const UNIT: &[&str] = &[ + "c", "f", "pct", "speed", "pressure", "index", "distance", "money", + ]; + pub const FORMAT: &[&str] = &[ + "money", + "signed_money", + "signed_pct", + "compact", + "ratio", + "time", + "date", + ]; + pub const WIDTH: &[&str] = &["fill", "fit", "day", "rank", "temp"]; + pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; + pub const PAD: &[&str] = &["page", "tight", "none"]; + pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; + + pub type Args = &'static [(&'static str, ArgKind)]; + + use ArgKind::*; + + /// Every constructor L0 admits, with the arguments each accepts. + pub const CONSTRUCTORS: &[(&str, Args)] = &[ + ("Surface", &[("pad", Token(PAD))]), + ("Photo", &[("src", Path), ("pad", Token(PAD))]), + ("Panel", &[]), + ("Card", &[("on_tap", Event), ("value", Any)]), + ("Col", &[("align", Token(ALIGN)), ("gap", Number)]), + ( + "Row", + &[ + ("align", Token(ALIGN)), + ("gap", Number), + ("on_tap", Event), + ("value", Any), + ], + ), + ("Grid", &[("cols", Number)]), + ("Rule", &[]), + ( + "TextHero", + &[ + ("text", Text), + ("value", Data), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ("on_tap", Event), + ], + ), + ( + "TextTitle", + &[("text", Text), ("width", TokenOrPath(WIDTH))], + ), + ("TextBody", &[("text", Text), ("width", TokenOrPath(WIDTH))]), + ( + "TextStat", + &[("value", Data), ("format", Token(FORMAT)), ("tint", Path)], + ), + ("TextRow", &[("text", Text), ("width", TokenOrPath(WIDTH))]), + ( + "TextCaption", + &[ + ("text", Text), + ("value", Data), + ("glyph", Text), + ("suffix", Text), + ("unit", TokenOrPath(UNIT)), + ("width", TokenOrPath(WIDTH)), + ], + ), + ( + "TextValue", + &[ + ("value", Data), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ("tint", Path), + ], + ), + ( + "Tile", + &[ + ("label", Text), + ("value", Data), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ], + ), + ( + "Chip", + &[ + ("text", Text), + ("on_tap", Event), + ("value", Any), + ("active", Bool), + ], + ), + ("WeatherIcon", &[("cond", Path), ("size", Token(ICON_SIZE))]), + ( + "TempBar", + &[("lo", Path), ("hi", Path), ("min", Path), ("max", Path)], + ), + ("SunArc", &[("rise", Path), ("set", Path), ("now", Path)]), + ("MoonPhase", &[("phase", Path), ("illum", Path)]), + ( + "AqiContour", + &[("lat", Path), ("lon", Path), ("span", Number)], + ), + ( + "StockPlot", + &[("symbol", Path), ("range", TokenOrPath(UNIT))], + ), + ]; + + pub fn lookup(name: &str) -> Option { + CONSTRUCTORS + .iter() + .find(|(n, _)| *n == name) + .map(|(_, a)| *a) + } + + /// The CLOSED set of helpers a `source` may name, with the arguments each + /// accepts. Mirrors `[sources.*]` in `docs/ui-l0-constructors.toml`. + /// + /// Without this the parser accepted any dotted name and `source_plan` handed + /// it to the host as the function to resolve, so + /// `source leak sys.shell(command: "cat /etc/passwd")` produced a clean, + /// well-formed request. Confinement then rested on the host keeping its own + /// allowlist — the policy boundary §1 claims L0 removes. A card must not be + /// able to NAME a capability it was not granted. + pub const SOURCES: &[(&str, &[&str])] = &[ + ("sys.geocode", &["name"]), + ( + "sys.weather", + &["lat", "lon", "days", "fields", "aggregate"], + ), + ("sys.daylight", &["lat", "lon"]), + ("sys.airquality", &["lat", "lon"]), + ("sys.moonphase", &["lat", "lon"]), + ("sys.photo", &["query"]), + ("sys.locale", &[]), + ("sys.news", &["count", "offset", "fields"]), + ("sys.news_item", &["id", "fields"]), + ("sys.movers", &["count", "fields"]), + ("sys.quote", &["ticker", "fields"]), + ( + "sys.series", + &["ticker", "range", "points", "fields", "aggregate"], + ), + ]; + + pub fn source(name: &str) -> Option<&'static [&'static str]> { + SOURCES.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + } +} + +// ──────────────────────────────────────────────────────────────────── validation ── + +fn validate(card: &Card, sink: &mut Diagnostics) { + // A card with no `view root` has nothing to render. Realization says so, + // but the CHECKER accepted it — and the checker is what a host asks before + // storing a record, so an unusable card could be appended and only fail + // when someone tried to look at it. + if !card.views.iter().any(|v| v.name == "root") { + sink.push( + 1, + 1, + "a card needs a `view root`: it is the entry point a host realizes".into(), + ); + } + + // Components must not form a cycle — profile §6, condition 1. Without this + // an L0 realization need not terminate. + check_component_acyclicity(card, sink); + validate_transitions(card, sink); + validate_sources(card, sink); + + // Which props of which components end up rendering a fact (§4). + let data_positions = data_position_params(card); + + let view_names: Vec<&str> = card.views.iter().map(|v| v.name.as_str()).collect(); + let component_names: Vec<&str> = card.components.iter().map(|c| c.name.as_str()).collect(); + + for view in &card.views { + let mut scope = Scope::card(card); + walk( + &data_positions, + &view.body, + &mut scope, + &view_names, + &component_names, + card, + sink, + ); + } + for component in &card.components { + let mut scope = Scope::component(card, component); + walk( + &data_positions, + &component.body, + &mut scope, + &view_names, + &component_names, + card, + sink, + ); + } +} + +/// Profile §3: every transition names a declared state and uses a total form. +/// +/// A component's events target its OWN state. Card state is written by card-scope +/// events, which a component reaches only by receiving one as a prop (§5.4) — so +/// the transition still runs in the scope that declared it. +fn validate_transitions(card: &Card, sink: &mut Diagnostics) { + check_state_initials(&card.states, sink, 1); + for component in &card.components { + check_state_initials(&component.states, sink, component.line); + for param in &component.params { + let Some(default) = ¶m.default else { + continue; + }; + if !value_fits_shape(¶m.shape, default) { + sink.push( + component.line, + 1, + format!( + "{}.{} declares {} but a default of {default}", + component.name, + param.name, + shape_name(¶m.shape) + ), + ); + } + } + } + + // A path-valued `initial` is a rendering position by proxy: whatever it + // names ends up on screen. Checking only direct `copy.x` references let + // model-copy through by way of a state. + let card_scope = Scope::card(card); + for state in &card.states { + if let Some(path) = &state.initial_path { + check_path(path, &card_scope, 1, 1, sink); + } + } + for component in &card.components { + let scope = Scope::component(card, component); + for state in &component.states { + if let Some(path) = &state.initial_path { + check_path(path, &scope, component.line, 1, sink); + } + } + } + + // `set(path)` READS. Its operand must therefore be a readable name, which + // an event is not: `set(some_event)` parses, resolves to nothing and writes + // garbage into the cell. + let mut card_readable: Vec = card.sources.iter().map(|s| root_of(&s.name)).collect(); + card_readable.extend(card.states.iter().map(|s| root_of(&s.path))); + card_readable.push("copy".into()); + let card_events: Vec = card.events.iter().map(|e| e.name.clone()).collect(); + + check_event_batch( + &card.events, + &card.states, + &card_readable, + &card_events, + &card.copies, + sink, + ); + for component in &card.components { + let mut readable: Vec = card.sources.iter().map(|s| root_of(&s.name)).collect(); + readable.push("copy".into()); + // An EVENT prop is not readable — `set(out)` where `out` is an event + // was accepted, because a param's shape was unknown so none could be + // excluded. Now only value-shaped props are readable, and event-shaped + // ones join the event names below. + readable.extend( + component + .params + .iter() + .filter(|p| !matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); + readable.extend(component.states.iter().map(|s| root_of(&s.path))); + let mut events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + events.extend( + component + .params + .iter() + .filter(|p| matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); + check_event_batch( + &component.events, + &component.states, + &readable, + &events, + &card.copies, + sink, + ); + } +} + +/// A shape's name, for diagnostics. +fn shape_name(shape: &Shape) -> &'static str { + match shape { + Shape::Text => "text", + Shape::Number => "a number", + Shape::Bool => "bool", + Shape::Enum(_) => "an enum", + Shape::Record => "a record", + Shape::Collection => "a collection", + Shape::Event => "an event", + Shape::Other => "of an unnamed shape", + } +} + +/// Profile §2: a declared `initial` must fit the shape it is declared with. +/// Neither was checked against the other, so `shape: number, initial: "oops"` +/// mounted a string into a numeric cell before any event ran. +/// Whether a literal agrees with the shape it was declared alongside. Shared by +/// state initials and parameter defaults so the two cannot drift. +fn value_fits_shape(shape: &Shape, value: &serde_json::Value) -> bool { + match (shape, value) { + (Shape::Text, serde_json::Value::String(_)) => true, + (Shape::Number, v) => v.is_number(), + (Shape::Bool, serde_json::Value::Bool(_)) => true, + (Shape::Enum(members), serde_json::Value::String(s)) => members.iter().any(|m| m == s), + // A shape that names no literal form, or one we could not classify. + (Shape::Record | Shape::Collection | Shape::Event | Shape::Other, _) => true, + _ => false, + } +} + +fn check_state_initials(states: &[StateDecl], sink: &mut Diagnostics, line: usize) { + for state in states { + let Some(initial) = &state.initial else { + continue; + }; + let fits = value_fits_shape(&state.shape, initial); + if !fits { + sink.push( + line, + 1, + format!( + "state {:?} declares {} but an initial of {initial}", + state.path, + shape_name(&state.shape) + ), + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn check_event_batch( + events: &[EventDecl], + states: &[StateDecl], + readable: &[String], + event_names: &[String], + copies: &[CopyDecl], + sink: &mut Diagnostics, +) { + for event in events { + for transition in &event.transitions { + let Some(state) = states.iter().find(|s| s.path == transition.target) else { + sink.push( + transition.line, + transition.column, + format!( + "{:?} is not a state declared in this scope", + transition.target + ), + ); + continue; + }; + + match &transition.form { + Form::NotTotal(text) => sink.push( + transition.line, + transition.column, + format!( + "{text:?} is not a total transition form; L0 admits set(…), toggle, cycle(…) and clear" + ), + ), + Form::Toggle if state.shape != Shape::Bool => sink.push( + transition.line, + transition.column, + format!( + "`toggle` needs a bool state, but {:?} is not bool", + transition.target + ), + ), + Form::Set(SetSource::Num(_)) if !matches!(state.shape, Shape::Number | Shape::Other) => { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes a number, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } + Form::Set(SetSource::Bool(_)) if !matches!(state.shape, Shape::Bool | Shape::Other) => { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes a bool, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } + Form::Set(SetSource::Token(t)) => { + if !matches!(state.shape, Shape::Enum(_) | Shape::Other) { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes the token .{t}, but {:?} is {} — only an enum \ + has members", + transition.target, + shape_name(&state.shape) + ), + ); + } + if let Shape::Enum(members) = &state.shape { + if !members.iter().any(|m| m == t) { + sink.push( + transition.line, + transition.column, + format!( + ".{t} is not a member of {:?}'s enum; it accepts {}", + transition.target, + members + .iter() + .map(|m| format!(".{m}")) + .collect::>() + .join(", ") + ), + ); + } + } + } + Form::Set(SetSource::Text(_)) + if !matches!(state.shape, Shape::Text | Shape::Other) => + { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes text, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } + Form::Set(SetSource::Path(path)) => { + // §4 travels with the value: writing model-authored text + // into state renders it just as surely as naming it in a + // view, one step later. + if let Some(name) = path.strip_prefix("copy.") { + match copies.iter().find(|c| c.name == name) { + Some(decl) if decl.provenance == Provenance::ModelCopy => { + sink.push( + transition.line, + transition.column, + format!( + "copy.{name} is `model-copy` and may not be written \ + into state; only `vocabulary` and `user-copy` may \ + (profile §4)" + ), + ); + } + None => sink.push( + transition.line, + transition.column, + format!("copy.{name} is not declared"), + ), + _ => {} + } + } + let root = root_of(path); + if event_names.contains(&root) { + sink.push( + transition.line, + transition.column, + format!( + "{root:?} is an event, not a value; `set` reads a name and \ + cannot trigger one (profile §6.4)" + ), + ); + } else if !readable.contains(&root) { + sink.push( + transition.line, + transition.column, + format!("{root:?} is not a readable name"), + ); + } + } + Form::Set(_) => {} + Form::Cycle => match &state.shape { + Shape::Enum(members) => { + for member in &transition.tokens { + if let Some(bare) = member.strip_prefix('!') { + sink.push( + transition.line, + transition.column, + format!( + "`cycle` takes tokens; {bare:?} is a path — enum members carry a leading dot" + ), + ); + } else if !members.contains(member) { + sink.push( + transition.line, + transition.column, + format!( + ".{member} is not a member of {:?}'s enum", + transition.target + ), + ); + } + } + // Advancing uses the ENUM's declaration order (§3), so a + // `cycle` listing a different one would run an order the + // card does not say. Rejected rather than reordered: + // what is written must be what happens. + if !transition.tokens.is_empty() + && transition.tokens.iter().all(|t| members.contains(t)) + && transition.tokens != *members + { + sink.push( + transition.line, + transition.column, + format!( + "`cycle` advances in declaration order ({}), but lists {} — \ + reorder the enum or the cycle so they agree (profile §3)", + members.join(", "), + transition.tokens.join(", ") + ), + ); + } + } + _ => sink.push( + transition.line, + transition.column, + format!("`cycle` needs an enum state, but {:?} is not", transition.target), + ), + }, + _ => {} + } + } + } +} + +/// Profile §4: a `source` may only name a catalogued capability. +/// +/// This is the difference between "the host refuses to run it" and "the card +/// cannot ask". `source leak sys.shell(command: "cat /etc/passwd")` previously +/// parsed clean and reached the host as a well-formed request; confinement then +/// depended on the host keeping an allowlist that the profile claims to make +/// unnecessary. +fn validate_sources(card: &Card, sink: &mut Diagnostics) { + // A source may read card state, other sources and copy — the same names a + // view may, minus anything a component introduces. + let scope = &Scope::card(card); + for source in &card.sources { + let Some(accepted) = catalog::source(&source.helper) else { + let mut known: Vec<&str> = catalog::SOURCES.iter().map(|(n, _)| *n).collect(); + known.sort(); + sink.push( + source.line, + 1, + format!( + "{:?} is not a source capability L0 admits; a card may only name a \ + catalogued helper (profile §4). Known: {}", + source.helper, + known.join(", ") + ), + ); + continue; + }; + 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 + // and its arguments were not. + if let SourceArg::Path(path) = arg { + // A source addresses card state with an explicit `state.` + // prefix — `sys.geocode(name: state.city)` — which the view + // scope does not carry, since a view names its state directly. + match path.strip_prefix("state.") { + Some(rest) => { + let root = root_of(rest); + if !card.states.iter().any(|st| root_of(&st.path) == root) { + sink.push(source.line, 1, format!("{root:?} is not a declared state")); + } + } + None => check_path(path, scope, source.line, 1, sink), + } + } + if !accepted.contains(&name.as_str()) { + sink.push( + source.line, + 1, + format!( + "{:?} does not accept an argument named {name:?}; it accepts: {}", + source.helper, + if accepted.is_empty() { + "(none)".to_string() + } else { + accepted.join(", ") + } + ), + ); + } + } + } +} + +/// Profile §6, condition 1: the declaration graph must be acyclic. +/// +/// This walks COMPONENTS AND VIEWS as one graph. Walking components alone +/// missed two shapes that both recurse forever at realization: +/// +/// ```text +/// view a b view b a two views referencing each other +/// component A { view v } view v { A() } a component via a view +/// ``` +/// +/// Both passed the old check and recursed until the node cap tripped, which is +/// a resource bound catching a structural error — and only because the caller +/// happened to set one. +fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { + // name -> (what it references, line) + let mut edges: Vec<(String, Vec, usize)> = Vec::new(); + for component in &card.components { + let mut refs = component.instantiates.clone(); + let mut views = Vec::new(); + collect_references(&component.body, &mut views); + refs.extend(views); + edges.push((component.name.clone(), refs, component.line)); + } + for view in &card.views { + let mut refs = Vec::new(); + collect_constructors(&view.body, &mut refs); + let mut views = Vec::new(); + collect_references(&view.body, &mut views); + refs.extend(views); + edges.push((view.name.clone(), refs, 1)); + } + + // Keep only edges that name a NODE of this graph. `collect_constructors` + // yields every catalog constructor, so an unfiltered edge list is + // proportional to the card's size rather than to its component graph — and + // the traversal budget could then be exhausted by padding before the walk + // reached a real cycle. Filtering makes the budget a backstop rather than + // the thing that decides the answer. + let node_names: Vec = edges.iter().map(|(n, _, _)| n.clone()).collect(); + for (_, refs, _) in edges.iter_mut() { + refs.retain(|r| node_names.contains(r)); + refs.dedup(); + } + + let referenced = |name: &str| -> Option<&Vec> { + edges.iter().find(|(n, _, _)| n == name).map(|(_, r, _)| r) + }; + + for (name, _, line) in &edges { + let mut seen: Vec<&str> = vec![name.as_str()]; + let mut queue: Vec<&str> = referenced(name) + .map(|r| r.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let mut steps = 0usize; + while let Some(next) = queue.pop() { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS { + break; + } + if next == name.as_str() { + sink.push( + *line, + 1, + format!( + "{name:?} is recursive; L0 requires an acyclic declaration graph \ + (profile §6.1)" + ), + ); + break; + } + if seen.contains(&next) { + continue; + } + seen.push(next); + if let Some(more) = referenced(next) { + queue.extend(more.iter().map(|s| s.as_str())); + } + } + } +} + +/// Names a body SPLICES — `view` references, as distinct from component calls. +fn collect_references(element: &Element, out: &mut Vec) { + if element.is_reference { + out.push(element.name.clone()); + } + for child in &element.children { + collect_references(child, out); + } +} + +/// Names visible to one record. Profile §5.3: a component sees its props, its own +/// state and events, and card-scope `source` and `copy` — but never card `state`. +struct Scope { + roots: Vec, + /// Roots that name a source. Only these carry a `$state` lifecycle. + source_roots: Vec, + /// Declared copy, so a rendering position can refuse model-authored text. + copies: Vec, + events: Vec, + /// Card state, tracked separately so reading it from a component is a + /// specific diagnostic rather than "unknown name". + forbidden_state: Vec, +} + +impl Scope { + fn card(card: &Card) -> Self { + let mut roots: Vec = Vec::new(); + // The FULL declared name. Storing the root meant `source env.locale` + // authorised `env.theme.secret` — an undeclared dependency the card + // then renders, which §4 says is a defect by construction. + roots.extend(card.sources.iter().map(|s| s.name.clone())); + roots.extend(card.states.iter().map(|s| root_of(&s.path))); + roots.push("copy".into()); + let events = card.events.iter().map(|e| e.name.clone()).collect(); + Self { + roots, + // The FULL declared name, not its root. `source env.locale` answers to + // `env.locale.$state`; comparing against the root alone rejected that + // while accepting `env.$state`, which names no source and has no + // lifecycle to report. + source_roots: card.sources.iter().map(|s| s.name.clone()).collect(), + copies: card.copies.clone(), + events, + forbidden_state: Vec::new(), + } + } + + fn component(card: &Card, component: &Component) -> Self { + let mut roots: Vec = Vec::new(); + roots.extend(card.sources.iter().map(|s| s.name.clone())); + roots.push("copy".into()); + roots.extend(component.params.iter().map(|p| p.name.clone())); + roots.extend(component.states.iter().map(|s| root_of(&s.path))); + let mut events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + // Events arrive as props too — §5.4, outputs are event props. Only the + // ones DECLARED as events: every param used to be added here, so a + // `text` prop could be handed to `on_tap` and a runtime string that + // happened to match an event name would route it. + events.extend( + component + .params + .iter() + .filter(|p| matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); + Self { + roots, + source_roots: card.sources.iter().map(|s| s.name.clone()).collect(), + copies: card.copies.clone(), + events, + forbidden_state: card.states.iter().map(|s| root_of(&s.path)).collect(), + } + } + + /// Whether `path` is covered by a declared name. + /// + /// A declared name matches its own segments only: `env.locale` answers + /// `env.locale.lang` and does NOT answer `env.theme.secret`. Comparing + /// roots made every sibling of a dotted source readable. + fn knows(&self, path: &str) -> bool { + self.roots.iter().any(|r| { + path == r + || path + .strip_prefix(r.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }) + } +} + +fn root_of>(path: S) -> String { + path.as_ref() + .split('.') + .next() + .unwrap_or_default() + .to_string() +} + +#[allow(clippy::too_many_arguments)] +fn walk( + data_positions: &BTreeMap>, + element: &Element, + scope: &mut Scope, + views: &[&str], + components: &[&str], + card: &Card, + sink: &mut Diagnostics, +) { + match element.name.as_str() { + "" | "slot" | "into" => {} + "for" => { + // The key expression names the loop's own binder. Its first segment + // was discarded unconditionally, so `key typo.id` behaved exactly + // like `key item.id` — and §5.1's claim that changing a key moves + // identity was false, because the name was never read. + if let (Some(binder), Some(key)) = (element.binders.first(), &element.key_path) { + let root = root_of(key); + if root != *binder { + sink.push( + element.line, + element.column, + format!( + "the key names {root:?}, but this loop binds {binder:?} — a key \ + must be a path into the item being iterated (profile §5.1)" + ), + ); + } + } + for binder in &element.binders { + scope.roots.push(binder.clone()); + } + if let Some(Arg { + value: Operand::Path(p), + line, + column, + .. + }) = element.args.first() + { + check_path(p, scope, *line, *column, sink); + } + } + "when" => { + if let Some(Arg { + value: Operand::Path(p), + line, + column, + .. + }) = element.args.first() + { + check_path(p, scope, *line, *column, sink); + // 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); + } + } + } + _ if element.is_reference => { + if !views.contains(&element.name.as_str()) { + sink.push( + element.line, + element.column, + format!("{:?} is not a declared view", element.name), + ); + } + } + name => { + let known = catalog::lookup(name); + let is_component = components.contains(&name); + if is_component { + if let Some(component) = card.components.iter().find(|c| c.name == name) { + let offered = slot_names(component); + for child in &element.children { + if child.name != "into" { + continue; + } + let wanted = child.binders.first().cloned().unwrap_or_default(); + if !offered.contains(&wanted) { + let list = if offered.iter().all(|s| s.is_empty()) { + "it has only an anonymous slot".to_string() + } else { + format!( + "it offers: {}", + offered + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(", ") + ) + }; + sink.push( + child.line, + child.column, + format!( + "{name:?} has no slot named {wanted:?}, so these children \ + would not render — {list} (profile §5.5)" + ), + ); + } + } + } + } + if known.is_none() && !is_component { + sink.push( + element.line, + element.column, + format!("{name:?} is not an L0 constructor or a declared component"), + ); + } + for arg in &element.args { + check_arg( + data_positions, + name, + arg, + known, + is_component, + scope, + card, + sink, + ); + } + } + } + + for child in &element.children { + walk(data_positions, child, scope, views, components, card, sink); + } + + if element.name == "for" { + for _ in &element.binders { + scope.roots.pop(); + } + } +} + +/// Eight parameters because a card-level check needs card-level context: +/// the catalog entry, the scope, the card, and the data-position map are all +/// consulted per argument. Splitting them into a struct would move the noise +/// rather than remove it. +#[allow(clippy::too_many_arguments)] +fn check_arg( + data_positions: &BTreeMap>, + ctor: &str, + arg: &Arg, + known: Option, + is_component: bool, + scope: &Scope, + card: &Card, + sink: &mut Diagnostics, +) { + use catalog::ArgKind::*; + + // A component's own props are declared by the card, not the catalog. + let kind = match known { + Some(args) => match args.iter().find(|(n, _)| *n == arg.name) { + Some((_, k)) => Some(*k), + None => { + sink.push( + arg.line, + arg.column, + format!("{ctor} has no argument {:?}", arg.name), + ); + return; + } + }, + None if is_component => { + // A component's props are declared by the CARD, not the catalog — + // but they ARE declared, so an argument the component does not + // accept is a typo, not a pass-through. It used to be accepted and + // then silently dropped at realization, rendering an em dash. + if let Some(component) = card.components.iter().find(|c| c.name == ctor) { + match component.params.iter().find(|p| p.name == arg.name) { + Some(param) => { + check_prop(ctor, arg, param, scope, sink); + // §4 again, one level out: a literal handed to a prop + // that lands in a data position is the same authored + // fact, just laundered. + if matches!(arg.value, Operand::Str(_) | Operand::Num(_)) + && data_positions + .get(ctor) + .is_some_and(|ps| ps.contains(&arg.name)) + { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} reaches a data position and must be bound; \ + a literal passed here is a model-authored fact \ + (profile §4)", + arg.name + ), + ); + } + } + None => { + let mut names: Vec<&str> = + component.params.iter().map(|p| p.name.as_str()).collect(); + names.sort(); + sink.push( + arg.line, + arg.column, + format!( + "{ctor} has no prop {:?}; it declares: {}", + arg.name, + if names.is_empty() { + "(none)".to_string() + } else { + names.join(", ") + } + ), + ); + } + } + } + None + } + None => return, + }; + + match (&arg.value, kind) { + (Operand::Token(tok), Some(Token(set) | TokenOrPath(set))) => { + let bare = tok.trim_start_matches('.'); + if !set.contains(&bare) { + sink.push( + arg.line, + arg.column, + format!( + "{tok:?} is not a legal token for {ctor}.{}; expected one of {}", + arg.name, + set.iter() + .map(|t| format!(".{t}")) + .collect::>() + .join(", ") + ), + ); + } + } + (Operand::Str(lit), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the literal {lit:?} — a literal in a \ + data position is a model-authored fact (profile §4)", + arg.name + ), + ); + } + (Operand::Num(lit), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the literal {lit} — a literal in a \ + data position is a model-authored fact (profile §4)", + arg.name + ), + ); + } + // A data position renders a fact and must be bound, not authored. + (Operand::Token(t), Some(Data)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {t:?} is a token \ + (profile §4)", + arg.name + ), + ); + } + (Operand::Str(_) | Operand::Num(_), Some(Data)) => { + let shown = match &arg.value { + Operand::Num(n) => format!("{n}"), + Operand::Str(t) => format!("{t:?}"), + other => format!("{other:?}"), + }; + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {shown} is a literal the \ + model authored (profile §4). Layout numbers like `gap:` may be literal; \ + a measurement may not", + arg.name + ), + ); + } + // A number position rendering a fact must be bound, not authored. + // `TextHero(value: "34 mph")` is the case §4 exists to make + // unrepresentable: units concatenated onto a value the model invented. + (Operand::Str(lit), Some(Number)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {lit:?} is a literal, and \ + text in a number position is a fact the model authored (profile §4). \ + Units belong in `unit:`, formatting in `format:`", + arg.name + ), + ); + } + (Operand::Token(tok), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the token {tok:?}", + arg.name + ), + ); + } + (Operand::Path(path), Some(Token(_))) => { + // Enum members are tokens; a bare name here is a path and cannot + // stand in for one. + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a token; {path:?} is a path — tokens carry a leading dot", + arg.name + ), + ); + } + (Operand::Path(path), Some(Event)) if !scope.events.iter().any(|e| e == path) => { + sink.push( + arg.line, + arg.column, + format!("{path:?} is not a declared event"), + ); + } + (Operand::Path(_), Some(Event)) => {} + // A component prop has no catalogued kind, and may legitimately receive an + // event name — profile §5.4, outputs are event props. Accept either. + (Operand::Path(path), None) if scope.events.iter().any(|e| e == path) => {} + (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); + } + } + _ => {} + } + + let _ = card; +} + +/// Check an argument against the prop it fills — §5.2. +/// +/// Only the cases a card can actually get wrong and that the shape decides: +/// an event where a value belongs, a value where an event belongs, and a token +/// outside a declared enum. Anything else is left alone, because a prop shape +/// is a contract about the DATA and most positions legitimately accept a path +/// whose type is not knowable until the host answers. +/// Which of a component's params end up in a DATA position — an argument the +/// catalog declares as `Data` or `Path`, i.e. one that renders a fact. +/// +/// Without this, §4 is enforced only at the literal's immediate call site, and +/// laundering it through one prop defeats it entirely: +/// +/// ```text +/// component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) } +/// view root Bar(n: 41.2) +/// ``` +/// +/// Computed to a fixpoint so passing the value through a chain of components is +/// no better a hiding place than passing it directly. +fn data_position_params(card: &Card) -> BTreeMap> { + let mut tainted: BTreeMap> = BTreeMap::new(); + + // Bound by the number of PARAM HOPS a value can take, not by the component + // count. A single component whose params chain a -> b -> c -> data needs + // three rounds, which `N + 1` with N = 1 does not allow — and the loop must + // still terminate if the graph turns out to be cyclic, since this runs + // before acyclicity is proven. + let rounds = card.components.len() + + card + .components + .iter() + .map(|c| c.params.len()) + .sum::() + + 2; + for _ in 0..rounds { + let mut changed = false; + for component in &card.components { + let names: Vec<&str> = component.params.iter().map(|p| p.name.as_str()).collect(); + let mut found: Vec = Vec::new(); + + fn walk( + element: &Element, + names: &[&str], + tainted: &BTreeMap>, + out: &mut Vec, + ) { + let known = catalog::lookup(&element.name); + for arg in &element.args { + let Operand::Path(path) = &arg.value else { + continue; + }; + let root = root_of(path); + if !names.iter().any(|n| *n == root) { + continue; + } + // Directly in a data position... + let direct = known + .and_then(|a| a.iter().find(|(n, _)| *n == arg.name)) + .is_some_and(|(_, k)| { + matches!(k, catalog::ArgKind::Data | catalog::ArgKind::Path) + }); + // ...or handed to another component that puts it in one. + let onward = tainted + .get(&element.name) + .is_some_and(|ps| ps.contains(&arg.name)); + if (direct || onward) && !out.contains(&root) { + out.push(root); + } + } + for child in &element.children { + walk(child, names, tainted, out); + } + } + + walk(&component.body, &names, &tainted, &mut found); + let entry = tainted.entry(component.name.clone()).or_default(); + for name in found { + if !entry.contains(&name) { + entry.push(name); + changed = true; + } + } + } + if !changed { + break; + } + } + tainted +} + +fn check_prop(ctor: &str, arg: &Arg, param: &Param, scope: &Scope, sink: &mut Diagnostics) { + let name = ¶m.name; + match (¶m.shape, &arg.value) { + // An event prop must receive an event, and nothing else may. + (Shape::Event, Operand::Path(p)) if !scope.events.iter().any(|e| e == p) => { + sink.push( + arg.line, + arg.column, + format!("{ctor}.{name} takes an event; {p:?} is not a declared event"), + ); + } + (Shape::Event, Operand::Str(_) | Operand::Num(_) | Operand::Token(_)) => { + sink.push( + arg.line, + arg.column, + format!("{ctor}.{name} takes an event, not a literal"), + ); + } + (shape, Operand::Path(p)) + if !matches!(shape, Shape::Event) && scope.events.iter().any(|e| e == p) => + { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{name} takes a value, but {p:?} is an event — declare the prop \ + as `{name}: event` to pass one (profile §5.4)" + ), + ); + } + (Shape::Enum(members), Operand::Token(t)) => { + let bare = t.trim_start_matches('.'); + if !members.iter().any(|m| m == bare) { + sink.push( + arg.line, + arg.column, + format!( + "{t:?} is not a member of {ctor}.{name}; it accepts {}", + members + .iter() + .map(|m| format!(".{m}")) + .collect::>() + .join(", ") + ), + ); + } + } + _ => {} + } +} + +fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut Diagnostics) { + if path.is_empty() { + return; + } + + // `copy.x` where `x` is model-authored. §4's whole point: a model-written + // string on screen is indistinguishable from a fact the model invented, so + // the class exists to be refused rather than to be recorded. + if let Some(name) = path.strip_prefix("copy.") { + if let Some(decl) = scope.copies.iter().find(|c| c.name == name) { + if decl.provenance == Provenance::ModelCopy { + sink.push( + line, + column, + format!( + "copy.{name} is `model-copy` and may not be rendered; only \ + `vocabulary` and `user-copy` may (profile §4)" + ), + ); + } + } else { + sink.push(line, column, format!("copy.{name} is not declared")); + } + return; + } + + // A `[inner]` segment holds a path of its own. Checking it here is what + // makes `stories[typo].title` a diagnostic rather than a silent em dash. + for segment in path.split('.') { + if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + check_path(inner, scope, line, column, sink); + } + } + let root = root_of(path); + + // `.$state` is the fetch lifecycle (§5.9). It is meaningful only for + // something that is fetched — a local state has no pending or failed. + if let Some(owner) = path.strip_suffix(".$state") { + if scope.source_roots.iter().any(|r| r == owner) { + return; + } + sink.push( + line, + column, + format!("`$state` is only available on a source; {owner:?} is not one (profile §5.9)"), + ); + return; + } + if path.contains("$state") { + sink.push( + line, + column, + "`$state` must be the last segment of a path (profile §5.9)".to_string(), + ); + return; + } + + // The whole path, so a declared name can be matched by its own segments. + if scope.knows(path) { + return; + } + if scope.forbidden_state.contains(&root) { + sink.push( + line, + column, + format!( + "a component may not read card state {root:?}; pass it as a prop (profile §5.3)" + ), + ); + return; + } + sink.push(line, column, format!("{root:?} is not a declared name")); +} + +// ─────────────────────────────────────────────────────────────────── realization ── + +/// A realized node. Renderer-neutral: a backend maps `kind` to its own widget. +#[derive(Clone, Debug, PartialEq)] +pub struct UiNode { + pub kind: String, + /// Instance identity — the declaration path plus every enclosing loop key. + /// Never derived from position, so an edit that inserts a sibling above does + /// not rebind this node's state (profile §5.1). + pub key: String, + pub args: Vec<(String, NodeValue)>, + pub children: Vec, +} + +/// A resolved argument value. +#[derive(Clone, Debug, PartialEq)] +pub enum NodeValue { + Text(String), + Number(f64), + Bool(bool), + /// A `.token`, carried through with its meaning intact for the backend. + Token(String), + /// A declared event name for the runtime to bind at dispatch — never a + /// string constructed by the card (profile §3.1). + Event(String), + /// A binding that resolved to nothing. Surfaced rather than defaulted: a + /// silently empty field is how a card renders an em dash and looks fine. + Missing, + /// A source's lifecycle, readable as `now.$state` — §5.9. A card can show a + /// spinner or an error rather than an em dash, and the distinction between + /// "not yet" and "failed" is the runtime's to report, not the card's to + /// guess from an absent value. + Status(SourceStatus), +} + +/// Where a source is in its lifecycle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceStatus { + /// Never resolved. + Pending, + /// Resolved, and current. + Ready, + /// Resolved, but the value is past its freshness budget. + Stale, + /// The fetch failed. The card may say so; it may not say why. + Failed, +} + +impl SourceStatus { + pub fn as_token(self) -> &'static str { + match self { + SourceStatus::Pending => "pending", + SourceStatus::Ready => "ready", + SourceStatus::Stale => "stale", + SourceStatus::Failed => "failed", + } + } +} + +/// Traversal bounds. L0 has no expressions, so there is nothing to evaluate and +/// no instruction budget to set — these bound the walk instead. +#[derive(Clone, Copy, Debug)] +pub struct RealizeLimits { + pub max_nodes: usize, + pub max_depth: usize, + pub max_collection: usize, +} + +impl Default for RealizeLimits { + fn default() -> Self { + Self { + max_nodes: 8_192, + max_depth: 64, + max_collection: 512, + } + } +} + +#[derive(Clone, Debug)] +pub struct RealizeReport { + pub root: Option, + pub diagnostics: Vec, + pub nodes: usize, + /// A bound was reached; the tree is partial. + pub truncated: bool, + /// Every component instance this realization produced, plus the card cell. + /// Pass to [`InstanceStore::prune`] to forget instances that went away — + /// without it the store grows for the life of the app, and a key that + /// reappears inherits a stale value (§5.7, unmount). + pub live_keys: Vec, + /// Nodes carried over from a previous tree by [`realize_patch`] rather than + /// rebuilt. Zero for a full realization. + pub reused: usize, +} + +/// Realize a card against resolved data. +/// +/// `data` carries what the runtime fetched and holds: source results, state +/// values and copy, keyed by declaration name. **No capability is reachable from +/// here** — not because one is blocked, but because realization is a tree walk +/// and there is no evaluator to reach anything with. +pub fn realize(source: &str, data: &serde_json::Value, limits: RealizeLimits) -> RealizeReport { + realize_inner(source, data, None, limits) +} + +fn realize_inner( + source: &str, + data: &serde_json::Value, + store: Option<&InstanceStore>, + limits: RealizeLimits, +) -> RealizeReport { + let mut sink = Diagnostics::default(); + + let check = check_ui_l0_named("card.l0", source); + if !check.valid { + return RealizeReport { + root: None, + diagnostics: check.diagnostics, + nodes: 0, + truncated: false, + live_keys: Vec::new(), + reused: 0, + }; + } + + let tokens = match lex(source, &mut sink) { + Some(t) => t, + None => { + return RealizeReport { + root: None, + diagnostics: sink.items, + nodes: 0, + truncated: false, + live_keys: Vec::new(), + reused: 0, + } + } + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + let Some(root) = card.views.iter().find(|v| v.name == "root") else { + sink.push(1, 1, "a card needs a `view root`".into()); + return RealizeReport { + root: None, + diagnostics: sink.items, + nodes: 0, + truncated: false, + live_keys: Vec::new(), + reused: 0, + }; + }; + + let mut ctx = Realizer { + depth: 0, + card: &card, + limits, + nodes: 0, + truncated: false, + sink: &mut sink, + store, + live: vec![CARD_STATE_KEY.to_string()], + slots: Vec::new(), + }; + // Card-scope state resolves from the store first, then the injected data, + // 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(); + for state in &card.states { + let value = 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)); + } + let mut scope = ValueScope { + frames, + data, + copies: &card.copies, + }; + let mut out = Vec::new(); + ctx.element(&root.body, &mut scope, "root", &mut out); + + let nodes = ctx.nodes; + let truncated = ctx.truncated; + let live_keys = std::mem::take(&mut ctx.live); + RealizeReport { + root: out.into_iter().next(), + reused: 0, + diagnostics: sink.items, + nodes, + truncated, + live_keys, + } +} + +/// Bindings introduced by loops and component props, innermost last. +struct ValueScope<'a> { + frames: Vec<(String, serde_json::Value)>, + data: &'a serde_json::Value, + copies: &'a [CopyDecl], +} + +impl ValueScope<'_> { + fn lookup(&self, path: &str) -> Option { + let mut segments = path.split('.'); + let root = segments.next()?; + + // `.$state` is the lifecycle the runtime reported. It is data + // like any other, so a guard can branch on it without an expression. + if let Some(source) = path.strip_suffix(".$state") { + // A dotted source name navigates the data, so `env.locale` looks + // under {env: {locale: …}} rather than for a key spelled + // "env.locale" — which is nowhere. + let resolved = data_path(self.data, source); + let status = self + .data + .get("$status") + .and_then(|m| m.get(source)) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + // No report means: resolved if there is a value, pending if + // not. A host that tracks status explicitly overrides this. + if resolved.is_some() { + "ready" + } else { + "pending" + } + }); + return Some(serde_json::Value::String(status.to_string())); + } + + // `copy.` is authored in the ledger, not fetched. Resolve it from + // the card's declarations, choosing the language the runtime reported + // and falling back to English — a card that ships zh text should not + // render an em dash because the device is in en. + if root == "copy" { + let name = segments.next()?; + let entry = self.copies.iter().find(|c| c.name == name)?; + let lang = self + .data + .get("env") + .and_then(|e| e.get("locale")) + .and_then(|l| l.get("lang")) + .and_then(|v| v.as_str()) + .unwrap_or("en"); + let text = entry + .locales + .iter() + .find(|(k, _)| k == lang) + .or_else(|| entry.locales.iter().find(|(k, _)| k == "en")) + .or_else(|| entry.locales.first())?; + 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(), + None => self.data.get(root)?.clone(), + }; + for segment in segments { + // `[inner]` — resolve the inner path, then index by its value. + if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let key = self.lookup(inner)?; + current = match &key { + serde_json::Value::String(k) => current.get(k.as_str())?.clone(), + v => current.get(v.as_u64()? as usize)?.clone(), + }; + continue; + } + current = match segment.parse::() { + Ok(index) => current.get(index)?.clone(), + Err(_) => current.get(segment)?.clone(), + }; + } + Some(current) + } +} + +struct Realizer<'a> { + card: &'a Card, + limits: RealizeLimits, + nodes: usize, + /// Actual recursion depth. Previously approximated by the instance key's + /// LENGTH, which bounded neither reliably. + depth: usize, + truncated: bool, + sink: &'a mut Diagnostics, + /// Where component-local values live. Absent means every instance sees its + /// declared initial. + store: Option<&'a InstanceStore>, + /// Instance keys produced, for pruning. + live: Vec, + /// Children passed at a component call site, realized where `slot` appears. + /// A stack, so a component instantiated inside another's slot still sees its + /// own children. + slots: Vec)>>, +} + +impl Realizer<'_> { + fn element( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + if self.nodes >= self.limits.max_nodes || self.depth >= self.limits.max_depth { + self.truncated = true; + return; + } + self.depth += 1; + self.element_inner(element, scope, key, out); + self.depth -= 1; + } + + fn element_inner( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + match element.name.as_str() { + "" => {} + "slot" => { + // Children are realized in the CALLER's scope, not the + // component's — a child expression sees the call site's + // bindings (§5.5). Taking the frame off the stack while + // realizing prevents a component's own slot from re-entering. + if let Some(groups) = self.slots.pop() { + // An unnamed `slot` takes the default group; `slot header` + // takes the group a call site directed with `into header`. + let wanted = element.binders.first().cloned().unwrap_or_default(); + if let Some((_, children)) = groups.iter().find(|(n, _)| *n == wanted) { + for (segment, child) in sibling_segments(children) { + self.element(child, scope, &format!("{key}/s:{segment}"), out); + } + } + self.slots.push(groups); + } + } + "for" => self.iteration(element, scope, key, out), + "when" => { + if self.guard_holds(element, scope) { + for (segment, child) in sibling_segments(&element.children) { + self.element(child, scope, &format!("{key}/w:{segment}"), out); + } + } + } + _ if element.is_reference => { + let card = self.card; + match card.views.iter().find(|v| v.name == element.name) { + Some(view) => { + // Keyed by the referenced view's own name, so its nodes + // keep identity wherever it is spliced. + self.element(&view.body, scope, &format!("{key}/{}", element.name), out); + } + None => self.sink.push( + element.line, + element.column, + format!("{:?} is not a declared view", element.name), + ), + } + } + name => { + let card = self.card; + if let Some(component) = card.components.iter().find(|c| c.name == name) { + self.component(component, element, scope, key, out); + } else { + self.constructor(element, scope, key, out); + } + } + } + } + + fn constructor( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + self.nodes += 1; + let mut node = UiNode { + kind: element.name.clone(), + key: key.to_string(), + args: Vec::new(), + children: Vec::new(), + }; + for arg in &element.args { + let value = self.value(&element.name, &arg.name, &arg.value, scope); + node.args.push((arg.name.clone(), value)); + } + for (segment, child) in sibling_segments(&element.children) { + self.element( + child, + scope, + &format!("{key}/{segment}"), + &mut node.children, + ); + } + out.push(node); + } + + fn component( + &mut self, + component: &Component, + call: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + // Props bind by name; a prop the caller omits is simply absent, and any + // path inside the component resolves against this frame first. + // Bind by DECLARED SHAPE. The shape was retained at parse and then + // discarded here, which cost two bugs: an event prop was looked up as + // data, so a state whose name collided with an event hijacked the + // routing; and an enum token bound with its leading dot, so every + // comparison against it inside the component was false. + let mut bound = 0usize; + for param in &component.params { + let supplied = call.args.iter().find(|a| a.name == param.name); + let value = match (supplied, ¶m.shape) { + // An event names itself. It has no value in data, and looking + // one up is how the wrong event came to fire. + (Some(arg), Shape::Event) => match &arg.value { + Operand::Path(p) => serde_json::Value::String(p.clone()), + other => literal_of(other), + }, + // A token is a bare member; the dot is lexical marking, not + // part of the value. + (Some(arg), _) => match &arg.value { + Operand::Token(t) => { + serde_json::Value::String(t.trim_start_matches('.').to_string()) + } + Operand::Path(p) | Operand::Predicate { path: p, .. } => scope + .lookup(p) + .unwrap_or_else(|| serde_json::Value::String(p.clone())), + other => literal_of(other), + }, + // Omitted: the declared default, or nothing. Binding a frame + // either way is what stops lookup falling through to injected + // 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)); + bound += 1; + } + let instance_key = format!("{key}/{}", component.name); + self.live.push(instance_key.clone()); + self.slots.push(split_slots(&call.children)); + + // Local state resolves PER INSTANCE: from the store when it holds a + // value for this key, otherwise from the declared initial. Two instances + // of one component therefore never share a cell — the property the + // component model exists to provide. + let schema = schema_id(component); + for state in &component.states { + // A live cell is readable when the schema still matches, or — under + // §5.8's opt-in — when this field asked to survive and its own shape + // is what it was written under. The shape check is what keeps + // `keep` from becoming the guess the reset exists to prevent. + let live = self + .store + .filter(|s| { + s.schemas.get(&component.name).is_none_or(|k| { + *k == schema || state.keep && s.shape_unchanged(component, state) + }) + }) + .and_then(|s| s.get(&instance_key, &state.path)) + .cloned(); + let from_path = state.initial_path.as_ref().and_then(|p| scope.lookup(p)); + scope.frames.push(( + state.path.clone(), + live.or_else(|| state.initial.clone()) + .or(from_path) + .unwrap_or_else(|| initial_for(&state.shape)), + )); + bound += 1; + } + + self.element(&component.body, scope, &instance_key, out); + self.slots.pop(); + for _ in 0..bound { + scope.frames.pop(); + } + } + + fn iteration( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + let Some(Arg { + value: Operand::Path(path), + .. + }) = element.args.first() + 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 Some(items) = collection.as_array() else { + self.sink.push( + element.line, + element.column, + format!("{path:?} is not a collection"), + ); + return; + }; + + // §5.7: duplicate keys are an error at realization. Untracked, two items + // with the same key produced the same instance key — one state cell + // shared by two rows, so tapping either toggled both. + let mut seen_keys: Vec = Vec::new(); + + for (index, item) in items.iter().enumerate() { + if index >= self.limits.max_collection { + self.truncated = true; + break; + } + // The key expression is evaluated per item, and identity is the + // enclosing key plus it — never the index. + let item_key = element + .key_path + .as_deref() + .and_then(|k| { + let mut segments = k.split('.'); + segments.next()?; + let mut current = item.clone(); + for segment in segments { + current = current.get(segment)?.clone(); + } + Some(json_to_key(¤t)) + }) + .unwrap_or_else(|| index.to_string()); + + // Report the collision, then disambiguate so the two instances do + // not share a cell. Rendering both with distinct identity is safer + // than dropping one: a missing row is harder to notice than a + // diagnostic. + let item_key = if seen_keys.contains(&item_key) { + self.sink.push( + element.line, + element.column, + format!( + "duplicate key {item_key:?} in this loop; keys must be unique or two \ + instances share one state cell (profile §5.7)" + ), + ); + format!("{item_key}#{index}") + } else { + seen_keys.push(item_key.clone()); + item_key + }; + + let mut bound = 0usize; + if let Some(binder) = element.binders.first() { + scope.frames.push((binder.clone(), item.clone())); + bound += 1; + } + if let Some(index_binder) = element.binders.get(1) { + scope + .frames + .push((index_binder.clone(), serde_json::Value::from(index + 1))); + bound += 1; + } + + for (segment, child) in sibling_segments(&element.children) { + self.element(child, scope, &format!("{key}[{item_key}]/{segment}"), out); + } + + for _ in 0..bound { + scope.frames.pop(); + } + } + } + + fn guard_holds(&mut self, element: &Element, scope: &mut ValueScope) -> bool { + let Some(Arg { + value: Operand::Path(path), + .. + }) = element.args.first() + else { + return false; + }; + let left = scope.lookup(path); + + let Some(cmp) = element.cmp.as_deref() else { + // Bare boolean guard — amendment #10. + return matches!(left, Some(serde_json::Value::Bool(true))); + }; + let right = element.rhs.as_ref().and_then(|r| scope_value(scope, r)); + compare(left, cmp, right) + } + + fn value( + &mut self, + ctor: &str, + arg: &str, + operand: &Operand, + scope: &mut ValueScope, + ) -> NodeValue { + // An event argument carries a declared NAME through to the runtime, + // which binds it at dispatch. Nothing about it is looked up in data, and + // nothing about it is constructed by the card. + let is_event = catalog::lookup(ctor) + .and_then(|args| args.iter().find(|(n, _)| *n == arg)) + .is_some_and(|(_, k)| matches!(k, catalog::ArgKind::Event)); + if is_event { + if let Operand::Path(p) = operand { + // Through a component prop the name arrives bound; directly it + // is the path itself. + return match scope.lookup(p) { + Some(serde_json::Value::String(name)) => NodeValue::Event(name), + _ => NodeValue::Event(p.clone()), + }; + } + } + match operand { + Operand::Token(t) => NodeValue::Token(t.trim_start_matches('.').to_string()), + Operand::Str(s) => NodeValue::Text(s.clone()), + Operand::Num(n) => NodeValue::Number(*n), + Operand::Predicate { path, cmp, rhs } => { + NodeValue::Bool(compare(scope.lookup(path), cmp, scope_value(scope, rhs))) + } + 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), + Some(v) => match v.as_f64() { + Some(n) => NodeValue::Number(n), + None => NodeValue::Missing, + }, + None => NodeValue::Missing, + }, + } + } +} + +fn json_to_key(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +// ──────────────────────────────────────────────────────── makepad text lowering ── + +/// Lower a realized tree to the Splash DSL a Makepad host renders. +/// +/// **This is one backend's lowering and belongs in Splash-Makepad**, not here. It +/// lives beside the node model only until that crate is wired up; nothing in +/// [`realize`] depends on it, and a different backend consumes [`UiNode`] +/// directly rather than going through text. +/// +/// The output is fully concrete: realization already substituted the runtime's +/// data, so the DSL carries values rather than `sys.*` calls. That is the ledger +/// discipline holding — the card declares questions, and only the *realized +/// output* contains answers. +pub mod makepad { + use super::{NodeValue, UiNode}; + use std::fmt::Write as _; + + // Matching octos-one's `plan/common.rs`, so a lowered card sits beside the + // existing ones rather than looking like a different app. + const BASE: &str = "#0a0e14"; + const SCRIM: &str = "#00000066"; + const PANEL: &str = "#ffffff12"; + /// A selected chip. Brighter than PANEL so "which one is active" is legible + /// at a glance rather than a subtle tint. + const ACTIVE: &str = "#ffffff38"; + const HAIRLINE: &str = "#ffffff1a"; + const TEXT: &str = "#ffffff"; + const SOFT: &str = "#ffffffe6"; + const DIM: &str = "#ffffff99"; + const UP: &str = "#32d74b"; + const DOWN: &str = "#ff453a"; + const PAGE_PAD: &str = "Inset{left: 20 top: 54 right: 20 bottom: 24}"; + + pub fn lower(root: &UiNode) -> String { + let mut out = String::new(); + let _ = writeln!(out, "// name: l0-card"); + let _ = writeln!(out, "// REALIZED from an L0 ledger — do not edit."); + element(root, 0, &mut out); + out + } + + /// The tap binding for a node that declares `on_tap`. + /// + /// `UiNode` carried the event name and the instance key all along and + /// lowering emitted neither, so every Row, Card, Chip and TextHero rendered + /// inert: the stock range controls could not reach `dispatch` at all. The + /// key is what lets a tap name WHICH instance was hit, which is the whole + /// basis of §5.1 identity. + fn tap_binding(node: &UiNode) -> String { + let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { + return String::new(); + }; + let mut out = format!(" l0_event: {event:?} l0_key: {:?}", node.key); + // `value:` is the payload the event carries — `$value` at the receiving + // transition. Without it a tap says "this happened" but not "to what". + match arg(node, "value") { + Some(NodeValue::Text(t)) => out.push_str(&format!(" l0_value: {t:?}")), + Some(NodeValue::Number(n)) => out.push_str(&format!(" l0_value: {:?}", trim_num(*n))), + Some(NodeValue::Token(t)) => out.push_str(&format!(" l0_value: {t:?}")), + _ => {} + } + out + } + + fn arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a NodeValue> { + node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) + } + + /// 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. + 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, + } + } + + pub(super) fn trim_num(n: f64) -> String { + if (n - n.round()).abs() < f64::EPSILON { + format!("{}", n as i64) + } else { + format!("{n:.1}") + } + } + + /// Apply a declared `format`. This is the runtime's job, not the card's: a + /// card that wrote "$" or "41.2M" itself would be authoring a fact, and + /// currency, grouping and compaction are all locale-dependent besides. + fn formatted(kind: &str, n: f64) -> String { + let sign = if n > 0.0 { "+" } else { "" }; + match kind { + "money" => format!("${:.2}", n), + "signed_money" => format!("{sign}${:.2}", n), + "signed_pct" => format!("{sign}{:.1}%", n), + "ratio" => format!("{:.1}", n), + "compact" => { + let a = n.abs(); + let (v, suffix) = if a >= 1e12 { + (n / 1e12, "T") + } else if a >= 1e9 { + (n / 1e9, "B") + } else if a >= 1e6 { + (n / 1e6, "M") + } else if a >= 1e3 { + (n / 1e3, "K") + } else { + (n, "") + }; + if suffix.is_empty() { + trim_num(v) + } else { + format!("{:.1}{suffix}", v) + } + } + // A float hour — 18.9 is 18:54, not "18.9". + "time" => { + let h = n.floor().max(0.0) as i64; + let m = ((n - n.floor()) * 60.0).round() as i64; + format!("{h}:{m:02}") + } + _ => trim_num(n), + } + } + + /// A value plus its unit suffix, which is a presentation concern the runtime + /// owns — the card never wrote "°" next to a number. + fn valued(node: &UiNode) -> String { + let value = arg(node, "value"); + let 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" => "%", + _ => "", + }; + let glyph = match arg(node, "glyph") { + Some(NodeValue::Text(g)) => g.clone(), + _ => String::new(), + }; + // `suffix` is a declared unit word — "412 pts", not "412". It was in the + // catalog and ignored here, so every score and comment count on the news + // card rendered as a bare number. + let suffix = match arg(node, "suffix") { + Some(NodeValue::Text(t)) => format!(" {t}"), + _ => String::new(), + }; + let format_kind = match arg(node, "format") { + Some(NodeValue::Token(t)) => Some(t.clone()), + _ => None, + }; + match value { + Some(NodeValue::Missing) => "\"—\"".into(), + Some(v) => { + let body = match (v, format_kind.as_deref()) { + (NodeValue::Number(n), Some(kind)) => formatted(kind, *n), + (NodeValue::Number(n), None) => trim_num(*n), + (NodeValue::Text(s), _) => s.clone(), + _ => String::new(), + }; + format!("{:?}", format!("{glyph}{body}{unit}{suffix}")) + } + None => text_of(arg(node, "text")), + } + } + + fn pad(depth: usize) -> String { + " ".repeat(depth.min(32)) + } + + fn children(node: &UiNode, depth: usize, out: &mut String) { + for child in &node.children { + element(child, depth + 1, out); + } + } + + /// A node that declares `on_tap`, wrapped so the tap can actually be + /// received. + /// + /// `tap_binding` alone emits `l0_event`/`l0_key`/`l0_value` as attributes, + /// and nothing reads them: the VM has no hit-testing for an arbitrary + /// attribute, so every one of those cards rendered inert. The mechanism that + /// DOES work on device is the one hand-written cards already use — a + /// transparent `Button` overlaid on the content, calling `agent.notify`, + /// which the host receives as `SplashAction::Notify`. + /// + /// So this wraps a tappable element in `flow: Overlay` and lays that button + /// over it. The attributes stay: they cost nothing, they are what the tests + /// assert, and they are how a lowered card can be read back. + fn element(node: &UiNode, depth: usize, out: &mut String) { + let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { + return element_body(node, depth, out); + }; + let p = pad(depth); + // The wrapper must size like what it wraps. A Chip is `width: Fit` and + // sits in a Row of chips; wrapping it in `Fill` makes the first chip eat + // the row. Text roles are likewise intrinsic. + let fill = matches!(node.kind.as_str(), "Row" | "Panel" | "Card"); + let w = if fill { "Fill" } else { "Fit" }; + let _ = writeln!(out, "{p}View{{ width: {w} height: Fit flow: Overlay"); + element_body(node, depth + 1, out); + let _ = writeln!(out, "{p} {}", tap_button(node, event)); + let _ = writeln!(out, "{p}}}"); + } + + /// The transparent hit target. `agent.notify`'s first argument must be a + /// LITERAL for the host's `tag_notify_calls` to prefix it with the card id — + /// an untagged event is discarded as unattributable — so the event name + /// travels in the payload rather than in the channel name. + fn tap_button(node: &UiNode, event: &str) -> String { + let value = match arg(node, "value") { + Some(NodeValue::Text(t)) => t.clone(), + Some(NodeValue::Number(n)) => trim_num(*n), + Some(NodeValue::Token(t)) => t.clone(), + _ => String::new(), + }; + format!( + "Button{{ width: Fill height: Fill draw_bg.color: #00000000 \ + draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 \ + draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 \ + draw_bg.border_radius: 10 text: \"\" \ + on_click: || agent.notify(\"l0\", {{key: {:?}, event: {event:?}, value: {value:?}}}) }}", + node.key + ) + } + + fn element_body(node: &UiNode, depth: usize, out: &mut String) { + let p = pad(depth); + match node.kind.as_str() { + "Surface" => { + 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}}}"); + } + "Photo" => { + // Overlay: photo, scrim, then the column. A `Fit` overlay takes + // its tallest child, so the image needs a fixed height or the + // card ends in a band of bare base colour. + let src = match arg(node, "src") { + Some(NodeValue::Text(s)) => s.clone(), + _ => String::new(), + }; + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: Fit flow: Overlay new_batch: true \ + draw_bg.color: {BASE}" + ); + if !src.is_empty() { + let _ = writeln!( + out, + "{p} Image{{ src: http_resource({src:?}) fit: ImageFit.CropToFill \ + width: Fill height: 2000 }}" + ); + } + let _ = writeln!( + out, + "{p} SolidView{{ width: Fill height: Fill draw_bg.color: {SCRIM} }}" + ); + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fit flow: Down padding: {PAGE_PAD}" + ); + children(node, depth + 1, out); + let _ = writeln!(out, "{p} }}"); + let _ = writeln!(out, "{p}}}"); + } + "Col" => { + let align = + matches!(arg(node, "align"), Some(NodeValue::Token(t)) if t == "center"); + let a = if align { " align: Align{x: 0.5}" } else { "" }; + let _ = writeln!(out, "{p}View{{ width: Fill height: Fit flow: Down{a}"); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Row" => { + let _ = writeln!( + out, + "{p}View{{ width: Fill height: Fit flow: Right align: Align{{y: 0.5}} spacing: 6{}", + tap_binding(node) + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Panel" | "Card" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {PANEL} draw_bg.border_radius: 14.0 \ + margin: Inset{{top: 16}} padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}}{}", + tap_binding(node) + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Grid" => { + // Two columns, emitted as rows of two so the existing layout + // engine needs no grid primitive. + let _ = writeln!( + out, + "{p}View{{ width: Fill height: Fit flow: Down spacing: 8" + ); + for pair in node.children.chunks(2) { + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fit flow: Right spacing: 8" + ); + for cell in pair { + element(cell, depth + 2, out); + } + let _ = writeln!(out, "{p} }}"); + } + let _ = writeln!(out, "{p}}}"); + } + "Rule" => { + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: 1 draw_bg.color: {HAIRLINE} }}" + ); + } + "Tile" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {PANEL} draw_bg.border_radius: 18.0 \ + padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}} spacing: 4" + ); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {DIM} }}", + text_of(arg(node, "label")) + ); + let _ = writeln!( + out, + "{p} TextStat{{ text: {} draw_text.color: {TEXT} }}", + valued(node) + ); + let _ = writeln!(out, "{p}}}"); + } + "TempBar" => { + let _ = writeln!( + out, + "{p}TempBar{{ width: Fill height: 8 margin: Inset{{left: 10 right: 10}} \ + draw_bg.tlo: {} draw_bg.thi: {} draw_bg.wmin: {} draw_bg.wmax: {} }}", + trim_num(num_of(arg(node, "lo"))), + trim_num(num_of(arg(node, "hi"))), + trim_num(num_of(arg(node, "min"))), + trim_num(num_of(arg(node, "max"))), + ); + } + "WeatherIcon" => { + let size = matches!(arg(node, "size"), Some(NodeValue::Token(t)) if t == "hero"); + let (w, h) = if size { (64, 64) } else { (34, 26) }; + let _ = writeln!( + out, + "{p}WeatherIcon{{ width: {w} height: {h} draw_bg.cond: {} }}", + trim_num(num_of(arg(node, "cond"))) + ); + } + "SunArc" => { + let _ = writeln!( + out, + "{p}SunArc{{ width: Fill height: 90 draw_bg.rise: {} draw_bg.set: {} draw_bg.now: {} }}", + trim_num(num_of(arg(node, "rise"))), + trim_num(num_of(arg(node, "set"))), + trim_num(num_of(arg(node, "now"))), + ); + } + "MoonPhase" => { + let _ = writeln!( + out, + "{p}MoonPhase{{ width: 64 height: 64 draw_bg.phase: {} }}", + trim_num(num_of(arg(node, "phase"))) + ); + } + "AqiContour" => { + // lat/lon/span, not a field: the widget fetches its own data. + // Emitting `draw_bg.idx` was writing a GPU uniform from the + // card, which is the ABI leak the widget was rewritten to end. + let _ = writeln!( + out, + "{p}AqiContour{{ width: Fill height: 190 lat: {} lon: {} span: {} }}", + trim_num(num_of(arg(node, "lat"))), + trim_num(num_of(arg(node, "lon"))), + trim_num(num_of(arg(node, "span"))), + ); + } + "StockPlot" => { + // The card names WHICH series; the widget fetches it. Emitting + // neither left the chart empty on every card that used one. + let _ = writeln!( + out, + "{p}StockPlot{{ width: Fill height: 180 symbol: {} range: {} }}", + text_of(arg(node, "symbol")), + text_of(arg(node, "range")), + ); + } + "Chip" => { + // `active` selects the fill. Dropping it made every range chip + // render identically, so a card could not show which was + // selected — and the golden recorded that as correct. + let on = matches!(arg(node, "active"), Some(NodeValue::Bool(true))); + let (fill, ink) = if on { (ACTIVE, TEXT) } else { (PANEL, SOFT) }; + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fit height: Fit draw_bg.color: {fill} \ + draw_bg.border_radius: 12.0 padding: Inset{{left: 10 right: 10 top: 5 bottom: 5}}\ + {}", + tap_binding(node) + ); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {ink} }}", + text_of(arg(node, "text")) + ); + let _ = writeln!(out, "{p}}}"); + } + role if role.starts_with("Text") => { + // `tint` colours by SIGN — the card says "tint by this number", + // and which colours mean up and down is the runtime's to decide. + let colour = match arg(node, "tint") { + Some(NodeValue::Number(n)) if *n > 0.0 => UP, + Some(NodeValue::Number(n)) if *n < 0.0 => DOWN, + _ => match role { + "TextCaption" => DIM, + "TextRow" => SOFT, + _ => TEXT, + }, + }; + // 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 body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { + valued(node) + } else { + text_of(arg(node, "text")) + }; + // 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 + // "this is 62 points". + let size = if role == "TextHero" { + let glyphs = body.trim_matches('"').chars().count(); + let pt = match glyphs { + 0..=4 => 62, + 5..=6 => 50, + 7..=8 => 40, + 9..=12 => 32, + _ => 24, + }; + format!(" draw_text.text_style.font_size: {pt}") + } else { + String::new() + }; + let _ = writeln!( + out, + "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size}{} }}", + tap_binding(node) + ); + } + other => { + // An unmapped constructor is shown, not skipped. A card that + // silently drops a section looks correct and is not. + let _ = writeln!( + out, + "{p}TextCaption{{ text: {:?} draw_text.color: #ffb4b4 }}", + format!("⚠ no makepad lowering for {other}") + ); + } + } + } +} + +// ────────────────────────────────────────────────────────────── instance state ── + +use std::collections::BTreeMap; + +/// The key card-scope state lives under. Card state has no instance, so it needs +/// a fixed cell rather than one derived from a position in the tree. +pub const CARD_STATE_KEY: &str = "@card"; + +/// Per-instance local state — the runtime's half of the immutable/mutable split. +/// +/// The ledger declares a component's state *shape*; the values live here, keyed +/// by instance identity (§5.1). Seven `ForecastRow` instances therefore hold +/// seven independent cells, none of which appear in the card. +/// +/// Nothing in the store is authored by the model. A card cannot name an +/// instance, cannot address another instance's cell, and cannot write anything +/// except through a declared transition on its own state. +#[derive(Clone, Debug, Default)] +pub struct InstanceStore { + /// `instance-key` → field → value. + cells: BTreeMap>, + /// Component name → the schema id its live cells were written under. + schemas: BTreeMap, + /// Component name → state path → the shape that cell was written under. + /// A `keep` cell is preserved across a schema change only when this still + /// matches, so opting in never means "trust whatever was there". + shapes: BTreeMap>, +} + +impl InstanceStore { + pub fn get(&self, key: &str, field: &str) -> Option<&serde_json::Value> { + self.cells.get(key)?.get(field) + } + + fn set(&mut self, key: &str, field: &str, value: serde_json::Value) { + self.cells + .entry(key.to_string()) + .or_default() + .insert(field.to_string(), value); + } + + /// Whether `state` has the shape the store's live cells were written under. + fn shape_unchanged(&self, component: &Component, state: &StateDecl) -> bool { + self.shapes + .get(&component.name) + .and_then(|m| m.get(&state.path)) + .is_some_and(|old| *old == format!("{:?}", state.shape)) + } + + /// Drop every cell belonging to instances of `component`, except the fields + /// listed in `keep` — §5.8's opt-in migration. + fn reset_component(&mut self, component: &str, keep: &[String]) { + let marker = format!("/{component}"); + self.cells.retain(|key, fields| { + if !key.contains(&marker) { + return true; + } + fields.retain(|field, _| keep.iter().any(|k| k == field)); + !fields.is_empty() + }); + } + + /// Profile §5.8: a component whose state declarations changed gets a new + /// schema id, and its live cells are dropped rather than migrated. Guessing + /// that an old value still fits is how a rolled-back ledger feeds version-A + /// state to version-B code. + fn reconcile_schema(&mut self, component: &Component, schema: u64) { + let name = component.name.as_str(); + let shapes: BTreeMap = component + .states + .iter() + .map(|s| (s.path.clone(), format!("{:?}", s.shape))) + .collect(); + + match self.schemas.get(name) { + Some(&known) if known == schema => {} + Some(_) => { + // A cell survives only if it asked to AND its own shape is + // unchanged. Opting in is a claim about one field, not a + // blanket assertion that the old values still fit. + let previous = self.shapes.get(name); + let keep: Vec = component + .states + .iter() + .filter(|s| s.keep) + .filter(|s| { + previous + .and_then(|p| p.get(&s.path)) + .is_some_and(|old| *old == format!("{:?}", s.shape)) + }) + .map(|s| s.path.clone()) + .collect(); + self.reset_component(name, &keep); + self.schemas.insert(name.to_string(), schema); + self.shapes.insert(name.to_string(), shapes); + } + None => { + self.schemas.insert(name.to_string(), schema); + self.shapes.insert(name.to_string(), shapes); + } + } + } + + /// Forget instances that no longer appear — the unmount half of §5.7. + pub fn prune(&mut self, live: &[String]) { + self.cells.retain(|key, _| live.iter().any(|k| k == key)); + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } +} + +fn schema_id(component: &Component) -> u64 { + let mut acc: u64 = 0xcbf2_9ce4_8422_2325; + let mut fields: Vec = component + .states + .iter() + .map(|s| { + format!( + "{}:{:?}:{:?}:{:?}", + s.path, s.shape, s.initial, s.initial_path + ) + }) + .collect(); + fields.sort(); + for byte in fields.join(",").bytes() { + acc ^= byte as u64; + acc = acc.wrapping_mul(0x100_0000_01b3); + } + acc +} + +fn initial_for(shape: &Shape) -> serde_json::Value { + match shape { + Shape::Bool => serde_json::Value::Bool(false), + Shape::Enum(members) => members + .first() + .map(|m| serde_json::Value::String(m.clone())) + .unwrap_or(serde_json::Value::Null), + Shape::Text => serde_json::Value::String(String::new()), + Shape::Number => serde_json::Value::from(0.0), + Shape::Record | Shape::Collection | Shape::Event | Shape::Other => serde_json::Value::Null, + } +} + +/// Realize against a store, so component-local state resolves per instance. +/// +/// [`realize`] is this with an empty store: a card with no local state behaves +/// identically either way. +pub fn realize_with_state( + source: &str, + data: &serde_json::Value, + store: &InstanceStore, + limits: RealizeLimits, +) -> RealizeReport { + realize_inner(source, data, Some(store), limits) +} + +/// Apply an event's transitions to one instance's cells. +/// +/// Returns whether anything applied. The event name is resolved against the +/// component's *declarations* — a card cannot construct one, because it has no +/// expression form to build a name with. +pub fn dispatch(source: &str, store: &mut InstanceStore, instance_key: &str, event: &str) -> bool { + dispatch_with(source, store, instance_key, event, None) +} + +/// Apply an event's transitions, supplying the payload the raising element +/// carried in its `value:` argument. +/// +/// The payload is data the runtime already holds — a bound path or a literal +/// resolved at realization — never anything the card computed. +pub fn dispatch_with( + source: &str, + store: &mut InstanceStore, + instance_key: &str, + event: &str, + payload: Option<&serde_json::Value>, +) -> bool { + dispatch_with_data( + source, + store, + instance_key, + event, + payload, + &serde_json::Value::Null, + ) +} + +/// `dispatch_with`, plus the data a `set(path)` reads from. +/// +/// Separated rather than folded in because most events do not read: `toggle`, +/// `cycle`, `clear` and `set(.token)` need nothing. Only a transition that +/// names a path needs the host's data, and passing Null simply makes that one +/// form a no-op instead of writing something wrong. +pub fn dispatch_with_data( + source: &str, + store: &mut InstanceStore, + instance_key: &str, + event: &str, + payload: Option<&serde_json::Value>, + data: &serde_json::Value, +) -> bool { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return false; + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + // A backend dispatches with the key of the NODE that was tapped, which sits + // inside the component rather than at its boundary — `…/Rowy/0/1`. The cell + // belongs to the instance, so the key is truncated to the component segment. + // Where components nest, the innermost one declaring this event wins. + let mut owner: Option<(&Component, usize)> = None; + for component in &card.components { + if !component.events.iter().any(|e| e.name == event) { + continue; + } + let marker = format!("/{}", component.name); + if let Some(at) = instance_key.rfind(&marker) { + let end = at + marker.len(); + // Only a whole segment matches: `/Row` must not match `/Rowy`. + let whole = instance_key[end..] + .chars() + .next() + .is_none_or(|c| c == '/' || c == '['); + if whole && owner.as_ref().is_none_or(|(_, best)| end > *best) { + owner = Some((component, end)); + } + } + } + // An event declared on the CARD rather than a component targets card state, + // which lives under a fixed key. A component event wins when both declare the + // name, since the innermost scope owns the cell. + let (declared, states, instance_key, schema_owner) = match owner { + Some((component, boundary)) => match component.events.iter().find(|e| e.name == event) { + Some(d) => ( + d, + &component.states, + &instance_key[..boundary], + Some((component, schema_id(component))), + ), + None => return false, + }, + None => match card.events.iter().find(|e| e.name == event) { + Some(d) => (d, &card.states, CARD_STATE_KEY, None), + None => return false, + }, + }; + + if let Some((component, schema)) = schema_owner { + store.reconcile_schema(component, schema); + } + + // §3: a batch is applied ATOMICALLY. Writing straight to the store as we go + // 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(); + + for transition in &declared.transitions { + let Some(state) = states.iter().find(|s| s.path == transition.target) else { + return false; + }; + // 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 + // comes from the host started in the wrong state. + let declared_initial = state + .initial + .clone() + .or_else(|| state.initial_path.as_ref().and_then(|p| data_path(data, p))); + + let current = staged + .iter() + .rev() + .find(|(t, _)| *t == transition.target) + .map(|(_, v)| v.clone()) + .or_else(|| store.get(instance_key, &transition.target).cloned()) + .or_else(|| declared_initial.clone()) + .unwrap_or_else(|| initial_for(&state.shape)); + + let next = match (&transition.form, &state.shape) { + (Form::Toggle, Shape::Bool) => { + serde_json::Value::Bool(!current.as_bool().unwrap_or(false)) + } + (Form::Clear, shape) => declared_initial + .clone() + .unwrap_or_else(|| initial_for(shape)), + (Form::Cycle, Shape::Enum(members)) if !members.is_empty() => { + let at = current + .as_str() + .and_then(|s| members.iter().position(|m| m == s)) + .unwrap_or(0); + serde_json::Value::String(members[(at + 1) % members.len()].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 false, + // No payload is a no-op rather than a silent clear: falling + // back to the initial would look like a deliberate reset. + None => return false, + }, + // A path READS. Treating it as the payload meant + // `n: set(config.answer)` wrote whatever the tap carried — or + // nothing at all when it carried nothing. + SetSource::Path(p) => match data_path(data, p) { + 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 false, + }, + 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 false, + }; + staged.push((transition.target.clone(), next)); + } + + // Commit. + let applied = !staged.is_empty(); + for (target, value) in staged { + store.set(instance_key, &target, value); + } + applied +} + +// ───────────────────────────────────────────────────────────────── source plan ── + +/// One capability query for the host to answer, with the name its result binds. +#[derive(Clone, Debug)] +pub struct SourceRequest { + /// The name the card binds the result to — `now`, `week`, `env.locale`. + pub name: String, + /// The helper that answers it — `sys.weather`. Resolving this to an actual + /// function is the HOST's job; splash-core never calls anything. + pub helper: String, + pub args: Vec<(String, SourceArg)>, + /// Names of other sources this one reads. The host must resolve those first. + pub depends_on: Vec, +} + +/// What a card needs fetched, in an order that satisfies its dependencies. +#[derive(Clone, Debug, Default)] +pub struct SourcePlan { + pub requests: Vec, + pub diagnostics: Vec, +} + +/// Read a card's `source` declarations and order them for resolution. +/// +/// Sources form a DAG: `source now sys.weather(lat: place.lat, …)` cannot be +/// fetched before `place`. The order is topological, so a host can walk the list +/// and always have what the next request refers to. +/// +/// This returns *what to fetch*, never fetches it. The card names a helper; only +/// the host knows what answers it. That separation is what lets realization run +/// against an empty host surface. +pub fn source_plan(source: &str) -> SourcePlan { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return SourcePlan { + requests: Vec::new(), + diagnostics: sink.items, + }; + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + // The plan is what a host acts on, so the capability check has to happen + // HERE too and not only in `check_ui_l0`. A caller that skips checking must + // still not be handed `sys.shell` as a function to resolve. + validate_sources(&card, &mut sink); + + let declared: Vec<&str> = card.sources.iter().map(|s| s.name.as_str()).collect(); + + let mut pending: Vec = card + .sources + .iter() + .filter(|decl| catalog::source(&decl.helper).is_some()) + .map(|decl| { + let mut depends_on: Vec = decl + .args + .iter() + .filter_map(|(_, arg)| match arg { + SourceArg::Path(p) => { + // Match a declared name by its own segments, longest + // first: `env.locale.lang` depends on `env.locale`. + // Reducing to the root compared `env` against the full + // name and recorded no edge at all, so the dependent + // was planned before the thing it reads. + let mut hits: Vec<&&str> = declared + .iter() + .filter(|d| { + *p == ***d + || p.strip_prefix(**d).is_some_and(|r| r.starts_with('.')) + }) + .collect(); + hits.sort_by_key(|d| std::cmp::Reverse(d.len())); + hits.first().map(|d| (**d).to_string()) + } + _ => None, + }) + .collect(); + depends_on.sort(); + depends_on.dedup(); + SourceRequest { + name: decl.name.clone(), + helper: decl.helper.clone(), + args: decl.args.clone(), + depends_on, + } + }) + .collect(); + + // Topological order. A cycle is reported rather than resolved: two sources + // that read each other cannot both go first, and picking one silently would + // fetch against a value that does not exist yet. + let mut ordered: Vec = Vec::new(); + let mut done: Vec = Vec::new(); + while !pending.is_empty() { + let ready: Vec = pending + .iter() + .enumerate() + .filter(|(_, r)| r.depends_on.iter().all(|d| done.iter().any(|n| n == d))) + .map(|(i, _)| i) + .collect(); + + if ready.is_empty() { + for stuck in &pending { + sink.push( + card.sources + .iter() + .find(|d| d.name == stuck.name) + .map(|d| d.line) + .unwrap_or(0), + 1, + format!( + "source {:?} is in a dependency cycle with {:?}", + stuck.name, + stuck.depends_on.join(", ") + ), + ); + } + break; + } + + for i in ready.into_iter().rev() { + let request = pending.remove(i); + done.push(request.name.clone()); + ordered.push(request); + } + } + + SourcePlan { + requests: ordered, + diagnostics: sink.items, + } +} + +// ─────────────────────────────────────────────────────────────── reconciliation ── + +/// What one record reads, so a state write can be confined to its dependents. +#[derive(Clone, Debug)] +pub struct RecordDeps { + /// A `view` or `component` name. + pub record: String, + /// Roots it reads, transitively through referenced views and instantiated + /// components — `now`, `units`, `week`. + pub reads: Vec, +} + +/// What each record reads. +/// +/// L0 needs no dynamic read-tracking: it has no expression form, so every +/// binding is structurally visible and the dependency set is computable without +/// evaluating anything. That is the difference between this and the signal +/// graphs a dynamic language requires — there is no branch whose reads depend on +/// a value. +/// +/// The set is transitive and deliberately **over**-approximate at the edges: a +/// record inherits everything the views it splices and the components it +/// instantiates read. Over-approximating re-realizes too much; under- +/// approximating shows stale data, and only one of those is a correctness bug. +pub fn record_dependencies(source: &str) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + // Direct reads, plus the records each one pulls in. + let mut direct: Vec<(String, Vec, Vec)> = Vec::new(); + for view in &card.views { + let (reads, pulls) = scan_element(&view.body); + direct.push((view.name.clone(), reads, pulls)); + } + for component in &card.components { + let (mut reads, pulls) = scan_element(&component.body); + // A prop is supplied by the caller, so it is not a dependency of its own. + reads.retain(|r| !component.params.iter().any(|p| p.name == *r)); + // Local state is a dependency: writing it must re-realize this instance. + for state in &component.states { + reads.push(root_of(&state.path)); + } + direct.push((component.name.clone(), reads, pulls)); + } + + // Close over references. Bounded by record count, so a reference cycle + // terminates rather than spinning. + let mut out: Vec = Vec::new(); + for (name, _, _) in &direct { + let mut reads: Vec = Vec::new(); + let mut queue = vec![name.clone()]; + let mut seen: Vec = Vec::new(); + let mut steps = 0usize; + while let Some(current) = queue.pop() { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS || seen.contains(¤t) { + continue; + } + seen.push(current.clone()); + if let Some((_, r, pulls)) = direct.iter().find(|(n, _, _)| *n == current) { + reads.extend(r.iter().cloned()); + queue.extend(pulls.iter().cloned()); + } + } + reads.sort(); + reads.dedup(); + out.push(RecordDeps { + record: name.clone(), + reads, + }); + } + out +} + +/// The smallest set of records a backend must re-realize. +/// +/// [`dirty_records`] is transitive, so `root` appears for almost any write — it +/// splices every other record, so it reads everything they read. Patching root +/// would rebuild the tree, which is the behaviour reconciliation exists to +/// avoid. A record is a **patch point** only when its OWN bindings read a +/// changed path; an ancestor that merely contains it is handled by patching the +/// descendant. +/// Which sources a change invalidates, following the cascade. +/// +/// Re-fetching is the expensive half of reconciliation — realization walks a +/// ninety-node tree, a source is a network round trip — so this is what a host +/// needs after an event. `patch_points` computed it internally and discarded it, +/// which left the cheap half exposed and the costly half unreachable. +/// +/// The cascade matters: `city` feeds `place`, `place` feeds `now`. A host that +/// refetched only the direct dependent would read a fresh coordinate against a +/// stale forecast. +pub fn stale_sources(source: &str, changed: &[&str]) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + let declared: Vec<&str> = card.sources.iter().map(|s| s.name.as_str()).collect(); + invalidated_by(&card, changed) + .into_iter() + .filter(|n| declared.iter().any(|d| *d == n)) + .collect() +} + +/// The transitive closure of what `changed` invalidates: the changed roots +/// themselves, plus every source that reads one, to a fixpoint. +fn invalidated_by(card: &Card, changed: &[&str]) -> Vec { + let mut invalidated: Vec = changed.iter().map(root_of).collect(); + let mut steps = 0usize; + loop { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS { + break; + } + let before = invalidated.len(); + for decl in &card.sources { + let reads_changed = decl.args.iter().any(|(_, arg)| match arg { + SourceArg::Path(p) => { + // `state.city` names the state directly; `place.lat` names + // another source. + let mut parts = p.split('.'); + let head = parts.next().unwrap_or_default(); + let target = if head == "state" { + parts.next().unwrap_or_default() + } else { + head + }; + invalidated.iter().any(|i| i == target) + } + _ => false, + }); + if reads_changed && !invalidated.contains(&decl.name) { + invalidated.push(decl.name.clone()); + } + } + if invalidated.len() == before { + break; + } + } + invalidated +} + +/// Realize, reusing the subtrees a change cannot have touched. +/// +/// This is what makes `patch_points` load-bearing rather than advisory. It +/// computed which records a change reaches and nothing consumed the answer, so +/// every event rebuilt the whole card. +/// +/// The reuse rule is deliberately conservative: a subtree is carried over only +/// when its record is **absent** from the patch set, so a record whose +/// dependencies could not be determined is rebuilt. Being wrong in that +/// direction costs work; being wrong in the other renders a stale value, and a +/// stale value on screen is indistinguishable from a correct one. +/// +/// The result must equal a full realization. A test asserts that on the same +/// card, because an optimisation that changes what is rendered is not an +/// optimisation. +pub fn realize_patch( + source: &str, + data: &serde_json::Value, + store: Option<&InstanceStore>, + previous: &UiNode, + changed: &[&str], + limits: RealizeLimits, +) -> RealizeReport { + let dirty = patch_points(source, changed); + let mut report = realize_inner(source, data, store, limits); + + if let Some(root) = report.root.as_mut() { + report.reused = carry_over(root, previous, &dirty); + } + report +} + +/// Copy nodes from `previous` into `next` wherever the record they belong to is +/// not dirty, and count what was carried. +/// +/// Identity is the instance key, which already encodes the declaration path and +/// enclosing loop keys — the same key that decides state ownership, so reuse and +/// state agree by construction rather than by a second rule. +fn carry_over(next: &mut UiNode, previous: &UiNode, dirty: &[String]) -> usize { + // A key names the records it passes through: `root/when#0/w:stream#0/stream`. + let touches_dirty = |key: &str| { + dirty + .iter() + .any(|d| key.split(['/', '#', '[']).any(|seg| seg == d)) + }; + + // A subtree is reusable only if NOTHING inside it is dirty. Checking the + // node's own key alone lets the root short-circuit the whole tree, because + // `patch_points` deliberately excludes ancestors that merely *contain* a + // change — so a clean parent above a dirty child is the normal case, not an + // edge one. + fn subtree_is_clean(n: &UiNode, touches_dirty: &dyn Fn(&str) -> bool) -> bool { + !touches_dirty(&n.key) + && n.children + .iter() + .all(|c| subtree_is_clean(c, touches_dirty)) + } + + if next.key == previous.key + && next.kind == previous.kind + && subtree_is_clean(next, &touches_dirty) + { + *next = previous.clone(); + return count_nodes(previous); + } + + let mut reused = 0; + for child in next.children.iter_mut() { + if let Some(was) = previous.children.iter().find(|p| p.key == child.key) { + reused += carry_over(child, was, dirty); + } + } + reused +} + +fn count_nodes(n: &UiNode) -> usize { + 1 + n.children.iter().map(count_nodes).sum::() +} + +pub fn patch_points(source: &str, changed: &[&str]) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + let invalidated = invalidated_by(&card, changed); + + let mut out = Vec::new(); + for view in &card.views { + let (reads, _) = scan_element(&view.body); + if reads.iter().any(|r| invalidated.iter().any(|i| i == r)) { + out.push(view.name.clone()); + } + } + for component in &card.components { + let (mut reads, _) = scan_element(&component.body); + reads.retain(|r| !component.params.iter().any(|p| p.name == *r)); + for state in &component.states { + reads.push(root_of(&state.path)); + } + if reads.iter().any(|r| invalidated.iter().any(|i| i == r)) { + out.push(component.name.clone()); + } + } + out +} + +/// Which records must re-realize when these paths change. +/// +/// 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(); + record_dependencies(source) + .into_iter() + .filter(|d| d.reads.iter().any(|r| roots.iter().any(|c| c == r))) + .map(|d| d.record) + .collect() +} + +/// Paths read directly by an element tree, and the records it pulls in. +fn scan_element(element: &Element) -> (Vec, Vec) { + let mut reads = Vec::new(); + let mut pulls = Vec::new(); + collect_reads(element, &mut reads, &mut pulls); + reads.sort(); + reads.dedup(); + pulls.sort(); + pulls.dedup(); + (reads, pulls) +} + +fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec) { + // A loop binder shadows: `for d in week` makes `d` local, not a dependency. + let binders = &element.binders; + + for arg in &element.args { + match &arg.value { + Operand::Path(p) | Operand::Predicate { path: p, .. } => { + let root = root_of(p); + if !binders.contains(&root) { + reads.push(root); + } + } + _ => {} + } + } + if let Some(Operand::Path(p)) = element.rhs.as_ref() { + reads.push(root_of(p)); + } + + if element.is_reference || !element.name.is_empty() { + // A referenced view or an instantiated component contributes its own + // reads; a plain constructor is not a record and contributes nothing. + let n = &element.name; + if !n.is_empty() && n != "for" && n != "when" && n != "slot" { + pulls.push(n.clone()); + } + } + + for child in &element.children { + let mut child_reads = Vec::new(); + collect_reads(child, &mut child_reads, pulls); + // Binders introduced here shadow inside this subtree only. + for r in child_reads { + if !binders.contains(&r) { + reads.push(r); + } + } + } +} + +// ────────────────────────────────────────────── DSL lowering, consumer-checked ── + +/// What a lowering produced, and what it could not. +pub struct DslReport { + pub dsl: String, + /// Roles with no kind in the consumer's table. A card containing one is + /// incomplete, and saying so is the difference between a missing chart and + /// a missing chart nobody was told about. + pub diagnostics: Vec, +} + +/// Lower to the Splash DSL data form, reporting what could not be mapped. +/// +/// The kinds and attribute names below are the consumer's, established by +/// running `splash_render::build` rather than by reading about it. Three facts +/// shaped this and each cost a revert to learn: +/// +/// - `splash-render`'s tag table is closed. An unknown kind at the root rejects +/// the whole document; as a child it is dropped with no diagnostic at all. +/// - Its `Attrs` is a fixed 37-field struct, not an open bag, so an attribute +/// it does not name is simply unread. Emitting one is the same as emitting +/// nothing, only harder to notice. +/// - The root must be a literal object. A theme function call in that position +/// fails inside the VM, so a card's outermost role cannot lower to one. +/// +/// Six roles have no kind here — the data visualisations. They are reported, +/// not emitted: a card that loses its temperature bars should say so. +pub fn lower_dsl_checked(root: &UiNode) -> DslReport { + let mut out = String::from("// REALIZED from an L0 ledger — do not edit.\n"); + let mut diagnostics = Vec::new(); + emit_dsl(root, 0, &mut out, &mut diagnostics); + out.push('\n'); + DslReport { + dsl: out, + diagnostics, + } +} + +/// The DSL text alone, for callers that have already checked. +pub fn lower_dsl(root: &UiNode) -> String { + lower_dsl_checked(root).dsl +} + +/// A role's kind in the consumer's table, or `None` if it has none. +/// +/// Deliberately not a fallback: an unmapped role must reach the caller as a +/// diagnostic. Guessing a kind is what produced five names the consumer had +/// never heard of. +fn dsl_kind(role: &str) -> Option<&'static str> { + Some(match role { + "Surface" | "Col" => "column", + "Row" => "row", + "Grid" => "grid", + "Panel" | "Card" => "card", + "Rule" => "divider", + "Tile" => "listitem", + "Chip" => "chip", + "Photo" => "image", + "WeatherIcon" => "weathericon", + role if role.starts_with("Text") => "text", + // TempBar, SunArc, MoonPhase, AqiContour, StockPlot — no kind exists. + _ => return None, + }) +} + +/// Attribute names the consumer's `Attrs` actually reads. Anything else is +/// dropped on arrival, so emitting it only hides the loss. +fn dsl_attr(name: &str) -> Option<&'static str> { + Some(match name { + "text" => "text", + "label" => "label", + "src" => "src", + "gap" => "spacing", + "cols" => "w", + "pad" => "pad", + _ => return None, + }) +} + +fn emit_dsl(node: &UiNode, depth: usize, out: &mut String, sink: &mut Vec) { + use std::fmt::Write as _; + let pad = " ".repeat(depth); + + let Some(kind) = dsl_kind(&node.kind) else { + sink.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "{} has no kind in the renderer's table, so it cannot be lowered — a card \ + using it would render without it and look complete (profile §1.1)", + node.kind + ), + }); + return; + }; + + let _ = write!(out, "{pad}{{t: {kind:?}"); + for (name, value) in &node.args { + let Some(attr) = dsl_attr(name) else { continue }; + let _ = match value { + NodeValue::Text(s) => write!(out, ", {attr}: {s:?}"), + NodeValue::Number(n) => write!(out, ", {attr}: {}", makepad::trim_num(*n)), + NodeValue::Token(t) => write!(out, ", {attr}: {t:?}"), + _ => Ok(()), + }; + } + + if node.children.is_empty() { + out.push_str("}\n"); + return; + } + out.push_str(", c: [\n"); + for child in &node.children { + emit_dsl(child, depth + 1, out, sink); + } + let _ = writeln!(out, "{pad}]}}"); +} diff --git a/crates/splash-ui-l0/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card new file mode 100644 index 0000000..ade95a5 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/news.card @@ -0,0 +1,109 @@ +# ledger news@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/news.rs lowers: Masthead, LeadStory, StoryFeed. +# +# Note what is absent: no headline, byline, score or url appears anywhere. A +# fabricated headline is worse than a wrong coordinate — it is quotable and +# indistinguishable from a real one — so there is no field that could hold one. + +# TWO sources, not one sliced two ways. The feed must skip the lead story, and +# the profile refuses a derived sub-collection (`stories.rest`) — `rest` invites +# `filter`, which invites a predicate, which invites arithmetic. The runtime +# shapes the data; the view does not reshape it. +source lead sys.news(count: 1, fields: [id, title, author, points, comments, url]) +source feed sys.news(count: 7, offset: 1, fields: [id, title, author, points]) +source article sys.news_item(id: state.selected, + fields: [title, author, points, comments, url]) +source env.locale sys.locale() + +state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id + +event open_story { selected: set($value) } +event back { selected: clear } + +copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } +copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } +copy latest { class: vocabulary, en: "LATEST", zh: "最新" } +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: "作者" } + +view root Surface(pad: .page) { + when selected == "" { stream } + when selected != "" { story } + } + +view stream Col { + masthead + headline + latest + } + +view masthead Col { + TextCaption(text: copy.source) + TextTitle(text: copy.masthead) + } + +# The lead story, given room. Every field is live. +view headline Card(on_tap: open_story, value: lead.0.id) { + TextCaption(text: copy.lead) + TextBody(text: lead.0.title, width: .fill) + Row(gap: 6) { + TextCaption(value: lead.0.points, suffix: copy.pts) + TextCaption(value: lead.0.comments, suffix: copy.comments) + TextCaption(text: copy.by) + TextCaption(text: lead.0.author) + } + } + +# Exercises the component contracts (profile §5.2-§5.7): +# - typed props with a `record` shape +# - an EVENT prop: the row raises open_story, which is declared at card scope, +# so the transition runs where it was declared rather than in the child +# - reads card-scope `copy` directly (§5.3 permits copy and source, not state) +# - has no local state, so its schema id is empty and it never resets +# +# The prop is `position`, not `rank`: `width: .rank` is a layout token, and a +# prop of the same name would collide. See finding #9. +component StoryRow(story: record, position: number, on_open: event) { + view Col { + Row(align: .center, on_tap: on_open, value: story.id) { + TextRow(text: position, width: .rank) + Col(gap: 3) { + TextRow(text: story.title, width: .fill) + Row(gap: 6) { + TextCaption(value: story.points, suffix: copy.pts) + TextCaption(text: story.author) + } + } + } + Rule() + } +} + +# `i` is the loop index binding (amendment #5) — the rank column is supplied by +# the iterator, not computed in the card. +view latest Col { + TextCaption(text: copy.latest) + Panel { + for s, i in feed key s.id { + StoryRow(story: s, position: i, on_open: open_story) + } + } + } + +view story Col { + Row(on_tap: back) { TextCaption(glyph: "‹") } + TextTitle(text: article.title, width: .fill) + Row(gap: 6) { + TextCaption(value: article.points, suffix: copy.pts) + TextCaption(text: copy.by) + TextCaption(text: article.author) + } + TextCaption(text: article.url) + } diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card new file mode 100644 index 0000000..33b90ec --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -0,0 +1,93 @@ +# ledger stock@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/stock.rs lowers: MoversList, QuoteHeader, PriceChart, +# StatGrid. The hardest of the three: it is the only one with a genuine +# list↔detail switch, and the only one whose chart depends on a second piece +# of state. + +source movers sys.movers(count: 10, fields: [ticker, name, last, change, pct]) +source quote sys.quote(ticker: state.selected, + fields: [name, last, change, pct, open, high, low, + volume, mktcap, pe]) +source series sys.series(ticker: state.selected, range: state.range, + fields: [points, min, max]) +source env.locale sys.locale() + +state selected { shape: text, initial: "" } # "" ⇒ movers list +state range { shape: enum[d1, w1, m1, m6, y1], initial: .m1 } + +event open_quote { selected: set($value) } +event back { selected: clear, range: clear } # two writes, one gesture +event set_range { range: set($value) } + +copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy open { class: vocabulary, en: "Open", zh: "开盘" } +copy high { class: vocabulary, en: "High", zh: "最高" } +copy low { class: vocabulary, en: "Low", zh: "最低" } +copy volume { class: vocabulary, en: "Volume", zh: "成交量" } +copy mktcap { class: vocabulary, en: "Mkt Cap", zh: "市值" } +copy pe { class: vocabulary, en: "P/E", zh: "市盈率" } + +view root Surface(pad: .page) { + when selected == "" { list } + when selected != "" { detail } + } + +view list Col { + TextTitle(text: copy.movers) + Panel { + for m in movers key m.ticker { + Row(align: .center, on_tap: open_quote, value: m.ticker) { + Col(gap: 2) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) + } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) + } + Rule() + } + } + } + +view detail Col { + Row(on_tap: back) { TextCaption(glyph: "‹") } + header + chart + ranges + stats + } + +view header Col { + TextCaption(text: quote.name) + TextHero(value: quote.last, format: .money) + Row(gap: 8) { + TextStat(value: quote.change, format: .signed_money, tint: quote.change) + TextStat(value: quote.pct, format: .signed_pct, tint: quote.change) + } + } + +view chart StockPlot(symbol: selected, range: range) + +# Range switching is a set() over a declared enum — no expression, and the +# selected range is a guard rather than a computed style. +view ranges Row(gap: 6) { + Chip(text: "1D", on_tap: set_range, value: .d1, active: range == .d1) + Chip(text: "1W", on_tap: set_range, value: .w1, active: range == .w1) + Chip(text: "1M", on_tap: set_range, value: .m1, active: range == .m1) + Chip(text: "6M", on_tap: set_range, value: .m6, active: range == .m6) + Chip(text: "1Y", on_tap: set_range, value: .y1, active: range == .y1) + } + +view stats Grid(cols: 2) { + Tile(label: copy.open, value: quote.open, format: .money) + Tile(label: copy.high, value: quote.high, format: .money) + Tile(label: copy.low, value: quote.low, format: .money) + Tile(label: copy.volume, value: quote.volume, format: .compact) + Tile(label: copy.mktcap, value: quote.mktcap, format: .compact) + Tile(label: copy.pe, value: quote.pe, format: .ratio) + } diff --git a/crates/splash-ui-l0/tests/fixtures/trip_planner.splash b/crates/splash-ui-l0/tests/fixtures/trip_planner.splash new file mode 100644 index 0000000..19d5196 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/trip_planner.splash @@ -0,0 +1,664 @@ +// 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}}" + +// origin — defaults to a San Jose start. An intent-seeded origin QUERY (oq, +// e.g. "Saratoga High School" parsed from "from A to B") resolves to its top +// search hit; an explicitly picked origin (find == "orig") still wins. +let olat = 37.3350 +let olon = -121.8850 +let oname = "San Jose (downtown)" +// Prefer the DEVICE's real GPS fix as the start when the trip names no origin. +// sys.gps reads the last LocationListener fix synchronously ("ok" < 1 until the +// first fix lands, then this takes over from the SJ default). An intent origin +// (oq) or an explicitly picked origin (orig) below still overrides it. +if sys.gps("ok") >= 1 { + olat = sys.gps("lat") + olon = sys.gps("lon") + oname = "Your location" +} +if oq != "0" { + if sys.searchnum(oq, 0, "lat") >= -900 { + olat = sys.searchnum(oq, 0, "lat") + olon = sys.searchnum(oq, 0, "lon") + oname = sys.search(oq, 0, "name") + } +} +if orig != "0" { + olat = sys.coord(orig, "lat") + olon = sys.coord(orig, "lon") + oname = sys.coord(orig, "name") +} +let dlat = sys.coord(dest, "lat") +let dlon = sys.coord(dest, "lon") + +// selected search result (destination preview) +let plat = 0 +let plon = 0 +if sel != "0" { + plat = sys.searchnum(q, ss - 1, "lat") + plon = sys.searchnum(q, ss - 1, "lon") +} + +// waypoints -> OSRM vias string ("lat,lon;lat,lon") +let vias = "" +if wp1 != "0" { vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") } +if wp2 != "0" { + if vias != "" { vias = vias + ";" } + vias = vias + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") +} + +// map annotation pins: origin(0);stop(1);stop(1);destination(2) +let mk = "" + olat + "," + olon + ",0" +if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } +if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } +mk = mk + ";" + dlat + "," + dlon + ",2" + +// normalized travel mode (default drive) +let mdn = md +if md == "0" { mdn = "drive" } + +// screen selector +let scr = "search" +if find == "orig" { scr = "find" } +if find == "stop" { scr = "find" } +if find == "dest" { scr = "find" } +if dest == "0" { if find == "0" { if sel != "0" { scr = "preview" } } } +if dest != "0" { if find == "0" { scr = "plan" } } +if go == "1" { scr = "drive" } + +fn tick() { + // A CHANGED destination (find -> "dest") sets `dest` = "lat,lon|name". + // Re-resolve its coords here too (top-level dlat/dlon freeze at build) so the + // route + end pin follow the new destination. sys.coord is synchronous. + if dest != "0" { + dlat = sys.coord(dest, "lat") + dlon = sys.coord(dest, "lon") + } + // Re-resolve WAYPOINTS -> the OSRM `vias` string here too (top-level freezes + // at build): otherwise ADDING A STOP never reroutes the trip through it. + vias = "" + if wp1 != "0" { vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") } + if wp2 != "0" { + if vias != "" { vias = vias + ";" } + vias = vias + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + } + // The intent may seed an ORIGIN query (oq, parsed from "from A to B"). The + // top-level origin resolution FREEZES at build time — before oq's search + // lands — so re-resolve it here EVERY tick (tick re-runs on live data) and + // rebuild the marker string, so the route starts from the requested origin + // instead of the San Jose default. An explicitly picked origin still wins. + if orig == "0" { if oq != "0" { if sys.searchnum(oq, 0, "lat") >= -900 { + olat = sys.searchnum(oq, 0, "lat") + olon = sys.searchnum(oq, 0, "lon") + oname = sys.search(oq, 0, "name") + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } } } + // No named origin (no picked `orig`, no intent `oq`) -> follow the DEVICE GPS. + // GPS may land AFTER build, so (re)resolve it here each tick and rebuild the + // marker string, exactly like the oq path above. + if orig == "0" { if oq == "0" { if sys.gps("ok") >= 1 { + olat = sys.gps("lat") + olon = sys.gps("lon") + oname = "Your location" + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } } } + // An explicitly PICKED start point (find -> "orig") sets `orig` = "lat,lon|name". + // Re-resolve it here too (not only at the top level, which freezes at build): + // otherwise "Change" the start point has no effect and the origin stays at the + // intent's oq / default. sys.coord is synchronous so this lands immediately. + if orig != "0" { + olat = sys.coord(orig, "lat") + olon = sys.coord(orig, "lon") + oname = sys.coord(orig, "name") + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } + // ---- search / find overlay: live results for the typed query ---- + if scr == "search" { if q != "0" { + // sys.searchnum "count" is -9999 while the fetch is loading OR failed, + // and a real Photon count is small (0..=8). Default to "Searching…" and + // only show a real count when it's in the sane 0..=500 range — so a + // pending/failed fetch never leaks the raw "-9999 results" sentinel. + let sc = sys.searchnum(q, 0, "count") + ui.cnt.set_text("Searching…") + if sc >= 0 { if sc <= 500 { ui.cnt.set_text("" + sc + " results") } } + ui.sr0n.set_text(sys.search(q, 0, "name")) + ui.sr0c.set_text(sys.search(q, 0, "cat")) + ui.sr0l.set_text(sys.search(q, 0, "label")) + ui.sr1n.set_text(sys.search(q, 1, "name")) + ui.sr1c.set_text(sys.search(q, 1, "cat")) + ui.sr1l.set_text(sys.search(q, 1, "label")) + ui.sr2n.set_text(sys.search(q, 2, "name")) + ui.sr2c.set_text(sys.search(q, 2, "cat")) + ui.sr2l.set_text(sys.search(q, 2, "label")) + ui.sr3n.set_text(sys.search(q, 3, "name")) + ui.sr3c.set_text(sys.search(q, 3, "cat")) + ui.sr3l.set_text(sys.search(q, 3, "label")) + ui.sr4n.set_text(sys.search(q, 4, "name")) + ui.sr4c.set_text(sys.search(q, 4, "cat")) + ui.sr4l.set_text(sys.search(q, 4, "label")) + } } + if scr == "find" { + ui.ftot.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias) + " · " + sys.navroute(olat, olon, dlat, dlon, "km", vias)) + // keep the origin name current (inline text: oname freezes before the + // origin geocode lands; the tick has the resolved value). + if find != "orig" { ui.ffromn.set_text(oname) } + if q == "0" { ui.ytstart.set_text(oname) } + } + if scr == "find" { if q != "0" { + let fc = sys.searchnum(q, 0, "count") + ui.fcnt.set_text("Searching…") + if fc >= 0 { if fc <= 500 { ui.fcnt.set_text("" + fc + " results") } } + ui.fr0n.set_text(sys.search(q, 0, "name")) + ui.fr0l.set_text(sys.search(q, 0, "label")) + ui.fr1n.set_text(sys.search(q, 1, "name")) + ui.fr1l.set_text(sys.search(q, 1, "label")) + ui.fr2n.set_text(sys.search(q, 2, "name")) + ui.fr2l.set_text(sys.search(q, 2, "label")) + ui.fr3n.set_text(sys.search(q, 3, "name")) + ui.fr3l.set_text(sys.search(q, 3, "label")) + } } + // ---- destination preview ---- + // Re-read the picked result's coords EVERY tick (not the once-computed + // top-level plat/plon): when the card opens straight on preview (intent + // seeded the destination) the search hasn't resolved yet, so top-level + // coords are -9999; recomputing here lets the route/ETA/map fill in the + // moment sys.search lands. + if scr == "preview" { + let pplat = sys.searchnum(q, ss - 1, "lat") + let pplon = sys.searchnum(q, ss - 1, "lon") + ui.pvname.set_text(sys.search(q, ss - 1, "name")) + ui.pvcat.set_text(sys.search(q, ss - 1, "cat")) + ui.pvaddr.set_text(sys.search(q, ss - 1, "label")) + ui.pveta.set_text(sys.navroute(olat, olon, pplat, pplon, "min") + " · " + sys.navroute(olat, olon, pplat, pplon, "km")) + ui.pmap.set_nav_polyline(sys.navroute(olat, olon, pplat, pplon, "polyline")) + ui.pmap.set_route_markers("" + olat + "," + olon + ",0;" + pplat + "," + pplon + ",2") + } + // ---- plan ---- + if scr == "plan" { + ui.oname.set_text(oname) + ui.dname.set_text(sys.coord(dest, "name")) + if md == "walk" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "walk", vias)) } + if md == "bike" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "bike", vias)) } + if md == "drive" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias)) } + if md == "0" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias)) } + ui.etad.set_text(sys.navroute(olat, olon, dlat, dlon, "km", vias)) + ui.themap.set_nav_polyline(sys.navroute(olat, olon, dlat, dlon, "polyline", vias)) + ui.themap.set_route_markers(mk) + if wp1 != "0" { ui.wp1n.set_text(sys.coord(wp1, "name")) } + if wp2 != "0" { ui.wp2n.set_text(sys.coord(wp2, "name")) } + // per-leg time · distance + if wp1 != "0" { + ui.leg0.set_text(sys.navroute(olat, olon, sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), "min") + " · " + sys.navroute(olat, olon, sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), "km")) + if wp2 != "0" { + ui.leg1.set_text(sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), "min") + " · " + sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), "km")) + ui.leg2.set_text(sys.navroute(sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), dlat, dlon, "min") + " · " + sys.navroute(sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), dlat, dlon, "km")) + } + if wp2 == "0" { + ui.leg1.set_text(sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), dlat, dlon, "min") + " · " + sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), dlat, dlon, "km")) + } + } + } + // ---- drive (turn-by-turn) ---- + if scr == "drive" { + let km = sys.navroutenum(olat, olon, dlat, dlon, "km", vias) + let d = sys.navsecs(km * 1000 / 15.2) * 15.2 + ui.dvmap.set_nav_polyline(sys.navroute(olat, olon, dlat, dlon, "polyline", vias)) + ui.dvmap.set_route_markers(mk) + ui.instr.set_text(sys.navstep(olat, olon, dlat, dlon, d, "instr", vias)) + ui.arw.set_text(sys.navstep(olat, olon, dlat, dlon, d, "arrow", vias)) + ui.ndist.set_text(sys.navstep(olat, olon, dlat, dlon, d, "dist", vias)) + let road = sys.navstep(olat, olon, dlat, dlon, d, "road", vias) + ui.roadlbl.set_text(road) + ui.roadpill.set_visible(road != "") + ui.remmin.set_text(sys.navstep(olat, olon, dlat, dlon, d, "remmin", vias) + " min") + ui.remrest.set_text(sys.navstep(olat, olon, dlat, dlon, d, "rem", vias) + " · 34 mph") + } +} + +SolidView{ width: Fill height: 812 flow: Overlay new_batch: true draw_bg.color: #0b1017 + + // ===================== MAP BACKDROP ===================== + if scr == "drive" { + if vw == "2d" { dvmap := MapView{ width: Fill height: 812 nav_mode: "2d" nav_period: 100 nav_route_width: 11.0 zoom: 16.0 min_zoom: 3.0 max_zoom: 19.0 use_network: true use_local_mbtiles: false } } + if vw != "2d" { dvmap := MapView{ width: Fill height: 812 nav_mode: "3d" nav_period: 100 nav_route_width: 14.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 19.0 use_network: true use_local_mbtiles: false } } + if vw != "2d" { GradientYView{ width: Fill height: 150 draw_bg.color: #8fb2d8 draw_bg.color_2: #d8e3ed } } + } + + // ===================== DESTINATION SEARCH ===================== + if scr == "search" { + View{ width: Fill height: Fill flow: Down padding: Inset{left: 16 top: 50 right: 16 bottom: 10} spacing: 12 + RoundedView{ width: Fill height: 52 flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 26 draw_bg.border_size: 1.0 draw_bg.border_color: #2a3644 padding: Inset{left: 19 right: 18} spacing: 12 + View{ width: 20 height: 20 flow: Overlay + CircleView{ width: 15 height: 15 draw_bg.color: #8b99b0 } + CircleView{ width: 10 height: 10 margin: Inset{left: 2.5 top: 2.5} draw_bg.color: #141c28 } + RoundedView{ width: 7.5 height: 2.7 margin: Inset{left: 11 top: 13.5} draw_bg.color: #8b99b0 draw_bg.border_radius: 1.3 } + } + TextInput{ width: Fill height: Fill empty_text: "Search a destination" return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + cnt := Label{ text: "Type a place, then search" draw_text.color: #7c8aa6 draw_text.text_style.font_size: 12 } + if q != "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "1" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr0n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr0c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr0l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "1"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "2" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr1c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr1l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "2"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "3" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr2c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr2l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "3"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "4" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr3n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr3c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr3l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "4"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "5" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr4n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr4c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr4l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "5"}) } + } + } + } + } + } + + // ===================== ORIGIN / STOP SEARCH OVERLAY ===================== + if scr == "find" { + SolidView{ width: Fill height: 812 flow: Down new_batch: true draw_bg.color: #0b1017 padding: Inset{left: 16 top: 50 right: 16 bottom: 10} spacing: 12 + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 10 + View{ width: 85 height: 34 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 10 padding: Inset{left: 12 top: 8 right: 12 bottom: 8} Label{ text: "‹ Back" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 13 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "find", value: "0"}) } + } + View{ width: Fill height: Fit flow: Right align: Align{x: 1.0 y: 0.5} ftot := Label{ text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 14 } } + } + // FROM/TO stay visible while searching (Google-style): the field being + // edited is the active input; the other shows its current value and is + // tappable to switch to editing it. + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #22c55e } + if find == "orig" { + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: oname return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + if find != "orig" { + View{ width: Fill height: 44 flow: Overlay + RoundedView{ width: Fill height: Fill flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 12 padding: Inset{left: 16 right: 14} ffromn := Label{ width: Fill text: oname draw_text.color: #cdd8e6 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 12 text: "" on_click: || { agent.notify("set", {key: "find", value: "orig"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + } + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #ff6b6b } + if find == "dest" { + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: sys.coord(dest, "name") return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + if find != "dest" { + View{ width: Fill height: 44 flow: Overlay + RoundedView{ width: Fill height: Fill flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 12 padding: Inset{left: 16 right: 14} Label{ width: Fill text: sys.coord(dest, "name") draw_text.color: #ffffff draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 12 text: "" on_click: || { agent.notify("set", {key: "find", value: "dest"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + } + if find == "stop" { + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: "Add a stop" return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + } + fcnt := Label{ text: "Your trip" draw_text.color: #7c8aa6 draw_text.text_style.font_size: 12 } + // Default items (before a search): the current start + destination, so you + // can pick either without typing (tap the destination while editing the + // start to reverse the trip, etc.). Replaced by live results once you type. + if q == "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 12 top: 12 right: 14 bottom: 12} + CircleView{ width: 9 height: 9 draw_bg.color: #22c55e } + View{ width: Fill height: Fit flow: Down spacing: 2 + ytstart := Label{ width: Fill text: oname draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + Label{ width: Fill text: "Start point" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 12 top: 12 right: 14 bottom: 12} + CircleView{ width: 9 height: 9 draw_bg.color: #ff6b6b } + View{ width: Fill height: Fit flow: Down spacing: 2 + Label{ width: Fill text: sys.coord(dest, "name") draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + Label{ width: Fill text: "Destination" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + } + } + if q != "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr0n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr0l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr1l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr2l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr3n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr3l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + } + } + } + } + + // ===================== DESTINATION PREVIEW ===================== + if scr == "preview" { + View{ width: Fill height: Fill flow: Overlay + pmap := MapView{ width: Fill height: 812 nav_mode: "plan" nav_route_width: 40.0 zoom: 14.0 min_zoom: 3.0 max_zoom: 16.0 use_network: true use_local_mbtiles: false } + View{ width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 1.0} + RoundedView{ width: Fill height: Fit flow: Overlay draw_bg.color: #0f1620 draw_bg.border_radius: 22 margin: Inset{left: 8 right: 8 bottom: 30} + // transparent full-bleed catch so touches on the sheet's empty areas don't + // fall through to the map behind and pan it. Real buttons on top still win. + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || {} } + View{ width: Fill height: Fit flow: Down padding: Inset{left: 14 top: 13 right: 14 bottom: 13} spacing: 7 + View{ width: 110 height: 24 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 10 padding: Inset{left: 12 top: 8 right: 12 bottom: 8} Label{ text: "‹ Results" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 9 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "sel", value: "0"}) } + } + pvname := Label{ width: Fill text: "…" draw_text.color: #ffffff draw_text.text_style.font_size: 15 } + View{ width: Fill height: Fit flow: Right align: Align{x: 0.5 y: 0.5} spacing: 12 + pvcat := Label{ text: "" draw_text.color: #f5a623 draw_text.text_style.font_size: 9 } + pveta := Label{ text: "" draw_text.color: #4ade80 draw_text.text_style.font_size: 9 } + } + pvaddr := Label{ width: Fill text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 9 } + View{ width: Fill height: 42 flow: Overlay margin: Inset{top: 6} + RoundedView{ width: Fill height: 42 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 14 Label{ text: "Directions ›" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "dest", value: sys.searchnum(q, ss - 1, "lat") + "," + sys.searchnum(q, ss - 1, "lon") + "|" + sys.search(q, ss - 1, "name") }) } + } + } + } + } + } + } + + // ===================== PLAN ===================== + if scr == "plan" { + View{ width: Fill height: Fill flow: Overlay + themap := MapView{ width: Fill height: 812 nav_mode: "plan" nav_route_width: 40.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 16.0 use_network: true use_local_mbtiles: false } + View{ width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 1.0} + RoundedView{ width: Fill height: Fit flow: Overlay draw_bg.color: #0f1620 draw_bg.border_radius: 22 margin: Inset{left: 8 right: 8 bottom: 30} + // transparent full-bleed catch: touches on the sheet's empty (non-button) + // areas are captured HERE instead of falling through to the map behind and + // panning it (the "sheet not responding" feel). Real buttons sit on top and win. + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || {} } + View{ width: Fill height: Fit flow: Down padding: Inset{left: 14 top: 11 right: 14 bottom: 11} spacing: 7 + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 8 + View{ width: 84 height: 24 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 padding: Inset{left: 10 top: 6 right: 10 bottom: 6} Label{ text: "‹ Search" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "dest", value: "0"}) } + } + View{ width: 70 height: 24 flow: Overlay + if mdn == "drive" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Drive" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "drive" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Drive" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "drive"}) } + } + View{ width: 64 height: 24 flow: Overlay + if mdn == "walk" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Walk" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "walk" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Walk" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "walk"}) } + } + View{ width: 64 height: 24 flow: Overlay + if mdn == "bike" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Bike" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "bike" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Bike" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "bike"}) } + } + } + View{ width: Fill height: Fit flow: Right align: Align{x: 0.5 y: 0.5} spacing: 8 + eta := Label{ text: "…" draw_text.color: #4ade80 draw_text.text_style.font_size: 14 } + etad := Label{ text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 9 } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff12 } + // origin (editable) — the WHOLE row is tappable (Fill-width button, + // reliable hit area) so tapping the start field opens the origin search. + View{ width: Fill height: 30 flow: Overlay + View{ width: Fill height: Fill flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #22c55e } + oname := Label{ width: Fill text: "…" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "find", value: "orig"}); agent.notify("set", {key: "q", value: "0"}) } } + } + // per-leg time · distance (shown only when the trip has stops) + if wp1 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg0 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // stop 1 + if wp1 != "0" { + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + wp1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } + View{ width: 30 height: 30 flow: Overlay + RoundedView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #38445a draw_bg.border_radius: 15 Label{ text: "×" draw_text.color: #c6d0de draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "wp1", value: "0"}) } + } + } + } + if wp1 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg1 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // stop 2 + if wp2 != "0" { + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + wp2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } + View{ width: 30 height: 30 flow: Overlay + RoundedView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #38445a draw_bg.border_radius: 15 Label{ text: "×" draw_text.color: #c6d0de draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "wp2", value: "0"}) } + } + } + } + if wp2 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg2 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // destination (editable) — the WHOLE row is tappable, like the origin, + // so tapping the end field opens the destination search. + View{ width: Fill height: 30 flow: Overlay + View{ width: Fill height: Fill flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #ff6b6b } + dname := Label{ width: Fill text: "…" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "find", value: "dest"}); agent.notify("set", {key: "q", value: "0"}) } } + } + // add stop (searchable) — only if a slot is free + if wp2 == "0" { + View{ width: 140 height: 34 flow: Overlay margin: Inset{top: 2} + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1b2534 draw_bg.border_radius: 18 spacing: 6 + Label{ text: "+" draw_text.color: #72c0ff draw_text.text_style.font_size: 10 } + Label{ text: "Add stop" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 9 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || { agent.notify("set", {key: "find", value: "stop"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + View{ width: Fill height: 42 flow: Overlay margin: Inset{top: 4} + RoundedView{ width: Fill height: 42 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 14 Label{ text: "▶ Start" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "go", value: "1"}) } + } + } + } + } + // map controls (top-right): +/- zoom pill, then a "my location" button that + // recenters on the trip origin. Pinch-to-zoom and one-finger drag also work. + View{ width: Fill height: Fill flow: Down align: Align{x: 1.0 y: 0.0} padding: Inset{top: 74 right: 12} spacing: 12 + RoundedView{ width: 50 height: 104 flow: Down align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 16 + View{ width: 50 height: 51 flow: Overlay + Label{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} text: "+" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 26 } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 14 text: "" on_click: || ui.themap.nav_zoom_by("0.7") } + } + SolidView{ width: 34 height: 1 draw_bg.color: #ffffff22 } + View{ width: 50 height: 51 flow: Overlay + Label{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} text: "−" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 26 } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 14 text: "" on_click: || ui.themap.nav_zoom_by("-0.7") } + } + } + View{ width: 50 height: 50 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 25 Label{ text: "◎" draw_text.color: #72c0ff draw_text.text_style.font_size: 24 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 25 text: "" on_click: || ui.themap.nav_center_origin("1") } + } + } + } + } + + // ===================== DRIVE (turn-by-turn) ===================== + if scr == "drive" { + View{ width: Fill height: Fill flow: Down padding: Inset{left: 10 top: 44 right: 10 bottom: 10} + roadpill := RoundedView{ width: Fill height: Fit flow: Right align: Align{y: 0.5} draw_bg.color: #0d3a2f draw_bg.border_radius: 13 padding: Inset{left: 9 top: 7 right: 12 bottom: 7} spacing: 9 + CircleView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 arw := Label{ text: "↑" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } } + View{ width: Fill height: Fit flow: Down spacing: 2 align: Align{x: 0.5} + instr := Label{ text: "Starting…" draw_text.color: #ffffff draw_text.text_style.font_size: 11 } + ndist := Label{ text: "" draw_text.color: #a9d6c8 draw_text.text_style.font_size: 9 } + } + } + roadlbl := Label{ text: "" draw_text.color: #00000000 draw_text.text_style.font_size: 1 } + // 2D/3D toggle floats on the LEFT side, above the bottom bar (map control, + // not stacked under End) + View{ width: Fill height: Fill flow: Down align: Align{x: 0.0 y: 1.0} + View{ width: 60 height: 48 flow: Overlay margin: Inset{left: 4 bottom: 8} + if vw == "2d" { + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 14 Label{ text: "3D" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "view", value: "3d"}) } + } + if vw != "2d" { + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 14 Label{ text: "2D" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "view", value: "2d"}) } + } + } + } + // bottom sheet. The handle + time/distance form one big swipe target: a Button + // sits ON TOP of them (transparent) so it (a) catches the swipe to toggle and + // (b) occludes the map so the swipe never leaks through to pan it. End is a + // separate row BELOW the swipe target, so it stays tappable when expanded. + // Swipe toggles endrow's visibility DIRECTLY (ui.endrow.set_visible) — no + // agent.notify/eval_body, so the map is never torn down / re-rendered. + View{ width: Fill height: Fit flow: Down align: Align{x: 0.5 y: 1.0} padding: Inset{left: 4 right: 4} + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #0c1420 draw_bg.border_radius: 16 padding: Inset{left: 18 top: 7 right: 18 bottom: 12} spacing: 4 + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 4 align: Align{x: 0.5} + View{ width: Fill height: 6 flow: Right align: Align{x: 0.5 y: 0.5} + RoundedView{ width: 44 height: 5 draw_bg.color: #3a4658 draw_bg.border_radius: 3 } + } + remmin := Label{ width: Fill align: Align{x: 0.5} text: "" draw_text.color: #4ade80 draw_text.text_style.font_size: 22 } + remrest := Label{ width: Fill align: Align{x: 0.5} text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 13 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" swipe: true on_swipe_up: || ui.endrow.set_visible(true) on_swipe_down: || ui.endrow.set_visible(false) } + } + endrow := View{ width: Fill height: 44 flow: Overlay margin: Inset{top: 6} visible: false + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #7a1f1f draw_bg.border_radius: 12 Label{ text: "× End" draw_text.color: #ffd9d9 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "go", value: "0"}) } + } + } + } + } + } + +} diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card new file mode 100644 index 0000000..01a6c38 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -0,0 +1,126 @@ +# ledger weather@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/weather.rs lowers: CurrentConditions, Forecast, +# AirQualityField, SunMoon, Details. + +# ── sources: queries the runtime executes; the view never calls them ────────── +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]) +source week sys.weather(lat: place.lat, lon: place.lon, days: state.days, + fields: [dayname, hi, lo, cond], + aggregate: [min_lo, max_hi]) +# `week` is a RECORD: `week.days` is the collection, `week.min_lo`/`max_hi` are +# the bounds every bar scales against. Those bounds were a hand-authored +# `wmin`/`wmax` in an earlier card and got invented by the model; here they come +# back with the data they describe. +source sun sys.daylight(lat: place.lat, lon: place.lon) +source moon sys.moonphase() +source aqi sys.airquality(lat: place.lat, lon: place.lon) +source scene sys.photo(query: place.name) +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 } + +# ── events: total transitions, applied atomically ──────────────────────────── +event toggle_units { units: cycle(.c, .f) } +event pick_city { city: set($value) } + +# ── copy: declared literals, all host-owned vocabulary ─────────────────────── +copy feels { class: vocabulary, en: "Feels like", zh: "体感" } +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 air { class: vocabulary, en: "Air Quality", zh: "空气质量" } +copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } +copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } + +# ── view ───────────────────────────────────────────────────────────────────── +view root Photo(src: scene, pad: .page) { + current + forecast + airfield + sunmoon + details + } + +view current Col(align: .center) { + TextTitle(text: place.name) + WeatherIcon(cond: now.cond, size: .hero) + TextHero(value: now.temp, unit: units, on_tap: toggle_units) + Row(gap: 8) { + TextCaption(glyph: "↑", value: now.hi, unit: units) + TextCaption(glyph: "↓", value: now.lo, unit: units) + TextCaption(glyph: "≈", value: now.feels, unit: units) + } + } + +# Exercises the other half of the component contracts: +# - LOCAL state and a local event (§5.7) — seven rows, seven `expanded` cells, +# keyed by (declaration path, loop key), none of them in the ledger +# - reads card-scope `source` directly (week.min_lo) — permitted by §5.3 +# - takes `unit` as a PROP, because card state may not be read directly; +# that keeps the dependency visible to the tracker +# - the transition is `toggle`, a total form, not `!expanded` (§3) +# +# Prop named `item`, not `day`: `width: .day` is a layout token. See finding #9. +component ForecastRow(item: record, unit: enum[c, f]) { + state expanded { shape: bool, initial: false } + event toggle { expanded: toggle } + view Col { + Row(align: .center, on_tap: toggle) { + TextRow(text: item.dayname, width: .day) + WeatherIcon(cond: item.cond, size: .row) + TextValue(value: item.lo, unit: unit) + TempBar(lo: item.lo, hi: item.hi, min: week.min_lo, max: week.max_hi) + TextValue(value: item.hi, unit: unit) + } + when expanded { + Row(gap: 8) { + TextCaption(text: item.cond) + } + } + } +} + +view forecast Panel { + for d in week.days key d.dayname { + ForecastRow(item: d, unit: units) + } + } + +view airfield Panel { + TextCaption(text: copy.air) + AqiContour(lat: place.lat, lon: place.lon, span: 1.6) + } + +view sunmoon Panel { + Row { + Col { TextCaption(text: copy.sunrise) TextStat(value: sun.rise, format: .time) } + SunArc(rise: sun.rise, set: sun.set, now: sun.now) + Col { TextCaption(text: copy.sunset) TextStat(value: sun.set, format: .time) } + } + MoonPhase(phase: moon.phase, illum: moon.illumination) + } + +# The plan's `tiles: Vec` becomes explicit structure. The model picks +# tiles by writing them, not by naming them in an array — more expressive, and +# it removes a loop over a model-authored list. +view details Grid(cols: 2) { + Tile(label: copy.feels, value: now.feels, unit: units) + 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) + } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs new file mode 100644 index 0000000..36be672 --- /dev/null +++ b/crates/splash-ui-l0/tests/profile.rs @@ -0,0 +1,4126 @@ +//! The L0 profile checked against the reference cards. +//! +//! These three cards are the evidence for the profile: any construct they need +//! that the grammar lacks is a defect in `docs/ui-profile-l0.md`, not in the +//! cards. They are the same files as `octos-one/docs/l0/*.card`. + +use splash_ui_l0::{catalog, check_ui_l0_named, Level}; + +const WEATHER: &str = include_str!("fixtures/weather.card"); +const NEWS: &str = include_str!("fixtures/news.card"); +const STOCK: &str = include_str!("fixtures/stock.card"); + +fn accepts(name: &str, source: &str) { + let report = check_ui_l0_named(name, source); + assert!( + report.valid, + "{name} should be L0, but: {:#?}", + report.diagnostics + ); + assert_eq!(report.level, Level::L0); +} + +fn rejects_at(name: &str, source: &str, level: Level) -> String { + let report = check_ui_l0_named(name, source); + assert!(!report.valid, "{name} should have been rejected"); + assert_eq!( + report.level, level, + "wrong level for {name}: {:#?}", + report.diagnostics + ); + report + .diagnostics + .first() + .map(|d| d.message.clone()) + .unwrap_or_default() +} + +// ─────────────────────────────────────────────────────────── the reference cards ── + +#[test] +fn the_three_reference_cards_are_l0() { + accepts("weather", WEATHER); + accepts("news", NEWS); + accepts("stock", STOCK); +} + +/// Every constructor the cards use must be catalogued, or the checker is +/// accepting names it cannot check the arguments of. +#[test] +fn every_constructor_the_cards_use_is_catalogued_or_declared() { + for (name, source) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let declared: Vec<&str> = source + .lines() + .filter_map(|l| l.trim().strip_prefix("component ")) + .filter_map(|l| l.split(['(', ' ']).next()) + .collect(); + + for line in source.lines() { + let line = line.split('#').next().unwrap_or(""); + for word in line.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + let is_ctor = word.chars().next().is_some_and(|c| c.is_uppercase()) + && line.contains(&format!("{word}(")); + if is_ctor && catalog::lookup(word).is_none() && !declared.contains(&word) { + panic!("{name}: {word:?} is neither catalogued nor declared"); + } + } + } + } +} + +// ──────────────────────────────────────────────────────────── level classification ── + +#[test] +fn a_function_is_l2() { + let msg = rejects_at("fn", "fn tick() { }\nview root Panel", Level::L2); + assert!(msg.contains("unbounded work"), "{msg}"); +} + +#[test] +fn an_imperative_widget_command_is_l2() { + let msg = rejects_at( + "ui", + "view root Panel { Row(on_tap: go) }\nevent go { ui.map.set_text(1) }", + Level::L2, + ); + assert!(msg.contains("imperative"), "{msg}"); +} + +#[test] +fn module_access_is_l2() { + let msg = rejects_at("mod", "source x mod.fs.read(\"/etc/passwd\")", Level::L2); + assert!(msg.contains("host surface is empty"), "{msg}"); +} + +#[test] +fn arithmetic_is_l1() { + let msg = rejects_at( + "arith", + "view hero TextHero(value: now.temp * 2)", + Level::L1, + ); + assert!(msg.contains("fabricated fact"), "{msg}"); +} + +/// The correction that mattered: `expanded: !expanded` is computation, however +/// small, and L0 has `toggle` precisely so it need not be written. +#[test] +fn negation_in_a_transition_is_l1() { + let msg = rejects_at( + "neg", + "state open { shape: bool, initial: false }\nevent t { open: !open }", + Level::L1, + ); + assert!(msg.contains("toggle"), "{msg}"); +} + +#[test] +fn a_ternary_is_l1() { + rejects_at( + "ternary", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: u == .c ? .f : .c }", + Level::L1, + ); +} + +// ────────────────────────────────────────────────────────────────── grammar rules ── + +/// Identity may never come from position — profile §6.3. +#[test] +fn a_loop_without_a_key_is_rejected() { + let report = check_ui_l0_named( + "nokey", + "source week sys.weather()\nview f Panel { for d in week { Row() } }", + ); + assert!(!report.valid); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("key")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_unknown_constructor_is_rejected() { + let report = check_ui_l0_named("unknown", "view root Blockchain(hype: 11)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not an L0 constructor"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_unknown_argument_is_rejected() { + let report = check_ui_l0_named("badarg", "view root Panel { Rule(sparkle: 3) }"); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("no argument"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_illegal_token_is_rejected_with_the_legal_set() { + let report = check_ui_l0_named("badtoken", "view root Surface(pad: .enormous)"); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!( + m.contains(".page"), + "the diagnostic should name the legal tokens: {m}" + ); +} + +/// The distinction lexical marking exists to make: a bare name is a path, and a +/// path cannot stand in for a token. +#[test] +fn a_bare_name_where_a_token_belongs_is_rejected() { + let report = check_ui_l0_named("bare", "view root Surface(pad: page)"); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!(m.contains("leading dot"), "{m}"); +} + +#[test] +fn an_undeclared_name_is_rejected() { + let report = check_ui_l0_named("undeclared", "view root TextTitle(text: nowhere.name)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a declared name"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_undeclared_event_is_rejected() { + let report = check_ui_l0_named("noevent", "view root Card(on_tap: nope)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a declared event"), + "{:#?}", + report.diagnostics + ); +} + +// ──────────────────────────────────────────────────────────── component contracts ── + +/// Profile §5.3: card state must arrive as a prop, or the dependency is +/// invisible to the tracker. +#[test] +fn a_component_may_not_read_card_state() { + let report = check_ui_l0_named( + "scope", + "state units { shape: enum[c, f], initial: .c }\n\ + component Row2(item: record) { view TextValue(value: item.hi, unit: units) }\n\ + view root Panel { }", + ); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!(m.contains("pass it as a prop"), "{m}"); +} + +/// A component reading a card-scope source is fine — sources are immutable and +/// already declared dependencies. +#[test] +fn a_component_may_read_a_card_source() { + let report = check_ui_l0_named( + "srcread", + "source week sys.weather()\n\ + component Bar(item: record) { view TempBar(lo: item.lo, hi: item.hi, min: week.min, max: week.max) }\n\ + view root Panel { }", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Profile §6, condition 1. Without it an L0 realization need not terminate. +#[test] +fn a_recursive_component_is_rejected() { + let report = check_ui_l0_named( + "rec", + "component Node(item: record) { view Col { Node(item: item) } }\nview root Panel { }", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("recursive"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn mutual_recursion_is_rejected_too() { + let report = check_ui_l0_named( + "mutual", + "component A(x: record) { view Col { B(x: x) } }\n\ + component B(x: record) { view Col { A(x: x) } }\n\ + view root Panel { }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +// ────────────────────────────────────────────────────────────────────── bounds ── + +#[test] +fn diagnostics_are_bounded() { + let mut source = String::from("view root Panel {\n"); + for _ in 0..500 { + source.push_str(" Blockchain()\n"); + } + source.push('}'); + let report = check_ui_l0_named("many", &source); + assert!(!report.valid); + assert!(report.diagnostics.len() <= splash_ui_l0::MAX_UI_L0_DIAGNOSTICS); + assert!(report.diagnostics_truncated); +} + +#[test] +fn deeply_nested_source_terminates() { + let depth = 4_000; + let mut source = String::from("view root "); + for _ in 0..depth { + source.push_str("Panel { "); + } + for _ in 0..depth { + source.push('}'); + } + // The contract is that it returns, bounded, rather than overflowing. + let report = check_ui_l0_named("deep", &source); + assert!(!report.valid); +} + +#[test] +fn a_truncated_card_terminates() { + for source in [ + "view root Panel {", + "component A(", + "state x {", + "view root Row(align:", + "for d in", + ] { + let _ = check_ui_l0_named("partial", source); + } +} + +// ─────────────────────────────────────────────────────────── the negative fixture ── + +/// The shipping nav card. It is the reason the level model exists: 127 +/// conditionals that L0 could express, and a `fn tick()` plus 65 imperative +/// widget commands that it cannot. Level is the MAXIMUM any construct requires, +/// so this must classify L2 rather than stopping at the first arithmetic it hits. +const NAV: &str = include_str!("fixtures/trip_planner.splash"); + +#[test] +fn the_nav_card_is_l2() { + let report = check_ui_l0_named("trip-planner", NAV); + assert!(!report.valid); + assert_eq!( + report.level, + Level::L2, + "expected L2, got {:?}: {:#?}", + report.level, + report.diagnostics.first() + ); +} + +// ─────────────────────────────────────────────────────────────────── transitions ── + +#[test] +fn a_transition_must_name_a_declared_state() { + let report = check_ui_l0_named("nostate", "event go { nowhere: clear }\nview root Panel"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a state declared"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn toggle_requires_a_bool() { + let report = check_ui_l0_named( + "toggle", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: toggle }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("needs a bool"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn cycle_members_must_be_declared_in_the_shape() { + let report = check_ui_l0_named( + "cycle", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: cycle(.c, .kelvin) }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("not a member"), + "{:#?}", + report.diagnostics + ); +} + +/// Enum members are tokens. A bare name is a path and cannot stand in for one. +#[test] +fn cycle_rejects_bare_names() { + let report = check_ui_l0_named( + "cyclebare", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: cycle(c, f) }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("leading dot"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_untotal_transition_form_is_rejected() { + let report = check_ui_l0_named( + "untotal", + "state n { shape: number, initial: 0 }\nevent t { n: increment }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("total transition form"), + "{:#?}", + report.diagnostics + ); +} + +// ────────────────────────────────────────────────────────────────── realization ── + +use splash_ui_l0::{realize, NodeValue, RealizeLimits}; + +fn weather_data() -> serde_json::Value { + serde_json::json!({ + "place": { "name": "Kyoto" }, + "now": { "temp": 18.0, "feels": 16.0, "hi": 21.0, "lo": 12.0, "cond": "cloudy", + "humidity": 61.0, "wind": 9.0, "pressure": 1013.0, "uv": 3.0, + "visibility": 10.0 }, + // `week` is a record: the days plus the bounds every bar scales against. + // An array could not carry both, which is what the first data snapshot + // got wrong and silently zeroed every TempBar. + "week": { "days": [ { "dayname": "Mon", "hi": 21.0, "lo": 12.0, "cond": 2.0 }, + { "dayname": "Tue", "hi": 23.0, "lo": 13.0, "cond": 0.0 }, + { "dayname": "Wed", "hi": 19.0, "lo": 11.0, "cond": 3.0 } ], + "min_lo": 11.0, "max_hi": 23.0 }, + "sun": { "rise": 5.1, "set": 18.9, "now": 12.0 }, + "moon": { "phase": 0.5, "illumination": 0.5 }, + "aqi": { "grid": [1, 2], "index": 42.0, "band": "good" }, + "scene": "https://example.invalid/kyoto.jpg", + "units": "c", + "days": 7.0, + "city": "Kyoto", + "env": { "locale": { "temp_unit": "c" } }, + "copy": { "feels": "Feels like", "humidity": "Humidity", "wind": "Wind", + "pressure": "Pressure", "uv": "UV Index", "visibility": "Visibility", + "air": "Air Quality", "sunrise": "Sunrise", "sunset": "Sunset" } + }) +} + +fn find<'a>(node: &'a splash_ui_l0::UiNode, kind: &str, out: &mut Vec<&'a splash_ui_l0::UiNode>) { + if node.kind == kind { + out.push(node); + } + for c in &node.children { + find(c, kind, out); + } +} + +#[test] +fn a_card_realizes_to_a_node_tree() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.expect("a root node"); + assert_eq!(root.kind, "Photo"); + assert!( + report.nodes > 20, + "expected a populated tree, got {}", + report.nodes + ); +} + +/// Bindings resolve against injected data — the card names a question, the +/// runtime supplied the answer. +#[test] +fn bindings_resolve_from_injected_data() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut titles = Vec::new(); + find(&root, "TextTitle", &mut titles); + let (_, place) = titles[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!(*place, NodeValue::Text("Kyoto".into())); +} + +/// One row per item, keyed by the declared key expression rather than position. +#[test] +fn a_loop_produces_one_instance_per_item_keyed_by_its_key() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut bars = Vec::new(); + find(&root, "TempBar", &mut bars); + assert_eq!(bars.len(), 3, "three days of data, three rows"); + for (bar, day) in bars.iter().zip(["Mon", "Tue", "Wed"]) { + assert!( + bar.key.contains(day), + "key {:?} should carry {day}", + bar.key + ); + } +} + +/// Identity must survive an edit that inserts a sibling above — that is why keys +/// come from the declaration path and loop key, not position. +#[test] +fn keys_are_stable_when_a_sibling_is_inserted_above() { + let before = realize(WEATHER, &weather_data(), RealizeLimits::default()) + .root + .unwrap(); + let edited = WEATHER.replace( + "view current Col(align: .center) {", + "view current Col(align: .center) {\n Rule()", + ); + let after = realize(&edited, &weather_data(), RealizeLimits::default()) + .root + .unwrap(); + + let (mut a, mut b) = (Vec::new(), Vec::new()); + find(&before, "TempBar", &mut a); + find(&after, "TempBar", &mut b); + let keys_a: Vec<&str> = a.iter().map(|n| n.key.as_str()).collect(); + let keys_b: Vec<&str> = b.iter().map(|n| n.key.as_str()).collect(); + assert_eq!( + keys_a, keys_b, + "inserting a node above must not rebind the forecast rows" + ); +} + +/// A bare boolean guard — amendment #10 — and it must actually gate. +#[test] +fn a_guard_includes_children_only_when_it_holds() { + let mut data = weather_data(); + let report = realize(WEATHER, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut captions = Vec::new(); + find(&root, "TextCaption", &mut captions); + let collapsed = captions.len(); + + // `expanded` is component-local state the runtime holds; with none supplied + // the guard is false and the detail row is absent. + data["expanded"] = serde_json::Value::Bool(true); + let expanded = realize(WEATHER, &data, RealizeLimits::default()) + .root + .unwrap(); + let mut captions2 = Vec::new(); + find(&expanded, "TextCaption", &mut captions2); + assert!( + captions2.len() >= collapsed, + "an open guard must not remove nodes" + ); +} + +/// A missing binding is surfaced, not defaulted. A silently empty field is how a +/// card renders an em dash and still looks correct. +#[test] +fn an_unresolved_binding_is_reported_as_missing() { + let mut data = weather_data(); + data["now"]["humidity"] = serde_json::Value::Null; + let report = realize(WEATHER, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut tiles = Vec::new(); + find(&root, "Tile", &mut tiles); + assert!( + tiles.iter().any(|t| t + .args + .iter() + .any(|(n, v)| n == "value" && *v == NodeValue::Missing)), + "a null source field should realize as Missing" + ); +} + +#[test] +fn events_carry_their_declared_name_rather_than_a_constructed_string() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut heroes = Vec::new(); + find(&root, "TextHero", &mut heroes); + let (_, ev) = heroes[0].args.iter().find(|(n, _)| n == "on_tap").unwrap(); + assert_eq!(*ev, NodeValue::Event("toggle_units".into())); +} + +#[test] +fn realization_is_bounded() { + let limits = RealizeLimits { + max_nodes: 10, + ..RealizeLimits::default() + }; + let report = realize(WEATHER, &weather_data(), limits); + assert!(report.truncated); + assert!(report.nodes <= 10); +} + +#[test] +fn a_long_collection_is_truncated_rather_than_unbounded() { + let mut data = weather_data(); + let week: Vec = (0..5_000) + .map(|i| serde_json::json!({ "dayname": format!("d{i}"), "hi": 1.0, "lo": 0.0, "cond": "x" })) + .collect(); + data["week"] = serde_json::json!({ "days": week, "min_lo": 0.0, "max_hi": 1.0 }); + let limits = RealizeLimits { + max_collection: 8, + ..RealizeLimits::default() + }; + let report = realize(WEATHER, &data, limits); + let root = report.root.unwrap(); + let mut bars = Vec::new(); + find(&root, "TempBar", &mut bars); + assert_eq!(bars.len(), 8); + assert!(report.truncated); +} + +#[test] +fn the_other_two_cards_realize_too() { + let news = serde_json::json!({ + "lead": [{ "id": "1", "title": "T", "author": "A", "points": 9.0, "comments": 3.0, "url": "u" }], + "feed": [{ "id": "2", "title": "U", "author": "B", "points": 4.0 }], + "article": { "title": "T", "author": "A", "points": 9.0, "comments": 3.0, "url": "u" }, + "selected": "", + "env": { "locale": {} }, + "copy": { "masthead": "Top Stories", "latest": "LATEST", "lead": "LEAD", + "pts": "pts", "comments": "comments", "by": "by" } + }); + let report = realize(NEWS, &news, RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + assert!(report.root.is_some()); + + let stock = serde_json::json!({ + "movers": [{ "ticker": "AAA", "name": "A", "last": 1.0, "change": 0.5, "pct": 2.0 }], + "quote": { "name": "A", "last": 1.0, "change": 0.5, "pct": 2.0, "open": 1.0, + "high": 2.0, "low": 0.5, "volume": 10.0, "mktcap": 99.0, "pe": 12.0 }, + "series": { "points": [1, 2], "min": 0.5, "max": 2.0 }, + "selected": "", "range": "m1", "env": { "locale": {} }, + "copy": { "movers": "Top Movers", "open": "Open", "high": "High", "low": "Low", + "volume": "Volume", "mktcap": "Mkt Cap", "pe": "P/E" } + }); + let report = realize(STOCK, &stock, RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + assert!(report.root.is_some()); +} + +// ───────────────────────────────────────────────────────────── makepad lowering ── + +use splash_ui_l0::makepad; + +#[test] +fn a_realized_card_lowers_to_renderable_dsl() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + + // Exactly one top-level node. Siblings at top level lay out SIDE BY SIDE and + // squeeze the card into half the width — a real generated-card bug. + let tops = dsl + .lines() + .filter(|l| !l.starts_with("//") && !l.starts_with(' ') && !l.trim().is_empty()) + .filter(|l| l.contains('{')) + .count(); + assert_eq!(tops, 1, "expected one root node:\n{dsl}"); + + assert!( + dsl.contains("http_resource"), + "the photo should be a resource" + ); + assert!( + dsl.contains("TempBar{"), + "forecast bars should survive lowering" + ); + assert!( + dsl.contains("MoonPhase{"), + "the moon phase should survive lowering" + ); + assert!( + dsl.contains("\"Kyoto\""), + "the bound city should be concrete" + ); + assert!( + !dsl.contains("sys."), + "realization already resolved sources: {dsl}" + ); +} + +/// Nothing may be silently dropped: an unmapped constructor renders a visible +/// warning instead of vanishing. +#[test] +fn an_unmapped_constructor_is_visible_rather_than_dropped() { + let node = splash_ui_l0::UiNode { + kind: "Hologram".into(), + key: "root".into(), + args: vec![], + children: vec![], + }; + let dsl = makepad::lower(&node); + assert!(dsl.contains("no makepad lowering for Hologram"), "{dsl}"); +} + +/// A missing binding reaches the screen as an em dash rather than a blank. +#[test] +fn a_missing_binding_lowers_to_a_visible_dash() { + let mut data = weather_data(); + data["now"]["humidity"] = serde_json::Value::Null; + let report = realize(WEATHER, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + assert!(dsl.contains('—'), "an unresolved field should be visible"); +} + +// ────────────────────────────────────────────────────────────── instance state ── + +const TWO_ROWS: &str = r#" +source items sys.movers() +component Rowy(item: record) { + state open { shape: bool, initial: false } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + +fn two_rows_data() -> serde_json::Value { + serde_json::json!({ "items": [ {"id": "a", "name": "A"}, {"id": "b", "name": "B"} ] }) +} + +/// Each instance must own its `open` cell. Opening one row may not open the +/// other — the property the whole component model exists to provide. +#[test] +fn local_state_is_per_instance() { + let mut store = splash_ui_l0::InstanceStore::default(); + let data = two_rows_data(); + + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let root = first.root.expect("root"); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2); + + // Open only the first row. + let key = rows[0].key.clone(); + let applied = splash_ui_l0::dispatch(TWO_ROWS, &mut store, &key, "flip"); + assert!(applied, "flip should have applied at {key}"); + + let second = + splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let root = second.root.expect("root"); + let mut captions = Vec::new(); + find(&root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 1, + "exactly one row should be open, not {}", + captions.len() + ); + let (_, shown) = captions[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!( + *shown, + NodeValue::Text("A".into()), + "the row that was tapped" + ); +} + +/// Profile §5.8: a state schema change resets instances rather than guessing +/// that old values still fit. +#[test] +fn a_schema_change_resets_instance_state() { + let mut store = splash_ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + + // The component gains a field: same name, different state schema. + let edited = TWO_ROWS.replace( + "state open { shape: bool, initial: false }", + "state open { shape: bool, initial: false }\n state seen { shape: bool, initial: false }", + ); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 0, + "a schema change resets, it does not migrate" + ); +} + +// ─────────────────────────────────────────────────────── declared-but-missing ── + +const SETTER: &str = r#" +source items sys.movers() +state picked { shape: text, initial: "" } +event choose { picked: set($value) } +event home { picked: clear } +view root Panel { + when picked == "" { for it in items key it.id { Row(on_tap: choose, value: it.id) { TextRow(text: it.name) } } } + when picked != "" { Col { TextTitle(text: picked) Row(on_tap: home) { TextCaption(glyph: "‹") } } } +} +"#; + +/// `set($value)` carries the payload from the element that raised the event. +/// Without it a list cannot select anything — the interaction stock and news +/// both depend on. +#[test] +fn set_applies_the_event_payload() { + let mut store = splash_ui_l0::InstanceStore::default(); + let data = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); + + let before = splash_ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let root = before.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2, "the list should show both items"); + + let payload = serde_json::json!("b"); + let applied = splash_ui_l0::dispatch_with(SETTER, &mut store, "root", "choose", Some(&payload)); + assert!(applied, "choose should apply"); + + let after = splash_ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let root = after.root.unwrap(); + let mut titles = Vec::new(); + find(&root, "TextTitle", &mut titles); + assert_eq!( + titles.len(), + 1, + "selecting should switch to the detail view" + ); + let (_, shown) = titles[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!( + *shown, + NodeValue::Text("b".into()), + "the item that was tapped" + ); +} + +const SLOTTED: &str = r#" +component Framed(title: text) { + view Col { TextCaption(text: title) slot } +} +view root Panel { Framed(title: "Details") { TextRow(text: "inner") Rule() } } +"#; + +/// A component's children are realized where `slot` appears. Dropping them +/// silently is how a card renders a frame with nothing in it and still looks +/// deliberate. +#[test] +fn slot_realizes_the_children_passed_at_the_call_site() { + let report = realize(SLOTTED, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "TextRow", &mut rows); + assert_eq!( + rows.len(), + 1, + "the child passed at the call site must appear" + ); + let mut rules = Vec::new(); + find(&root, "Rule", &mut rules); + assert_eq!(rules.len(), 1); +} + +/// Profile §5.7: an instance that disappears loses its state. Without pruning +/// the store grows for the life of the app and a key that reappears inherits a +/// stale value. +#[test] +fn unmounted_instances_are_pruned() { + let mut store = splash_ui_l0::InstanceStore::default(); + let two = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); + + let report = splash_ui_l0::realize_with_state(TWO_ROWS, &two, &store, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + for row in &rows { + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &row.key, "flip"); + } + assert_eq!(store.len(), 2, "both rows hold state"); + + // `b` goes away. + let one = serde_json::json!({ "items": [{"id":"a","name":"A"}] }); + let report = splash_ui_l0::realize_with_state(TWO_ROWS, &one, &store, RealizeLimits::default()); + store.prune(&report.live_keys); + assert_eq!(store.len(), 1, "the departed instance's cell is dropped"); +} + +/// Formatting is the runtime's job, not the card's. A card that wrote "$" or +/// "41.2M" itself would be authoring a fact — and currency, grouping and +/// compaction are locale-dependent besides. +#[test] +fn declared_formats_are_applied_by_the_runtime() { + 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}, + "series": {"points":[1,2],"min":180.2,"max":185.6}, + "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")); + + for (expect, why) in [ + ("$184.20", "money gains a currency symbol and two places"), + ("+1.7%", "a signed percentage keeps its sign"), + ("41.2M", "compact scales volume"), + ("4.5T", "compact scales market cap"), + ("58.3", "a ratio is plain"), + ] { + assert!(dsl.contains(expect), "{why}: {expect} missing from\n{dsl}"); + } + assert!( + !dsl.contains("41200000"), + "the raw number should not survive" + ); +} + +/// A float hour is a time, not a decimal: 18.9 is 18:54. +#[test] +fn a_time_format_renders_as_a_clock() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + assert!( + dsl.contains("5:06"), + "sunrise 5.1 should render 5:06:\n{dsl}" + ); +} + +// ──────────────────────────────────────────────────────────── catalog agreement ── + +/// The module doc claims a test asserts the Rust catalog and the TOML spec +/// agree. It said so for two commits before this test existed — a documented +/// check that was never written is worse than no check, because a reader stops +/// looking. +#[test] +fn the_rust_catalog_matches_the_toml_spec() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + // Constructor headers are `[Name]`; `[kinds.x]` are shared token sets. + let documented: Vec<&str> = TOML + .lines() + .filter_map(|l| l.trim().strip_prefix('[')) + .filter_map(|l| l.strip_suffix(']')) + .filter(|n| !n.contains('.')) + .collect(); + + let implemented: Vec<&str> = catalog::CONSTRUCTORS.iter().map(|(n, _)| *n).collect(); + + for name in &documented { + assert!( + implemented.contains(name), + "{name:?} is in the TOML spec but not in the Rust catalog" + ); + } + for name in &implemented { + assert!( + documented.contains(name), + "{name:?} is in the Rust catalog but not in the TOML spec" + ); + } + + // Token sets must agree too, or a card legal by the spec is rejected by the + // implementation. + for (set_name, tokens) in [ + ("unit", catalog::UNIT), + ("format", catalog::FORMAT), + ("width", catalog::WIDTH), + ] { + let header = format!("[kinds.{set_name}]"); + let block = TOML + .split(&header) + .nth(1) + .unwrap_or_else(|| panic!("{header} missing from the TOML")); + let line = block + .lines() + .find(|l| l.trim_start().starts_with("tokens")) + .unwrap_or_else(|| panic!("{set_name} has no tokens line")); + for token in tokens { + assert!( + line.contains(&format!("\"{token}\"")), + "{set_name}: {token:?} is in the Rust catalog but not the TOML" + ); + } + } +} + +// ───────────────────────────────────────────────────────────────── copy locale ── + +/// `copy` is authored in the ledger, so it resolves from the CARD rather than +/// the injected data. Carrying it in both duplicated what the card already said, +/// and the two drifted — a card gained a `copy` the data blob lacked, and the +/// eyebrow rendered as an em dash on device. +#[test] +fn copy_resolves_from_the_card_not_the_data() { + let data = serde_json::json!({ + "lead": [{"id":"1","title":"T","author":"A","points":1.0,"comments":2.0,"url":"u"}], + "feed": [], "article": {}, "selected": "", + "env": {"locale": {"lang": "en"}} + // note: no "copy" key at all + }); + let report = realize(NEWS, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.expect("root")); + assert!( + dsl.contains("Top Stories"), + "card-declared copy should resolve:\n{dsl}" + ); + assert!( + dsl.contains("HACKER NEWS"), + "the eyebrow should resolve too" + ); +} + +/// A tap must be RECEIVABLE, not merely described. +/// +/// Lowering emitted `l0_event`/`l0_key`/`l0_value` as attributes and nothing +/// read them — the VM does not hit-test an arbitrary attribute — so every card +/// that declared `on_tap` rendered inert on device while these tests passed. +/// The mechanism that works is the one hand-written cards use: a transparent +/// `Button` over the content calling `agent.notify`, which the host receives. +#[test] +fn a_tappable_node_lowers_to_a_reachable_notify() { + let data = serde_json::json!({ + "movers": [{ "ticker": "NVDA", "name": "Nvidia", "last": 1.0, "change": 0.5, "pct": 2.0 }], + "quote": { "name": "Nvidia", "last": 1.0, "change": 0.5, "pct": 2.0, "open": 1.0, + "high": 2.0, "low": 0.5, "volume": 10.0, "mktcap": 99.0, "pe": 12.0 }, + "selected": "", "range": "m1", "env": { "locale": {} }, + "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")); + + // The row that opens a quote must carry all three: which event, which + // instance, and what payload. Missing the key means a tap cannot say WHICH + // row was hit, which is the whole basis of §5.1 identity. + let notify = dsl + .lines() + .find(|l| l.contains("agent.notify") && l.contains("open_quote")) + .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}"); + assert!( + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), + "instance key:\n{notify}" + ); + + // `agent.notify`'s first argument must stay a LITERAL. The host's + // `tag_notify_calls` only rewrites literals, and an untagged event is + // discarded as unattributable — so a computed channel name would render the + // card inert again, silently and in exactly the same way. + assert!( + notify.contains("agent.notify(\"l0\","), + "the channel must be a literal:\n{notify}" + ); + + // The discriminating half: buttons appear ONLY where a tap was declared. + // Wrapping every node would make the whole card a hit target, and the + // assertions above would still pass. + let buttons = dsl.matches("agent.notify").count(); + let taps = dsl.matches("l0_event:").count(); + assert_eq!( + buttons, taps, + "one hit target per declared tap, no more:\n{dsl}" + ); + assert!(taps > 0, "the stock card declares taps"); +} + +/// The declared language is chosen when the runtime reports one. +#[test] +fn copy_selects_the_reported_locale() { + let mut data = serde_json::json!({ + "lead": [{"id":"1","title":"T","author":"A","points":1.0,"comments":2.0,"url":"u"}], + "feed": [], "article": {}, "selected": "", + "env": {"locale": {"lang": "zh"}} + }); + let dsl = makepad::lower(&realize(NEWS, &data, RealizeLimits::default()).root.unwrap()); + assert!(dsl.contains("头条"), "zh text should be selected:\n{dsl}"); + + // A card shipping zh text must not render an em dash on an en device. + data["env"]["locale"]["lang"] = serde_json::Value::String("fr".into()); + let dsl = makepad::lower(&realize(NEWS, &data, RealizeLimits::default()).root.unwrap()); + assert!( + dsl.contains("Top Stories"), + "an unknown locale falls back to en" + ); +} + +// ───────────────────────────────────────────────────────────────── source plan ── + +use splash_ui_l0::{source_plan, SourceArg}; + +/// A card declares what data it needs; the plan is what a host fetches. Without +/// it the declaration was inert — the parser skipped the helper and every +/// argument, so nothing could act on `source now sys.weather(…)`. +#[test] +fn the_plan_reports_helpers_and_arguments() { + let plan = source_plan(WEATHER); + assert!(plan.diagnostics.is_empty(), "{:#?}", plan.diagnostics); + + let now = plan.requests.iter().find(|r| r.name == "now").expect("now"); + assert_eq!(now.helper, "sys.weather"); + + let (_, lat) = now.args.iter().find(|(n, _)| n == "lat").expect("lat"); + assert_eq!(*lat, SourceArg::Path("place.lat".into())); + + let (_, fields) = now + .args + .iter() + .find(|(n, _)| n == "fields") + .expect("fields"); + match fields { + SourceArg::List(items) => { + assert!(items.contains(&"temp".to_string()), "{items:?}"); + assert!(items.contains(&"humidity".to_string()), "{items:?}"); + } + other => panic!("fields should be a list, got {other:?}"), + } +} + +/// `now` reads `place.lat`, so `place` must be fetched first. A host walking the +/// list in order always has what the next request refers to. +#[test] +fn the_plan_orders_dependencies_before_dependents() { + let plan = source_plan(WEATHER); + let at = |name: &str| plan.requests.iter().position(|r| r.name == name).unwrap(); + assert!(at("place") < at("now"), "place must precede now"); + assert!(at("place") < at("week"), "place must precede week"); + assert!(at("place") < at("aqi"), "place must precede aqi"); + + let now = &plan.requests[at("now")]; + assert_eq!(now.depends_on, vec!["place".to_string()]); + + // A source reading only card state depends on no other fetch. + let place = &plan.requests[at("place")]; + assert!(place.depends_on.is_empty(), "{:?}", place.depends_on); +} + +/// Two sources that read each other cannot both go first. Picking one silently +/// would fetch against a value that does not exist yet. +#[test] +fn a_dependency_cycle_is_reported_rather_than_resolved() { + let plan = source_plan( + "source a sys.geocode(name: b.value)\nsource b sys.photo(query: a.value)\nview root Panel", + ); + assert!( + plan.diagnostics.iter().any(|d| d.message.contains("cycle")), + "{:#?}", + plan.diagnostics + ); +} + +#[test] +fn every_reference_card_yields_a_resolvable_plan() { + for (name, card) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let plan = source_plan(card); + assert!( + plan.diagnostics.is_empty(), + "{name}: {:#?}", + plan.diagnostics + ); + assert!(!plan.requests.is_empty(), "{name} declares no sources"); + for request in &plan.requests { + assert!( + request.helper.starts_with("sys."), + "{name}: {:?} has no helper", + request.name + ); + } + } +} + +// ─────────────────────────────────────────────────────────────── reconciliation ── + +use splash_ui_l0::{dirty_records, record_dependencies}; + +/// L0 needs no dynamic read-tracking: no expression form means every binding is +/// structurally visible, so the dependency set is computable without evaluating +/// anything. +#[test] +fn dependencies_are_computed_without_evaluation() { + let deps = record_dependencies(WEATHER); + let of = |name: &str| { + deps.iter() + .find(|d| d.record == name) + .unwrap_or_else(|| panic!("no deps for {name}")) + .reads + .clone() + }; + + assert!( + of("current").contains(&"now".to_string()), + "{:?}", + of("current") + ); + assert!(of("current").contains(&"units".to_string())); + assert!(of("details").contains(&"now".to_string())); + // The forecast reads the week and, through ForecastRow, the bounds. + assert!( + of("forecast").contains(&"week".to_string()), + "{:?}", + of("forecast") + ); + // `sunmoon` reads sun and moon, and must NOT read the stock of the card. + let sunmoon = of("sunmoon"); + assert!(sunmoon.contains(&"sun".to_string()) && sunmoon.contains(&"moon".to_string())); + assert!(!sunmoon.contains(&"week".to_string()), "{sunmoon:?}"); +} + +/// A loop binder is local. `for d in week` must not make `d` a dependency, or +/// every record with a loop would look like it reads everything. +#[test] +fn a_loop_binder_is_not_a_dependency() { + let deps = record_dependencies(WEATHER); + let forecast = &deps.iter().find(|d| d.record == "forecast").unwrap().reads; + assert!(!forecast.contains(&"d".to_string()), "{forecast:?}"); +} + +/// The measurable claim behind "one event, one state change, one reconciliation +/// pass" — toggling a unit touches the records that read it, not the tree. +#[test] +fn a_state_write_dirties_only_its_dependents() { + let all: Vec = record_dependencies(WEATHER) + .into_iter() + .map(|d| d.record) + .collect(); + let dirty = dirty_records(WEATHER, &["units"]); + + assert!(!dirty.is_empty(), "toggling units must dirty something"); + assert!( + dirty.len() < all.len(), + "it must not dirty everything: {dirty:?}" + ); + + // The sun/moon panel does not read units and must survive untouched — it is + // the panel whose rebuild would be most visible. + assert!(!dirty.contains(&"sunmoon".to_string()), "{dirty:?}"); + assert!(!dirty.contains(&"airfield".to_string()), "{dirty:?}"); + // The hero and the detail tiles do read it. + assert!(dirty.contains(&"current".to_string()), "{dirty:?}"); + assert!(dirty.contains(&"details".to_string()), "{dirty:?}"); +} + +/// A component's own state dirties that component, so an expanded row +/// re-realizes and its neighbours do not. +#[test] +fn component_local_state_dirties_its_component() { + let dirty = dirty_records(WEATHER, &["expanded"]); + assert!( + dirty.contains(&"ForecastRow".to_string()), + "local state must dirty its component: {dirty:?}" + ); + assert!(!dirty.contains(&"details".to_string()), "{dirty:?}"); +} + +/// Over-approximation is the safe direction: re-realizing too much is slow, +/// re-realizing too little shows stale data. +#[test] +fn an_unrelated_write_dirties_nothing() { + assert!(dirty_records(WEATHER, &["nothing_reads_this"]).is_empty()); +} + +use splash_ui_l0::patch_points; + +/// `dirty_records` is transitive, so `root` appears for almost any write — it +/// splices everything, so it reads everything. Patching root rebuilds the tree, +/// which is what reconciliation exists to avoid. +#[test] +fn patch_points_exclude_ancestors_that_merely_contain_the_change() { + let dirty = dirty_records(WEATHER, &["units"]); + let minimal = patch_points(WEATHER, &["units"]); + assert!( + dirty.contains(&"root".to_string()), + "root is transitively dirty" + ); + assert!( + !minimal.contains(&"root".to_string()), + "but patching a descendant suffices: {minimal:?}" + ); + assert!(minimal.len() < dirty.len()); +} + +/// The claim, at its sharpest: toggling one row's own state re-realizes that +/// component and nothing else. +#[test] +fn local_state_patches_exactly_one_record() { + let minimal = patch_points(WEATHER, &["expanded"]); + assert_eq!(minimal, vec!["ForecastRow".to_string()], "{minimal:?}"); +} + +/// A write that a SOURCE reads invalidates the fetch, and everything binding its +/// result. Changing the city re-geocodes, so every source downstream of `place` +/// is stale — a genuine full rebuild, and the graph should say so rather than +/// reporting nothing. +#[test] +fn a_write_a_source_reads_invalidates_its_dependents() { + let minimal = patch_points(WEATHER, &["city"]); + for record in ["current", "forecast", "airfield", "sunmoon", "details"] { + assert!( + minimal.contains(&record.to_string()), + "changing the city must invalidate {record}: {minimal:?}" + ); + } +} + +/// Under-approximating shows stale data; over-approximating is merely slow. +#[test] +fn an_unrelated_write_patches_nothing() { + assert!(patch_points(WEATHER, &["nothing_reads_this"]).is_empty()); +} + +// ─── named slots (§5.5, open question 5) ───────────────────────────────────── + +/// Every `text` argument in the tree, in render order. +fn texts(root: &splash_ui_l0::UiNode) -> Vec { + fn walk(n: &splash_ui_l0::UiNode, out: &mut Vec) { + for (name, v) in &n.args { + if name == "text" { + if let NodeValue::Text(t) = v { + out.push(t.clone()); + } + } + } + for c in &n.children { + walk(c, out); + } + } + let mut out = Vec::new(); + walk(root, &mut out); + out +} + +const TWO_SLOTS: &str = r#" +component Framed(title: text) { + view Col { + TextCaption(text: title) + slot header + Rule() + slot + } +} +view root Panel { + Framed(title: "Today") { + into header { TextRow(text: "in the header") } + TextRow(text: "in the body") + } +} +"#; + +#[test] +fn a_named_slot_card_is_l0() { + accepts("two-slots", TWO_SLOTS); +} + +#[test] +fn named_slot_routes_children_to_the_matching_slot() { + let report = realize(TWO_SLOTS, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + + // Order is what proves routing: the `into header` child is written first at + // the call site but the component places that slot above the anonymous one, + // so the two must come out in component order, not call-site order. + assert_eq!( + texts(&report.root.unwrap()), + vec!["Today", "in the header", "in the body"], + "each group should land at its own slot" + ); +} + +#[test] +fn a_slot_with_no_matching_group_renders_nothing() { + // Not an error: a component may offer a slot that a call site declines to + // fill. Children with nowhere to go are dropped rather than misplaced. + let source = r#" +component Framed() { view Panel { slot aside } } +view root Panel { Framed() { TextRow(text: "loose") } } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert!( + texts(&report.root.unwrap()).is_empty(), + "the anonymous group has no matching slot in this component" + ); +} + +#[test] +fn an_anonymous_slot_still_needs_no_ceremony() { + // The common case must stay as it was — no `into` required, and every + // existing card keeps working. + let source = r#" +component Framed() { view Panel { slot } } +view root Panel { Framed() { TextRow(text: "plain") } } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!(texts(&report.root.unwrap()), vec!["plain"]); +} + +// ─── source lifecycle (§5.9, open question 3) ──────────────────────────────── + +const LIFECYCLE: &str = r#" +source now sys.weather(lat: 35.0, lon: 135.0) +view root Col { + when now.$state == .pending { TextRow(text: "Loading") } + when now.$state == .failed { TextRow(text: "Unavailable") } + when now.$state == .ready { TextRow(text: "Live") } +} +"#; + +#[test] +fn a_source_lifecycle_card_is_l0() { + accepts("lifecycle", LIFECYCLE); +} + +#[test] +fn source_state_defaults_to_pending_when_no_value_arrived() { + let report = realize(LIFECYCLE, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["Loading"], + "a source with no value and no report is pending, so a card can say so \ + rather than rendering an em dash that looks like real data" + ); +} + +#[test] +fn source_state_is_ready_once_a_value_arrives() { + let data = serde_json::json!({"now": {"temp": 21.0}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert_eq!(texts(&report.root.unwrap()), vec!["Live"]); +} + +#[test] +fn the_host_can_report_failure_explicitly() { + // The distinction that matters: a fetch that FAILED is not the same as one + // that has not happened yet, and only the host knows which. + let data = serde_json::json!({"$status": {"now": "failed"}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["Unavailable"], + "failure must be distinguishable from pending" + ); +} + +#[test] +fn a_stale_source_is_neither_pending_nor_failed() { + // Stale means old, not wrong: the card keeps its last good value rather + // than blanking a number that is merely a few minutes behind. + let data = serde_json::json!({"now": {"temp": 21.0}, "$status": {"now": "stale"}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert!( + texts(&report.root.unwrap()).is_empty(), + "this card guards three states; stale is a fourth and matches none" + ); +} + +#[test] +fn state_is_rejected_on_something_that_is_not_a_source() { + let report = check_ui_l0_named( + "not-a-source", + "state units { shape: enum[c, f], initial: .c }\n\ + view root Col { when units.$state == .pending { Rule() } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("only available on a source")), + "local state has no fetch lifecycle: {:#?}", + report.diagnostics + ); +} + +/// A literal prop must survive the call. This failed for the life of the +/// component feature and no test caught it: the reference cards pass paths +/// (`story: s`, `position: i`), so the literal arm was never exercised and +/// `Framed(title: "Details")` rendered an em dash. Same shape as every other +/// "specified but not retained" bug — the parser kept the value and the +/// realizer dropped it on a catch-all arm. +#[test] +fn a_literal_prop_reaches_the_component() { + let source = r#" +component Framed(title: text, rank: number) { + view Col { TextCaption(text: title) TextRow(text: rank) } +} +view root Panel { Framed(title: "Details", rank: 3) } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.unwrap(); + + let mut caps = Vec::new(); + find(&root, "TextCaption", &mut caps); + assert_eq!( + caps[0].args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text("Details".into()), + "a string literal prop must arrive, not become Missing" + ); + + let mut rows = Vec::new(); + find(&root, "TextRow", &mut rows); + assert_eq!( + rows[0].args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Number(3.0), + "a number literal prop must arrive too" + ); +} + +#[test] +fn an_into_naming_an_absent_slot_is_rejected() { + // Children with nowhere to go vanish. Silent is the wrong failure mode: + // this is the same class as a binding that resolves to nothing. + let report = check_ui_l0_named( + "bad-slot", + "component Framed() { view Panel { slot body } }\n\ + view root Panel { Framed() { into headr { Rule() } } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("has no slot named") && d.message.contains("body")), + "the message should name the slots that do exist: {:#?}", + report.diagnostics + ); +} + +// ─── §6 condition 4: event cascades ────────────────────────────────────────── + +/// Profile §6.4 prohibits event cascades — a transition may not trigger another +/// event. There is no check for this because there is nothing to check: the four +/// total forms (`set`, `toggle`, `cycle`, `clear`) all name a STATE, and a +/// transition target is validated against declared state. A cascade is therefore +/// unrepresentable rather than merely rejected. +/// +/// This test pins that. It enumerates every syntax that could plausibly express +/// "and then fire another event" and asserts each is rejected. If someone later +/// adds a form that can raise one, termination stops being structural and this +/// test is where that shows up. +#[test] +fn an_event_cannot_trigger_another_event() { + const PRELUDE: &str = "state n { shape: number, initial: 0 }\n\ + state flag { shape: bool, initial: false }\n\ + event bump { n: set(1) }\n"; + + for (shape, body) in [ + ( + "a transition targeting an event name", + "event c { bump: toggle }", + ), + ("a transition setting an event", "event c { bump: set(1) }"), + ( + "an event named as a payload target", + "event c { n: set(bump) }", + ), + ("an emit form", "event c { emit bump }"), + ("a call form", "event c { bump() }"), + ("a then form", "event c { n: set(1) then bump }"), + ] { + let source = format!("{PRELUDE}{body}\nview root Panel {{ Rule() }}"); + let report = check_ui_l0_named("cascade", &source); + assert!( + !report.valid, + "{shape} would be a cascade and must not be accepted: {body:?}" + ); + } + + // The control: the same prelude with a legal transition IS accepted, so the + // rejections above are about cascades and not about the prelude. + let ok = check_ui_l0_named( + "no-cascade", + &format!("{PRELUDE}event c {{ flag: toggle }}\nview root Panel {{ Rule() }}"), + ); + assert!(ok.valid, "{:#?}", ok.diagnostics); +} + +// ─── §5.8 opt-in migration (open question 6) ───────────────────────────────── + +/// `keep: true` opts one cell out of the §5.8 reset. Reset stays the default; +/// this makes preservation expressible rather than impossible. +#[test] +fn a_keep_cell_survives_a_schema_change() { + const KEPT: &str = r#" +source items sys.movers() +component Rowy(item: record) { + state open { shape: bool, initial: false, keep: true } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + let mut store = splash_ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = splash_ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + + // An unrelated field is added: the schema id changes, but `open` itself is + // untouched and asked to survive. + let edited = KEPT.replace( + "event flip", + "state seen { shape: bool, initial: false }\n event flip", + ); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 1, + "a cell that opted in and did not change shape should survive" + ); +} + +/// The limit of the opt-in: `keep` is a claim about a field, not a promise that +/// any value fits. Retyping the field resets it anyway — otherwise `keep` would +/// be exactly the "guess it still fits" that §5.8 exists to prevent. +#[test] +fn keep_does_not_survive_a_change_to_that_field_itself() { + const KEPT: &str = r#" +source items sys.movers() +component Rowy(item: record) { + state open { shape: bool, initial: false, keep: true } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + let mut store = splash_ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = splash_ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + + // `open` is retyped bool → enum. The opt-in does not apply to a field whose + // own shape moved. + let edited = KEPT + .replace( + "state open { shape: bool, initial: false, keep: true }", + "state open { shape: enum[shut, ajar], initial: .shut, keep: true }", + ) + .replace( + "event flip { open: toggle }", + "event flip { open: cycle(.shut, .ajar) }", + ); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 0, + "a retyped field resets even with keep: true" + ); +} + +/// Reset remains the default: without `keep`, nothing changes. +#[test] +fn without_keep_a_schema_change_still_resets() { + // This is `a_schema_change_resets_instance_state` restated as the control + // for the two tests above — the opt-in must not have become the default. + let mut store = splash_ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + + let edited = TWO_ROWS.replace( + "event flip", + "state seen { shape: bool, initial: false }\n event flip", + ); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!(captions.len(), 0, "reset is still the default"); +} + +// ─── §7 component version pinning ──────────────────────────────────────────── + +/// §7 requires component versions to be pinned, "or an L0 card can be moved to +/// L2 by a definition it references being replaced". The report carries a digest +/// per component so a host can store it beside the approved level and notice. +#[test] +fn the_report_carries_a_digest_per_component() { + let report = check_ui_l0_named("weather", WEATHER); + assert!(report.valid); + assert!( + !report.closure.is_empty(), + "the weather card declares a component, so the closure cannot be empty" + ); + let names: Vec<&str> = report.closure.iter().map(|(n, _)| n.as_str()).collect(); + assert!(names.contains(&"ForecastRow"), "got {names:?}"); + + // Sorted, so a host comparing two closures does not have to. + let mut sorted = report.closure.clone(); + sorted.sort(); + assert_eq!(sorted, report.closure); +} + +#[test] +fn replacing_a_component_body_changes_its_digest() { + let before = check_ui_l0_named("weather", WEATHER).closure; + + // The change that motivates §7: a component gains a construct outside L0. + // The card text is otherwise identical, so a host that pinned only the + // card's level would carry the old L0 verdict onto a definition that no + // longer earns it. + let edited = WEATHER.replace( + "component ForecastRow(", + "component ForecastRow(extra: number, ", + ); + assert_ne!(edited, WEATHER, "the replacement must actually apply"); + let after = check_ui_l0_named("weather", &edited).closure; + + let digest = |c: &[(String, u64)]| { + c.iter() + .find(|(n, _)| n == "ForecastRow") + .map(|(_, d)| *d) + .unwrap() + }; + assert_ne!( + digest(&before), + digest(&after), + "a changed definition must produce a changed digest" + ); +} + +#[test] +fn reordering_declarations_is_not_a_version_change() { + // A digest that moved on a cosmetic edit would make pinning useless: every + // reformat would look like a replacement and hosts would learn to ignore it. + let source = "\ +component A(x: number) { view Panel { TextRow(text: x) } }\n\ +component B(y: number) { view Panel { TextRow(text: y) } }\n\ +view root Panel { A(x: 1) B(y: 2) }\n"; + let swapped = "\ +component B(y: number) { view Panel { TextRow(text: y) } }\n\ +component A(x: number) { view Panel { TextRow(text: x) } }\n\ +view root Panel { A(x: 1) B(y: 2) }\n"; + + let a = check_ui_l0_named("a", source); + let b = check_ui_l0_named("b", swapped); + assert!( + a.valid && b.valid, + "{:#?} {:#?}", + a.diagnostics, + b.diagnostics + ); + assert_eq!(a.closure, b.closure); +} + +// ─── §3 cycle ordering (open question 2) ───────────────────────────────────── + +/// `cycle` advances in the ENUM's declaration order. That is the guarantee +/// question 2 asked for, and it is what makes the transition total and O(1) — +/// there is no comparison to run, only a successor. +#[test] +fn cycle_advances_in_declaration_order() { + const TRI: &str = "\ +state mode { shape: enum[low, mid, high], initial: .low }\n\ +event step { mode: cycle(.low, .mid, .high) }\n\ +view root Panel { Row(on_tap: step) { Rule() } }\n"; + + let mut store = splash_ui_l0::InstanceStore::default(); + let report = realize(TRI, &serde_json::json!({}), RealizeLimits::default()); + let mut rows = Vec::new(); + let root = report.root.unwrap(); + find(&root, "Row", &mut rows); + let key = rows[0].key.clone(); + + for expected in ["mid", "high", "low"] { + splash_ui_l0::dispatch(TRI, &mut store, &key, "step"); + assert_eq!( + store.get("@card", "mode").and_then(|v| v.as_str()), + Some(expected), + "cycle should wrap through declaration order" + ); + } +} + +/// The corollary: a `cycle` that lists a different order is rejected rather +/// than quietly running the declared one. Accepting it would mean the card says +/// one order and the runtime performs another. +#[test] +fn a_cycle_listing_a_different_order_is_rejected() { + let report = check_ui_l0_named( + "reordered", + "state mode { shape: enum[low, mid, high], initial: .low }\n\ + event step { mode: cycle(.high, .mid, .low) }\n\ + view root Panel { Rule() }\n", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("declaration order")), + "{:#?}", + report.diagnostics + ); +} + +// ─── §5.1 identity is a path, not a position ───────────────────────────────── + +const TOGGLY: &str = r#" +component Toggly() { + state open { shape: bool, initial: false } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: "hdr") } + when open { TextCaption(text: "OPEN") } + } +} +view root Panel { Toggly() } +"#; + +/// §5.1: "L0 binds to a declared path so that identity survives a ledger edit +/// that inserts an element above." This is the profile's stated difference from +/// React, and it went untested — the implementation keyed children by their raw +/// index, so it was in fact using React's rule while the spec said it was not. +/// +/// The scenario is not hypothetical for a ledger: appending a record that adds +/// a rule above a list is an ordinary edit, and it should not collapse every +/// expanded row on screen. +#[test] +fn state_survives_an_element_inserted_above() { + let data = serde_json::json!({}); + let mut store = splash_ui_l0::InstanceStore::default(); + + let first = splash_ui_l0::realize_with_state(TOGGLY, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + let key = rows[0].key.clone(); + splash_ui_l0::dispatch(TOGGLY, &mut store, &key, "flip"); + + // The same card with a Rule inserted ABOVE the component. + let edited = TOGGLY.replace( + "view root Panel { Toggly() }", + "view root Panel { Rule() Toggly() }", + ); + assert_ne!(edited, TOGGLY, "the edit must actually apply"); + + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after_root = after.root.unwrap(); + let mut rows_after = Vec::new(); + find(&after_root, "Row", &mut rows_after); + assert_eq!( + rows_after[0].key, key, + "the instance key must not depend on how many siblings precede it" + ); + + let mut caps = Vec::new(); + find(&after_root, "TextCaption", &mut caps); + assert_eq!( + caps.len(), + 1, + "the toggled state must survive an insertion above (profile §5.1)" + ); +} + +/// The other half: sibling instances of the same component stay distinct, so +/// making keys insertion-stable must not make two of them collide. +#[test] +fn sibling_instances_of_one_component_keep_separate_state() { + let two = TOGGLY.replace( + "view root Panel { Toggly() }", + "view root Panel { Toggly() Toggly() }", + ); + let data = serde_json::json!({}); + let mut store = splash_ui_l0::InstanceStore::default(); + + let first = splash_ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2); + assert_ne!(rows[0].key, rows[1].key, "two instances need two keys"); + + // Flip only the first. + splash_ui_l0::dispatch(&two, &mut store, &rows[0].key.clone(), "flip"); + let after = splash_ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + let after_root = after.root.unwrap(); + let mut caps = Vec::new(); + find(&after_root, "TextCaption", &mut caps); + assert_eq!(caps.len(), 1, "exactly one instance should be expanded"); +} + +// ─── §4 source capabilities are a closed set ───────────────────────────────── + +/// The finding that motivated this: `source_plan` handed an arbitrary dotted +/// name to the host as the function to resolve, so a card could NAME a +/// capability it was never granted and produce a clean, well-formed request. +/// Confinement then rested on the host keeping its own allowlist — the policy +/// boundary §1 claims L0 removes. +#[test] +fn an_uncatalogued_source_helper_is_rejected() { + let report = check_ui_l0_named( + "leak", + "source leak sys.shell(command: \"cat /etc/passwd\")\nview root Rule()", + ); + assert!(!report.valid, "a card must not be able to name sys.shell"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a source capability")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_uncatalogued_helper_produces_no_source_request() { + // Belt and braces: even if a caller skips checking, the plan must not carry + // the request onward to a host that trusts it. + let plan = splash_ui_l0::source_plan( + "source leak sys.shell(command: \"cat /etc/passwd\")\nview root Rule()", + ); + assert!( + !plan.requests.iter().any(|r| r.helper == "sys.shell"), + "an uncatalogued helper must not reach the host: {:?}", + plan.requests.iter().map(|r| &r.helper).collect::>() + ); + assert!(!plan.diagnostics.is_empty(), "and it must say why"); +} + +#[test] +fn a_catalogued_helper_rejects_an_argument_it_does_not_accept() { + // A helper that silently ignores an unknown argument is how a card asks for + // something it did not get and renders as though it did. + let report = check_ui_l0_named( + "badarg", + "source w sys.weather(lat: 1.0, lon: 2.0, shell: \"rm -rf /\")\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("does not accept an argument named")), + "{:#?}", + report.diagnostics + ); +} + +/// The catalog in the TOML and the one in Rust must list the same capabilities +/// with the same arguments — in both directions, and this time actually. +#[test] +fn the_source_catalog_matches_the_toml_spec() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + let mut documented: Vec<(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()); + if let Some(name) = ¤t { + documented.push((name.clone(), Vec::new())); + } + } else if line.starts_with('[') { + current = None; + } else if current.is_some() { + if let Some(rest) = line.strip_prefix("args = [") { + let args: Vec = rest + .trim_end_matches(']') + .split(',') + .map(|a| a.trim().trim_matches('"').to_string()) + .filter(|a| !a.is_empty()) + .collect(); + documented.last_mut().unwrap().1 = args; + } + } + } + + assert!(!documented.is_empty(), "the TOML must declare [sources.*]"); + + for (name, args) in &documented { + let implemented = catalog::source(name) + .unwrap_or_else(|| panic!("{name:?} is in the TOML but not in the Rust catalog")); + assert_eq!( + implemented, + args.as_slice(), + "arguments for {name:?} disagree between the TOML and Rust" + ); + } + for (name, args) in catalog::SOURCES { + let (_, doc_args) = documented + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| panic!("{name:?} is in the Rust catalog but not in the TOML")); + assert_eq!( + *args, + doc_args.as_slice(), + "arguments for {name:?} disagree between Rust and the TOML" + ); + } +} + +// ─── 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 +// most common defect in this work, and each of these was found by the codex +// review rather than by a failing test — which is the point of writing them. + +/// `active: range == .d1` realized as the VALUE of `range` rather than a +/// boolean, because the parser kept the left path and dropped the comparator +/// and right operand. Live in stock.card, and the device golden recorded the +/// wrong rendering as correct. +#[test] +fn a_predicate_argument_evaluates_to_a_boolean() { + let source = "state range { shape: enum[d1, w1], initial: .d1 }\n\ + view root Col { Chip(text: \"1D\", active: range == .d1) \ + Chip(text: \"1W\", active: range == .w1) }"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut chips = Vec::new(); + find(&root, "Chip", &mut chips); + assert_eq!(chips.len(), 2); + let active = |c: &splash_ui_l0::UiNode| { + c.args + .iter() + .find(|(n, _)| n == "active") + .map(|(_, v)| v.clone()) + }; + assert_eq!(active(chips[0]), Some(NodeValue::Bool(true))); + assert_eq!(active(chips[1]), Some(NodeValue::Bool(false))); +} + +/// A guard's right operand is a binding too. Unchecked, `when x != absent` +/// resolved `absent` to nothing and the comparison decided the branch on that +/// absence — so the branch rendered with an undeclared name in it. +#[test] +fn a_guard_right_operand_must_be_declared() { + let report = check_ui_l0_named( + "rhs", + "state selected { shape: text, initial: \"\" }\n\ + view root Col { when selected != absent { Rule() } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("absent")), + "{:#?}", + report.diagnostics + ); +} + +/// `initial: env.locale.temp_unit` was recognised and dropped, so the state +/// fell back to the enum's first member — a phone set to Fahrenheit rendered +/// Celsius, which is weather.card's actual declaration. +#[test] +fn a_path_valued_initial_resolves_against_data() { + let source = "source env.locale sys.locale()\n\ + state units { shape: enum[c, f], initial: env.locale.temp_unit }\n\ + view root TextRow(text: units)"; + for locale in ["f", "c"] { + let data = serde_json::json!({"env": {"locale": {"temp_unit": locale}}}); + let report = realize(source, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + assert_eq!( + root.args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text(locale.into()), + "the declared initial must follow the host's locale" + ); + } +} + +/// `stories[selected].title` became `stories.title` — the index was parsed and +/// thrown away, so it resolved to nothing. +#[test] +fn a_dynamic_index_resolves_through_its_inner_path() { + let source = "source movers sys.movers()\n\ + state selected { shape: text, initial: \"b\" }\n\ + view root TextRow(text: movers[selected].title)"; + let data = serde_json::json!({"movers": {"a": {"title": "FIRST"}, "b": {"title": "SECOND"}}}); + let report = realize(source, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + assert_eq!( + root.args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text("SECOND".into()) + ); +} + +#[test] +fn a_dynamic_index_path_is_itself_checked() { + let report = check_ui_l0_named( + "idx", + "source movers sys.movers()\nview root TextRow(text: movers[typo].title)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("typo")), + "{:#?}", + report.diagnostics + ); +} + +/// `set` had no numeric or boolean variant, so `set(1)` fell through to the +/// payload form and did nothing at all when no payload was present. And +/// `SetSource::Path` was treated exactly like `$value`, so `set(config.answer)` +/// wrote whatever the tap carried rather than what it named. +#[test] +fn every_set_form_writes_what_it_names() { + let card = "state n { shape: number, initial: 0 }\n\ + state ok { shape: bool, initial: false }\n\ + source movers sys.movers(count: 1)\n\ + event bump { n: set(1) }\n\ + event frompath { n: set(movers.answer) }\n\ + event yes { ok: set(true) }\n\ + view root Row(on_tap: bump) { Rule() }"; + let data = serde_json::json!({"movers": {"answer": 7}}); + let mut store = splash_ui_l0::InstanceStore::default(); + + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "bump", None, &data); + assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(1.0)); + + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", None, &data); + assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(7.0)); + + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "yes", None, &data); + assert_eq!( + store.get("@card", "ok").and_then(|v| v.as_bool()), + Some(true) + ); + + // A payload must not hijack a transition that names a path to read. + let stray = serde_json::json!("STRAY"); + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", Some(&stray), &data); + assert_eq!( + store.get("@card", "n").and_then(|v| v.as_f64()), + Some(7.0), + "set(path) reads its path, not the payload" + ); +} + +#[test] +fn only_dollar_value_names_the_payload() { + // `$anything` was accepted as a synonym, so a typo silently became $value. + let report = check_ui_l0_named( + "typo", + "state n { shape: number, initial: 0 }\nevent e { n: set($valu) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("$value")), + "{:#?}", + report.diagnostics + ); +} + +// ─── the Makepad backend must carry interaction, not just appearance ───────── + +/// `UiNode` carried the event name and instance key all along and lowering +/// emitted neither, so every tappable node rendered inert — the stock range +/// controls could not reach `dispatch` at all. The cards looked right and did +/// nothing, which no golden image could detect. +#[test] +fn lowering_emits_the_event_and_the_instance_key() { + let data = serde_json::json!({ + "movers": [{ "ticker": "NVDA", "name": "NVIDIA", "last": 184.2, + "change": 3.1, "pct": 1.7 }], + "quote": {}, "series": {}, "selected": "", "range": "m1", + "env": { "locale": {} }, "copy": { "movers": "Top Movers" } + }); + let dsl = splash_ui_l0::makepad::lower( + &realize(STOCK, &data, RealizeLimits::default()) + .root + .unwrap(), + ); + assert!( + dsl.contains("l0_event: \"open_quote\""), + "a tappable row must carry its event" + ); + assert!( + dsl.contains("l0_key:"), + "and the key naming WHICH instance was tapped" + ); + assert!( + dsl.contains("l0_value: \"NVDA\""), + "and the payload, or a tap says what happened but not to what" + ); +} + +/// `active` selected nothing, so every range chip rendered identically and the +/// card could not show which was selected. +#[test] +fn lowering_distinguishes_an_active_chip() { + let source = "state range { shape: enum[d1, w1], initial: .d1 }\n\ + view root Col { Chip(text: \"1D\", active: range == .d1) \ + Chip(text: \"1W\", active: range == .w1) }"; + let dsl = splash_ui_l0::makepad::lower( + &realize(source, &serde_json::json!({}), RealizeLimits::default()) + .root + .unwrap(), + ); + let fills: Vec<&str> = dsl + .lines() + .filter(|l| l.contains("RoundedView")) + .filter_map(|l| l.split("draw_bg.color: ").nth(1)) + .filter_map(|l| l.split_whitespace().next()) + .collect(); + assert_eq!(fills.len(), 2, "two chips"); + assert_ne!( + fills[0], fills[1], + "the selected chip must not look like the unselected one" + ); +} + +// ─── §7 the header is checked, not decorated ───────────────────────────────── + +#[test] +fn the_header_is_read_from_a_real_card() { + let report = check_ui_l0_named("weather", WEATHER); + let header = report.header.expect("weather.card declares a header"); + assert_eq!(header.ledger.as_deref(), Some("weather")); + assert_eq!(header.version.as_deref(), Some("1.0.0")); + assert_eq!(header.level.as_deref(), Some("L0")); + assert_eq!(header.profile.as_deref(), Some("ui/l0")); +} + +/// §7: "Level is declared in the header and checked at append." Nothing read +/// it — every `#` line lexed as a comment — so a card could declare L2 and be +/// accepted and realized as L0. A declaration never compared to the derived +/// level is a comment, not a check. +#[test] +fn a_header_declaring_the_wrong_level_is_rejected() { + let report = check_ui_l0_named("evil", "# ledger evil@1.0.0\n# level: L2\nview root Rule()"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("declares level L2")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_header_declaring_an_unknown_profile_is_rejected() { + let report = check_ui_l0_named( + "wrong", + "# ledger x@1.0.0\n# profile: nonsense/v9\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("ui/l0")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_card_without_a_header_is_still_accepted() { + // The header is optional; only a header that CONTRADICTS the card is an + // error. Requiring one would reject every inline fragment in these tests. + let report = check_ui_l0_named("bare", "view root Rule()"); + assert!(report.valid, "{:#?}", report.diagnostics); + assert!(report.header.is_none()); +} + +// ─── §4 the no-facts rule ──────────────────────────────────────────────────── + +/// §4 calls a model-authored literal in a data position "a structural +/// violation, not a judgement call". It was neither — `kind = "path"` was +/// declared in the catalog and never enforced, so every drawing widget accepted +/// invented values. +#[test] +fn a_literal_in_a_data_position_is_rejected() { + for source in [ + "view root TextHero(value: \"34 mph\")", + "view root TextStat(value: 41.2)", + "view root TempBar(lo: 5, hi: 9, min: 0, max: 10)", + "view root WeatherIcon(cond: \"sunny\")", + ] { + let report = check_ui_l0_named("facts", source); + assert!(!report.valid, "should be rejected: {source}"); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("§4")), + "the diagnostic should cite the rule: {:#?}", + report.diagnostics + ); + } +} + +/// The line the rule actually draws: a card MAY choose its own layout, and may +/// not invent a measurement. Without this distinction the check either rejects +/// `gap: 6` or permits `value: 41.2`. +#[test] +fn a_layout_literal_is_not_a_fact() { + for source in [ + "view root Row(gap: 6) { Rule() }", + "view root Grid(cols: 2) { Rule() }", + "view root Col(gap: 3) { Rule() }", + ] { + let report = check_ui_l0_named("layout", source); + assert!(report.valid, "{source} — {:#?}", report.diagnostics); + } +} + +#[test] +fn a_bound_value_is_accepted() { + let report = check_ui_l0_named( + "bound", + "source now sys.weather(lat: 1.0, lon: 2.0)\nview root TextStat(value: now.temp)", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// The constructor catalog compared NAMES and three token sets, so changing +/// `TextHero.value` from `number` to `data` in the TOML left it green — and §8 +/// claimed on that basis that the two agreed "in both directions". They must +/// agree on every ARGUMENT and its kind, which is what the checker actually +/// consults. +#[test] +fn every_constructor_argument_agrees_with_the_toml() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + // [Ctor] followed by `name = { kind = "…" }` lines, until the next header. + let mut documented: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for line in TOML.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + if !name.contains('.') && !name.starts_with("kinds") { + documented.push((name.to_string(), Vec::new())); + } else { + // A [kinds.*] or [sources."…"] section ends the constructor run. + documented.push((String::new(), Vec::new())); + } + continue; + } + let Some((arg, rest)) = line.split_once('=') else { + continue; + }; + let Some(kind) = rest + .split("kind = \"") + .nth(1) + .and_then(|k| k.split('"').next()) + else { + continue; + }; + if let Some((name, args)) = documented.last_mut() { + if !name.is_empty() { + args.push((arg.trim().to_string(), kind.to_string())); + } + } + } + let documented: Vec<_> = documented + .into_iter() + .filter(|(n, _)| !n.is_empty()) + .collect(); + assert!( + documented.len() > 15, + "parsed {} constructors", + documented.len() + ); + + // The TOML's kind names, mapped onto what the Rust catalog stores. + let agrees = |kind: &str, actual: &catalog::ArgKind| -> bool { + use catalog::ArgKind::*; + match (kind, actual) { + ("path", Path) | ("event", Event) | ("any", Any) => true, + ("data", Data) | ("number", Number) | ("text", Text) | ("bool", Bool) => true, + ("token", Token(_)) => true, + ("unit", TokenOrPath(set)) => *set == catalog::UNIT, + ("format", Token(set)) => *set == catalog::FORMAT, + ("width", TokenOrPath(set)) => *set == catalog::WIDTH, + _ => false, + } + }; + + for (ctor, args) in &documented { + let implemented = catalog::lookup(ctor) + .unwrap_or_else(|| panic!("{ctor:?} is in the TOML but not in the Rust catalog")); + for (arg, kind) in args { + let (_, actual) = implemented + .iter() + .find(|(n, _)| n == arg) + .unwrap_or_else(|| panic!("{ctor}.{arg} is in the TOML but not in Rust")); + assert!( + agrees(kind, actual), + "{ctor}.{arg}: TOML says {kind:?}, Rust has {actual:?}" + ); + } + assert_eq!( + implemented.len(), + args.len(), + "{ctor} has {} arguments in Rust and {} in the TOML", + implemented.len(), + args.len() + ); + } +} + +// ─── §6.1 the declaration graph, including views ───────────────────────────── + +/// Acyclicity walked components only, so two shapes recursed at realization and +/// were caught — if at all — by the node cap, which is a resource bound +/// standing in for a structural check. +#[test] +fn mutually_recursive_views_are_rejected() { + let report = check_ui_l0_named("mutual", "view a b\nview b a\nview root a"); + assert!(!report.valid, "view a -> b -> a must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_component_recursing_through_a_view_is_rejected() { + let report = check_ui_l0_named( + "via-view", + "component A() { view Panel { v } }\nview v Panel { A() }\nview root A()", + ); + assert!(!report.valid, "A -> v -> A must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_acyclic_view_chain_is_still_accepted() { + // The check must not reject ordinary splicing, which the cards rely on. + let report = check_ui_l0_named( + "chain", + "view leaf Rule()\nview mid Panel { leaf }\nview root Panel { mid }", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Depth was approximated by the instance key's LENGTH, which bounds neither +/// direction: a long loop key truncated a shallow tree, and short view names +/// recursed far past max_depth. It is now an actual recursion counter. +#[test] +fn depth_is_counted_not_inferred_from_key_length() { + // A shallow tree whose keys are long must NOT truncate. + let long_key = "a_very_long_item_identifier_that_makes_instance_keys_lengthy"; + let source = format!( + "source movers sys.movers()\nview root Panel {{ for m in movers key m.{long_key} {{ Rule() }} }}" + ); + let data = serde_json::json!({ + "movers": [{ long_key: "x".repeat(120) }, { long_key: "y".repeat(120) }] + }); + let report = realize(&source, &data, RealizeLimits::default()); + assert!( + !report.truncated, + "a shallow tree must not truncate because its keys are long" + ); + let mut rules = Vec::new(); + let root = report.root.unwrap(); + find(&root, "Rule", &mut rules); + assert_eq!(rules.len(), 2, "both rows should realize"); +} + +// ─── §5.2 component props carry their declared shape ──────────────────────── +// +// The shape was parsed and discarded, which cost three separate defects. They +// looked unrelated in the review and shared one cause. + +/// An argument the component does not declare was accepted and then silently +/// dropped at realization, so `Framed(titel: "x")` rendered an em dash and +/// looked deliberate. +#[test] +fn an_undeclared_prop_is_rejected() { + let report = check_ui_l0_named( + "typo", + "component F(title: text) { view Panel { TextRow(text: title) } }\n\ + view root F(titel: \"x\")", + ); + assert!(!report.valid); + let message = &report.diagnostics[0].message; + assert!( + message.contains("no prop") && message.contains("title"), + "the message should name the props that exist: {message}" + ); +} + +/// `set(out)` where `out` is an event prop was accepted, because no param's +/// shape was known so none could be excluded from the readable set. +#[test] +fn an_event_prop_cannot_be_read_by_set() { + let report = check_ui_l0_named( + "setevent", + "component F(out: event) { state n { shape: number, initial: 0 } \ + event e { n: set(out) } view Panel { Row(on_tap: e) { Rule() } } }\n\ + event go { }\nview root F(out: go)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("is an event, not a value")), + "{:#?}", + report.diagnostics + ); +} + +/// Every param used to be added to the component's event names, so a `text` +/// prop could be handed to `on_tap` — and a runtime string that happened to +/// equal a declared event would route it. Event routing became data-selected. +#[test] +fn a_value_prop_cannot_be_used_as_an_event() { + let report = check_ui_l0_named( + "asevent", + "component F(label: text) { view Panel { Row(on_tap: label) { Rule() } } }\n\ + view root F(label: \"x\")", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a declared event")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_token_outside_an_enum_prop_is_rejected() { + // Now possible because the prop's members survive parsing. + let report = check_ui_l0_named( + "enumprop", + "component F(unit: enum[c, f]) { view Panel { TextValue(value: unit) } }\n\ + view root F(unit: .kelvin)", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains(".c, .f"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_correct_call_site_is_still_accepted() { + let report = check_ui_l0_named( + "ok", + "component F(title: text, unit: enum[c, f], on_go: event) { \ + view Panel { Row(on_tap: on_go) { TextRow(text: title) TextValue(value: unit) } } }\n\ + event go { }\nview root F(title: \"x\", unit: .c, on_go: go)", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Retyping between `text` and `number` moved neither the schema id nor the +/// closure digest, because both parsed to the same unclassified shape — so a +/// live string survived into number-shaped state, and a host pinning the digest +/// saw no change. +#[test] +fn retyping_between_text_and_number_is_visible() { + let base = "component F(x: text) { state s { shape: text, initial: \"\" } \ + view Panel { TextRow(text: s) } }\nview root F(x: \"a\")"; + + let retyped_state = base + .replace("shape: text", "shape: number") + .replace("initial: \"\"", "initial: 0"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &retyped_state).closure, + "retyping state between text and number must move the digest" + ); + + let retyped_param = base.replace("F(x: text)", "F(x: number)"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &retyped_param).closure, + "retyping a prop must move the digest" + ); +} + +// ─── second codex round ────────────────────────────────────────────────────── + +#[test] +fn a_contradictory_duplicate_header_field_is_rejected() { + // Last-wins meant a card could declare what it needed and then declare the + // truth after it: `# level: L2` followed by `# level: L0` was accepted. + // + // The FIRST value must be one that passes on its own, or this test proves + // nothing. An earlier version used `L2` then `L0`: the retained `L2` failed + // the level-vs-derived check, so the card was rejected by a different rule + // and the test passed even with the contradiction check disabled entirely. + // Mutation testing is what surfaced that. + let report = check_ui_l0_named( + "dup-profile", + "# ledger x@1.0.0\n# profile: ui/l0\n# profile: nonsense/v9\nview root Rule()", + ); + assert!(!report.valid, "a contradictory header must be refused"); + assert_eq!( + report.diagnostics.len(), + 1, + "exactly one rule should fire, or this does not isolate it: {:#?}", + report.diagnostics + ); + assert!( + report.diagnostics[0] + .message + .contains("declares profile twice"), + "{:#?}", + report.diagnostics + ); + + // And the level form, which is the one the defect was found in. + let level = check_ui_l0_named( + "dup-level", + "# ledger x@1.0.0\n# level: L0\n# level: L2\nview root Rule()", + ); + assert!(!level.valid); + assert!( + level + .diagnostics + .iter() + .any(|d| d.message.contains("declares level twice")), + "{:#?}", + level.diagnostics + ); +} + +#[test] +fn a_token_in_a_data_position_is_rejected() { + // `Data` rejected strings and numbers and let tokens through. + let report = check_ui_l0_named("tok", "view root TextHero(value: .money)"); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("token"), + "{:#?}", + report.diagnostics + ); +} + +/// `set` wrote whatever it was given regardless of what the state could hold. +#[test] +fn set_must_fit_the_target_shape() { + for (source, expect) in [ + ( + "state ok { shape: bool, initial: false }\nevent e { ok: set(1) }\nview root Rule()", + "writes a number", + ), + ( + "state m { shape: enum[a, b], initial: .a }\nevent e { m: set(true) }\nview root Rule()", + "writes a bool", + ), + ( + "state n { shape: number, initial: 0 }\nevent e { n: set(\"x\") }\nview root Rule()", + "writes text", + ), + ( + "state m { shape: enum[a, b], initial: .a }\nevent e { m: set(.c) }\nview root Rule()", + "not a member", + ), + ] { + let report = check_ui_l0_named("shape", source); + assert!(!report.valid, "should be rejected: {source}"); + assert!( + report.diagnostics.iter().any(|d| d.message.contains(expect)), + "expected {expect:?}: {:#?}", + report.diagnostics + ); + } +} + +/// A dotted source name registered only its ROOT, so the lifecycle of +/// `source env.locale` could not be asked for, while `env.$state` — which names +/// no source at all — was accepted. +#[test] +fn a_dotted_source_reports_its_own_lifecycle() { + let source = "source env.locale sys.locale()\n\ + view root Col { when env.locale.$state == .ready { TextRow(text: \"READY\") } \ + when env.locale.$state == .pending { TextRow(text: \"WAIT\") } }"; + assert!(check_ui_l0_named("dotted", source).valid); + + let ready = realize( + source, + &serde_json::json!({"env": {"locale": {"lang": "en"}}}), + RealizeLimits::default(), + ); + assert_eq!(texts(&ready.root.unwrap()), vec!["READY"]); + + let pending = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!(texts(&pending.root.unwrap()), vec!["WAIT"]); + + let bogus = check_ui_l0_named( + "aggregate", + "source env.locale sys.locale()\nview root Col { when env.$state == .ready { Rule() } }", + ); + assert!( + !bogus.valid, + "`env` names no source, so it has no lifecycle to report" + ); +} + +/// §5.8 says the schema id covers "names, shapes and initials". It hashed only +/// path and shape, so changing an initial did not reset live state. +#[test] +fn changing_an_initial_changes_the_schema() { + let base = "component F() { state s { shape: number, initial: 0 } \ + view Panel { TextRow(text: s) } }\nview root F()"; + let changed = base.replace("initial: 0", "initial: 7"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &changed).closure, + "an initial is part of the state schema (§5.8)" + ); +} + +// ─── §4 copy provenance ────────────────────────────────────────────────────── + +/// §4 requires a rendered literal to be "vocabulary or source-derived". That +/// was undecidable, not merely unenforced: `parse_locale_map` kept only entries +/// whose value was a STRING, and `class: vocabulary` is an identifier — so the +/// provenance was discarded before anything could read it. +#[test] +fn provenance_survives_parsing_and_model_copy_is_refused() { + let vocabulary = check_ui_l0_named( + "vocab", + "copy hi { class: vocabulary, en: \"Top Stories\" }\nview root TextTitle(text: copy.hi)", + ); + assert!(vocabulary.valid, "{:#?}", vocabulary.diagnostics); + + let user = check_ui_l0_named( + "user", + "copy note { class: user-copy, en: \"my note\" }\nview root TextTitle(text: copy.note)", + ); + assert!( + user.valid, + "the user may write their own text: {:#?}", + user.diagnostics + ); + + let model = check_ui_l0_named( + "model", + "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n\ + view root TextTitle(text: copy.claim)", + ); + assert!( + !model.valid, + "model-authored text must not reach the screen" + ); + assert!( + model + .diagnostics + .iter() + .any(|d| d.message.contains("model-copy")), + "{:#?}", + model.diagnostics + ); +} + +/// The hyphen in `user-copy` lexes as a minus, and the level classifier read it +/// as arithmetic — so the two spellings §4 depends on could not appear in a card +/// at all. The rule was unstatable in its own language. +#[test] +fn a_hyphenated_copy_class_is_not_arithmetic() { + for class in ["vocabulary", "user-copy"] { + let report = check_ui_l0_named( + "class", + &format!("copy c {{ class: {class}, en: \"x\" }}\nview root TextTitle(text: copy.c)"), + ); + assert_eq!(report.level, Level::L0, "{class} should not classify as L1"); + assert!(report.valid, "{class}: {:#?}", report.diagnostics); + } +} + +#[test] +fn an_unknown_copy_class_is_rejected() { + let report = check_ui_l0_named( + "bogus", + "copy c { class: whatever, en: \"x\" }\nview root TextTitle(text: copy.c)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a copy class")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_undeclared_copy_reference_is_rejected() { + // Previously `copy` was a known root and any name under it resolved to + // nothing, so a typo rendered an em dash. + let report = check_ui_l0_named("missing", "view root TextTitle(text: copy.nope)"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not declared")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn the_reference_cards_declare_their_copy_provenance() { + // All 23 copy declarations across the three cards are `vocabulary`, which + // is the discipline the profile assumes and could not previously verify. + for (name, source) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let report = check_ui_l0_named(name, source); + assert!(report.valid, "{name}: {:#?}", report.diagnostics); + } +} + +// ─── rules the validator enforces that no test was holding ─────────────────── +// +// Found by mutation-testing the validator (mutate.py): suppress one diagnostic, +// run the suite, see whether anything goes red. These nine did not. Each was +// enforced and unprotected — free to regress silently, which is exactly how the +// `initial` fix ended up with a regression test that checked the wrong artifact +// and could not fail. + +#[test] +fn state_must_be_the_last_segment_of_a_path() { + let report = check_ui_l0_named( + "midstate", + "source now sys.weather(lat: 1.0, lon: 2.0)\nview root TextRow(text: now.$state.x)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("last segment")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn cycle_requires_an_enum_state() { + let report = check_ui_l0_named( + "cyclebool", + "state flag { shape: bool, initial: false }\n\ + event e { flag: cycle(.a, .b) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("needs an enum state")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_loop_over_something_that_is_not_a_collection_is_reported() { + // A REALIZATION diagnostic, not a validation one: whether a source's value + // is an array is the host's answer, not the card's declaration. Writing + // this against check_ui_l0 first is how I learned that. + let report = realize( + "source movers sys.movers()\nview root Panel { for x in movers key x.id { Rule() } }", + &serde_json::json!({"movers": 7}), + RealizeLimits::default(), + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a collection")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_reference_to_an_undeclared_view_is_rejected() { + let report = check_ui_l0_named("noview", "view root Panel { missing_view }"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("is not a declared view")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn set_from_an_unreadable_name_is_rejected() { + let report = check_ui_l0_named( + "unreadable", + "state n { shape: number, initial: 0 }\n\ + event e { n: set(nowhere.value) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a readable name")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_into_on_a_component_with_only_an_anonymous_slot_is_rejected() { + // The complement of `an_into_naming_an_absent_slot_is_rejected`, which + // exercised the branch where the component DOES offer named slots. This is + // the other message, and it was unheld. + let report = check_ui_l0_named( + "onlyanon", + "component F() { view Panel { slot } }\n\ + view root Panel { F() { into header { Rule() } } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("only an anonymous slot")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_token_where_a_path_belongs_is_rejected() { + let report = check_ui_l0_named("tokpath", "view root WeatherIcon(cond: .sunny)"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("takes a bound path")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_literal_where_an_event_prop_belongs_is_rejected() { + let report = check_ui_l0_named( + "litevent", + "component F(on_go: event) { view Panel { Row(on_tap: on_go) { Rule() } } }\n\ + view root F(on_go: \"nope\")", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("takes an event, not a literal")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_card_without_a_root_view_is_rejected() { + let report = check_ui_l0_named("noroot", "view sidebar Panel { Rule() }"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("view root")), + "{:#?}", + report.diagnostics + ); +} + +/// The level-classified rules (`let`, `while`, arithmetic, `fn`, module access) +/// are held by the LEVEL a card is assigned, not by their diagnostic text — so +/// suppressing the message changes nothing and mutation testing reports them as +/// unprotected. Assert on the level itself, which is the thing §7 actually +/// promises: escalation is never silent. +#[test] +fn every_construct_outside_l0_raises_the_level() { + for (source, expected, what) in [ + // L2, not L1: an imperative binding is not a pure expression, so it + // sits with the widget commands rather than with arithmetic. + ("view root Panel { }\nlet x = 1", Level::L2, "let"), + ("view root Panel { }\nwhile true { }", Level::L2, "while"), + ( + "state n { shape: number, initial: 0 }\nview root TextStat(value: n + 1)", + Level::L1, + "arithmetic", + ), + ("view root Panel { }\nfn tick() { }", Level::L2, "fn"), + ] { + let report = check_ui_l0_named("level", source); + assert!(!report.valid, "{what} must not be L0-valid"); + assert_eq!( + report.level, expected, + "{what} should classify as {expected:?}: {:#?}", + report.diagnostics + ); + } +} + +// ─── codex round three ─────────────────────────────────────────────────────── +// Written BEFORE the fixes, and each confirmed failing first. + +/// #6: `parse_state` scanned every token in the body for a shape word instead +/// of reading the one after `shape:`. So an enum whose members include a shape +/// name was silently retyped by its own member. +#[test] +fn an_enum_member_named_like_a_shape_does_not_retype_the_state() { + let report = check_ui_l0_named( + "shadow", + "state mode { shape: enum[a, text], initial: .a }\n\ + event e { mode: set(\"arbitrary\") }\nview root Rule()", + ); + assert!( + !report.valid, + "`mode` is an enum; a member happening to be called `text` must not \ + turn it into text and let any string be written" + ); +} + +/// #7: a misspelled shape produced `Other` with no diagnostic, and the `set` +/// checks treat `Other` as "accepts anything" — so the typo disabled them. +#[test] +fn a_misspelled_shape_is_rejected_rather_than_silently_permissive() { + let report = check_ui_l0_named( + "typo-shape", + "state n { shape: nubmer, initial: 0 }\nevent e { n: set(\"oops\") }\nview root Rule()", + ); + assert!(!report.valid, "`nubmer` is not a shape"); +} + +/// #8: a token was checked only when the target was an enum; every other shape +/// fell through, so `set(.c)` into a number wrote the string "c". +#[test] +fn a_token_set_into_a_non_enum_is_rejected() { + let report = check_ui_l0_named( + "toknum", + "state n { shape: number, initial: 0 }\nevent e { n: set(.c) }\nview root Rule()", + ); + assert!(!report.valid); +} + +/// #7 (second half): a declared initial was never checked against the shape it +/// was declared alongside. +#[test] +fn an_initial_must_fit_its_declared_shape() { + let report = check_ui_l0_named( + "badinit", + "state n { shape: number, initial: \"oops\" }\nview root Rule()", + ); + assert!(!report.valid); +} + +/// #3: this falsifies §4's claim that "a fabricated number cannot reach a +/// numeric position" — which I wrote into the spec the same day. A literal +/// launders through a prop into the drawing widgets, which exist to render +/// live data and hold no authored values. +#[test] +fn a_literal_cannot_launder_through_a_prop_into_a_data_position() { + let report = check_ui_l0_named( + "launder", + "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\n\ + view root Bar(n: 41.2)", + ); + assert!( + !report.valid, + "an authored number must not reach TempBar by way of a prop" + ); +} + +/// #4: prop shapes were retained at parse and discarded again at realization, +/// so an enum token bound with its leading dot and every comparison against it +/// inside the component was false. +#[test] +fn an_enum_token_prop_binds_its_bare_member() { + let source = "component F(unit: enum[c, f]) { \ + view Col { when unit == .c { TextRow(text: \"MATCHED\") } } }\n\ + view root F(unit: .c)"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["MATCHED"], + "`.c` must bind as `c`, or the component cannot compare against its own prop" + ); +} + +/// #4 (second half): an event prop was looked up as DATA at realization, so a +/// state whose name collided with an event name hijacked the routing. +#[test] +fn an_event_prop_binds_the_event_not_same_named_state() { + let source = "state go { shape: text, initial: \"wrong\" }\n\ + event go { go: set(\"x\") }\n\ + event wrong { go: set(\"y\") }\n\ + component F(out: event) { view Panel { Row(on_tap: out) { Rule() } } }\n\ + view root F(out: go)"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + let on_tap = rows[0] + .args + .iter() + .find(|(n, _)| n == "on_tap") + .map(|(_, v)| v.clone()); + assert_eq!( + on_tap, + Some(NodeValue::Event("go".into())), + "the event named at the call site must be the event that fires" + ); +} + +/// #5: a declared default was parsed and dropped, an omitted argument created +/// no binding, and lookup then fell through to top-level injected data — so an +/// unfilled prop could capture whatever the host happened to supply. +#[test] +fn a_prop_default_is_used_and_injected_data_cannot_capture_it() { + let source = "component Banner(title: text = \"Safe\") { view TextRow(text: title) }\n\ + view root Banner()"; + + let empty = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&empty.root.unwrap()), + vec!["Safe"], + "an omitted prop must fall back to its declared default" + ); + + let hostile = realize( + source, + &serde_json::json!({"title": "attacker"}), + RealizeLimits::default(), + ); + assert_eq!( + texts(&hostile.root.unwrap()), + vec!["Safe"], + "and must not be captured by same-named data the host injected" + ); +} + +#[test] +fn laundering_a_literal_through_a_chain_of_props_is_also_refused() { + // One hop would be a spot fix. The check runs to a fixpoint, so a longer + // chain is no better a hiding place than a short one. + let bar = "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\n"; + for depth in [ + format!("{bar}view root Bar(n: 41.2)"), + format!("{bar}component Outer(v: number) {{ view Panel {{ Bar(n: v) }} }}\nview root Outer(v: 41.2)"), + format!( + "{bar}component Mid(m: number) {{ view Panel {{ Bar(n: m) }} }}\n\ + component Outer(v: number) {{ view Panel {{ Mid(m: v) }} }}\nview root Outer(v: 41.2)" + ), + ] { + assert!( + !check_ui_l0_named("chain", &depth).valid, + "an authored number must not reach a data position at any depth" + ); + } + + // And the two things that must stay legal: a BOUND value through the same + // chain, and a literal in a position that renders no fact. + let bound = format!( + "source now sys.weather(lat: 1.0, lon: 2.0)\n{bar}\ + component Outer(v: number) {{ view Panel {{ Bar(n: v) }} }}\nview root Outer(v: now.temp)" + ); + assert!(check_ui_l0_named("bound", &bound).valid); + assert!( + check_ui_l0_named( + "label", + "component Lbl(t: text) { view TextTitle(text: t) }\nview root Lbl(t: \"Heading\")" + ) + .valid, + "a literal label renders no fact and must stay legal" + ); +} + +/// #1: provenance failed OPEN. A declaration with no `class` at all defaulted +/// to trusted, so the check only bit when a card volunteered the label — which +/// a model with something to hide would not. +#[test] +fn copy_without_a_class_is_refused_rather_than_trusted() { + let report = check_ui_l0_named( + "noclass", + "copy claim { en: \"Revenue: $41.2M\" }\nview root TextTitle(text: copy.claim)", + ); + assert!( + !report.valid, + "undeclared provenance must not default to vocabulary" + ); +} + +/// #2: even correctly-marked model-copy laundered through a state initial, +/// because provenance was checked only where an element referenced copy.x +/// directly. +#[test] +fn model_copy_cannot_launder_through_a_state_initial() { + let report = check_ui_l0_named( + "via-state", + "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n\ + component F() { state s { shape: text, initial: copy.claim } view TextTitle(text: s) }\n\ + view root F()", + ); + assert!(!report.valid); +} + +// ─── parser and resource bounds ────────────────────────────────────────────── +// +// Found by the second mutation run, after fixing the harness's own blind spot: +// it had been selecting diagnostics by keyword and silently missing newer ones. +// Four of these are §6's termination bounds, which the profile claims outright. + +#[test] +fn a_source_over_the_size_limit_is_rejected() { + let huge = format!("view root Panel {{ }}\n# {}", "x".repeat(300_000)); + let report = check_ui_l0_named("huge", &huge); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("byte limit")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn malformed_input_is_rejected_rather_than_parsed_loosely() { + for (source, expect, what) in [ + ( + "state n { shape: number, initial: 0 }\nbanana root Rule()", + "expected a declaration", + "unknown declaration", + ), + ( + "view root Panel { 42 }", + "expected an element", + "a literal where an element belongs", + ), + ( + "component F() { banana }", + "expected state, event or view", + "junk in a component body", + ), + ( + "view root TextRow(text: \"unterminated)", + "unterminated string", + "an unterminated string", + ), + ( + "view root Panel { } \u{0007}", + "unexpected character", + "a character outside the grammar", + ), + ] { + let report = check_ui_l0_named("malformed", source); + assert!(!report.valid, "{what} must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains(expect)), + "{what}: {:#?}", + 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. +#[test] +fn the_resource_bounds_hold() { + let deep = format!( + "view root {}Rule(){}", + "Panel { ".repeat(200), + " }".repeat(200) + ); + let report = check_ui_l0_named("deep", &deep); + assert!(!report.valid, "nesting must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too deep")), + "{:#?}", + report.diagnostics + ); + + let many = format!( + "{}view root Rule()", + (0..2_000) + .map(|i| format!("state s{i} {{ shape: number, initial: 0 }}\n")) + .collect::() + ); + let report = check_ui_l0_named("many", &many); + assert!(!report.valid, "declaration count must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too many declarations")), + "{:#?}", + report.diagnostics + ); + + let wide = format!("view root Panel {{ {} }}", "Rule() ".repeat(3_000)); + let report = check_ui_l0_named("wide", &wide); + assert!(!report.valid, "block width must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too many elements")), + "{:#?}", + report.diagnostics + ); +} + +/// #9: `source_roots` was fixed to hold full names, but ordinary `Scope.roots` +/// still held the ROOT — so declaring one source authorised reading everything +/// beside it. The card then renders an undeclared dependency, which is exactly +/// what §4's "anything reachable without a declared source is a defect by +/// construction" says cannot happen. +#[test] +fn a_dotted_source_does_not_authorise_its_whole_root() { + let report = check_ui_l0_named( + "sibling", + "source env.locale sys.locale()\nview root TextRow(text: env.theme.secret)", + ); + assert!( + !report.valid, + "declaring env.locale must not grant env.theme" + ); + + // The declared one still works. + assert!( + check_ui_l0_named( + "ok", + "source env.locale sys.locale()\nview root TextRow(text: env.locale.lang)" + ) + .valid + ); +} + +/// #10: a dotted source's dependency edge was lost, because the argument was +/// reduced to its root and compared against the full declared name. The plan +/// then emitted the dependent BEFORE the thing it depends on. +#[test] +fn a_dotted_source_dependency_is_ordered_correctly() { + let plan = splash_ui_l0::source_plan( + "source env.locale sys.locale()\n\ + source scene sys.photo(query: env.locale.lang)\n\ + view root Rule()", + ); + let order: Vec<&str> = plan.requests.iter().map(|r| r.name.as_str()).collect(); + // Unwrap both: `None < Some(_)` is true in Rust, so comparing the Options + // directly would let an ABSENT prerequisite satisfy the ordering assertion. + let locale = order + .iter() + .position(|n| *n == "env.locale") + .expect("env.locale must be planned"); + let scene = order + .iter() + .position(|n| *n == "scene") + .expect("scene must be planned"); + assert!( + locale < scene, + "a source must be fetched before the one that reads it: {order:?}" + ); + let scene_req = plan.requests.iter().find(|r| r.name == "scene").unwrap(); + assert!( + scene_req.depends_on.iter().any(|d| d == "env.locale"), + "the edge must be recorded: {:?}", + scene_req.depends_on + ); +} + +/// #11: only `level` and `profile` were checked for contradictory duplicates. +/// A card could declare two different ledger identities and the last one won. +#[test] +fn a_contradictory_duplicate_ledger_is_rejected() { + let report = check_ui_l0_named( + "twoledgers", + "# ledger real@1.0.0\n# ledger impostor@9.9.9\nview root Rule()", + ); + assert!(!report.valid, "a card has one identity"); +} + +/// #13: the regression test for §5.8's schema id compared `report.closure`, +/// which the definition digest already made sensitive to initials — so it +/// passed with the schema fix reverted. It must exercise the store. +#[test] +fn changing_an_initial_resets_live_component_state() { + const BEFORE: &str = "source items sys.movers()\n\ + component Rowy(item: record) { state n { shape: number, initial: 0 } \ + event bump { n: set(1) } \ + view Col { Row(on_tap: bump) { TextRow(text: item.name) } \ + when n == 1 { TextCaption(text: item.name) } } }\n\ + view root Panel { for it in items key it.id { Rowy(item: it) } }"; + + let data = serde_json::json!({"items": [{"id": "a", "name": "A"}]}); + let mut store = splash_ui_l0::InstanceStore::default(); + let first = splash_ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + let applied = splash_ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); + assert!( + applied, + "the event must actually apply, or this proves nothing" + ); + + // Prove the caption is PRESENT before the edit. Without this, a dispatch + // regression alone satisfies the assertion below and masks the schema + // regression the test is named for. + let live = splash_ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let live_root = live.root.unwrap(); + let mut before_caps = Vec::new(); + find(&live_root, "TextCaption", &mut before_caps); + assert_eq!( + before_caps.len(), + 1, + "state must be live before the schema change" + ); + + let after = BEFORE.replace("initial: 0", "initial: 7"); + let out = splash_ui_l0::realize_with_state(&after, &data, &store, RealizeLimits::default()); + let out_root = out.root.unwrap(); + let mut caps = Vec::new(); + find(&out_root, "TextCaption", &mut caps); + assert_eq!( + caps.len(), + 0, + "a changed initial is a schema change (§5.8), so live state must reset" + ); +} + +// ─── codex round four: merge blockers ──────────────────────────────────────── + +/// A 60 KB card — well under the 256 KiB byte limit — overflowed the parser's +/// stack and ABORTED the process. `dotted_name` recursed once per nested +/// dynamic index with only a local guard, and predicate right-hand sides had +/// the same shape. +/// +/// This is a crash on untrusted input in the component whose entire purpose is +/// accepting untrusted input, so a documented limit would not do. +#[test] +fn deeply_nested_paths_do_not_overflow_the_stack() { + for depth in [2_000, 20_000, 60_000] { + let source = format!( + "source a sys.movers()\nview root TextRow(text: {}a{})", + "a[".repeat(depth), + "]".repeat(depth) + ); + assert!( + source.len() < 262_144, + "the probe must stay under the byte limit, or it proves nothing" + ); + // Reaching the assertion at all is the test: an overflow aborts the + // whole binary rather than failing this case. + let report = check_ui_l0_named("deep-path", &source); + assert!( + !report.valid, + "nesting past the bound must be refused, not accepted" + ); + } +} + +/// The same shape on the other recursive path: a predicate's right-hand side. +#[test] +fn deeply_nested_predicates_do_not_overflow_the_stack() { + let depth = 20_000; + let source = format!( + "state n {{ shape: number, initial: 0 }}\nview root Col {{ when n == {}n{} {{ Rule() }} }}", + "n[".repeat(depth), + "]".repeat(depth) + ); + assert!(source.len() < 262_144); + let report = check_ui_l0_named("deep-pred", &source); + assert!(!report.valid); +} + +/// §5.7 says duplicate keys are an error at realization. Nothing tracked them, +/// so two items with the same id produced IDENTICAL instance keys — one cell +/// shared by two rows, and tapping either toggled both. +#[test] +fn duplicate_loop_keys_are_reported_and_do_not_share_state() { + const CARD: &str = "source items sys.movers()\n\ + component R(it: record) { state n { shape: bool, initial: false } \ + event f { n: toggle } \ + view Col { Row(on_tap: f) { TextRow(text: it.name) } \ + when n { TextCaption(text: it.name) } } }\n\ + view root Panel { for i in items key i.id { R(it: i) } }"; + + let data = serde_json::json!({ + "items": [{"id": "same", "name": "A"}, {"id": "same", "name": "B"}] + }); + let report = realize(CARD, &data, RealizeLimits::default()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("duplicate")), + "a duplicate key must be reported: {:#?}", + report.diagnostics + ); + + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2, "both rows still render"); + assert_ne!( + rows[0].key, rows[1].key, + "two instances must not share one state cell" + ); +} + +/// The key expression itself was never validated: its first segment was +/// discarded unconditionally, so `key typo.id` behaved exactly like +/// `key item.id` — which also means changing a key does not reliably move +/// identity, contrary to §5.1. +#[test] +fn a_loop_key_must_name_the_loop_binder() { + let report = check_ui_l0_named( + "badkey", + "source items sys.movers()\nview root Panel { for it in items key typo.id { Rule() } }", + ); + assert!(!report.valid, "`typo` is not the binder `it`"); +} + +/// `validate_sources` checked the helper name and its argument NAMES, never the +/// paths those arguments carry. So a card could reference anything and the +/// value went to the host inside a well-formed request — which is precisely +/// the undeclared dependency §4 says is "a defect by construction". +#[test] +fn a_source_argument_path_must_be_declared() { + let report = check_ui_l0_named( + "leak-arg", + "source photo sys.photo(query: secrets.token)\nview root Rule()", + ); + assert!(!report.valid, "`secrets` is not declared"); + + // The plan must not carry it either, for a caller that skips checking. + let plan = + splash_ui_l0::source_plan("source photo sys.photo(query: secrets.token)\nview root Rule()"); + assert!( + !plan.diagnostics.is_empty(), + "the plan must report it rather than hand the host an undeclared path" + ); + + // A declared one still works. + assert!( + check_ui_l0_named( + "ok", + "state city { shape: text, initial: \"\" }\n\ + source place sys.geocode(name: city)\nview root Rule()" + ) + .valid + ); +} + +/// The acyclicity walk counted every popped name against one budget, and +/// `collect_constructors` yields ordinary catalog constructors, not just graph +/// nodes. So padding a component with enough `Rule()`s exhausts the budget +/// before the traversal reaches the recursive edge, and the card is reported +/// acyclic — which means §6's first termination condition is not enforced. +#[test] +fn padding_cannot_hide_a_cycle_from_the_acyclicity_check() { + let padding = "Rule() ".repeat(600); + let source = format!( + "component A() {{ view Col {{ A() Panel {{ {padding} }} Panel {{ {padding} }} }} }}\n\ + view root A()" + ); + let report = check_ui_l0_named("padded", &source); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "a recursive component must be caught however much noise surrounds it: {:#?}", + report + .diagnostics + .iter() + .map(|d| &d.message) + .collect::>() + ); +} + +/// A header containing ONLY duplicate `model:` lines never set `saw_any`, so +/// `parse_header` returned None and the contradiction vanished with it. +#[test] +fn a_model_only_duplicate_header_is_still_caught() { + let report = check_ui_l0_named( + "dupmodel", + "# model: first\n# model: second\nview root Rule()", + ); + assert!(!report.valid, "a card names one model"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("model twice")), + "{:#?}", + report.diagnostics + ); +} + +/// A parameter default was accepted regardless of the shape declared beside it, +/// and the spec's own `enum[c, f] = c` spelling was not parsed at all. +#[test] +fn a_parameter_default_must_fit_its_declared_shape() { + for source in [ + "component F(n: number = \"oops\") { view TextRow(text: n) }\nview root F()", + "component G(m: enum[a, b] = .missing) { view TextRow(text: m) }\nview root G()", + "component H(b: bool = 3) { view Col { when b { Rule() } } }\nview root H()", + ] { + let report = check_ui_l0_named("baddefault", source); + assert!(!report.valid, "should be rejected: {source}"); + } +} + +#[test] +fn a_bare_enum_member_is_a_valid_default() { + // §5.2's own example spells it without the dot. It parsed as nothing, so + // the documented form silently produced no default at all. + let report = check_ui_l0_named( + "bare", + "component F(unit: enum[c, f] = c) { view TextValue(value: unit) }\nview root F()", + ); + assert!(report.valid, "{:#?}", report.diagnostics); + + let out = realize( + "component F(unit: enum[c, f] = c) { view TextRow(text: unit) }\nview root F()", + &serde_json::json!({}), + RealizeLimits::default(), + ); + assert_eq!( + texts(&out.root.unwrap()), + vec!["c"], + "the default must bind" + ); +} + +// ─── phase 3: the three claims the spec makes and did not keep ─────────────── + +/// §3: "an event names several transitions, applied atomically". They were +/// applied sequentially straight to the store, so a batch that fails partway +/// leaves the earlier writes standing — a half-applied event. +#[test] +fn a_transition_batch_is_atomic() { + const CARD: &str = "state a { shape: number, initial: 0 }\n\ + state b { shape: number, initial: 0 }\n\ + source cfg sys.movers()\n\ + event both { a: set(1), b: set(cfg.missing) }\n\ + view root Row(on_tap: both) { Rule() }"; + + let mut store = splash_ui_l0::InstanceStore::default(); + // `cfg.missing` does not resolve, so the batch cannot complete. + let applied = splash_ui_l0::dispatch_with_data( + CARD, + &mut store, + "root", + "both", + None, + &serde_json::json!({"cfg": {}}), + ); + assert!( + !applied, + "a batch that cannot complete must not report success" + ); + assert_eq!( + store.get("@card", "a"), + None, + "the first write must not survive a batch that failed on the second" + ); +} + +/// §3: `set($value)` performed no shape check at dispatch, so a string payload +/// entered a number cell — the transition type system stops at the boundary. +#[test] +fn a_payload_must_fit_the_state_it_is_written_to() { + const CARD: &str = "state n { shape: number, initial: 0 }\n\ + event pick { n: set($value) }\n\ + view root Row(on_tap: pick, value: \"x\") { Rule() }"; + let mut store = splash_ui_l0::InstanceStore::default(); + let payload = serde_json::json!("not a number"); + let applied = splash_ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); + assert!(!applied, "a payload of the wrong shape must be refused"); + assert_eq!(store.get("@card", "n"), None); +} + +/// §3: `clear` restores "the declared initial". It ignored a path-valued one and +/// fell back to the shape default, and `cycle` started from the shape default +/// too — so a card whose initial comes from the host began in the wrong state. +#[test] +fn clear_restores_a_path_valued_initial() { + const CARD: &str = "source env.locale sys.locale()\n\ + state units { shape: enum[c, f], initial: env.locale.temp_unit }\n\ + event reset { units: clear }\n\ + event flip { units: cycle(.c, .f) }\n\ + view root Row(on_tap: flip) { Rule() }"; + let data = serde_json::json!({"env": {"locale": {"temp_unit": "f"}}}); + let mut store = splash_ui_l0::InstanceStore::default(); + + splash_ui_l0::dispatch_with_data(CARD, &mut store, "root", "flip", None, &data); + assert_eq!( + store.get("@card", "units").and_then(|v| v.as_str()), + Some("c"), + "cycle must advance from the host-supplied initial `f`, not from the shape default" + ); + + splash_ui_l0::dispatch_with_data(CARD, &mut store, "root", "reset", None, &data); + assert_eq!( + store.get("@card", "units").and_then(|v| v.as_str()), + Some("f"), + "clear must restore the declared initial, path-valued or not" + ); +} + +/// §4 refuses model-authored text in a rendering position. It was enforced only +/// where an element named `copy.x` directly, so any indirection defeated it. +/// These are the routes: through a component prop, through a prop default, and +/// through a transition that writes it into state. +#[test] +fn model_copy_cannot_reach_the_screen_by_any_route() { + const DECL: &str = "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n"; + + for (route, source) in [ + ( + "directly", + format!("{DECL}view root TextTitle(text: copy.claim)"), + ), + ( + "through a prop", + format!( + "{DECL}component F(t: text) {{ view TextTitle(text: t) }}\n\ + view root F(t: copy.claim)" + ), + ), + ( + "through a state initial", + format!( + "{DECL}component F() {{ state s {{ shape: text, initial: copy.claim }} \ + view TextTitle(text: s) }}\nview root F()" + ), + ), + ( + "through a transition", + format!( + "{DECL}state s {{ shape: text, initial: \"\" }}\n\ + event e {{ s: set(copy.claim) }}\nview root Row(on_tap: e) {{ Rule() }}" + ), + ), + ] { + assert!( + !check_ui_l0_named("route", &source).valid, + "model-copy must not reach the screen {route}" + ); + } + + // Vocabulary still travels every one of those routes. + let ok = "copy label { class: vocabulary, en: \"Top Stories\" }\n\ + component F(t: text) { view TextTitle(text: t) }\n\ + view root F(t: copy.label)"; + assert!(check_ui_l0_named("vocab", ok).valid); +} + +/// §6, condition 5 caps nesting depth AND node count, and they are different +/// bounds. The existing deep-source test builds a tree that trips the node cap +/// on the way down, so removing the depth check entirely left it green — the +/// rebuilt mutation harness caught that. +/// +/// This isolates depth: a tree deeper than `max_depth` but far short of +/// `max_nodes`, so only the depth bound can stop it. +#[test] +fn realization_depth_is_bounded_independently_of_node_count() { + // Between the two bounds: under the parser's 128-deep limit so it parses, + // over the realizer's default 64 so only the realization bound can stop it. + let depth = 100; + let source = format!( + "view root {}Rule(){}", + "Col { ".repeat(depth), + " }".repeat(depth) + ); + let limits = RealizeLimits { + max_nodes: 100_000, // far above what this tree needs + max_depth: 64, + max_collection: 512, + }; + let report = realize(&source, &serde_json::json!({}), limits); + assert!( + report.truncated, + "a tree deeper than max_depth must truncate even when node count is fine \ + ({} nodes realized)", + report.nodes + ); + assert!( + report.nodes < 100_000, + "and it must stop well short of the node cap, or this proves nothing" + ); +} + +/// §1.1 as a check rather than a paragraph: a card must have no way to state a +/// colour, a size in pixels, or a font. The principle survives the revert of the +/// lowering that was meant to implement it — a card naming roles rather than +/// appearance is right regardless of what consumes the result. +#[test] +fn no_role_accepts_presentation() { + for (ctor, args) in catalog::CONSTRUCTORS { + for (arg, _) in *args { + assert!( + !matches!( + *arg, + "color" + | "colour" + | "bg" + | "fill" + | "font" + | "font_size" + | "weight" + | "radius" + | "elevation" + | "shadow" + | "padding" + | "margin" + ), + "{ctor}.{arg} is presentation; §1.1 says a card names roles and the theme \ + decides appearance" + ); + } + } +} + +// ─── reconciliation, applied ───────────────────────────────────────────────── + +/// `patch_points` computes which SOURCES a change invalidates and then throws +/// that away, returning only the records to re-realize. Re-fetching is the +/// expensive half — realization is a tree walk over ~90 nodes, a source is a +/// network round trip — so the invalidation set is what a host actually needs. +#[test] +fn a_state_change_names_the_sources_that_must_be_refetched() { + const CARD: &str = r#" +state city { shape: text, initial: "" } +state units { shape: enum[c, f], initial: .c } +source place sys.geocode(name: state.city) +source now sys.weather(lat: place.lat, lon: place.lon) +source moon sys.moonphase() +view root Col { TextRow(text: place.name) TextValue(value: now.temp, unit: units) } +"#; + + // `city` feeds `place`, and `place` feeds `now` — the cascade must be + // followed, or a host refetches a coordinate and reads a stale forecast. + let stale = splash_ui_l0::stale_sources(CARD, &["city"]); + assert!(stale.contains(&"place".to_string()), "{stale:?}"); + assert!( + stale.contains(&"now".to_string()), + "the cascade must be followed: {stale:?}" + ); + assert!( + !stale.contains(&"moon".to_string()), + "the moon does not depend on the city: {stale:?}" + ); + + // A display-only unit change costs no fetch at all. This is the case the + // whole mechanism exists for. + assert!( + splash_ui_l0::stale_sources(CARD, &["units"]).is_empty(), + "changing a display unit must not refetch anything" + ); +} + +/// The second half: realization reuses the subtrees a change cannot have +/// touched, instead of rebuilding the card. +#[test] +fn realization_reuses_the_records_a_change_did_not_touch() { + const CARD: &str = r#" +state units { shape: enum[c, f], initial: .c } +source now sys.weather(lat: 1.0, lon: 2.0) +source news sys.news(count: 1, fields: [title]) +view root Col { temps headline } +view temps Col { TextValue(value: now.temp, unit: units) } +view headline Col { TextRow(text: news.0.title) } +"#; + let data = serde_json::json!({ + "now": {"temp": 21.0}, "news": [{"title": "unchanged"}], "units": "c" + }); + + let full = realize(CARD, &data, RealizeLimits::default()); + let before = full.root.expect("a tree"); + + let patched = splash_ui_l0::realize_patch( + CARD, + &data, + None, + &before, + &["units"], + RealizeLimits::default(), + ); + let after = patched.root.expect("a tree"); + + assert_eq!( + shape_signature(&before), + shape_signature(&after), + "a patch must produce the same tree as a full realize" + ); + assert!( + patched.reused > 0, + "the headline reads no changed path and should have been reused" + ); + assert!( + patched.reused < patched.nodes, + "and the temperature record must have been rebuilt, not reused" + ); +} + +fn shape_signature(n: &splash_ui_l0::UiNode) -> String { + let kids: Vec = n.children.iter().map(shape_signature).collect(); + format!("{}[{}]", n.kind, kids.join(",")) +} + +/// §5.10.1's premise, on the real weather card rather than a synthetic one. +/// +/// The argument there is that component-local state cannot simply be moved down +/// to the component kit, because L0 keeps deciding when a subtree is rebuilt and +/// would no longer know the state exists. That argument is only worth making if +/// the rebuild actually happens — so this asserts the premise, not the +/// conclusion: a source refresh really does replace the subtrees that read it. +/// +/// If this ever goes green in the other direction — forecast rows surviving a +/// `week` refresh — then §5.10.1 is wrong and the move is cheaper than stated. +#[test] +fn a_source_refresh_replaces_the_subtrees_that_read_it() { + let data = weather_data(); + let store = splash_ui_l0::InstanceStore::default(); + let limits = RealizeLimits::default(); + + let before = splash_ui_l0::realize_with_state(WEATHER, &data, &store, limits) + .root + .expect("the reference card realizes"); + + let patch = |changed: &str| { + splash_ui_l0::realize_patch(WEATHER, &data, Some(&store), &before, &[changed], limits) + }; + + // `week` feeds the forecast rows, which is where `ForecastRow.expanded` + // lives. Rebuilding them is what would discard a kit-owned flag. + let week = patch("week"); + assert!( + week.reused < week.nodes, + "a week refresh must rebuild the records that read it, not reuse everything" + ); + + // The counterpart: a name no source or state declares dirties nothing, so + // the whole tree carries over. Without this, the assertion above would also + // pass for an implementation that never reuses anything at all. + let untouched = patch("no_such_record"); + assert_eq!( + untouched.reused, untouched.nodes, + "a change touching no declared record must reuse the entire tree" + ); +} + +/// The catalog described an interface these widgets abandoned. `AqiContour` +/// took `field`/`index`/`band` and `StockPlot` took `points`/`min`/`max` — but +/// both fetch their own data now, and the widget's own comment says why: +/// supplying the field meant sixteen scalar uniforms, and "that put a GPU +/// uniform layout into the authoring language — it is not a widget contract, it +/// is an ABI." +/// +/// So a card names a LOCATION or a SERIES, and the arguments it names must +/// reach the widget. +#[test] +fn a_contour_carries_the_location_it_was_given() { + let dsl = splash_ui_l0::makepad::lower( + &realize( + "source p sys.geocode(name: \"Kyoto\")\n\ + view root Col { AqiContour(lat: p.lat, lon: p.lon, span: 1.6) }", + &serde_json::json!({"p": {"lat": 35.0, "lon": 135.7}}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("AqiContour"), "{dsl}"); + for v in ["35", "135.7", "1.6"] { + assert!( + dsl.contains(v), + "the bound {v} must reach the widget, or it samples the wrong place: {dsl}" + ); + } +} + +#[test] +fn a_plot_carries_the_series_it_was_given() { + let dsl = splash_ui_l0::makepad::lower( + &realize( + "state sel { shape: text, initial: \"NVDA\" }\n\ + view root Col { StockPlot(symbol: sel, range: .money) }", + &serde_json::json!({}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("StockPlot"), "{dsl}"); + assert!( + dsl.contains("NVDA"), + "the symbol must reach the widget, or it plots nothing: {dsl}" + ); +} + +// ─── DSL lowering, verified against the real consumer ──────────────────────── + +/// A previous attempt at this was reverted because it asserted a contract with +/// `splash-render` that nobody had opened: five of 23 kinds did not exist, the +/// consumer reads `variant` where it emitted `role`, and its `Attrs` is a fixed +/// 37-field struct rather than an open bag, so about twenty attributes were +/// unread. All of that was one `grep` away. +/// +/// So this lowering emits ONLY what the consumer accepts, and refuses loudly +/// where it cannot. The constraints below were found by running +/// `splash_render::build`, not by reading about it: +/// +/// - the root must be a literal object; a function call there fails +/// - an unknown kind at the root rejects the document, and as a child is +/// dropped without a diagnostic +#[test] +fn dsl_lowering_emits_only_kinds_the_consumer_accepts() { + const ACCEPTED: &[&str] = &[ + "column", + "row", + "stack", + "scroll", + "list", + "grid", + "text", + "image", + "button", + "card", + "listitem", + "divider", + "spacer", + "chip", + "weathericon", + ]; + let dsl = splash_ui_l0::lower_dsl( + &realize(NEWS, &news_fixture(), RealizeLimits::default()) + .root + .unwrap(), + ); + for kind in dsl + .split("{t: \"") + .skip(1) + .filter_map(|s| s.split('"').next()) + { + assert!( + ACCEPTED.contains(&kind), + "{kind:?} is not in splash-render's tag table; as a child it would be \ + dropped without a diagnostic and as a root it would reject the card" + ); + } +} + +/// A role with no accepted kind must produce a DIAGNOSTIC, not a node the +/// consumer silently discards. Silent dropping is how a card renders with four +/// of its five visualisations missing and still looks finished. +#[test] +fn an_unmappable_role_is_reported_rather_than_dropped() { + let report = splash_ui_l0::lower_dsl_checked( + &realize( + "source w sys.weather(lat: 1.0, lon: 2.0)\n\ + view root Col { TempBar(lo: w.lo, hi: w.hi, min: w.min, max: w.max) }", + &serde_json::json!({"w": {"lo": 1.0, "hi": 2.0, "min": 0.0, "max": 3.0}}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!( + !report.diagnostics.is_empty(), + "TempBar has no kind in splash-render, so lowering it must say so" + ); + assert!( + report.diagnostics[0].message.contains("TempBar"), + "{:#?}", + report.diagnostics + ); +} + +fn news_fixture() -> serde_json::Value { + serde_json::json!({ + "lead": [{"id": "a", "title": "T", "author": "A", "points": 1, "comments": 2}], + "feed": [{"id": "b", "title": "U", "author": "B", "points": 3}], + "article": {}, "selected": "", "env": {"locale": {}} + }) +} diff --git a/docs/grammar.md b/docs/grammar.md index 1034f95..4b42539 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -237,6 +237,26 @@ it remains outside this grammar and cannot run through `splash-cli`. It requires a Makepad UI host that supplies widget modules and `ui`; see [Makepad UI compatibility](makepad-ui-compatibility.md). +### Generated UI source + +The two boundaries above leave a gap. This grammar rejects UI constructs, and +the compatibility APIs that accept them must not receive generated source — so +a host wanting LLM-authored UI has no supported path and must embed the raw +Makepad VM, which restores the very bindings this profile masks. + +[UI Profile Level 0](ui-profile-l0.md) is that third path: a sibling canonical +profile that admits UI constructs while admitting nothing which can reach a +capability. Sources are queries the runtime resolves before realization and +injects as data. + +It is **implemented** — `splash_core::ui_l0::check_ui_l0` accepts or rejects, +and `realize` produces a renderer-neutral node tree. Note what that entry point +does *not* do: L0 has no expression form, so there is nothing to evaluate and +realization never enters the VM. Authority confinement is therefore structural +in the strongest sense available — not a sandbox blocking calls, and not an +evaluator holding an empty module table, but no execution machinery in the path +at all. + ## Canonical Workflow Source ```splash diff --git a/docs/language-profile.md b/docs/language-profile.md index dba2b3f..c1d57b1 100644 --- a/docs/language-profile.md +++ b/docs/language-profile.md @@ -8,6 +8,18 @@ broader runtime profile. The v0.1 fixture remains a backward-compatibility regression. Parser features outside the grammar and fixtures remain inherited compatibility features until separately specified. +**Scope.** This is the *workflow* profile: it is shaped for bounded dataflow and +tool calls, and it deliberately rejects UI constructs. A second profile for +generated UI is proposed but unimplemented — see +[UI Profile Level 0](ui-profile-l0.md). The two are siblings rather than nested: +a card is not a workflow with widgets bolted on, and neither profile is a subset +of the other. Where they agree is the property that matters — a producer must be +unable to write anything that reaches a capability it was not granted. + +Until L0 exists, `splash profile` reports only this profile, and a host with +generated UI source has no canonical entry point (see +[Generated UI source](grammar.md#generated-ui-source)). + ## Source contract Provide normal Splash source. The runtime adds its own internal terminal diff --git a/docs/roadmap.md b/docs/roadmap.md index ea11009..f676002 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -371,6 +371,75 @@ reviewed adapter's host-owned resolver; broader selectors must remain denied until each is enforced. +## Complete: a canonical profile for generated UI + +Splash has no supported path for LLM-authored UI. The canonical grammar rejects +UI constructs; the compatibility APIs accept them but must not receive generated +source. A host that needs one must embed the raw Makepad VM, which restores the +inherited UI, debug, shader, GC and direct-output bindings the standalone +runtime masks — and with them an ambient filesystem, process and network +surface. That is not a hypothetical: it is what a downstream host did, and how +its generated cards ended up able to read and write arbitrary files. + +[UI Profile Level 0](ui-profile-l0.md) proposes the narrowest useful path — +constructors, bindings, keyed loops, guarded branches and components with +declared local state, with no expression form and no module access, evaluated +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 +rejected as L2; four cases render on a OnePlus 6T through an unmodified downstream host, checked +against golden images. + +Building it settled one claim in the profile's own favour and against its wording. L0 has no +expression form, so there is nothing to evaluate: realization never enters the VM, and the +implementation contains no reference to it. Authority confinement is structural in the +strongest sense available — no execution machinery in the path, rather than a sandbox or an +empty module table. + +Since resolved — every question the profile listed as open is now settled, and the +constructs behind them are implemented: + +- **Component version pinning.** The check returns a per-component definition + digest. Level is a judgment over the transitive closure, so pinning the level + alone let a replaced definition carry an approved L0 verdict onto code that no + longer earned it. The digest ignores declaration order, so a reformat is not a + version change. +- **Named slots** — `slot name` in a component, `into name { … }` at the call + site, with the anonymous slot unchanged as the default. An `into` naming a + slot that does not exist is rejected rather than dropping its children. +- **State migration** — `keep: true` exempts one cell from the §5.8 schema + reset, and only while that field's own shape is unchanged. +- **Source lifecycle** — `.$state` yields pending/ready/stale/failed, so + a card can distinguish "loading" from "the fetch failed" from "no value". + Previously all three rendered the same em dash, which is the presentation a + card gives to data it actually has. +- **Termination.** All five of the profile's §6 conditions are now accounted + for: acyclicity and total transition forms are validated, collection length, + node count and nesting depth are enforced at realization, and the prohibition + on event cascades holds structurally — no total form can name an event, so a + cascade is unrepresentable rather than rejected. That last one is pinned by a + test, because a future form that could raise an event would silently move + termination from structural to unchecked. + +Remaining: + +- **Live sources.** `source_plan` says what to fetch and in what order, and a + host-side resolver fulfils it, but nothing yet maps a `source` declaration to + 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`, + `dirty_records` and `patch_points` compute what a state write invalidates; + realization still rebuilds the tree rather than patching those points. +- **`makepad::lower` is in the wrong crate.** It belongs in a backend crate, not + in `splash-core`, which otherwise names no renderer. +- **`StockPlot` and `AqiContour` lower without their data arrays**, so both + render as empty frames on device. +- **L1 and L2 are unimplemented.** Both are specified only as what L0 excludes, + so the level classifier can name them but cannot check them. + ## Before a stable language release - Additional semantic editor features beyond lexical completion, fixed diff --git a/docs/scoped-state.md b/docs/scoped-state.md new file mode 100644 index 0000000..b1ffeb3 --- /dev/null +++ b/docs/scoped-state.md @@ -0,0 +1,313 @@ +# Splash Language Contract: Scoped State + +**Status:** proposed. Nothing here is implemented. + +**Superseded in part — read this first.** `ui-profile-l0.md` §5.10 classifies state by owner and +reaches a different arrangement than §7 below assumes. §7 has L0's lowering emit its own state +declarations and transitions into the VM. Under §5.10 the state that would live in the VM is +kind 2 — component-local — which is a candidate for belonging to the *component kit*, declared +there rather than in an L0 card. If that move happens, the cells here are the kit's and L0 never +mentions them. + +What survives regardless: §3's per-instance cell keyed by an opaque emitter-supplied key, §3.1's +collision rule, §4's redraw-on-write, and §5's lifetime. What needs rewriting is §7, and the +question of *who emits* the declaration. §5.10.1 adds an obligation this document does not +carry: whoever owns the cells must be handed instance identity, because L0 rebuilds subtrees on +data changes and a cell that cannot be re-found is a cell that is lost. + +`UPSTREAM.md` permits a behavioural change to the vendored VM "only when [it] +implements a published Splash language contract". This is that contract. It is +deliberately narrow: it publishes the smallest state mechanism that lets an +interaction be handled locally, and defers everything that can be deferred. + +**This contract has more than one implementer.** Splash's vendored VM +(`vendor/makepad/platform/script`, pinned to upstream `4f9ce7a8`) and the VM that +ships on device in octos-one (`makepad/platform/script`) are independent +lineages — neither contains the other's history. That is a deliberate position, +not an accident awaiting a merge, and it has one consequence that governs +everything below: **a contract is only as real as the conformance that holds its +implementations together.** §9 is therefore not an appendix. It is the part +that makes the rest true. + +Nothing in §2–§6 names a VM, a crate or a language. If a sentence here can only +be satisfied by one implementation, that sentence is a defect. + +--- + +## 1. What Splash has today, and why it is not enough + +Splash has no state in the VM. What looks like state is two separate mechanisms +that never meet: + +| | mechanism | where it lives | +|---|---|---| +| read | `{{state.q}}` | textual substitution when the card is seeded | +| write | `agent.notify("set", {key: "sel", value: "1"})` | a host callback out of the VM | + +So a tap leaves the VM, reaches the host, and the host re-seeds the entire card. +Nothing in the program holds a value between those two points. `{{state.q}}` is +not a binding — by the time the VM sees the source, the interpolation has already +happened and the text is a literal. + +Three consequences follow, and each is a reason for this contract: + +**Every interaction costs a whole card.** Toggling a row rebuilds and re-seeds +the program. That is the correct cost for "the user asked for something new" and +a poor one for "the user expanded a row". + +**State cannot be per-instance.** The namespace is flat, so seven rows each +needing an `expanded` flag must synthesise seven distinct names — the manual +keying that component models exist to remove. A card author who gets the keying +wrong gets two rows sharing one flag, silently. + +**Every render backend must re-solve it.** Splash lowers to three backends +(`splash-oh`, `splash-android`, `splash-makepad`). State handled above the DSL is +handled three times; state handled in the DSL is handled once. + +--- + +## 2. What this contract publishes + +Exactly three things: a way to declare a cell, a way to read it, and a way to +write it. Everything else — persistence, migration, reactivity beyond redraw — is +out of scope and named as such in §7. + +### 2.1 Declaration + +``` +state : = +``` + +`shape` is one of `text`, `number`, `bool`. A declaration is a *statement*, valid +where statements are valid, and its position determines the cell's scope (§3). + +A shape is required. It is what makes a write checkable and what lets a host +reject a payload that does not fit, which is the one check that cannot be done +statically. + +### 2.2 Read + +A declared name reads as an ordinary value: + +``` +state open: bool = false +... +if open { ... } +``` + +This is a real binding, not interpolation. `{{state.q}}` keeps its current +meaning — seed-time substitution against host-supplied values — and the two do +not interact. A program may use both; they are different namespaces and this +contract does not merge them. + +### 2.3 Write + +``` +state.set(, ) +state.toggle() +``` + +A write updates the cell and marks its scope for redraw. It does not call the +host. Nothing else in this contract writes. + +`set` requires the value to fit the declared shape. A mismatch is an error at the +write, not a coercion: coercing is how a string reaches a numeric cell and is +displayed as though it had always been a number. + +--- + +## 3. Scope, and what identifies a cell + +**A cell belongs to the innermost enclosing instance.** Splash's existing flat +`state.` namespace is global by construction; this one is not, and the difference +is the point of the contract. + +An instance is named by an **opaque key the emitter supplies**: + +``` +View { l0_key: "root/for#0[NVDA]/Rowy#0" ... } +``` + +The VM does not interpret the key. It compares it. Everything the key must +express — declaration path, enclosing loop keys, stability across an insertion — +is decided by whatever generated the program, and the VM's only obligation is +that two different keys never share a cell and one key always finds its own. + +This is deliberate. Identity is a hard problem with a settled answer one layer +up: `ui_l0` already computes `(declaration path, enclosing loop keys)` and +already emits `l0_key` on every tappable node. Re-deciding it in the VM would +create a second identity model to keep aligned with the first. + +A program that supplies no key gets one global scope, which is today's +behaviour. + +### 3.1 Collision is an error, not a merge + +Two instances presenting the same key is a defect in the emitter. The VM reports +it rather than silently sharing a cell — the failure mode is otherwise two rows +that toggle together, which looks like a rendering bug and is not. + +--- + +## 4. What a write triggers + +A write marks its scope dirty and returns. Redraw is the host's, on its own +schedule. + +This contract does **not** publish reactivity. There is no dependency tracking, +no computed value, no watcher, no effect. A cell changed; something should draw +again. That is all, and it is all that is needed to handle a tap locally. + +Anything more should be a separate contract, argued separately. + +--- + +## 5. Lifetime + +**A cell lives as long as its instance is on screen.** An instance that +disappears loses its cells. + +**No cell survives the program being re-seeded.** Re-seeding is how a card is +replaced; replacing a card discards its state. + +**Nothing is persisted.** There is no serialization in this contract, and this is +the limitation most likely to be felt: card-level state such as "which city" or +"which stock" arguably *should* survive a restart, and under this contract it +does not. + +That gap is named rather than closed because persistence needs a storage +contract, a migration story, and a decision about what a user is entitled to get +back — none of which should be smuggled in under a state mechanism. + +--- + +## 6. What this contract deliberately does not do + +**Schema versioning stays above the DSL.** `ui-profile-l0.md` §5.8 resets a cell +when its component's state schema changes, and preserves it under `keep` only +while that field's shape is unchanged. That needs declared shapes, an initial, +and a schema id — none of which the DSL carries. The VM holds cells and compares +keys; the layer that knows what a component *is* decides when to discard them. + +**Expressions are unaffected.** Splash already has them. This contract adds no +evaluation, no operators, and no new call forms. + +**`{{state.q}}` is untouched.** It keeps working exactly as it does, including +its round-trip through `agent.notify`. A program that never declares a `state` +behaves identically before and after this contract. + +--- + +## 7. Consequences worth stating plainly + +**For L0.** Its lowering currently resolves state away before emitting, so the +DSL never sees it. Under this contract, lowering would emit declarations and +transitions instead, and `InstanceStore` would shrink to whatever §5.8 still +needs. L0's transitions would then execute in the VM — as fixed shapes emitted by +trusted lowering, but executing. `ui-profile-l0.md` §1 must be narrowed to say +so, precisely, rather than claiming no execution machinery is in the path. + +**For the three backends.** Each must redraw a dirty scope. That is the one +obligation this contract places on them, and it is the reason to put state here +rather than above. + +**For the agent.** Interactions that are pure state changes stop reaching it. It +keeps every interaction that genuinely needs new content. + +--- + +## 8. Open questions + +| # | Question | +|---|---| +| 1 | Should `state.set` be an expression returning the new value, or a statement? A statement is narrower; an expression composes | +| 2 | Do `record` and `collection` shapes belong here, or only the three scalars? The scalars cover every reference card | +| 3 | What happens to a cell whose instance is off-screen but not destroyed — a scrolled-away row? Kept or dropped is a real UX difference | +| 4 | Should the VM expose cells for inspection, for a debugger? | +| 5 | Is a key collision fatal to the program, or reported and disambiguated as `ui_l0` currently does for duplicate loop keys? | + +--- + +## 9. Conformance + +Two independent implementations agree only if something checks that they do. +Prose does not check. The mechanism is a **corpus of cases, in a form both +implementations execute**, and a rule that a case is normative rather than +illustrative. + +### 10.1 Shape of a case + +A case is a program, an ordered list of events, and the expected cell values +after each. It says nothing about rendering, because rendering is where the +implementations legitimately differ: + +```toml +[[case]] +id = "toggle-is-per-instance" +program = """ + state open: bool = false + View { l0_key: "row[a]" Button{ on_click: || state.toggle(open) } } + View { l0_key: "row[b]" Button{ on_click: || state.toggle(open) } } +""" +events = [ { key = "row[a]", click = 0 } ] +expect = [ { key = "row[a]", name = "open", value = true }, + { key = "row[b]", name = "open", value = false } ] +``` + +That single case is the whole point of the contract: without per-instance +scoping, `row[b]` reads `true` and the case fails. A case that both +implementations pass before the feature exists is not a conformance case. + +### 10.2 What the corpus must cover + +Every normative statement in §2–§6, and specifically the ones most likely to +drift because they are easy to implement *almost* right: + +- a write updates only its own scope (§3) +- an absent key means one global scope, i.e. today's behaviour (§3) +- two instances presenting the same key is an error, not a merge (§3.1) +- `set` refuses a value that does not fit the declared shape (§2.3) +- a cell dies with its instance (§5) +- re-seeding discards every cell (§5) +- `{{state.q}}` and `agent.notify` behave identically to before (§6) + +That last one deserves its own emphasis. **The most valuable cases are the ones +asserting nothing changed.** A state mechanism that quietly alters interpolation +or the host callback breaks every existing card, and neither implementation +would notice from cases that only exercise the new feature. + +### 10.3 Where the corpus lives + +In this repository, beside the contract, because the contract is the shared +artifact. Each implementation vendors or fetches it and runs it in its own test +suite. A case is added here first and fails in both implementations until each +lands the behaviour — which makes divergence visible as a failing case rather +than as a device-only bug six weeks later. + +### 10.4 What conformance does not buy + +It checks behaviour the corpus names. It cannot check behaviour nobody thought +to write down, and a case absent from the corpus is untested *and* unmentioned — +the same failure the L0 mutation harness had, where selecting rules by keyword +silently missed the six that mattered most. + +So the corpus must be treated as the specification's tests, not as a smoke +screen: when an implementation finds a behaviour the contract does not settle, +the fix is a new case here, not a local decision there. + +--- + +## 10. What would make this contract wrong + +Written down so it can be checked rather than defended: + +- If a walking skeleton — one card, one component, one bool, one toggle, on + device — cannot be built against it, the contract is wrong. +- If per-instance identity cannot be expressed by an opaque emitter-supplied key, + §3 is wrong and the VM needs its own identity model. +- If redraw-on-write is insufficient to make an interaction feel local, §4 is + wrong and reactivity is not separable after all. +- If a conformance case cannot be written for a normative statement in §2–§6, + that statement is not testable and should be cut or restated until it is. +- If the two implementations pass the corpus and still behave differently in a + way a card can observe, the corpus is incomplete and §9.2 is missing a case. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml new file mode 100644 index 0000000..f8d4271 --- /dev/null +++ b/docs/ui-l0-constructors.toml @@ -0,0 +1,243 @@ +# L0 constructor catalog — the normative argument contract for +# docs/ui-profile-l0.md. Derived from the three reference cards in +# octos-one/docs/l0/, not invented: every constructor and argument here is one +# they use. +# +# TOKENS ARE LEXICALLY MARKED with a leading dot: `.fill`, `.money`, `.center`. +# +# This supersedes amendment #9's resolution. That rule said a bare identifier +# resolved by the argument's declared shape — which fails the moment one +# argument admits both, and `unit` does: `unit: units` is a path to card state, +# `unit: .pct` is a token. Marking tokens removes the ambiguity at the lexical +# level rather than the semantic one, so a prop named `fill` can never collide +# with `.fill`, and the catalog only has to say which tokens are LEGAL rather +# than disambiguate what a name means. +# +# kinds: +# token one of `tokens`, written with a leading dot +# path a bound path — source, state, prop, copy or loop binder +# event a declared event name (local or received as a prop) +# number/text/bool a literal of that type, or a path of that shape +# data a bound path that renders a FACT. Distinct from `number` +# because `gap: 6` is layout the card may choose and +# `value: 41.2` is a measurement it may not (§4 no-facts) +# any path or literal +# record/collection component props only + +# ─── containers ─────────────────────────────────────────────────────────────── + +[Surface] +pad = { kind = "token", tokens = ["page", "tight", "none"] } + +[Photo] +src = { kind = "path" } +pad = { kind = "token", tokens = ["page", "tight", "none"] } + +[Panel] + +[Card] +on_tap = { kind = "event" } +value = { kind = "any" } + +[Col] +align = { kind = "token", tokens = ["start", "center", "end"] } +gap = { kind = "number" } + +[Row] +align = { kind = "token", tokens = ["start", "center", "end", "baseline"] } +gap = { kind = "number" } +on_tap = { kind = "event" } +value = { kind = "any" } + +[Grid] +cols = { kind = "number" } + +[Rule] + +# ─── text roles ─────────────────────────────────────────────────────────────── +# Seven ROLES. A role says what a piece of text is FOR; the active theme decides +# what it looks like (profile §1.1). `TextHero` is "the one number this card +# exists to show", not white-at-62pt — a different theme answers the same role +# differently, and a card written today should survive that. +# +# A role is a constructor rather than a `font` argument for the same reason: a +# model-authored font_family once dropped CJK coverage and rendered 上海 as tofu. +# Naming the role lets the theme carry the whole glyph fallback chain. + +[TextHero] +text = { kind = "text" } +value = { kind = "data" } +unit = { kind = "unit" } +format = { kind = "format" } +on_tap = { kind = "event" } + +[TextTitle] +text = { kind = "text" } +width = { kind = "width" } + +[TextBody] +text = { kind = "text" } +width = { kind = "width" } + +[TextStat] +value = { kind = "data" } +format = { kind = "format" } +tint = { kind = "path" } + +[TextRow] +text = { kind = "text" } +width = { kind = "width" } + +[TextCaption] +text = { kind = "text" } +value = { kind = "data" } +glyph = { kind = "text" } +suffix = { kind = "text" } +unit = { kind = "unit" } +width = { kind = "width" } + +[TextValue] +value = { kind = "data" } +unit = { kind = "unit" } +format = { kind = "format" } +tint = { kind = "path" } + +[Tile] +label = { kind = "text" } +value = { kind = "data" } +unit = { kind = "unit" } +format = { kind = "format" } + +[Chip] +text = { kind = "text" } +on_tap = { kind = "event" } +value = { kind = "any" } +active = { kind = "bool" } # a predicate is a bool operand + +# ─── data-visualisation roles ───────────────────────────────────────────────── +# Every argument is a path. These render live data and hold no authored values — +# the class of bug that put an invented `condition` and invented `wmin`/`wmax` +# into shipped cards. +# +# These six cannot be theme compositions (profile §1.1): a gradient bar or a sun +# arc is a fragment shader parameterised by data, and `splash-render` notes that +# a DSL node cannot carry shader source, because MPSL compiles at build time. +# +# HOW they reach a backend is unsettled. An earlier version of this comment said +# each backend implements them "exactly as it already does for Map", which +# asserted a contract that does not exist: five of the six are absent from +# splash-render's tag table, and its fallback drops the node. Six of six are +# implemented in octos-one today and one in Splash-Makepad, so the pipeline +# should be proven there before the vocabulary is fixed. +# +# The card still names only a role and its data. Colour, thickness and cap shape +# remain the backend's, so §1.1 holds here as everywhere else. + +[WeatherIcon] +cond = { kind = "path" } +size = { kind = "token", tokens = ["hero", "row", "tile"] } + +[TempBar] +lo = { kind = "path" } +hi = { kind = "path" } +min = { kind = "path" } +max = { kind = "path" } + +[SunArc] +rise = { kind = "path" } +set = { kind = "path" } +now = { kind = "path" } + +[MoonPhase] +phase = { kind = "path" } +illum = { kind = "path" } + +# The card supplies a LOCATION, never the field. An earlier version took +# `field`/`index`/`band`, which described an interface the widget deliberately +# abandoned: supplying the field meant sixteen scalar uniforms in the card, and +# 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. +[AqiContour] +lat = { kind = "path" } +lon = { kind = "path" } +span = { kind = "number" } + +# Likewise: the card names WHICH series, and the widget fetches it. Passing +# `points`/`min`/`max` made the card responsible for a scaled series it has no +# way to compute — §4's no-facts rule with the arithmetic hidden in the host. +[StockPlot] +symbol = { kind = "path" } +range = { kind = "unit" } + +# ─── shared token sets ──────────────────────────────────────────────────────── +# Named kinds referenced above, so a token set is defined once rather than +# repeated at every argument that admits it. + +[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. +accepts = ["token", "path"] +tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money"] + +[kinds.format] +accepts = ["token"] +tokens = ["money", "signed_money", "signed_pct", "compact", "ratio", "time", "date"] + +[kinds.width] +accepts = ["token", "number"] +tokens = ["fill", "fit", "day", "rank", "temp"] + +# ─── source capabilities ────────────────────────────────────────────────────── +# The CLOSED set of helpers a `source` may name. Like the constructor catalog +# above, this is derived from the reference cards rather than invented. +# +# Without it, `source_plan` hands an arbitrary dotted name to the host as the +# function to resolve, and a card can write +# +# source leak sys.shell(command: "cat /etc/passwd") +# +# which parses clean and produces a well-formed request. Confinement then rests +# entirely on the host having its own allowlist — the policy boundary §1 claims +# L0 removes. A card must not be able to NAME a capability it was not granted, +# and that is a property the profile can decide rather than delegate. +# +# `args` lists the argument names each helper accepts. An argument outside the +# list is rejected: a helper that silently ignores an unknown argument is how a +# card asks for something it did not get and renders as though it did. + +[sources."sys.geocode"] +args = ["name"] + +[sources."sys.weather"] +args = ["lat", "lon", "days", "fields", "aggregate"] + +[sources."sys.daylight"] +args = ["lat", "lon"] + +[sources."sys.airquality"] +args = ["lat", "lon"] + +[sources."sys.moonphase"] +args = ["lat", "lon"] + +[sources."sys.photo"] +args = ["query"] + +[sources."sys.locale"] +args = [] + +[sources."sys.news"] +args = ["count", "offset", "fields"] + +[sources."sys.news_item"] +args = ["id", "fields"] + +[sources."sys.movers"] +args = ["count", "fields"] + +[sources."sys.quote"] +args = ["ticker", "fields"] + +[sources."sys.series"] +args = ["ticker", "range", "points", "fields", "aggregate"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md new file mode 100644 index 0000000..3a2f641 --- /dev/null +++ b/docs/ui-profile-l0.md @@ -0,0 +1,893 @@ +# Splash UI Profile — Level 0 + +**Status:** **implemented.** Normative producer contract for generated UI source at Level 0. +Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 317 tests, three +reference cards, and a card rendered on a OnePlus 6T through an unmodified host. +**Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset of +the workflow language. `check_syntax` rejects UI source and `eval_vm_compatibility` must not +receive generated source, so neither canonical entry point admits a generated card. This +profile is the third path, and `check_ui_l0` is its entry point. + +--- + +## 1. What Level 0 is for + +A generated card is untrusted source. The workflow profile answers this by refusing UI +constructs; the compatibility parser answers it by refusing untrusted input. L0 answers it a +third way: **admit UI constructs, but admit nothing that could reach a capability.** + +Sources are resolved by the runtime *before* realization and injected as data. There is no +dangerous call to block, because no call can be written. + +**L0 needs no evaluator at all.** Earlier drafts of this section said an L0 *evaluator* is +handed a host surface with no modules, and that a bounded evaluator — heap, stack, call-depth, +instruction and time limits — remains mandatory. Implementing the profile showed that to be +wrong in an interesting direction: **L0 has no expression form, so there is nothing to +evaluate.** Realization is a pure walk over the parsed tree with data substituted. The +reference implementation contains no reference to the VM whatsoever. + +### 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 +render backend.** `TextHero` means "the one number this card exists to show". It does not mean +white, or 62pt, or any particular font — those are a *theme's* answer to the role, and a +different theme answers differently. + +Three layers, each knowing one thing: + +``` +L0 card names roles TextHero, Panel, Chip, TempBar + ↓ +theme kit role → presentation components//*.splash, authored as Splash DSL + ↓ +splash-render DSL → UiNode evaluated in the VM, renderer-free + ↓ +backend UiNode → native splash-makepad · Splash-OH · Splash-Android +``` + +A card therefore lowers to **semantic invocations of the active theme's component kit**, never +to a backend's own widget dialect: + +``` +card(title) not SolidView{ draw_bg.color: #0a0e14 … } +filled_button(copy.open) not RoundedView{ draw_bg.color: #ffffff12 … } +``` + +**Why this is a rule and not a preference.** A card is a ledger record that outlives the +device it was written on. If a card carries `#0a0e14`, then dark mode, a platform's native +look, and any later theme are all edits to every card ever written. Roles survive that; hex +codes do not. + +It also keeps the three backends honest. The branch point is `UiNode`; anything that lowers +past it reaches one platform and silently abandons the other two. + +**Some roles cannot be a theme composition.** Six of the catalogue — +`WeatherIcon`, `TempBar`, `SunArc`, `MoonPhase`, `AqiContour`, `StockPlot` — are small data +visualisations. `splash-render` states why they cannot be authored in the DSL: *a DSL node +cannot carry shader source — MPSL compiles at build time — but it can select a shader that was +compiled.* A gradient bar is a fragment shader parameterised by data, not a composition of +boxes and text. + +**How they reach a backend is unsettled, and a previous version of this section settled it +wrongly.** It declared six new node kinds and asserted that `splash-render` would route them. +Five of the six are not in that consumer's tag table, and its fallback is to return `None` — +so a card's temperature bars, sun arc, moon phase and air-quality field would have been +dropped without a diagnostic. The section asserted a cross-repository contract that nobody had +checked, in the same breath as claiming an unknown role should "fail visibly". + +What is true and what is not: + +- **True:** these six are widget-layer concerns, and each backend's widget layer is its own. + `TempBar` is a makepad widget with an MPSL shader; an OpenHarmony equivalent is an ArkUI + custom component; an Android one is a Canvas view. There is no shared implementation to + unify — only a shared name and data contract. +- **Not settled:** what that contract is. `Shader`/`Sdf` with a variant is the mechanism the + consumer already documents and was dismissed too quickly; adding kinds is the alternative + and obliges every backend at once. + +**The order this implies.** Prove the pipeline end to end on the backend that already +implements these widgets — six of six exist and ship in octos-one today, against one in +`Splash-Makepad` — and let the vocabulary be whatever that requires. Then port to the others +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. + +**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 +that were never checked against the consumer: five of 23 did not exist, `role:` was emitted +where the consumer reads `variant`, `Photo` became an image node although the weather card uses +it as the *container* around the whole card, `Tile` became a `listitem` whose fields are +label/text rather than value/unit, and string tokens landed in numeric properties as zero. The +consumer holds a fixed attribute struct rather than an open bag, so roughly twenty emitted +attributes were simply unread. + +Two lessons belong in the profile rather than in a commit message. **A lowering target is a +contract with a consumer, and this repository has no dependency on that consumer** — so +nothing prevents the two schemas drifting, and they did, immediately. And **a lowering must +carry semantics forward, not merely drop presentation**: the reverted attempt correctly removed +a colour palette and a font-size ramp, and incorrectly removed the interaction contract, a +chip's selected state, and formatting intent. `tint` is the instructive case — red-versus-green +is presentation, but *"this value is negative"* is meaning, and deleting the attribute lost +both. + +What that changes: + +| | Earlier claim | Actual | +|---|---|---| +| Host surface | empty | **absent** — no evaluator to hand one to | +| Instruction limit | mandatory | not applicable | +| Heap / stack / call-frame caps | mandatory | not applicable | +| Bounds still required | — | node count, nesting depth, collection length — all traversal bounds | + +So authority confinement is structural in the strongest available sense: not a sandbox that +blocks calls, and not an evaluator with an empty module table, but **no execution machinery in +the path at all**. + +**What is still not bought.** Two properties are decidable at L0 — membership in the grammar, +and absence of authority-bearing operations. **Termination is not a consequence of the grammar +alone**; §6 states the five conditions that make it hold, and traversal bounds enforce the rest. + +--- + +## 2. Grammar + +```ebnf +source = [ header ] , { declaration } ; (* header optional — see §7 *) + +header = "#" , "ledger" , ident , "@" , semver , { meta-line } ; +meta-line = "#" , ident , ":" , value ; (* level, model, profile *) + +declaration = source-decl | state-decl | event-decl | copy-decl + | component-decl | view-decl ; + +source-decl = "source" , path , capability-query ; +state-decl = "state" , path , "{" , shape-spec , "}" ; +event-decl = "event" , ident , transition-block ; +copy-decl = "copy" , path , "{" , "class" , ":" , provenance , "," , locale-map , "}" ; +component-decl= "component" , Ident , "(" , [ params ] , ")" , "{" , { local } , view-body , "}" ; +view-decl = "view" , path , view-body ; + +local = state-decl | event-decl ; + +view-body = element ; +element = construct | iteration | guard | reference | slot | fill ; +construct = Constructor , [ "(" , [ arguments ] , ")" ] , [ block ] ; +slot = "slot" , [ ident ] ; (* in a component body *) +fill = "into" , ident , block ; (* at a call site *) +reference = path ; (* splices another view record *) +iteration = "for" , ident , [ "," , ident ] , "in" , path , "key" , path , block ; +guard = "when" , predicate , block ; +block = "{" , { element } , "}" ; + +arguments = argument , { "," , argument } ; +argument = ident , ":" , operand ; +operand = path | literal | predicate ; (* NO expressions *) + +predicate = path , [ comparator , ( path | literal | token ) ] ; +comparator = "==" | "!=" | "<=" | ">=" ; (* lone < and > are not parsed *) + +path = root , { "." , ident | "." , integer | "[" , path , "]" } + , [ "." , "$state" ] ; (* source lifecycle, §5.9 *) +root = ident ; + +transition-block = "{" , transition , { "," , transition } , "}" ; +transition = path , ":" , total-form ; +total-form = "set" , "(" , ( literal | token | path | "$value" ) , ")" + (* literal = string | number | "true" | "false" *) + | "toggle" (* boolean state only *) + | "cycle" , "(" , token , { "," , token } , ")" + | "clear" ; (* restore declared initial *) + +shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] + , [ "," , "keep" , ":" , "true" ] ; +shape = "text" | "number" | "bool" | "enum" , "[" , ident , { "," , ident } , "]" + | "record" | "collection" | "event" ; (* prop shapes, §5.2 *) +provenance = "vocabulary" | "user-copy" | "model-copy" ; +``` + +### 2.0 Amendments found by the reference cards + +The grammar above already incorporates eight corrections that the three reference cards in +`octos-one/docs/l0/` forced. They are listed because the process matters: the cards were +written against the first draft, and **every construct they needed that the draft lacked is a +defect in this document, not in the cards.** + +| # | Missing | Needed by | Resolution | +|---|---|---|---| +| 1 | element referencing another `view` record | all three — `root` splices named blocks | `reference = path` | +| 2 | `initial` from the environment | weather — `units` defaults by locale | `initial : literal \| path` | +| 3 | constant index into a collection | news — `stories.0` is the lead story | `"." integer` in `path` | +| 4 | index by a state value | news, stock — `stories[selected]` | `"[" path "]"` in `path` | +| 5 | loop index binding | news — the rank column | optional second binder: `for s, i in …` | +| 6 | predicate as an argument value | stock — `active: range == d1` | `operand` admits `predicate` | +| 7 | event payload at the call site | news, stock — which row was tapped | `value:` argument, §3.1 | +| 8 | sub-collection of a source | news — feed skips the lead | **not granted** — see below | +| 9 | bare identifiers are ambiguous | both components — `width: rank` is a token, `text: position` is a path | tokens marked lexically, §2.2 | +| 10 | a bare boolean guard | weather — `when expanded { … }` | `predicate` admits a lone path | + +**None of these introduces an expression.** Indexing is a total lookup, a predicate as an +operand is the form `when` already admits, and a loop index is supplied by the iterator. The +authority property and the absence of arithmetic both survive. + +### 2.2 Tokens are lexically marked + +`width: rank` and `text: position` are both `ident : ident`, but the first names a layout token +and the second a bound path. Resolving this by scope — *a path if it names a binding, else a +token* — would be a trap: declaring a prop named `fill` would silently change what +`width: fill` meant everywhere in that component. + +**Tokens carry a leading dot.** `width: .fill`, `format: .money`, `align: .center`. A bare +identifier is always a path. + +```ebnf +operand = path | literal | token | predicate ; +token = "." , ident ; +``` + +An earlier draft resolved this by argument shape instead — *each argument accepts either a +token set or a value, so the argument decides what a bare name means.* **That rule is +withdrawn**, because it fails on any argument admitting both, and one does: `unit: units` +follows card state while `unit: .pct` is fixed. Marking tokens removes the ambiguity +lexically, so the catalog only has to say which tokens are **legal** rather than disambiguate +what a name **means**. + +The ambiguity surfaced only once components existed, because a component's props are the first +bindings whose names the card author chooses freely — both reference cards had to rename a +prop, `day` → `item` and `rank` → `position`, before it was visible at all. The `unit` case +surfaced one step later still, while cataloguing arguments. Each pass found what the previous +one could not have. + +**Enum members are tokens too.** A `shape: enum[c, f]` declaration *defines* the set with bare +names; every use is dotted — `cycle(.c, .f)`, `initial: .m1`, `range == .d1`. Same rule as any +other token: a bare identifier is always a path, so an enum member can never be shadowed by a +binding of the same name. + +The normative argument contract is [`ui-l0-constructors.toml`](ui-l0-constructors.toml): +which constructors exist, which arguments each admits, and which tokens are legal per +argument. + +**#8 was refused.** The news card wanted `stories.rest` to skip the lead story. Granting a +derived sub-collection would be the thin end of computation — `rest` invites `filter`, which +invites a predicate expression, which invites arithmetic. The card instead binds +`sys.news(...)` twice, or the source declares `lead` and `rest` as fields. **The runtime +shapes the data; the view does not reshape it.** + +### 2.1 What is absent, and deliberately + +| Construct | Status | Why | +|---|---|---| +| arithmetic, string concatenation | **absent** | any expression can hide a fabricated fact (§5) | +| function definition, call | **absent** | would reintroduce unbounded work | +| `while`, unbounded `for` | **absent** | iteration is only over a declared collection | +| `ui..` | **absent** | imperative widget commands are L2 | +| module access of any kind | **absent** | this is the authority property | +| assignment outside a transition | **absent** | state changes only through declared events | + +**`operand` admits no expressions.** `Text(value: now.temp)` is legal; `Text(value: now.temp * 9 / 5 + 32)` is not. Unit conversion and formatting belong to the runtime (§4). + +--- + +## 3. Transitions are total by construction + +This is the point where an earlier draft of this design was wrong, and the correction is +load-bearing. + +A transition written as an expression — `expanded: !expanded`, or +`units: units == c ? f : c` — **is computation**, however small. Declared storage does not +make it total or bounded, and once expressions are admitted the grammar has to answer for +recursion, unbounded numerics, and event cascades. + +L0 therefore has no expression form in transitions. It has four **total forms**: + +| Form | Applies to | Semantics | +|---|---|---| +| `set($value)` | any | the payload the raising element carried in its `value:` argument | +| `set(.token)` | `enum` | a declared member | +| `set("text")` | `text` | a literal | +| `set(path)` | any | a bound path, resolved from injected data | +| `toggle` | `bool` | the other value | +| `cycle(.a, .b, …)` | `enum` | successor, wrapping; members must be declared in the shape | +| `clear` | any | restore the declared `initial` | + +A `set($value)` raised with no payload is a **no-op**, not a clear: falling back to the +initial would read as a deliberate reset. + +Each is total on its declared shape and terminates in constant time. `units: cycle(c, f)` +replaces the ternary; `expanded: toggle` replaces the negation. + +An event may name several transitions, applied **atomically as one batch** — every write is +staged and the batch commits only if all of them resolve, so a transition that cannot be +computed leaves the store exactly as it was rather than half-applied. + +**A payload is checked at dispatch.** `set($value)` is the one form whose value arrives from +outside the card, so it is the one the declared shape cannot decide statically; a payload that +does not fit the target state is refused and the batch does not commit. Every other `set` +spelling is decided at check time against the declared shape. A single-assignment +rule would be wrong: production code already fires two state writes from one gesture, and the +stock reference card's `back` does the same — clearing both `selected` and `range`. + +### 3.1 The event payload + +An element that raises an event supplies its payload at the call site: + +``` +Row(on_tap: open_quote, value: m.ticker) +``` + +`on_tap` names a declared event; `value` is the payload the transition receives as `$value`. +Both are ordinary operands, so a payload can be a bound path or a literal but never a computed +one. An event whose transitions do not mention `$value` ignores any payload. + +This replaces the string-tagging approach used by the current runtime, where a card's identity +is prepended to its event name by rewriting generated source. A card that constructs its event +name dynamically escapes that rewrite; here the event is a declared name resolved by the +runtime, so there is nothing to construct. + +--- + +## 4. Sources, and the no-facts rule + +A `source` body is a **query the runtime executes**, never a call the view makes. Views cannot +invoke it; they bind to its result. + +``` +source place sys.geocode(name: state.city) +source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, hi, lo, cond]) +``` + +The evaluator never sees `sys.geocode`. The runtime resolves it, injects the result as data, +and the view reads `place.name`. + +**A literal in a data position must be `vocabulary` or `source-derived`.** This is decidable +at L0 precisely because `view` is a typed tree — a `model-copy` literal bound to a data +argument is a structural violation, not a judgement call. It is the property that makes +`"34 mph"` concatenated onto a live value unrepresentable rather than merely discouraged. + +**What is actually enforced, and what is not.** Both halves matter, and only one is complete: + +| Position | Rule | +|---|---| +| A `value` argument (`TextHero`, `TextStat`, `TextValue`, `TextCaption`, `Tile`) | Must be bound. A literal — string, number or token — is refused. `TextHero(value: "34 mph")` is rejected | +| Any `path`-kind argument (the drawing widgets) | Must be bound. `TempBar(lo: 5)` is rejected | +| `copy.x`, by any route | Refused when `x` is declared `model-copy` — named in a view, passed as a prop, used as a state initial, or written by a transition. A `copy.x` that is not declared at all is refused too | +| A **raw string literal** in a `text`, `label` or `glyph` position | **Accepted** | + +The last row is a deliberate decision, not an oversight. A raw literal carries no declared +provenance, and by this section's own logic that makes it model-copy — so `TextTitle(text: +"Revenue: $41.2M")` passes, and a model that simply writes the string inline is unaffected by +the rule. Refusing it would mean every human-readable string is declared `copy`, which is a +real ergonomic cost and buys less than it appears to: of the eleven literals in the reference +cards, six are `glyph` symbols (`‹ ↑ ↓ ≈`) that are closed presentation vocabulary rather than +language, and translating them is meaningless. + +So the guarantee this section provides is narrower than its first paragraph implies. **A +fabricated number cannot reach a numeric position — by any route, including laundered through +a chain of component props — and fabricated prose can reach a text position, but only as a raw +literal.** Text that is *declared* model-authored is refused wherever it tries to surface. The first is the failure mode that produced the six shipped bugs this rule exists +for — an invented `condition`, an invented `wmin`/`wmax` — and it is closed. The second is open +by choice, and would need retained provenance on every string, not a broader argument category, +to close. + +Implicit dependencies are declared like any other source: + +``` +source env.locale sys.locale() +source env.theme sys.theme() +``` + +Anything reachable without a declared source is a defect by construction — which is also what +makes an L0 view's dependency set readable without evaluating it. + +--- + +## 5. Components and instance identity + +``` +component ForecastRow(day) { + state expanded { shape: bool, initial: false } + event toggle { expanded: toggle } + view Row { + Text(role: Row, text: day.dayname) + TempBar(lo: day.lo, hi: day.hi, min: week.min_lo, max: week.max_hi) + when expanded { Detail(day: day) } + } +} +``` + +Local state is declared in the ledger; **its values live only in the runtime.** + +### 5.1 Identity is a path, not a position + +An instance is identified by the tuple + +``` +( card namespace, parent instance identity, instantiation site, + component name and version, vector of enclosing loop keys ) +``` + +Recursive by construction, because a static declaration path is not enough: two `TripPanel` +instances each containing an unkeyed `Toggle` must not share one state cell. + +**This is not React's rule.** React associates state with position in the realized tree, where +component type and keys both affect preservation. L0 binds to a declared path, so identity +survives a ledger edit that inserts a **differently named** element above — and only that. +§5.1's closing paragraph lists exactly what does and does not move the path; read it before +relying on this sentence, which states the motivation rather than the guarantee. + +**Neither the card namespace nor the version is currently a key segment.** Keys begin at the +root view and append component names; the schema id is held in a separate map keyed by component +name. So the tuple above describes the intended identity, and the implementation covers the +instantiation site and enclosing loop keys only. One consequence worth knowing: two cards +sharing one store can collide, because card state lives under a single fixed key. + +**"Version" means the STATE SCHEMA version** (§5.8), not the component's whole definition. +Using the definition digest would contradict §5.8 directly: a view-only edit would change +identity and reset state, which is precisely what §5.8 promises it does not do. The two are +different instruments — the schema id decides whether live state still fits, and the closure +digest (§7) tells a host that a definition was replaced at all. + +**What path-based identity does and does not survive.** Insertion of a differently named +sibling is stable, because siblings are numbered within each element name. Identity moves when +the PATH moves: reordering two same-name siblings, inserting another instance of the same +component above, adding or removing an enclosing guard, loop, slot or view reference, and +changing a loop's key expression. + +Only the element name and its ordinal enter a segment, so editing a guard's *condition* — +`when a` to `when b`, both true — leaves the path untouched and the state survives. An earlier +version of this paragraph said any change to an enclosing guard moved identity; that is wrong, +and wrong in the direction that matters, since it would have justified not looking. Fully edit-stable identity needs a persistent site id in the source or +edit provenance from the ledger; neither exists yet, and the numbering is an improvement on +tree position rather than a solution. + +### 5.2 Props + +```ebnf +params = param , { "," , param } ; +param = ident , ":" , shape , [ "=" , literal ] ; +``` + +Props are **typed by the same shape vocabulary as state**, may carry a literal default, and +are **immutable inside the component**. A prop is an input, not storage; a component that +wants to change something declares local state or raises an event. + +``` +component ForecastRow(day: record, unit: enum[c, f] = c, on_pick: event) +``` + +`record` and `collection` are added to the shape vocabulary for props, since a row receives a +whole item rather than a scalar. + +### 5.3 Scope: what a component may reference + +| May read | May not read | +|---|---| +| its own props | card `state` — pass it as a prop | +| its own `state` and `event` | another component's locals | +| card-scope `source` | — | +| card-scope `copy` | — | + +**Sources and copy are readable; state is not.** Both are immutable and already declared +dependencies the tracker sees, so reading them creates nothing implicit. Card state is what +drives reconciliation, so it must arrive explicitly as a prop or the dependency becomes +invisible — the failure §6.2 warns about. + +This is why `week.min_lo` may be read directly by a forecast row, while `units` must be passed. + +### 5.4 Outputs are event props + +A component raises an event the parent declared, passed in as a prop: + +``` +component StoryRow(story: record, on_open: event) { + view Row(on_tap: on_open, value: story.id) { … } +} + +view latest Panel { for s, i in feed key s.id { StoryRow(story: s, on_open: open_story) } } +``` + +**This needs no new mechanism.** An event name is a path, `operand` already admits paths, and +the transition runs in the scope where it was *declared* — the parent's — so a child cannot +reach state it was not handed a route to. It is React's callback prop with the scoping made +explicit. + +A component may only name events it declares locally or receives as props. There is no way to +raise an arbitrary event by constructing its name, because there is no expression form. + +### 5.5 Slots + +A single anonymous slot, marked in the component body: + +``` +component Panel(title: text) { + view Col { TextCaption(text: title) slot } +} + +Panel(title: "Details") { Tile(…) Tile(…) } +``` + +Children are realized in the parent's scope, not the component's — a child expression sees the +call site's bindings. + +A component may also offer **named** slots, and a call site directs children at one with `into`: + +``` +component Framed(title: text) { + view Col { TextCaption(text: title) slot header Rule() slot } +} + +Framed(title: "Today") { + into header { TextRow(text: "above the rule") } + TextRow(text: "below it") +} +``` + +Children written outside any `into` fill the anonymous slot, so a component with one slot needs +no ceremony and every card written before named slots existed is unchanged. + +**An `into` naming a slot the component does not offer is rejected.** Its children would +otherwise render nowhere, which is the same silent-drop failure as a binding that resolves to +nothing — the card looks deliberate and the content is simply gone. + +### 5.6 What is refused, and why + +**Hoisted or shared state.** If two components need the same state it lives at card scope and +arrives as props, with the event declared at card scope too. Hoisting would raise an ownership +question the grammar cannot answer, and a Context-like mechanism reintroduces exactly the +invisible dependencies §6.2 exists to prevent. + +**Cross-field invariants are not expressible.** Two date pickers cannot enforce +start-before-end through transitions, because total forms cannot compare. This is a real limit, +not an oversight. Where an invariant matters it belongs in the **shape**, enforced by the +runtime: + +``` +state start { shape: date, initial: today, max: end } +``` + +**That example is illustrative, not grammatical.** `date` is not one of §2's shapes, `today` +is not a literal form, and `max:` is not part of `shape-spec` — it sketches where such an +invariant would belong if the shape vocabulary grew, and nothing in the profile accepts it +today. It is retained because the *limit* it describes is real: total forms cannot compare, so +a cross-field invariant has nowhere to live at L0. + +If that is insufficient, the card is L1 — and should say so rather than smuggling a +comparison into a transition. + +**Per-component sources.** A component may not declare its own `source`. Sources are card-scope +and resolved before evaluation; a component instantiated *n* times would fan out *n* fetches, +with *n* determined by data. That breaks the bound §6.2 requires, and makes fetch volume a +function of a collection the model does not control. + +### 5.7 Lifecycle + +There are no lifecycle hooks — §2.2 rejects `useEffect` and its dependency arrays. But the +runtime needs defined behaviour, and identity (§5.1) decides all of it: + +| Event | Behaviour | +|---|---| +| **Mount** — identity appears | local state initialized from `initial` | +| **Update** — identity persists, props differ | view re-realizes; **local state persists** | +| **Unmount** — identity disappears | local state discarded | + +Realization reports every instance key it produced. A host passes them to `prune` after each +pass; without it the store grows for the life of the app, and a key that reappears inherits a +stale value. + +**Duplicate keys are reported at realization, and the colliding instances are given distinct +identity rather than sharing one cell. Duplicate keys are an error**, surfaced as a diagnostic — never coalesced. +Silent coalescing would make two rows share one state cell, which is the failure mode keys +exist to prevent. A missing key on an iteration is a grammar error. + +### 5.8 State schema versioning + +A component's **state schema id** is derived from its `state` declarations — names, shapes and +initials. + +**It is not part of the instance key**, and an earlier version of this section said it was. The +key is `{parent}/{component}`; the schema id is held separately, per component name, and a +schema change reconciles the live cells *in place* rather than minting a new identity. The +observable behaviour below is what the implementation does; the mechanism is not identity. + +| Definition change | Effect | +|---|---| +| view or props only | schema id unchanged → **local state persists** | +| any `state` declaration added, removed, renamed or retyped | schema id changes → **new identity → state reset** | + +**Reset is the default; preservation is opt-in**, never inferred. A ledger edit that adds a +field cannot be assumed compatible with live values, and guessing is how a rolled-back ledger +ends up feeding version-A state to version-B code — at which point last-known-good is no longer +known good. + +**The opt-in is `keep: true`** on a state declaration: + +``` +state expanded { shape: bool, initial: false, keep: true } +``` + +A cell that opts in survives a schema change **only while its own shape is unchanged**. Adding +an unrelated field elsewhere in the component preserves it; retyping `expanded` itself resets it +regardless. Without that second condition `keep` would be precisely the "assume the old value +still fits" that this section exists to prevent — it would just have moved the guess from the +runtime to the card author, where it is no more reliable and considerably less visible. + +Reset therefore remains the default in both directions: a component that says nothing behaves +exactly as before, and a component that opts in still resets on any change to the field it +opted in for. + +**Consequence worth stating plainly:** folding the ledger reconstructs the *program*, not the +visible application state. Local state is disposable UI state — scroll positions, expansion +flags. Anything that must survive a definition change belongs in a `source`, not in a component. + +An earlier version of this paragraph offered card state "with a declared migration" as the other +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.9 Source lifecycle + +Every source carries a lifecycle, readable as `.$state`, whose value is one of four +tokens: + +| Token | Meaning | +|---|---| +| `.pending` | never resolved | +| `.ready` | resolved and current | +| `.stale` | resolved, but past its freshness budget | +| `.failed` | the fetch failed | + +``` +when now.$state == .pending { TextRow(text: copy.loading) } +when now.$state == .failed { TextRow(text: copy.offline) } +``` + +This exists because **absence is not a state.** Without it a card can only ask whether a value +arrived, so "still loading", "the network is down" and "this field genuinely has no value" all +render the same em dash. The card then looks like working software displaying real data, which +is the worst of the three outcomes. + +`$state` must be the last segment of a path, and is available **only on a source** — a local +state is not fetched and has no pending or failed. Both are rejected rather than resolving to +nothing. + +The host reports the lifecycle; the card never computes it. + +**The fallback, and what it assumes.** When the host reports no `$status`, a source with a +value is treated as `.ready` and one without as `.pending`. That is an inference, and this +section has just argued that absence cannot carry it — so it is sound *only* under an explicit +host contract: **a source key is present if and only if it resolved successfully and is +current.** A host that caches a stale value without reporting status will have it read as +`.ready`; one that represents a successful empty result by omitting the key will have it read +as `.pending`. + +The fallback therefore exists for hosts that do not track status at all, and any host that can +distinguish these cases must report `$status` rather than rely on it. `.stale` and `.failed` +are not derivable from data by construction, which is the whole reason status reporting exists; +the fallback does not change that, it only declines to guess for the two cases that are +derivable under the invariant above. + +**Cadence and freshness budget belong to the capability, not the card.** A card names what it +needs; how often `sys.weather` is worth re-fetching is a property of weather, and duplicating it +per card is how two cards on one screen disagree about how old their data is. + +### 5.10 Who owns state, and what L1 must be told + +L0 does not hold all the state a running card has. Three owners exist, and confusing them is +how a layer boundary gets drawn in the wrong place: + +| # | State | Owner | Example | May a source depend on it? | +|---|---|---|---|---| +| 1 | **Card state** | L0 | `selected`, `city`, `units` | Yes — `sys.quote(ticker: state.selected)` | +| 2 | **Component-local state** | L0 today | `ForecastRow.expanded` | No — §5.3 keeps it out of source arguments | +| 3 | **Source lifecycle** | the host | `weather.$state` | Not applicable — it *is* the fetch's status | + +**Kind 1 cannot be pushed below L0.** It is the state composition creates: `selected` is read by +the list, the detail, the header, the chart, and by two sources. It is not any component's +state, it is what *relates* them, so only the layer that composes can declare it. A component +kit cannot supply it however complete the kit is. + +**Kind 2 is a candidate for moving down, and that move is not yet justified.** The argument for +it is that expansion flags and scroll offsets are widget business the composer should not have +to mention — and the reference corpus agrees, containing six kind-1 declarations and exactly +one of kind 2. The argument against is §5.10.1. + +**Kind 3 was missed by an earlier draft of this section**, which offered a two-way split. It +fits neither: the host owns it, several components read it, it drives guards and refetch, and it +outlives any widget instance. + +Ownership is also not the only axis. A cell has a **visibility** (private, card-shared), +an **effect** (render-only, layout, source query, navigation), and a **lifetime** (instance, +card, session). `selected` is card-shared by visibility but *navigation* by effect; `units` +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. + +#### 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` +exists — and L0 still decides when a subtree is rebuilt. Measured on the weather card: + +``` +week changes -> 28 of 79 nodes reused forecast rows are REPLACED +city changes -> 0 of 79 nodes reused whole tree rebuilt +units changes -> 13 of 79 nodes reused +``` + +The forecast rows read `week`. So when forecast data refreshes, L0 rebuilds those subtrees. +Today that is harmless: `expanded` lives in the instance store, keyed by instance identity and +held *outside* the tree, so it survives. Move the value down without moving identity down and a +row the user expanded collapses whenever data arrives. + +**So the layers do not need a shared value store, but they do need a shared protocol** — +instance identity, mount and unmount, what survives a rebuild, and what invalidates. Precisely +*because* L0 would not own kind 2, it must hand identity down so the owner can preserve it. + +That protocol does not exist. `docs/scoped-state.md` is the nearest thing and is written for +the opposite arrangement — it has L0 lowering emit its own state declarations into the VM — so +it needs rewriting around kit-owned state before it describes this. + +**The identity that would have to cross is not the renderer's `key`.** Three distinct things +currently share the name: + +| Name | What it is | +|---|---| +| `UiNode.key` | L0's declaration-and-loop path, used for realization and patch reuse | +| emitted `l0_key` | that same path, written as a backend attribute for tap routing | +| `Attrs.key` in `splash-render` | a kit-selected name for a slot in a process-global `BTreeMap` — no instance scoping, no pruning | + +The third is not the first. Reusing its name for instance identity would put per-instance cells +into a global map keyed by whatever the kit chose, which is the collision §5.7 exists to +prevent. + +--- + +## 6. What makes L0 terminate + +Grammar membership alone does not bound execution. These conditions do, and an implementation +must enforce all of them: + +1. **The component graph is acyclic.** No recursive or mutually recursive components. +2. **Iteration is over a runtime-provided collection**, whose length the runtime bounds before + injection. +3. **Transitions are total forms** (§3), so a single event applies in constant time per key. +4. **Event cascades are prohibited** — a transition may not trigger another event. +5. **Node count and nesting depth are capped** at realization. + +With all five, an L0 realization terminates. **Without them the grammar does not**, which is +why "L0 is decidable" is a claim about authority and grammar membership, not about +termination. + +Condition 4 holds **because no admitted transition form raises an event** — `set`, `toggle`, +`cycle` and `clear` all write a state cell and nothing else, and a transition target is +validated against declared state, so an event cannot be named as a write target either. + +Stating that more carefully than an earlier draft did: `Form` carries a fifth variant, +`NotTotal`, for anything outside those four, and the dispatcher ends in a catch-all rather than +matching exhaustively. **So the compiler would not force a review if a variant were added.** +The property is true of the forms that exist; it is not structurally protected. Any new `Form` +variant must be checked against this condition by hand, and one that reads a path must exclude +event-typed paths explicitly. + +An earlier version of this section claimed the stronger property that "no total form can name +an event". That was false when written, because component prop types were discarded and +`set(out)` was accepted where `out` was an event prop. It is now true — prop shapes survive +parsing, and event-shaped props are excluded from the readable set — but it is true by a check +rather than by the grammar, which is the distinction worth keeping. **Any new `Form` variant +must be checked against this condition**, and any form that reads a path must exclude +event-typed ones explicitly, because nothing in the type system will do it for you. + +A test enumerates the syntaxes a cascade could take — `emit e`, `e()`, `n: set(1) then e`, a +transition targeting an event — and asserts each is refused. That test is a tripwire, not the +proof. + +--- + +## 7. Level determination + +A card's level is the **maximum** required by any record, resolved over the **transitive +component closure** — not by inspecting records locally: + +``` +uses any construct outside §2 → L1 or L2 +instantiates a component whose level is higher → that level +otherwise → L0 +``` + +Level is declared in the header and checked at append. A record needing a wider grammar is +rejected until the level is explicitly raised; escalation is never silent. **Component +versions must be pinned**, or an L0 card can be moved to L2 by a definition it references +being replaced. + +The check therefore returns a **closure digest**: every component the card declares, paired +with a hash of its definition — param names, state (path, shape, initial, keep), events and +view body — sorted by name. A host stores this beside the approved level. A digest that moved +means the level was derived from a definition that no longer exists and must be re-derived +before the card is accepted, rather than inherited from the record it no longer matches. + +The digest sorts **components**, so reordering declarations at the top level is not a version +change — a digest that moved on a cosmetic edit would make pinning useless in the way that +matters, since hosts would see it move constantly and learn to ignore it. Order *within* a +component is hashed, so moving a param, state or event does register as a change. + +**Known limits, stated so the digest is not trusted past them.** It hashes every declared +component rather than the closure reachable from the root view, so an unused definition causes +a false repin. And it is 64-bit FNV-1a, which is a change *detector* and not an adversarial +boundary: it is fit for noticing that a definition moved, and unfit for resisting a chosen +collision. Pinning is also report material — nothing in the runtime yet stores or compares it. + +It also omits a loop's `key` expression, whether an element is a view reference, and the bodies +of views a component references — so a card can change what it renders +without the digest moving. Parameter and state *shapes* are covered as of the change that made +prop shapes survive parsing; before that `x: number` → `x: text` was invisible to both the digest and §5.8's +schema id, so a live string could survive into number-shaped state. + +Three token tests are not sufficient and an earlier draft that used them was both incomplete +and self-contradictory. Level must be an effect judgment over the closure. + +**"Maximum" is a scan, not a search.** A classifier that returns at the first construct outside +L0 reports whichever appears earliest in the source, not the highest. The nav card demonstrates +it: arithmetic on line 55 precedes `fn tick()` on line 80, so a short-circuiting classifier +calls it L1 — a card with 65 imperative widget commands. The implementation must examine every +construct and keep the widest. + +--- + +## 8. Open questions + +| # | Question | +|---|---| +| ~~1~~ | ~~Whether `when` needs `else`~~ — **settled: no.** Two complementary guards suffice and both reference cards that branch already use them (`when selected == ""` / `when selected != ""`). An `else` would need the negation of the guard, and there is no expression form to negate with; adding one for this alone would buy nothing a second `when` does not already express | +| ~~2~~ | ~~Whether `enum` shapes need ordering guarantees for `cycle`~~ — **settled: yes, declaration order is normative.** `cycle` advances to the successor in the enum's declared order and wraps, which is what keeps it total and O(1). A `cycle(…)` listing a different order is **rejected**, not reordered: otherwise the card would say one order and the runtime would run another | +| ~~3~~ | ~~How a `source` declares refresh cadence, staleness and error surfaces~~ — **settled in §5.9.** Staleness and error are a four-token lifecycle the host reports and the card reads as `.$state`. Cadence is not a card concern: it belongs to the capability, since how often a fetch is worth repeating is a property of the data, not of the card displaying it | +| ~~4~~ | ~~A bounded `format()`~~ — **settled**: `format:` is a declared token and the runtime applies it. A card that wrote `"$"` or `"41.2M"` itself would be authoring a fact, and currency, grouping and compaction are locale-dependent besides | +| ~~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 | +| **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 +replaces the original seven is not a shorter list but a different kind of work: the profile is +specified and implemented, and the remaining gaps are in the surrounding system rather than in +the language. + +**Known gaps, stated plainly.** `makepad::lower` violates §1.1 and does so in three ways at +once: it lowers to a *backend dialect* rather than to the theme's component kit, it decides +*presentation* (ten hardcoded colours, a font-size ramp) which belongs to a theme, and it +therefore reaches one backend of three. It also defines a second `UiNode`, duplicating the one +`splash-render` already produces. The fix is not to move it to a backend crate — it is to +retarget it at the component kit and delete the colour table. L1 and L2 are +specified only as "what L0 excludes" — neither is implemented, and the level classifier can +therefore name them but not check them. + +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 +catalog claiming otherwise was the defect. + +**Three defects found by reviewing §5.10, not yet fixed.** Each is a checker gap rather than a +language question: + +- **A `source` and a card `state` may share a name**, and nothing rejects the collision. The + component then reads the source — §5.3 is not violated and card state does not leak — but + silent shadowing is not a defensible way to resolve two namespaces that should be disjoint. +- **A declared source that nothing reads is not reported.** `stock.card` shipped one: `series` + became unread when `StockPlot` started fetching its own, and no test noticed. An unread source + is a fetch the host performs for nothing. +- **Nothing connects a state write to a refetch.** `stale_sources` computes what a change + invalidates and `source_plan` lists what to request; both are called only from tests. + `dispatch_with_data` returns a bare `bool` and discards which fields changed. The pieces of + §5.9's invalidation story exist and are individually tested; the loop between them does not. + +Component contracts were question 1 and are now specified in §5.2–§5.8, validated by adding +components to two reference cards. That exercise produced amendment #9, which nothing had +surfaced before — props are the first bindings a card author names freely, so the +token/path ambiguity was invisible until one existed. + +The reference cards in `octos-one/docs/l0/` remain the evidence: any construct they need that +this document lacks is a defect here rather than in the cards. Two of the settlements above came +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. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index fc362cb..7a2e4f5 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -591,6 +591,7 @@ version = "0.1.0" dependencies = [ "makepad-script", "serde_json", + "splash-ui-l0", ] [[package]] @@ -670,6 +671,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "splash-ui-l0" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "splash-worker" version = "0.1.0" diff --git a/mutate.py b/mutate.py new file mode 100755 index 0000000..fa69a09 --- /dev/null +++ b/mutate.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Mutation-test the L0 checker: which of its rules does the suite actually hold? + +A passing suite proves the code does what the tests say. It does not prove a +test would notice if the code stopped. Four review rounds on this branch found +guarantees that were false within hours and two tests of mine that could not +fail, so "the suite is green" has repeatedly meant less than it looked like. + +This answers the harder question mechanically: disable one rule, run the suite, +see whether anything goes red. + + ./mutate.py # every rule + ./mutate.py # only rules whose id matches + ./mutate.py --list # show the rule table without running + +WHAT THIS REPLACED, AND WHY. The first version mutated a diagnostic's TEXT by +filtering it inside Diagnostics::push, and reported "42 rules, 0 unprotected". +That number was not sound, for five reasons a reviewer had to point out: + + - Rules that also set `valid` or the level directly were unaffected by a text + filter, so they reported unprotected while being perfectly well held. + - Its five documented exceptions matched ZERO of the strings it generated, so + relabelling them as "verified by hand" was cosmetic. + - It found messages by keyword, and missed the six rules that matter most — + the capability allowlist, provenance, laundering, recursion. + - One substring could suppress several rules, so a test for any one of them + flattered all of them. + - With no baseline run, a compile error counted as a rule being caught. + +So each rule below names a BEHAVIOUR and an edit that disables it, the edit must +apply at exactly one site, and a mutation that fails to build is reported as +unusable rather than counted as held. A stale patch string is loud, not silent: +that is the property the previous version lacked. +""" + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).parent +SRC = ROOT / "crates/splash-ui-l0/src/lib.rs" + + +@dataclass(frozen=True) +class Rule: + """One enforcement, and an edit that disables it.""" + + ident: str + claim: str + find: str + replace: str + + +RULES = [ + # ── capability confinement (§1, §4) ────────────────────────────────────── + Rule("source-allowlist", "a card may only name a catalogued capability", + "let Some(accepted) = catalog::source(&source.helper) else {", + "let Some(accepted) = catalog::source(&source.helper).or(Some(&[][..])) else {"), + Rule("source-arg-paths", "a source argument's path must be declared", + "None => check_path(path, scope, source.line, 1, sink),", + "None => { let _ = (path, scope); }"), + + # ── the no-facts rule (§4) ─────────────────────────────────────────────── + Rule("data-laundering", "a literal may not reach a data position through a prop", + "if matches!(arg.value, Operand::Str(_) | Operand::Num(_))", + "if false"), + Rule("model-copy-render", "model-authored text may not be rendered", + "if decl.provenance == Provenance::ModelCopy {", + "if false {"), + Rule("model-copy-transition", "model-authored text may not be written into state", + "Some(decl) if decl.provenance == Provenance::ModelCopy => {", + "Some(decl) if false => {"), + + # ── termination (§6) ───────────────────────────────────────────────────── + Rule("acyclic", "the declaration graph is acyclic", + "if next == name.as_str() {", + "if false {"), + Rule("depth-realize", "realization depth is bounded", + "if self.nodes >= self.limits.max_nodes || self.depth >= self.limits.max_depth {", + "if self.nodes >= self.limits.max_nodes {"), + + # ── identity (§5.1, §5.7) ──────────────────────────────────────────────── + Rule("duplicate-keys", "duplicate loop keys are reported and disambiguated", + "let item_key = if seen_keys.contains(&item_key) {", + "let item_key = if false {"), + Rule("key-names-binder", "a loop key names the loop's binder", + "if root != *binder {", + "if false {"), + # Reverts to POSITIONAL numbering — a shared counter across siblings — which + # is what the fix replaced. Dropping only the name prefix does not revert + # anything, because the counter is already per-name. + Rule("sibling-ordinals", "identity survives an insertion above", + """ let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + children + .iter() + .map(|child| { + let n = seen.entry(child.name.as_str()).or_insert(0); + let segment = format!("{}#{}", child.name, *n); + *n += 1; + (segment, child) + }) + .collect()""", + """ let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + let _ = &mut seen; + children + .iter() + .enumerate() + .map(|(i, child)| (format!("{i}"), child)) + .collect()"""), + + # ── transitions (§3) ───────────────────────────────────────────────────── + Rule("payload-shape", "a payload must fit the state it is written to", + "Some(v) if value_fits_shape(&state.shape, v) => v.clone(),", + "Some(v) => v.clone(),"), + Rule("initial-fits", "a declared initial fits its shape", + "let fits = value_fits_shape(&state.shape, initial);", + "let fits = true;"), + Rule("schema-initials", "a changed initial resets live state", + '"{}:{:?}:{:?}:{:?}",\n s.path, s.shape, s.initial, s.initial_path', + '"{}:{:?}",\n s.path, s.shape'), + + # ── component contracts (§5.2) ─────────────────────────────────────────── + Rule("card-state-private", "a component may not read card state", + "if scope.forbidden_state.contains(&root) {", + "if false {"), + + # ── reconciliation (§5.10.1) ───────────────────────────────────────────── + # Mutating to `true` (reuse everything) rather than `false` because that is + # the direction the first implementation got wrong: it checked a node's own + # key and let a clean root short-circuit its dirty children. The suite must + # notice a patch that reuses a subtree a change did touch. + Rule("patch-reuse", "a subtree is reused only when nothing under it is dirty", + """ !touches_dirty(&n.key) + && n.children + .iter() + .all(|c| subtree_is_clean(c, touches_dirty))""", + " let _ = (n, touches_dirty);\n true"), + + # ── level and header (§7) ──────────────────────────────────────────────── + Rule("header-level", "a declared level must match the derived one", + "if !matches {", + "if false {"), + Rule("header-duplicates", "a header may not contradict itself", + "for message in &header.contradictions {", + "for message in std::iter::empty::<&String>() {"), +] + + +def suite(): + """(built, passed). built=False means the mutation did not compile.""" + r = subprocess.run( + ["cargo", "test", "-q", "-p", "splash-ui-l0", "--test", "profile"], + cwd=ROOT, capture_output=True, text=True, + ) + out = r.stdout + r.stderr + built = "error[E" not in out and "could not compile" not in out + return built, r.returncode == 0 + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + only = args[0] if args else None + rules = [r for r in RULES if not only or only in r.ident] + + if "--list" in sys.argv: + for r in rules: + print(f" {r.ident:<24} {r.claim}") + return 0 + + original = SRC.read_text() + + # Baseline. Without it a pre-existing failure reads as every rule being + # held — the most flattering possible way to be wrong. + print("baseline… ", end="", flush=True) + built, passed = suite() + if not (built and passed): + print("FAILED — fix the suite first; mutation results would be meaningless") + return 2 + print("green\n") + + held, unprotected, unusable = [], [], [] + try: + for i, rule in enumerate(rules, 1): + hits = original.count(rule.find) + if hits != 1: + unusable.append((rule.ident, f"patch matches {hits} sites, expected 1")) + print(f" [{i:2}/{len(rules)}] STALE {rule.ident} ({hits} matches)") + continue + + SRC.write_text(original.replace(rule.find, rule.replace, 1)) + built, passed = suite() + SRC.write_text(original) + + if not built: + unusable.append((rule.ident, "mutation did not compile")) + print(f" [{i:2}/{len(rules)}] NO-BUILD {rule.ident}") + elif passed: + unprotected.append(rule) + print(f" [{i:2}/{len(rules)}] SURVIVED {rule.ident} — {rule.claim}") + else: + held.append(rule) + print(f" [{i:2}/{len(rules)}] held {rule.ident}") + finally: + SRC.write_text(original) + + print(f"\n{'=' * 74}") + print(f"held: {len(held)} UNPROTECTED: {len(unprotected)} unusable: {len(unusable)}") + print(f"\nThis covers {len(RULES)} named rules. It is NOT a coverage percentage:\n" + "a rule absent from the table is untested by this harness and unmentioned\n" + "by its output, which is how the previous version came to claim full\n" + "coverage while missing the six rules that mattered most.") + if unprotected: + print("\nRules no test would notice regressing:\n") + for r in unprotected: + print(f" - {r.ident}: {r.claim}") + if unusable: + print("\nMutations that could not be applied. These are NOT held — they are\n" + "untested, and a stale patch string is the likeliest reason:\n") + for ident, why in unusable: + print(f" - {ident}: {why}") + return 1 if (unprotected or unusable) else 0 + + +if __name__ == "__main__": + sys.exit(main())