diff --git a/Cargo.lock b/Cargo.lock index 06dfb45..53d378f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,166 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "octoscript-capabilities" +version = "0.1.0" +dependencies = [ + "blake3", + "getrandom 0.3.4", + "keyring", + "makepad-script", + "octoscript-core", + "octoscript-protocol", + "octoscript-sandbox", + "octoscript-schema", + "octoscript-storage", + "octoscript-worker", + "serde", + "serde_json", + "ureq", + "zeroize", +] + +[[package]] +name = "octoscript-cli" +version = "0.1.0" +dependencies = [ + "octoscript-capabilities", + "octoscript-core", + "octoscript-workflow", + "serde_json", +] + +[[package]] +name = "octoscript-core" +version = "0.1.0" +dependencies = [ + "makepad-script", + "octoscript-ui-l0", + "serde_json", +] + +[[package]] +name = "octoscript-linux-project-quota" +version = "0.1.0" +dependencies = [ + "libc", + "linux-raw-sys", +] + +[[package]] +name = "octoscript-linux-seccomp" +version = "0.1.0" +dependencies = [ + "libc", + "linux-raw-sys", +] + +[[package]] +name = "octoscript-lsp" +version = "0.1.0" +dependencies = [ + "lsp-server", + "lsp-types", + "octoscript-capabilities", + "octoscript-core", + "octoscript-schema", + "octoscript-workflow", + "serde_json", +] + +[[package]] +name = "octoscript-media-host" +version = "0.1.0" +dependencies = [ + "octoscript-capabilities", + "octoscript-core", + "octoscript-workflow", + "serde_json", +] + +[[package]] +name = "octoscript-protocol" +version = "0.1.0" +dependencies = [ + "blake3", + "constant_time_eq", + "octoscript-storage", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "octoscript-sandbox" +version = "0.1.0" +dependencies = [ + "close_fds", + "landlock", + "linux-raw-sys", + "octoscript-linux-project-quota", + "octoscript-linux-seccomp", + "octoscript-protocol", + "rustix", +] + +[[package]] +name = "octoscript-schema" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "octoscript-storage" +version = "0.1.0" +dependencies = [ + "base64", + "blake3", + "keyring", + "rusqlite", + "serde", + "serde_json", + "subtle", + "ureq", + "zeroize", +] + +[[package]] +name = "octoscript-ui-l0" +version = "0.1.0" +dependencies = [ + "blake3", + "serde_json", +] + +[[package]] +name = "octoscript-worker" +version = "0.1.0" +dependencies = [ + "octoscript-protocol", + "octoscript-storage", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "octoscript-workflow" +version = "0.1.0" +dependencies = [ + "blake3", + "getrandom 0.3.4", + "octoscript-capabilities", + "octoscript-core", + "octoscript-protocol", + "octoscript-sandbox", + "octoscript-schema", + "octoscript-storage", + "serde", + "serde_json", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -698,155 +858,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "octoscript-capabilities" -version = "0.1.0" -dependencies = [ - "blake3", - "getrandom 0.3.4", - "keyring", - "makepad-script", - "serde", - "serde_json", - "octoscript-core", - "octoscript-protocol", - "octoscript-sandbox", - "octoscript-schema", - "octoscript-storage", - "octoscript-worker", - "ureq", - "zeroize", -] - -[[package]] -name = "octoscript-cli" -version = "0.1.0" -dependencies = [ - "serde_json", - "octoscript-capabilities", - "octoscript-core", - "octoscript-workflow", -] - -[[package]] -name = "octoscript-core" -version = "0.1.0" -dependencies = [ - "makepad-script", - "serde_json", - "octoscript-ui-l0", -] - -[[package]] -name = "octoscript-linux-project-quota" -version = "0.1.0" -dependencies = [ - "libc", - "linux-raw-sys", -] - -[[package]] -name = "octoscript-linux-seccomp" -version = "0.1.0" -dependencies = [ - "libc", - "linux-raw-sys", -] - -[[package]] -name = "octoscript-lsp" -version = "0.1.0" -dependencies = [ - "lsp-server", - "lsp-types", - "serde_json", - "octoscript-capabilities", - "octoscript-core", - "octoscript-schema", - "octoscript-workflow", -] - -[[package]] -name = "octoscript-protocol" -version = "0.1.0" -dependencies = [ - "blake3", - "constant_time_eq", - "serde", - "serde_json", - "octoscript-storage", - "zeroize", -] - -[[package]] -name = "octoscript-sandbox" -version = "0.1.0" -dependencies = [ - "close_fds", - "landlock", - "linux-raw-sys", - "rustix", - "octoscript-linux-project-quota", - "octoscript-linux-seccomp", - "octoscript-protocol", -] - -[[package]] -name = "octoscript-schema" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "octoscript-storage" -version = "0.1.0" -dependencies = [ - "base64", - "blake3", - "keyring", - "rusqlite", - "serde", - "serde_json", - "subtle", - "ureq", - "zeroize", -] - -[[package]] -name = "octoscript-ui-l0" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "octoscript-worker" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "octoscript-protocol", - "octoscript-storage", - "zeroize", -] - -[[package]] -name = "octoscript-workflow" -version = "0.1.0" -dependencies = [ - "blake3", - "getrandom 0.3.4", - "serde", - "serde_json", - "octoscript-capabilities", - "octoscript-core", - "octoscript-protocol", - "octoscript-sandbox", - "octoscript-schema", - "octoscript-storage", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 51e352c..8805432 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/octoscript-workflow", "crates/octoscript-cli", "crates/octoscript-lsp", + "crates/octoscript-media-host", ] default-members = [ "crates/octoscript-core", diff --git a/crates/octoscript-media-host/Cargo.toml b/crates/octoscript-media-host/Cargo.toml new file mode 100644 index 0000000..4afe5c7 --- /dev/null +++ b/crates/octoscript-media-host/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "octoscript-media-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Example host: agent-authored Octoscript workflow orchestrating policy-bound media capabilities (image.generate, media.transcode)" + +[lints] +workspace = true + +[[bin]] +name = "octoscript-media-host" +path = "src/main.rs" + +[dependencies] +octoscript-capabilities = { path = "../octoscript-capabilities", features = ["http-endpoint-catalog"] } +octoscript-core = { path = "../octoscript-core" } +octoscript-workflow = { version = "0.1.0", path = "../octoscript-workflow", features = ["http-endpoint-catalog"] } +serde_json = "1.0.150" diff --git a/crates/octoscript-media-host/src/main.rs b/crates/octoscript-media-host/src/main.rs new file mode 100644 index 0000000..2398a5e --- /dev/null +++ b/crates/octoscript-media-host/src/main.rs @@ -0,0 +1,538 @@ +#![forbid(unsafe_code)] + +//! Example host: an agent-authored Octoscript workflow orchestrating two +//! policy-bound media capabilities. +//! +//! This is the "production host constructs its own reviewed catalog and policy" +//! pattern the README points to — NOT a demo baked into `octoscript-cli`. It: +//! +//! 1. registers `image.generate` and `media.transcode` as reviewed JSON +//! capabilities, each with an executable input/output contract; +//! 2. loads a *data-only* LLM-authored workflow draft and plans it; +//! 3. issues one least-privilege capability lease per step (host policy, +//! supplied as `step:tool:max` arguments); +//! 4. executes the bounded JSON dataflow and prints the audit trail. +//! +//! The Octoscript source never touches ffmpeg, curl, the API key, or a filesystem +//! path: it names WHAT (a prompt, a filter) and receives an OPAQUE handle. The +//! adapter chooses where bytes land and resolves handles back to real files — +//! the script cannot address the filesystem. Effects here run as ordinary Mac +//! subprocesses because the point is to prove the capability-orchestration +//! architecture end to end; a mobile host swaps these adapters for the +//! in-process bionic bindings behind the identical contract. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use serde_json::{json, Value}; +use octoscript_capabilities::{ + http_endpoint_catalog::{HttpEndpointMethod, HttpOrigin, HttpOriginCatalog}, + CapabilityLeaseGrant, JsonToolContract, JsonToolRequest, ToolDescriptor, ToolError, + ToolMetadata, ToolPolicy, +}; +use octoscript_core::{check_syntax_named, ExecutionLimits}; +use octoscript_workflow::{ + mobile::{MobileWorkflowBuilder, MobileWorkflowRuntime}, + WorkflowData, WorkflowDraft, WorkflowPlan, WorkflowStepCapabilityPolicy, +}; + +/// Opaque-handle -> host-chosen file path. Shared by both adapters so a +/// generated image can feed a transcode WITHOUT the script ever seeing a path. +type Registry = Arc>>; +type Seq = Arc>; + +fn main() -> ExitCode { + match run() { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(2), + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let args: Vec = std::env::args().skip(1).collect(); + if args.len() < 2 { + return Err( + "usage: octoscript-media-host [step:tool:max ...]".to_owned(), + ); + } + let draft_src = std::fs::read_to_string(&args[0]).map_err(|error| error.to_string())?; + let input_src = std::fs::read_to_string(&args[1]).map_err(|error| error.to_string())?; + let grants = args[2..] + .iter() + .map(|value| parse_grant(value)) + .collect::, _>>()?; + + let dir = PathBuf::from( + std::env::var("OCTOSCRIPT_MEDIA_DIR").unwrap_or_else(|_| "/tmp/octoscript-media".to_owned()), + ); + + // (2a) The check gate: preflight every generated step through the canonical + // syntax checker BEFORE anything is planned or run. This is where a model's + // JS/Python reflexes (ternary, one-line block, `=>`, spread) get caught and + // turned into line-numbered feedback — the "check" half of generate→check→fix. + // See docs/authoring-for-llms.md for the rules this enforces. + if !preflight(&draft_src)? { + println!("\nGenerated source failed the syntax gate; nothing was planned or executed."); + println!("Feed the diagnostics above back to the model, regenerate, and re-run."); + return Ok(false); + } + + // (2b) The untrusted plan is data until the host decides to run it. + let draft = WorkflowDraft::from_json(&draft_src) + .map_err(|error| format!("draft rejected as data (never executed): {error}"))?; + let input = WorkflowData::from_input_json(&input_src).map_err(|error| error.to_string())?; + + // (1) The host's own reviewed capability catalog. + let mut workflow = build_runtime(dir)?; + let plan = workflow + .plan_draft(draft) + .map_err(|error| format!("plan/syntax review failed: {error}"))?; + + // (3) One least-privilege lease per step, from host policy. + let catalog = workflow.tool_catalog(); + let policies = build_policies(&plan, &catalog, &grants)?; + + println!("catalog: {}", capability_names(&catalog).join(", ")); + println!( + "plan {} — {} step(s); leases granted by host policy:", + plan.id(), + plan.steps().len() + ); + if grants.is_empty() { + println!(" (none — every effectful step will be denied)"); + } + for grant in &grants { + println!(" {} may call {} up to {}x", grant.0, grant.1, grant.2); + } + + // (4) Execute the bounded JSON dataflow under those leases. + let approval = workflow + .approve_dataflow_with_step_capability_policies(&plan, input, policies) + .map_err(|error| error.to_string())?; + let outcome = workflow.execute_dataflow(&plan, approval); + + println!("--- audit (every capability call the runtime authorized) ---"); + for event in workflow.audit().iter() { + println!( + " #{} {} -> {:?} in={}B out={}B", + event.event_sequence, event.tool, event.outcome, event.input_bytes, event.output_bytes + ); + } + + match outcome { + Ok(data) => { + println!("--- step outputs (opaque handles the host can resolve) ---"); + println!( + "{}", + serde_json::to_string_pretty(&data.outputs()).unwrap_or_default() + ); + Ok(true) + } + Err(error) => { + println!("--- execution stopped: {error} ---"); + println!("--- debug: {error:?} ---"); + let mut source = std::error::Error::source(&error); + while let Some(cause) = source { + println!("--- caused by: {cause} ---"); + source = cause.source(); + } + println!("(a step called a capability it held no lease for, or an adapter failed)"); + Ok(false) + } + } +} + +/// The check gate: run each generated step's source through the canonical +/// syntax checker and print line-numbered diagnostics. Returns true iff every +/// step is valid canonical Octoscript. Effect-free — no capability host, no eval. +fn preflight(draft_json: &str) -> Result { + let parsed: Value = serde_json::from_str(draft_json).map_err(|error| error.to_string())?; + let steps = parsed["steps"] + .as_array() + .ok_or_else(|| "draft has no `steps` array".to_owned())?; + println!("--- preflight: canonical syntax check on each generated step ---"); + let mut all_valid = true; + for step in steps { + let id = step["id"].as_str().unwrap_or("?"); + let source = step["source"].as_str().unwrap_or(""); + let report = check_syntax_named(id, source, ExecutionLimits::default()) + .map_err(|error| error.to_string())?; + if report.valid { + println!(" step '{id}': OK"); + } else { + all_valid = false; + println!(" step '{id}': REJECTED"); + for diagnostic in &report.diagnostics { + println!( + " line {} col {}: {}", + diagnostic.line, diagnostic.column, diagnostic.message + ); + } + } + } + Ok(all_valid) +} + +/// Build the host runtime with the two reviewed media capabilities. +fn build_runtime(dir: PathBuf) -> Result { + let registry: Registry = Arc::new(Mutex::new(HashMap::new())); + let seq: Seq = Arc::new(Mutex::new(0)); + // A data-script that enriches a whole chart parses one JSON payload per + // row; the default 200k instruction budget is spent after ~6 rows, so raise + // it for chart-sized loops. + let limits = ExecutionLimits { + instruction_limit: 8_000_000, + // The default script deadline assumes no I/O; a chart of sequential + // network calls needs a wall-clock budget that covers real latency. + soft_timeout: std::time::Duration::from_secs(90), + hard_timeout: std::time::Duration::from_secs(120), + ..ExecutionLimits::default() + }; + let mut builder = + MobileWorkflowBuilder::with_limits(limits, octoscript_capabilities::DEFAULT_MAX_PENDING_TOOLS) + .map_err(|error| error.to_string())?; + + { + let registry = registry.clone(); + let seq = seq.clone(); + let dir = dir.clone(); + builder + .register_json_tool( + ToolPolicy::json("image.generate"), + ToolMetadata::new( + "Generate an image from a text prompt (OpenAI images API); returns an opaque handle.", + ), + image_contract()?, + move |request: &JsonToolRequest| image_generate(request, ®istry, &seq, &dir), + ) + .map_err(|error| error.to_string())?; + } + { + let registry = registry.clone(); + let seq = seq.clone(); + builder + .register_json_tool( + ToolPolicy::json("media.transcode"), + ToolMetadata::new( + "Apply a bounded ffmpeg -vf filter to a prior image handle; returns a new handle.", + ), + media_contract()?, + move |request: &JsonToolRequest| media_transcode(request, ®istry, &seq, &dir), + ) + .map_err(|error| error.to_string())?; + } + { + // (1c) A reviewed HTTP *origin* — not a bespoke `sys.movies` fetcher. + // The host fixes ONE origin (IMDb's keyless suggestion API, HTTPS, + // GET-only, byte-bounded); the Octoscript step decides which title to query + // by building a bounded URL under that origin. This is the whole point: + // the movie logic lives in the card's data-script, not in Rust. + let mut imdb = HttpOriginCatalog::default(); + imdb.insert( + HttpOrigin::https( + "imdb", + HttpEndpointMethod::Get, + "https://v3.sg.media-imdb.com", + ) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let mut imdb_policy = ToolPolicy::json("net.imdb"); + // One data-script enriches a whole chart, so the lease needs a call + // budget above the default 1; each suggestion payload is a few KB. + imdb_policy.max_calls = 16; + imdb_policy.max_output_bytes = 256 * 1024; + builder + .register_http_origin_catalog_tool( + imdb_policy, + ToolMetadata::new( + "IMDb suggestion API — one reviewed HTTPS origin, keyless GET. The script supplies a bounded URL.", + ), + imdb, + ) + .map_err(|error| error.to_string())?; + } + Ok(builder.build()) +} + +fn image_contract() -> Result { + JsonToolContract::new( + json!({ + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "size": {"type": "string"}, + "quality": {"type": "string"} + }, + "required": ["prompt"], + "additionalProperties": false + }), + json!({ + "type": "object", + "properties": { + "handle": {"type": "string"}, + "bytes": {"type": "integer"}, + "seconds": {"type": "integer"} + }, + "required": ["handle", "bytes", "seconds"], + "additionalProperties": false + }), + ) + .map_err(|error| error.to_string()) +} + +fn media_contract() -> Result { + JsonToolContract::new( + json!({ + "type": "object", + "properties": { + "src": {"type": "string"}, + "vf": {"type": "string"}, + "crf": {"type": "integer"} + }, + "required": ["src", "vf"], + "additionalProperties": false + }), + json!({ + "type": "object", + "properties": { + "handle": {"type": "string"}, + "bytes": {"type": "integer"}, + "seconds": {"type": "integer"} + }, + "required": ["handle", "bytes", "seconds"], + "additionalProperties": false + }), + ) + .map_err(|error| error.to_string()) +} + +fn image_generate( + request: &JsonToolRequest, + registry: &Registry, + seq: &Seq, + dir: &Path, +) -> Result { + let prompt = request.input["prompt"] + .as_str() + .ok_or_else(|| ToolError::Denied("image.generate requires a string prompt".to_owned()))?; + let size = request.input["size"].as_str().unwrap_or("1024x1024"); + let quality = request.input["quality"].as_str().unwrap_or("low"); + let key = std::env::var("OPENAI_API_KEY") + .map_err(|_| ToolError::Denied("OPENAI_API_KEY not set for image.generate".to_owned()))?; + + std::fs::create_dir_all(dir).map_err(|error| ToolError::Failed(format!("mkdir: {error}")))?; + let body = + json!({"model": "gpt-image-2", "prompt": prompt, "size": size, "quality": quality, "n": 1}); + let req_path = dir.join("request.json"); + let resp_path = dir.join("response.json"); + std::fs::write(&req_path, body.to_string()) + .map_err(|error| ToolError::Failed(format!("write request: {error}")))?; + + let started = Instant::now(); + let status = Command::new("curl") + .args([ + "-sS", + "-X", + "POST", + "https://api.openai.com/v1/images/generations", + "-H", + "Content-Type: application/json", + "-H", + &format!("Authorization: Bearer {key}"), + "--data-binary", + &format!("@{}", req_path.display()), + "-o", + ]) + .arg(&resp_path) + .status() + .map_err(|error| ToolError::Failed(format!("curl spawn: {error}")))?; + if !status.success() { + return Err(ToolError::Failed("curl exited non-zero".to_owned())); + } + + let text = std::fs::read_to_string(&resp_path) + .map_err(|error| ToolError::Failed(format!("read response: {error}")))?; + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ToolError::Failed(format!("parse response: {error}")))?; + let b64 = parsed["data"][0]["b64_json"].as_str().ok_or_else(|| { + let api = parsed["error"]["message"] + .as_str() + .unwrap_or("no b64_json in response"); + ToolError::Failed(format!("image API: {api}")) + })?; + let png = b64_decode(b64).ok_or_else(|| ToolError::Failed("bad base64".to_owned()))?; + + let n = next_seq(seq); + let out = dir.join(format!("gen_{n}.png")); + std::fs::write(&out, &png).map_err(|error| ToolError::Failed(format!("write png: {error}")))?; + let handle = format!("img_{n}"); + registry + .lock() + .map_err(|_| ToolError::Failed("registry poisoned".to_owned()))? + .insert(handle.clone(), out); + Ok(json!({"handle": handle, "bytes": png.len(), "seconds": started.elapsed().as_secs()})) +} + +fn media_transcode( + request: &JsonToolRequest, + registry: &Registry, + seq: &Seq, + dir: &Path, +) -> Result { + let src = request.input["src"].as_str().ok_or_else(|| { + ToolError::Denied("media.transcode requires a string src handle".to_owned()) + })?; + let vf = request.input["vf"] + .as_str() + .ok_or_else(|| ToolError::Denied("media.transcode requires a string vf".to_owned()))?; + // The vf string is untrusted script input: hold it to a bounded filtergraph + // charset so a card can never smuggle shell/filter escapes past the adapter. + if vf.is_empty() + || !vf + .chars() + .all(|c| c.is_ascii_alphanumeric() || "=:,.-_ ".contains(c)) + { + return Err(ToolError::Denied( + "vf has characters outside the allowed filtergraph set".to_owned(), + )); + } + + let input_path = registry + .lock() + .map_err(|_| ToolError::Failed("registry poisoned".to_owned()))? + .get(src) + .cloned() + .ok_or_else(|| ToolError::Denied(format!("unknown src handle: {src}")))?; + + let n = next_seq(seq); + let out = dir.join(format!("edit_{n}.png")); + let started = Instant::now(); + let status = Command::new("ffmpeg") + .args(["-nostdin", "-y", "-loglevel", "error", "-i"]) + .arg(&input_path) + .args(["-vf", vf]) + .arg(&out) + .status() + .map_err(|error| ToolError::Failed(format!("ffmpeg spawn: {error}")))?; + if !status.success() { + return Err(ToolError::Failed("ffmpeg exited non-zero".to_owned())); + } + + let bytes = std::fs::metadata(&out).map(|meta| meta.len()).unwrap_or(0); + let handle = format!("img_{n}"); + registry + .lock() + .map_err(|_| ToolError::Failed("registry poisoned".to_owned()))? + .insert(handle.clone(), out); + Ok(json!({"handle": handle, "bytes": bytes, "seconds": started.elapsed().as_secs()})) +} + +fn next_seq(seq: &Seq) -> u64 { + let mut guard = seq.lock().expect("seq mutex"); + *guard += 1; + *guard +} + +fn build_policies( + plan: &WorkflowPlan, + catalog: &[ToolDescriptor], + grants: &[(String, String, usize)], +) -> Result, String> { + for (step, tool, _) in grants { + if !plan.steps().iter().any(|candidate| &candidate.id == step) { + return Err(format!( + "grant references a step absent from the draft: {step}" + )); + } + if !catalog.iter().any(|descriptor| &descriptor.name == tool) { + return Err(format!( + "grant names a capability absent from the catalog: {tool}" + )); + } + } + Ok(plan + .steps() + .iter() + .map(|step| { + WorkflowStepCapabilityPolicy::new( + step.id.clone(), + grants + .iter() + .filter(|(candidate, _, _)| candidate == &step.id) + .map(|(_, tool, max)| CapabilityLeaseGrant::new(tool.clone(), *max)), + ) + }) + .collect()) +} + +fn capability_names(catalog: &[ToolDescriptor]) -> Vec { + catalog + .iter() + .map(|descriptor| descriptor.name.clone()) + .collect() +} + +fn parse_grant(value: &str) -> Result<(String, String, usize), String> { + let parts: Vec<&str> = value.split(':').collect(); + if parts.len() != 3 { + return Err(format!("bad grant '{value}', want step:tool:max")); + } + let max = parts[2] + .parse::() + .map_err(|_| format!("bad max-calls in grant '{value}'"))?; + Ok((parts[0].to_owned(), parts[1].to_owned(), max)) +} + +/// Standard-alphabet base64 decode (image API returns `b64_json`). +fn b64_decode(source: &str) -> Option> { + fn val(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(u32::from(byte - b'A')), + b'a'..=b'z' => Some(u32::from(byte - b'a' + 26)), + b'0'..=b'9' => Some(u32::from(byte - b'0' + 52)), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes: Vec = source + .bytes() + .filter(|byte| !b" \n\r\t".contains(byte)) + .collect(); + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut chunk = [0u32; 4]; + let mut filled = 0; + for &byte in &bytes { + if byte == b'=' { + break; + } + chunk[filled] = val(byte)?; + filled += 1; + if filled == 4 { + let word = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; + out.extend_from_slice(&[(word >> 16) as u8, (word >> 8) as u8, word as u8]); + filled = 0; + } + } + match filled { + 0 => {} + 2 => { + let word = (chunk[0] << 18) | (chunk[1] << 12); + out.push((word >> 16) as u8); + } + 3 => { + let word = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6); + out.extend_from_slice(&[(word >> 16) as u8, (word >> 8) as u8]); + } + _ => return None, + } + Some(out) +} diff --git a/crates/octoscript-ui-l0/Cargo.toml b/crates/octoscript-ui-l0/Cargo.toml index df197eb..a0d208b 100644 --- a/crates/octoscript-ui-l0/Cargo.toml +++ b/crates/octoscript-ui-l0/Cargo.toml @@ -5,17 +5,10 @@ edition = "2021" description = "UI Profile Level 0: check, realize and dispatch LLM-authored Octoscript 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: `octoscript-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. +# No VM dependency: hosts can use the profile across Makepad lineages. [dependencies] serde_json = "1" +blake3 = "1.8" [dev-dependencies] serde_json = "1" diff --git a/crates/octoscript-ui-l0/examples/l0validate.rs b/crates/octoscript-ui-l0/examples/l0validate.rs new file mode 100644 index 0000000..f8b57ca --- /dev/null +++ b/crates/octoscript-ui-l0/examples/l0validate.rs @@ -0,0 +1,27 @@ +//! TEMP soak-test validator (delete after use). `l0validate ` → +//! JSON {ok, root, diagnostics[]} from the real realize() gate, so a correction +//! loop can feed the diagnostic messages back to the model. +use octoscript_ui_l0::{realize, RealizeLimits}; + +fn main() { + let path = std::env::args().nth(1).expect("usage: l0validate "); + let src = std::fs::read_to_string(&path).expect("read card"); + let data_path = std::path::Path::new(&path).with_extension("data.json"); + let data = if data_path.exists() { + serde_json::from_str(&std::fs::read_to_string(data_path).expect("read fixture data")) + .expect("valid fixture JSON") + } else { serde_json::json!({}) }; + let report = realize(&src, &data, RealizeLimits::default()); + let msgs: Vec = report + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect(); + let ok = report.complete_root().is_ok(); + println!( + "{}", + serde_json::json!({"ok": ok, "root": report.root.is_some(), "diagnostics": msgs, + "state_initials": octoscript_ui_l0::state_initials(&src)}) + ); + if !ok { std::process::exit(1); } +} diff --git a/crates/octoscript-ui-l0/examples/lint_check.rs b/crates/octoscript-ui-l0/examples/lint_check.rs new file mode 100644 index 0000000..e80b05d --- /dev/null +++ b/crates/octoscript-ui-l0/examples/lint_check.rs @@ -0,0 +1,20 @@ +//! Report theme-portability lints for a card. +//! +//! Usage: cargo run -p octoscript-ui-l0 --example lint_check -- ... +fn main() { + for p in std::env::args().skip(1) { + let Ok(src) = std::fs::read_to_string(&p) else { + continue; + }; + let r = octoscript_ui_l0::check_ui_l0_named("card.l0", &src); + let name = p.rsplit('/').next().unwrap_or(&p); + if r.lints.is_empty() { + println!("{name}: portable"); + continue; + } + println!("{name}: {} lint(s), valid={}", r.lints.len(), r.valid); + for l in &r.lints { + println!(" {}:{} {}", l.line, l.column, l.message); + } + } +} diff --git a/crates/octoscript-ui-l0/src/approval.rs b/crates/octoscript-ui-l0/src/approval.rs new file mode 100644 index 0000000..0d5b325 --- /dev/null +++ b/crates/octoscript-ui-l0/src/approval.rs @@ -0,0 +1,97 @@ +//! A host policy approval pins the complete artifact and runtime bundle. +//! Component closure digests remain cache hints; they are not approvals. + +use crate::{check_ui_l0, Level}; +use serde_json::{json, Value}; + +const POLICY: &str = "octoscript-ui-policy-v1:checked-L0-L1:runtime-origins:complete-tree"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactApproval { + source: String, + artifact: String, + level: Level, +} + +fn digest(parts: &[&str]) -> String { + let mut hash = blake3::Hasher::new(); + for part in parts { + hash.update(&(part.len() as u64).to_le_bytes()); + hash.update(part.as_bytes()); + } + hash.finalize().to_hex().to_string() +} + +impl ArtifactApproval { + pub fn source_key(source: &str) -> String { + digest(&["octoscript-source-v1", source.trim()]) + } + + /// The trusted host supplies a digest of its implementation, dependencies, + /// capability bindings, and build configuration, plus the assembled kit. + pub fn admit(source: &str, runtime: &str, kit: &str) -> Result { + if runtime.is_empty() || kit.is_empty() { + return Err("empty runtime approval bundle".into()); + } + let report = check_ui_l0(source); + if !report.valid || report.level == Level::L2 { + return Err("card is outside the installed L0/L1 policy".into()); + } + let source = Self::source_key(source); + let artifact = digest(&[ + POLICY, + &source, + &format!("{:?}", report.level), + runtime, + kit, + include_str!("lib.rs"), + include_str!("value_origin.rs"), + include_str!("approval.rs"), + ]); + Ok(Self { + source, + artifact, + level: report.level, + }) + } + + pub fn verify(&self, source: &str, runtime: &str, kit: &str) -> Result<(), String> { + if *self != Self::admit(source, runtime, kit)? { + return Err( + "card approval does not match its source or runtime bundle; reapproval required" + .into(), + ); + } + Ok(()) + } + + pub fn to_json(&self) -> Value { + json!({"version":1,"source":self.source,"artifact":self.artifact,"level":format!("{:?}",self.level)}) + } + + pub fn from_json(value: &Value) -> Result { + let parse = || { + if value.get("version")?.as_u64()? != 1 { + return None; + } + let level = match value.get("level")?.as_str()? { + "L0" => Level::L0, + "L1" => Level::L1, + _ => return None, + }; + let hash = |name| { + let s = value.get(name)?.as_str()?; + (s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())) + .then(|| s.to_string()) + }; + Some(Self { + source: hash("source")?, + artifact: hash("artifact")?, + level, + }) + }; + parse().ok_or_else(|| "invalid stored card approval".into()) + } +} diff --git a/crates/octoscript-ui-l0/src/kit_pack.rs b/crates/octoscript-ui-l0/src/kit_pack.rs new file mode 100644 index 0000000..4f68f27 --- /dev/null +++ b/crates/octoscript-ui-l0/src/kit_pack.rs @@ -0,0 +1,304 @@ +//! Host-registered theme/component packs. The ledger supplies content and +//! structure; the host owns style tokens, native roles and placement contracts. +//! No filesystem, VM, renderer, or ambient asset access lives in this layer. +use crate::{NodeValue, UiNode}; +use serde_json::{Map, Value}; +use std::collections::BTreeSet; + +fn value(v: &NodeValue) -> Result { + match v { + NodeValue::Text(v) | NodeValue::Token(v) => Ok(v.clone().into()), + NodeValue::Number(v) => serde_json::Number::from_f64(*v) + .map(Value::Number) + .ok_or_else(|| "non-finite kit property".into()), + NodeValue::Bool(v) => Ok((*v).into()), + _ => Err("unresolved or unsupported kit property".into()), + } +} + +/// Octoscript uses bare property names; string contents remain JSON escaped. +pub fn literal(v: &Value) -> String { + match v { + Value::Object(m) => format!( + "{{{}}}", + m.iter() + .map(|(k, v)| format!("{k}: {}", literal(v))) + .collect::>() + .join(", ") + ), + Value::Array(a) => format!("[{}]", a.iter().map(literal).collect::>().join(", ")), + Value::Null => "nil".into(), + _ => v.to_string(), + } +} + +pub fn arguments(node: &UiNode) -> String { + let args: Map = node + .args + .iter() + .filter_map(|(k, v)| value(v).ok().map(|v| (k.clone(), v))) + .collect(); + literal(&Value::Object(args)) +} + +pub fn contains(root: &UiNode) -> bool { + root.kind == "Kit" || root.children.iter().any(contains) +} + +fn resolve(v: &Value, tokens: &Value) -> Result { + if let Some(key) = v.get("$token").and_then(Value::as_str) { + return tokens + .get(key) + .and_then(|t| t.get("value")) + .cloned() + .ok_or_else(|| format!("missing kit token {key}")); + } + Ok(v.clone()) +} + +/// Resolve every Kit instance, checking identity, role, props and layout. +/// Unknown references fail the whole card. Children come ONLY from the ledger. +pub fn tree(root: &UiNode, pack: &Value, data: &Value) -> Result { + if pack["schema_version"] != 1 { + return Err("unsupported kit schema".into()); + } + fn visit( + n: &UiNode, + pack: &Value, + data: &Value, + seen: &mut BTreeSet, + ) -> Result { + if n.kind != "Kit" { + return Err(format!( + "native pack requires a Kit component, got {}", + n.kind + )); + } + let args: Map = n + .args + .iter() + .map(|(k, v)| value(v).map(|v| (k.clone(), v))) + .collect::>()?; + let name = args + .get("component") + .and_then(Value::as_str) + .ok_or("missing component")?; + let instance = args + .get("instance") + .and_then(Value::as_str) + .ok_or("missing instance")?; + // A shared compound owns its hierarchy. Its one public identity binds + // local part names to source-qualified identities in checked host data. + let id = if let Some(part) = args.get("part") { + let part = part.as_str().ok_or("invalid kit part")?; + let parts = &data["$kit"]["instances"][instance]["parts"]; + if parts["p0"] != instance { + return Err("compound root identity mismatch".into()); + } + parts[part] + .as_str() + .ok_or_else(|| format!("missing kit part {instance}/{part}"))? + } else { + instance + }; + if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + || id.is_empty() + || !seen.insert(id.into()) + { + return Err(format!("invalid or duplicate kit instance {id}")); + } + let component = pack["components"] + .get(name) + .ok_or_else(|| format!("unknown kit component {name}"))?; + let placement = data["$kit"]["placements"] + .get(id) + .ok_or_else(|| format!("missing kit placement {id}"))?; + if placement["component"] != name { + return Err(format!("component/placement mismatch for {id}")); + } + let mut attrs = Map::new(); + for (key, v) in component["style"] + .as_object() + .ok_or("missing component style")? + { + attrs.insert(key.clone(), resolve(v, &pack["tokens"])?); + } + for (key, v) in placement["layout"] + .as_object() + .ok_or("missing component layout")? + { + if !["x", "y", "w", "h", "src", "image_width", "image_height"].contains(&key.as_str()) { + return Err(format!("unregistered layout property {key}")); + } + if key != "src" && !v.as_f64().is_some_and(|n| n.is_finite()) { + return Err(format!("invalid layout {key}")); + } + attrs.insert(key.clone(), v.clone()); + } + let props = component["props"] + .as_object() + .ok_or("missing component props")?; + for (key, v) in args + .iter() + .filter(|(k, _)| !matches!(k.as_str(), "component" | "instance" | "part")) + { + if key == "index" && !v.as_f64().is_some_and(|n| n >= -1. && n.fract() == 0.) { + return Err("kit index must be an integer >= -1".into()); + } + let property = props + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("{name} has no prop {key}"))?; + let v = if v.is_boolean() { + Value::from(i32::from(v.as_bool().unwrap())) + } else { + v.clone() + }; + attrs.insert(property.into(), v); + } + for key in props.keys() { + if !args.contains_key(key) { + return Err(format!("{name} requires {key}")); + } + } + if !n.children.is_empty() && component["slot"] != true { + return Err(format!("{name} has no child slot")); + } + attrs.insert("id".into(), id.into()); + if component["slot"] == true { + attrs.insert( + "c".into(), + Value::Array( + n.children + .iter() + .map(|n| visit(n, pack, data, seen)) + .collect::>()?, + ), + ); + } + Ok(Value::Object(attrs)) + } + visit(root, pack, data, &mut BTreeSet::new()) +} + +pub fn lower(root: &UiNode, pack: &Value, data: &Value) -> Result { + Ok(format!( + "let node = {}\nnode\n", + literal(&tree(root, pack, data)?) + )) +} + +/// The standard semantic L0 roles use the same measured theme tokens as the +/// imported variants. Sizes in this older role kit are Makepad points. +pub fn theme_source(pack: &Value) -> String { + let mappings = [ + ("color.surface.page", "l0_base", 1.), + ("color.surface.panel", "l0_sheet", 1.), + ("color.content.primary", "l0_text", 1.), + ("color.action.primary", "l0_accent", 1.), + ("typography.body.font_src", "l0_font_src", 1.), + ("typography.body.size", "font_body", 0.75), + ("typography.body.size", "font_row", 0.75), + ("typography.title.size", "font_title", 0.75), + ("typography.caption.size", "font_caption", 0.75), + ("shape.surface.radius", "radius_panel", 1.), + ]; + let mut out = String::new(); + for (token, name, scale) in mappings { + if let Some(v) = pack["tokens"][token].get("value") { + let v = if scale != 1. { + v.as_f64() + .map(|n| Value::from(n * scale)) + .unwrap_or(v.clone()) + } else { + v.clone() + }; + out.push_str(&format!("let {name} = {}\n", literal(&v))); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (UiNode, Value, Value) { + let card="theme camo\ncomponent Caption(id: text, label: text) {view Kit(component: \"caption\", instance: id, text: label)}\nview root Caption(id: \"title\", label: \"line 1\\n\\\"quoted\\\" \\u2603\")"; + let report = crate::realize(card, &serde_json::json!({}), Default::default()); + let root = report.complete_root().unwrap().clone(); + let pack = serde_json::json!({"schema_version":1,"tokens":{"ink":{"value":4294967295u64}}, + "components":{"caption":{"style":{"t":"text","color":{"$token":"ink"}},"props":{"text":"text"},"slot":false}}}); + let data = serde_json::json!({"$kit":{"placements":{"title":{"component":"caption","layout":{"x":0,"y":0,"w":100,"h":40}}}}}); + (root, pack, data) + } + #[test] + fn copy_escapes_survive_components_and_theme_tokens_resolve() { + let (root, pack, data) = fixture(); + let out = tree(&root, &pack, &data).unwrap(); + assert_eq!(out["text"], "line 1\n\"quoted\" ☃"); + assert_eq!(out["color"], 4294967295u64); + let mut light = pack.clone(); + light["tokens"]["ink"]["value"] = Value::from(4278190080u64); + assert_eq!(tree(&root, &light, &data).unwrap()["color"], 4278190080u64); + } + #[test] + fn unknown_components_tokens_props_and_placements_fail_closed() { + let (root, pack, data) = fixture(); + let mut bad = pack.clone(); + bad["tokens"] = serde_json::json!({}); + assert!(tree(&root, &bad, &data) + .unwrap_err() + .contains("missing kit token")); + let mut bad = pack.clone(); + bad["components"] = serde_json::json!({}); + assert!(tree(&root, &bad, &data) + .unwrap_err() + .contains("unknown kit component")); + let mut bad = pack.clone(); + bad["components"]["caption"]["props"] = serde_json::json!({}); + assert!(tree(&root, &bad, &data) + .unwrap_err() + .contains("has no prop text")); + let mut bad = data.clone(); + bad["$kit"]["placements"]["title"]["component"] = "other".into(); + assert!(tree(&root, &pack, &bad).unwrap_err().contains("mismatch")); + let mut bad = data.clone(); + bad["$kit"]["placements"]["title"]["layout"]["t"] = "image".into(); + assert!(tree(&root, &pack, &bad) + .unwrap_err() + .contains("unregistered layout property")); + } + #[test] + fn compound_parts_preserve_identity_and_reject_broken_host_bindings() { + let (mut root, pack, mut data) = fixture(); + root.args + .push(("part".into(), NodeValue::Text("p1".into()))); + data["$kit"]["instances"]["title"] = + serde_json::json!({"parts":{"p0":"title","p1":"caption"}}); + data["$kit"]["placements"]["caption"] = data["$kit"]["placements"]["title"].clone(); + assert_eq!(tree(&root, &pack, &data).unwrap()["id"], "caption"); + let mut bad = data.clone(); + bad["$kit"]["instances"]["title"]["parts"]["p0"] = "someone_else".into(); + assert!(tree(&root, &pack, &bad) + .unwrap_err() + .contains("root identity")); + let mut bad = data.clone(); + bad["$kit"]["instances"]["title"]["parts"]["p1"] = Value::Null; + assert!(tree(&root, &pack, &bad) + .unwrap_err() + .contains("missing kit part")); + let mut bad = data.clone(); + bad["$kit"]["placements"]["caption"]["component"] = "other".into(); + assert!(tree(&root, &pack, &bad).unwrap_err().contains("mismatch")); + } + #[test] + fn semantic_selection_index_preserves_declared_negative_initial() { + let card="state choice { shape: number, initial: -1 }\ncomponent Tabs(selected_index: number) {view Kit(component: \"tabs\", instance: \"tabs\", index: selected_index)}\nview root Tabs(selected_index: choice)"; + let report = crate::realize(card, &serde_json::json!({}), Default::default()); + let pack = serde_json::json!({"schema_version":1,"tokens":{},"components":{"tabs":{ + "style":{"t":"stack"},"props":{"index":"kit_index"},"slot":false}}}); + let data = serde_json::json!({"$kit":{"placements":{"tabs":{"component":"tabs","layout":{"w":100,"h":40}}}}}); + let lowered = tree(report.complete_root().unwrap(), &pack, &data).unwrap(); + assert_eq!(lowered["kit_index"].as_f64(), Some(-1.)); + } +} diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index 4d52260..785b5b5 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -14,7 +14,7 @@ //! //! # Why this is its own crate //! -//! It depends on `serde_json` and nothing else, so any host can adopt L0 without +//! It depends on `serde_json` and `blake3`, so any host can adopt L0 without //! adopting a runtime. That is not a stylistic preference: `octoscript-core` carries //! a vendored `makepad-script`, and an application already using a different //! makepad lineage cannot depend on it — Cargo refuses the lockfile, because @@ -43,6 +43,12 @@ //! //! Everything is effect-free and bounded: no evaluation, no imports, no host access. +pub mod approval; +pub mod kit_pack; +mod value_origin; +use value_origin::{card_state_origin, initial_origin, needs_data_origin}; +pub use value_origin::{event_payload_origin, ValueOrigin}; + /// One one-based location. Mirrors `octoscript_core::SyntaxDiagnostic` in shape; /// separate so this crate stands alone. #[derive(Clone, Debug, Eq, PartialEq)] @@ -64,7 +70,7 @@ 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. + /// Adds pure arithmetic; accepted when explicitly declared in the header. L1, /// Full Octoscript, including imperative widget commands. Not accepted here. L2, @@ -74,8 +80,8 @@ pub enum Level { #[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`]. + /// The admitted profile, or the required profile on a level mismatch. + /// An explicitly declared L1 is retained even if no arithmetic is used. pub level: Level, pub diagnostics: Vec, pub diagnostics_truncated: bool, @@ -91,6 +97,10 @@ pub struct UiL0Report { /// 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)>, + /// Advisories: the card is valid and will realize, but something in it will + /// not survive a change of theme. Separate from `diagnostics` on purpose — + /// a lint that could refuse a card would be a rule, and these are not rules. + pub lints: Vec, } /// What a card's header declares — ledger name, version, level and profile. @@ -202,6 +212,15 @@ pub fn check_ui_l0(source: &str) -> UiL0Report { /// Check source against the L0 profile, naming it for diagnostics. pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { + if let Some(report) = CHECK_CACHE.with(|cache| cache.borrow_mut().get(source)) { + return report; + } + let report = check_ui_l0_uncached(source); + CHECK_CACHE.with(|cache| cache.borrow_mut().insert(source, report.clone())); + report +} + +fn check_ui_l0_uncached(source: &str) -> UiL0Report { let mut sink = Diagnostics::default(); let header = parse_header(source); @@ -260,9 +279,70 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { report.closure = component_closure(&card); report.header = header.clone(); check_header(&header, report.level, &mut report); + check_theme_portability(source, &mut report); report } +/// Text that carries its own colour cannot follow a theme. +/// +/// A colour-font glyph — an emoji — is painted from the emoji font with the +/// colours baked into it, so `draw_text.color` never reaches it. A card using +/// one as an icon renders correctly under exactly the polarity its author had on +/// screen, and is invisible under the other. Measured: 54 of 967 corpus cards do +/// this, and on a light ground their icons vanish entirely — four separate judge +/// verdicts named it before anyone thought to look for the cause. +/// +/// This is an advisory, not a rule. The card is valid; it is just not portable, +/// and nothing else in the pipeline can tell you so. +fn check_theme_portability(source: &str, report: &mut UiL0Report) { + // A conservative sweep of the pictographic blocks plus the variation + // selector. Latin, CJK and punctuation are all monochrome and take the + // theme's ink correctly, so nothing outside these ranges is a concern. + fn pictographic(c: char) -> bool { + matches!(c as u32, + 0x1F300..=0x1FAFF // emoji, pictographs, symbols + | 0x2600..=0x27BF // misc symbols and dingbats + | 0x1F000..=0x1F2FF // tiles, enclosed characters + | 0xFE0F // variation selector: "render this in colour" + ) + } + + for (n, line) in source.lines().enumerate() { + // Only the argument positions that end up as painted text. A comment or + // a `query:` string never reaches a glyph. + for key in ["text:", "glyph:"] { + let mut rest = line; + let mut base = 0usize; + while let Some(at) = rest.find(key) { + let after = &rest[at + key.len()..]; + if let Some(open) = after.find('"') { + let body = &after[open + 1..]; + if let Some(close) = body.find('"') { + let lit = &body[..close]; + if let Some(c) = lit.chars().find(|c| pictographic(*c)) { + report.lints.push(SyntaxDiagnostic { + line: n + 1, + column: base + at + 1, + message: format!( + "{c:?} is a colour-font glyph, so it ignores the theme's \ + ink and stays the colour it was drawn in — this card will \ + render correctly in one polarity and invisibly in the \ + other. Use an icon role, or accept that the card is \ + single-theme." + ), + }); + } + base += at + key.len() + open + 1 + close + 1; + rest = &body[close + 1..]; + continue; + } + } + break; + } + } + } +} + /// §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. @@ -319,23 +399,23 @@ fn check_header(header: &Option, derived: Level, report: &mut UiL0Re /// 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 +/// The digest covers params, state, events and transitive component/view bodies. +/// External source/copy/theme contents are not approval-pinned by this report. +/// It excludes 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))) + .map(|c| (c.name.clone(), definition_digest(c, card))) .collect(); out.sort(); out } -fn definition_digest(component: &Component) -> u64 { +fn definition_digest(component: &Component, card: &Card) -> u64 { fn element(e: &Element, into: &mut String) { - into.push_str(&e.name); + into.push_str(&format!("{:?}", (&e.name, e.is_reference, &e.key_path))); into.push('('); for b in &e.binders { into.push_str(b); @@ -360,25 +440,66 @@ fn definition_digest(component: &Component) -> u64 { 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(','); + fn component_body(component: &Component, acc: &mut String) { + acc.push_str(&format!("component:{:?}", 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, acc); } - for st in &component.states { - acc.push_str(&format!( - "{}:{:?}:{:?}:{:?}:{}", - st.path, st.shape, st.initial, st.initial_path, st.keep - )); + fn references(e: &Element, out: &mut Vec<(bool, String)>, card: &Card) { + if e.is_reference { + out.push((true, e.name.clone())); + } else if card.components.iter().any(|c| c.name == e.name) { + out.push((false, e.name.clone())); + } + for child in &e.children { + references(child, out, card); + } } - 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)); + // Include the actual transitive definitions, including referenced views. + // Locations and declaration order are excluded; semantic ordering within a + // definition is retained. State schema identity remains separate. + let mut pending = vec![(false, component.name.clone())]; + let mut definitions = std::collections::BTreeMap::new(); + while let Some((is_view, name)) = pending.pop() { + if definitions.contains_key(&(is_view, name.clone())) { + continue; } + let mut definition = String::new(); + let body = if is_view { + let Some(view) = card.views.iter().find(|v| v.name == name) else { + continue; + }; + element(&view.body, &mut definition); + &view.body + } else { + let Some(component) = card.components.iter().find(|c| c.name == name) else { + continue; + }; + component_body(component, &mut definition); + &component.body + }; + references(body, &mut pending, card); + definitions.insert((is_view, name), definition); + } + let mut acc = format!("closure-v2:{:?}", component.name); + for (name, definition) in definitions { + acc.push_str(&format!("{:?}", (name, definition))); } - element(&component.body, &mut acc); let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in acc.bytes() { @@ -390,7 +511,7 @@ fn definition_digest(component: &Component) -> u64 { // ─────────────────────────────────────────────────────────────────── diagnostics ── -#[derive(Default)] +#[derive(Clone, Default)] struct Diagnostics { items: Vec, truncated: bool, @@ -432,6 +553,7 @@ impl Diagnostics { level, diagnostics: self.items, diagnostics_truncated: self.truncated, + lints: Vec::new(), } } } @@ -502,7 +624,7 @@ fn lex(source: &str, sink: &mut Diagnostics) -> Option> { // String literal. if c == '"' { - let mut text = String::new(); + let start = i; i += 1; col += 1; let mut closed = false; @@ -516,7 +638,16 @@ fn lex(source: &str, sink: &mut Diagnostics) -> Option> { if bytes[i] == '\n' { break; } - text.push(bytes[i]); + if bytes[i] == '\\' { + // An escaped quote does not terminate the literal. Decode + // once below so copy, state and component props all retain + // identical newlines, Unicode escapes and backslashes. + i += 1; + col += 1; + if i >= bytes.len() || bytes[i] == '\n' { + break; + } + } i += 1; col += 1; } @@ -524,6 +655,18 @@ fn lex(source: &str, sink: &mut Diagnostics) -> Option> { sink.push(start_line, start_col, "unterminated string".into()); return None; } + let raw: String = bytes[start..i].iter().collect(); + let text = match serde_json::from_str::(&raw) { + Ok(text) => text, + Err(error) => { + sink.push( + start_line, + start_col, + format!("invalid string escape: {error}"), + ); + return None; + } + }; out.push(Token { kind: Kind::Str, text, @@ -566,6 +709,17 @@ fn lex(source: &str, sink: &mut Diagnostics) -> Option> { // Comparators. if matches!(c, '=' | '!' | '<' | '>') { let two = i + 1 < bytes.len() && bytes[i + 1] == '='; + // A lone `<` or `>` IS a comparator; a lone `=` or `!` is not. + // + // These were one rule, and it made `when a > b` — the exact form + // §"what is not here" tells an agent to write instead of `if a > b` — + // unparseable. `compare` has implemented `<` and `>` all along and + // could never be reached with them, so the guards a card was told to + // write were refused for a lexer rule about `!x`. + // + // Nothing else in the grammar uses an angle bracket, so there is no + // ambiguity to preserve: no generics, no arrows, no tags. + let one_char_cmp = matches!(c, '<' | '>'); let text: String = if two { let s: String = bytes[i..i + 2].iter().collect(); i += 2; @@ -576,10 +730,9 @@ fn lex(source: &str, sink: &mut Diagnostics) -> Option> { 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 { + // A lone `=` or `!` is punctuation. Without this `!x` lexes as Cmp + // and the level classifier's punctuation check never fires. + if !two && !one_char_cmp { out.push(Token { kind: Kind::Punct, text, @@ -768,6 +921,53 @@ fn classify_beyond_l0(tokens: &[Token]) -> Option<(Level, SyntaxDiagnostic)> { worst } +// Source parsing/checking is independent of runtime data. Keep a small LRU per +// thread; exact source keys preserve diagnostics and source changes invalidate +// immediately. Oversized inputs are still checked, but never retained. +const SOURCE_CACHE_ENTRIES: usize = 8; +#[derive(Default)] +struct SourceCache { + entries: std::collections::VecDeque<(String, T)>, +} +impl SourceCache { + fn get(&mut self, source: &str) -> Option { + let index = self.entries.iter().position(|(key, _)| key == source)?; + let entry = self.entries.remove(index)?; + let value = entry.1.clone(); + self.entries.push_back(entry); + Some(value) + } + fn insert(&mut self, source: &str, value: T) { + if source.len() > DEFAULT_MAX_SOURCE_BYTES { + return; + } + if self.entries.len() == SOURCE_CACHE_ENTRIES { + self.entries.pop_front(); + } + self.entries.push_back((source.to_owned(), value)); + } +} +thread_local! { + static CHECK_CACHE: std::cell::RefCell> = + const { std::cell::RefCell::new(SourceCache { entries: std::collections::VecDeque::new() }) }; + static PARSE_CACHE: std::cell::RefCell>, Diagnostics)>> = + const { std::cell::RefCell::new(SourceCache { entries: std::collections::VecDeque::new() }) }; +} +fn parsed_card(source: &str, sink: &mut Diagnostics) -> Option> { + if let Some((card, diagnostics)) = PARSE_CACHE.with(|cache| cache.borrow_mut().get(source)) { + *sink = diagnostics; + return card; + } + let card = + lex(source, sink).map(|tokens| std::rc::Rc::new(Parser::new(&tokens, sink).parse_card())); + PARSE_CACHE.with(|cache| { + cache + .borrow_mut() + .insert(source, (card.clone(), sink.clone())) + }); + card +} + // ────────────────────────────────────────────────────────────────────────── ast ── #[derive(Debug, Default)] @@ -782,6 +982,16 @@ struct Card { copies: Vec, components: Vec, views: Vec, + /// The card's declared theme INTENT, and the line it was declared on. + /// + /// A name, never a colour. §4 forbids a card stating presentation, and that + /// does not change here: `theme dark` says which of a closed set of moods + /// this card is in, and the layer above — the component kit — decides what + /// dark looks like. A card that wants a look no theme name covers does not + /// get to describe it. + theme: Option<(String, usize)>, + /// Axis intents declared on the same line as the mood, in source order. + theme_axes: Vec<(String, String)>, } /// A declared capability dependency: the name it binds, the helper that answers @@ -1003,7 +1213,7 @@ struct Arg { column: usize, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] enum Operand { Path(String), Token(String), @@ -1179,6 +1389,83 @@ impl<'a> Parser<'a> { }); } } + "theme" => { + self.at += 1; + let line = t.line; + if let Some(name) = self.ident() { + if let Some((first, first_line)) = &card.theme { + self.sink.at( + &t, + format!( + "a card declares ONE theme; {first:?} was already \ + declared on line {first_line}" + ), + ); + let _ = first; + } else if catalog::theme(&name).is_none() { + self.sink.at( + &t, + format!( + "{name:?} is not a theme L0 admits; a card names a \ + catalogued mood and never a colour (profile §4). \ + Known: {}", + catalog::THEMES.join(", ") + ), + ); + } else { + card.theme = Some((name, line)); + } + // Axes follow the mood ON THE SAME LINE: + // theme light shape: .square accent: .amber + // The lexer discards newlines, so "same line" is + // enforced against `Token.line` rather than by grammar + // shape — without that check the next declaration's + // first identifier would be eaten as an axis name. + while self + .peek() + .is_some_and(|t| t.line == line && t.kind == Kind::Ident) + && self.peek_at(1).is_some_and(|t| t.is(Kind::Punct, ":")) + { + let axis = self.ident().unwrap_or_default(); + self.at += 1; // the colon + let Some(v) = self.peek().cloned() else { break }; + self.at += 1; + let value = v.text.trim_start_matches('.').to_owned(); + match catalog::axis(&axis) { + None => self.sink.at( + &v, + format!( + "{axis:?} is not a theme axis L0 admits. Known: {}", + catalog::AXES + .iter() + .map(|(n, _)| *n) + .collect::>() + .join(", ") + ), + ), + Some(legal) if !legal.contains(&value.as_str()) => self.sink.at( + &v, + format!( + "{value:?} is not a value {axis:?} admits; a card names \ + a catalogued intent and never a colour or a length \ + (profile §4). Known: {}", + legal.join(", ") + ), + ), + Some(_) => { + if card.theme_axes.iter().any(|(a, _)| *a == axis) { + self.sink.at( + &v, + format!("{axis:?} is declared twice on this theme"), + ); + } else { + card.theme_axes.push((axis, value)); + } + } + } + } + } + } "component" => { self.at += 1; if let Some(c) = self.parse_component() { @@ -1195,7 +1482,7 @@ impl<'a> Parser<'a> { self.sink.at( &t, format!( - "expected a declaration (source, state, event, copy, component, view), found {:?}", + "expected a declaration (source, state, event, copy, theme, component, view), found {:?}", t.text ), ); @@ -1358,6 +1645,18 @@ impl<'a> Parser<'a> { self.at += 1; SourceArg::Text(t.text) } + // A NEGATIVE literal, as `parse_atom` already allows in argument + // position. This grammar had no case for it, so `lon: -122.4194` + // failed at the `-` with "expected a name" — which meant no card + // could name a coordinate in the western hemisphere, or a + // temperature below zero, without going through `sys.geocode`. + Some(t) + if t.is_punct("-") && self.peek_at(1).is_some_and(|n| n.kind == Kind::Num) => + { + let n = self.peek_at(1).map(|n| n.text.clone()).unwrap_or_default(); + self.at += 2; + SourceArg::Number(-n.parse::().unwrap_or(0.0)) + } Some(t) if t.kind == Kind::Num => { self.at += 1; SourceArg::Number(t.text.parse().unwrap_or(0.0)) @@ -1487,6 +1786,38 @@ impl<'a> Parser<'a> { Kind::Num => { v.text.parse::().ok().map(serde_json::Value::from) } + Kind::Punct if v.is_punct("-") => { + // The lexer keeps unary '-' separate from + // the number. Dropping it silently changed + // a declared -1 (no selection) into the + // numeric default 0 during realization. + match self.tokens.get(scan + 3) { + Some(number) if number.kind == Kind::Num => { + if let Some(t) = self.tokens.get(scan + 4).filter(|t| { + t.kind == Kind::Punct + && matches!( + t.text.as_str(), + "+" | "-" | "*" | "/" | "%" + ) + }) { + self.sink.at(t, "`initial:` takes a literal or source path, not an expression".into()); + } + number + .text + .parse::() + .ok() + .map(|n| serde_json::Value::from(-n)) + } + _ => { + self.sink.at( + v, + "negative initial requires a numeric literal" + .into(), + ); + None + } + } + } Kind::Ident if v.text == "true" || v.text == "false" => { Some(serde_json::Value::Bool(v.text == "true")) } @@ -2319,11 +2650,7 @@ impl<'a> Parser<'a> { self.depth += 1; let rhs = self.parse_term(); self.depth -= 1; - lhs = Operand::Expr { - lhs: Box::new(lhs), - op: op.text, - rhs: Box::new(rhs), - }; + lhs = self.binary_operand(lhs, &op, rhs); } lhs } @@ -2342,15 +2669,41 @@ impl<'a> Parser<'a> { self.depth += 1; let rhs = self.parse_atom(); self.depth -= 1; - lhs = Operand::Expr { - lhs: Box::new(lhs), - op: op.text, - rhs: Box::new(rhs), - }; + lhs = self.binary_operand(lhs, &op, rhs); } lhs } + /// Parser recursion does not measure a left-associative chain's AST depth. + /// Bound each newly constructed operand before any recursive consumer (or + /// Drop) can see it. Error recovery discards only already-bounded trees. + fn bounded_operand(&mut self, value: Operand, at: &Token) -> Operand { + fn depth(value: &Operand) -> usize { + match value { + Operand::Expr { lhs, rhs, .. } => 1 + depth(lhs).max(depth(rhs)), + Operand::Predicate { rhs, .. } => 1 + depth(rhs), + _ => 1, + } + } + if depth(&value) > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(at, "expression tree is too deep".into()); + Operand::Num(0.0) + } else { + value + } + } + + fn binary_operand(&mut self, lhs: Operand, op: &Token, rhs: Operand) -> Operand { + self.bounded_operand( + Operand::Expr { + lhs: Box::new(lhs), + op: op.text.clone(), + rhs: Box::new(rhs), + }, + op, + ) + } + fn parse_atom(&mut self) -> Operand { let Some(t) = self.peek().cloned() else { return Operand::Str(String::new()); @@ -2396,11 +2749,7 @@ impl<'a> Parser<'a> { self.depth += 1; let inner = self.parse_atom(); self.depth -= 1; - return Operand::Expr { - lhs: Box::new(Operand::Num(0.0)), - op: "-".to_string(), - rhs: Box::new(inner), - }; + return self.binary_operand(Operand::Num(0.0), &t, inner); } match t.kind { Kind::Token => { @@ -2439,11 +2788,14 @@ impl<'a> Parser<'a> { // same one every other nested construct uses. let rhs = self.parse_operand(); self.depth -= 1; - return Operand::Predicate { - path, - cmp: op.text, - rhs: Box::new(rhs), - }; + return self.bounded_operand( + Operand::Predicate { + path, + cmp: op.text.clone(), + rhs: Box::new(rhs), + }, + &op, + ); } } Operand::Path(path) @@ -2514,8 +2866,11 @@ fn scope_value(scope: &ValueScope, operand: &Operand) -> Option 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, + Operand::Predicate { path, cmp, rhs } => Some(serde_json::Value::Bool(compare( + scope.lookup(path), + cmp, + scope_value(scope, rhs), + ))), Operand::Expr { lhs, op, rhs } => eval_expr(scope, lhs, op, rhs), } } @@ -2557,67 +2912,32 @@ fn apply_op(a: f64, op: &str, b: f64) -> Option { v.is_finite().then_some(v) } -/// Evaluate an expression with every path resolved by `resolve`. -/// -/// The shape §9.3's probe needs: the same tree, the same operators, arbitrary -/// values for the reads. -fn fold_expr(operand: &Operand, resolve: &dyn Fn(&str) -> Option) -> Option { - match operand { - Operand::Num(n) => Some(*n), - Operand::Path(p) => resolve(p), - Operand::Expr { lhs, op, rhs } => { - apply_op(fold_expr(lhs, resolve)?, op, fold_expr(rhs, resolve)?) - } - // A comparison is a boolean, not a number, and arithmetic over one is - // not in the grammar. +/// Establish a constant result only for a small set of structural identities. +/// `None` means unknown, not that dependence or factual provenance was proved. +/// The identities hold wherever all operands and the result are finite; missing +/// inputs still propagate at runtime. No trial inputs or reassociation are used. +fn constant_expr_value(operand: &Operand) -> Option { + let Operand::Expr { lhs, op, rhs } = operand else { + return match operand { + Operand::Num(n) if n.is_finite() => Some(*n), + _ => None, + }; + }; + let a = constant_expr_value(lhs); + let b = constant_expr_value(rhs); + if let (Some(a), Some(b)) = (a, b) { + return apply_op(a, op, b); + } + match op.as_str() { + "-" | "%" if lhs == rhs => Some(0.0), + "/" if lhs == rhs => Some(1.0), + "*" if a == Some(0.0) || b == Some(0.0) => Some(0.0), _ => None, } } -/// Whether an expression's value is INDEPENDENT of everything it reads. -/// -/// §9.3 requires an expression to read something, which stops `1547 * 3.2` and -/// does not stop `quote.last * 0 + 1547` — one real reading laundering a -/// fabricated number past the rule. That gap was recorded as needing an argument -/// nobody had; this is the argument. -/// -/// A formula is a formula because its answer MOVES when its inputs move. So the -/// expression is evaluated with its reads bound to several distinct assignments, -/// and an answer that never changes is a constant the model wrote with extra -/// steps. `temp * 9 / 5 + 32` moves; `last * 0 + 1547` does not. -/// -/// Distinct values PER PATH, varied across rounds, because binding every read to -/// the same number would make `a - b` constant and condemn a correct formula. -/// Three rounds of coprime-ish values: an expression that is constant across all -/// three and not constant in general is not something the five arithmetic -/// operators can express. -/// -/// Unresolvable in every round (a division by a probed zero, say) is NOT -/// degenerate — that is a partial expression, and §9.4 already renders it as -/// missing. fn expr_is_constant(expr: &Operand) -> bool { - let mut paths = Vec::new(); - expr_paths(expr, &mut paths); - paths.sort(); - paths.dedup(); - if paths.is_empty() { - // The must-read rule owns this case and reports it better. - return false; - } - let mut seen: Vec = Vec::new(); - for (base, step) in [(2.0, 1.0), (5.0, 3.0), (11.0, 7.0)] { - let resolve = |p: &str| -> Option { - paths - .iter() - .position(|q| q == p) - .map(|i| base + step * i as f64) - }; - match fold_expr(expr, &resolve) { - Some(v) => seen.push(v), - None => return false, - } - } - seen.windows(2).all(|w| w[0] == w[1]) + constant_expr_value(expr).is_some() } /// Compare two resolved values. Shared by guards (`when a == b`) and by @@ -2732,6 +3052,139 @@ fn collect_constructors(e: &Element, out: &mut Vec) { /// The argument contract. Mirrors `docs/ui-l0-constructors.toml`, which is the /// human-readable spec; a test asserts the two agree. pub mod catalog { + /// The themes a card may declare, as MOODS rather than looks. + /// + /// Closed, and closed for the same reason the role list is: a theme name is + /// a promise that every kit answers it. A card naming a mood no kit has + /// would render in whatever the kit's default palette is and look correct, + /// which is the §1.1 failure this list exists to make impossible — so an + /// unknown name is refused at parse time instead. + /// + /// `dark` is the default and needs no declaration; it is listed so a card + /// may say so explicitly. + /// `vibrant` and `minimal` were declared by 43 corpus cards that could not + /// realize — the checker refused them before they reached a colour, because + /// the vocabulary named four moods and the generator had been writing six. + pub const THEMES: &[&str] = &[ + "dark", + "light", + "glass", + "photo", + "vibrant", + "minimal", + // Theme PACKS — whole design systems minted from purchased kits by + // the lab/sketch pipeline: seeds + measured scale + family + depth. + // A pack is just a mood with luggage; every axis still composes on top. + "atro", + "atro_light", + "camo", + "camo_light", + "taskplan_light", + ]; + + /// The theme AXES a card may name beside its mood, and the closed set each + /// admits. A mood is one coordinate; these are the others. + /// + /// Every value is a token, never a colour, a length or a font name — the + /// card states an INTENT and the theme decides what it looks like, which is + /// the same contract `theme ` has always had. Four moods could only + /// ever be four looks; a corpus of 100 generated designs asked for square + /// AND soft geometry, restrained AND vivid palettes, grotesk AND serif + /// display, and one global setting cannot serve both sides of any of those. + /// + /// `shape`, `density`, `emphasis` and `icons` are knobs the palette already + /// carries and renders — they were simply not selectable per card. + pub const AXES: &[(&str, &[&str])] = &[ + ( + "accent", + &[ + "neutral", "indigo", "blue", "red", "green", "amber", "cyan", "magenta", "violet", + ], + ), + ("radius", &["none", "small", "large", "full"]), + ("density", &["compact", "regular", "airy"]), + ("emphasis", &["quiet", "clear", "poster"]), + ("icons", &["filled", "mono"]), + // A tiled surface grain. A NAME, like every other axis value — the card + // states a material and the theme owns the file, for the same reason it + // owns the colours. `Photo.src` refuses a literal because a literal in a + // data position is a model-authored fact (§4); a texture path would be + // the identical mistake wearing a different key. + ( + "texture", + &[ + "none", "paper", "linen", "concrete", "noise", "deco", "halftone", + ], + ), + // How a surface sits off the page. `soft` is the identity — the Material + // lift every mood already derives from elevation — so a card naming it + // and a card naming nothing render alike. + ("depth", &["soft", "flat", "hard", "glow"]), + // The typeface. `sans` is the identity. `display` is a serif headline + // over a sans body — one axis answering what the corpus counted as two + // separate wants (`serif_display` and `font_pair`), because a single + // family token cannot express a pairing and a pairing is what editorial + // design actually asks for. + ("type", &["sans", "serif", "display"]), + // The request's own language. A feel RESOLVES to axis defaults in the + // host (explicit axes still override), so a card can carry the user's + // actual word — "premium", "calm" — and re-resolve as the theme + // library improves. Every value ships only after a blind paired judge + // confirms it reads as its word; an unvalidated feel is the + // disconnected knob wearing a nicer name. + ( + "feel", + &[ + "bright", + "calm", + "bold", + "premium", + "playful", + "warm", + "cool", + "minimal", + "inspirational", + ], + ), + // A seeded page colour — the lever every measurement said was missing + // (judges named colour in 92% of failed cards; the deco teal was + // unreachable). Each value is NINE SEED SCALARS in its fragment; + // `_derive_color.octoscript` computes the palette in-kit via `mod.math`. + // Composes with `accent:` — the accent arrives as a hue SEED and + // `_derive_color` re-solves the ink against the seeded ground in-kit + // (unrolled contrast search; all 112 pairs audited AA-clean). + ( + "ground", + &[ + "teal", + "violet", + "indigo", + "navy", + "ivory", + "sand", + "terracotta", + "wine", + "forest", + "slate", + "paper", + "cream", + "midnight", + "blush", + "olive", + ], + ), + ]; + + /// The legal values for an axis, or `None` if L0 has no such axis. + pub fn axis(name: &str) -> Option<&'static [&'static str]> { + AXES.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) + } + + /// The catalogued theme of that name, or `None` if L0 does not admit it. + pub fn theme(name: &str) -> Option<&'static str> { + THEMES.iter().copied().find(|t| *t == name) + } + /// What an argument admits. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ArgKind { @@ -2804,9 +3257,82 @@ pub mod catalog { /// `primary` is the action a screen is FOR — the one thing you came to do. The /// theme draws it larger; the card only says which action it is. pub const TONE: &[&str] = &["normal", "primary", "danger"]; + /// A tab\'s selected state — the current screen\'s tab is `.on`. + pub const ONOFF: &[&str] = &["off", "on"]; pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; pub const PAD: &[&str] = &["page", "tight", "none"]; + /// The semantic icon vocabulary — meanings, never drawings. Measured off + /// the Atro corpus: a ~40-name set covered 86% of icon uses on 150 + /// commercial screens, and designers already name icons this way + /// (`bell-S-light`); the card states the meaning, the theme decides the + /// glyph set that answers it. + pub const ICON: &[&str] = &[ + "activity", + "alert", + "arrow_down", + "arrow_left", + "arrow_right", + "arrow_up", + "bell", + "bookmark", + "calendar", + "camera", + "chat", + "check", + "chevron_down", + "chevron_left", + "chevron_right", + "chevron_up", + "clock", + "close", + "cloud", + "edit", + "filter", + "heart", + "home", + "image", + "info", + "location", + "lock", + "mail", + "map", + "menu", + "mic", + "minus", + "moon", + "more", + "phone", + "play", + "plus", + "refresh", + "search", + "send", + "settings", + "share", + "star", + "sun", + "trash", + "user", + "users", + "video", + "wifi", + "zap", + ]; + pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; + /// A thumbnail is a list-row 16:9 tile unless the design shows a square + /// mosaic cell — a gallery is squares, a feed row is wide. + pub const THUMB_SHAPE: &[&str] = &["wide", "square", "hero"]; + /// A tile is a stat card unless the design shows a compact SQUARE cell — + /// a calendar day is a square, never a content-grown pill. + pub const TILE_SHAPE: &[&str] = &["card", "square"]; + /// A card\'s design colour when it differs from the pack gradient — + /// closed pastel roles; the numbers live in `card_tint`. + pub const CARD_TINT: &[&str] = &[ + "neutral", "green", "pink", "blue", "amber", "violet", "cyan", "red", + ]; + /// Which party a chat bubble belongs to. + pub const BUBBLE_SIDE: &[&str] = &["them", "me"]; pub type Args = &'static [(&'static str, ArgKind)]; @@ -2814,11 +3340,31 @@ pub mod catalog { /// Every constructor L0 admits, with the arguments each accepts. pub const CONSTRUCTORS: &[(&str, Args)] = &[ + // Host-registered component contract. Presentation and geometry are + // references into a kit; neither shader source nor widget DSL is L0. + ( + "Kit", + &[ + ("component", Text), + ("instance", Text), + ("part", Text), + ("text", Text), + ("placeholder", Text), + ("enabled", Bool), + ("checked", Bool), + ("selected", Bool), + ("index", Number), + ("value", Number), + ("value2", Number), + ("focused", Bool), + ("password", Bool), + ], + ), ("Surface", &[("pad", Token(PAD))]), ("Photo", &[("src", Path), ("pad", Token(PAD))]), // A row-sized image. `Photo` fills its width (it is a backdrop); a list // row needs a fixed 16:9 tile beside its text. - ("Thumb", &[("src", Path)]), + ("Thumb", &[("src", Path), ("shape", Token(THUMB_SHAPE))]), // A map. The card names the TRIP; the widget fetches its own route. // // The same correction `AqiContour` and `StockPlot` already took. The @@ -2901,7 +3447,14 @@ pub mod catalog { ("Panel", &[("dock", Token(DOCK))]), // Content a swipe reveals. See the catalog. ("Reveal", &[]), - ("Card", &[("on_tap", Event), ("value", Any)]), + ( + "Card", + &[ + ("on_tap", Event), + ("value", Any), + ("tint", Token(CARD_TINT)), + ], + ), // A column may say how WIDE, because a row of columns has to divide the // line somehow and only the card knows which column is the one that // should absorb what is left. A mover row is ticker-and-name beside a @@ -2932,6 +3485,28 @@ pub mod catalog { ), ("Grid", &[("cols", Number)]), ("Rule", &[]), + // One chat message. The side is meaning (who said it); the theme + // decides what mine-vs-theirs looks like. + ("Bubble", &[("text", Text), ("side", Token(BUBBLE_SIDE))]), + // The floating round action button, pinned over the page corner. + ("Fab", &[("name", Token(ICON))]), + // The bottom tab bar and its tabs — pinned to the page floor. + ("TabBar", &[]), + ( + "Tab", + &[ + ("icon", Token(ICON)), + ("label", Text), + ("active", Token(ONOFF)), + ], + ), + // A full-bleed inverse section band — a month strip, a dark app-bar + // stripe. Takes the strip's own title; it is chrome, not a container. + ("Band", &[("text", Text)]), + // A flexible blank. Zero arguments: its whole meaning is "the height + // that is left" — before a bottom bar it pins the bar to the bottom, + // around a centered stack it centers the stack vertically. + ("Space", &[]), ( "TextHero", &[ @@ -2952,6 +3527,10 @@ pub mod catalog { &[("value", Data), ("format", Token(FORMAT)), ("tint", Path)], ), ("TextRow", &[("text", Text), ("width", TokenOrPath(WIDTH))]), + // An EYEBROW: the small tracked-caps line above a hero. The mockup + // study priced its absence; the kit answers with tracking and caps, + // which is presentation and therefore never the card's to spell. + ("TextEyebrow", &[("text", Text)]), ( "TextCaption", &[ @@ -2979,8 +3558,28 @@ pub mod catalog { ("value", Data), ("unit", TokenOrPath(UNIT)), ("format", Token(FORMAT)), + ("glyph", Text), + ("shape", Token(TILE_SHAPE)), ], ), + ( + // A semantic icon: the card names a MEANING from the closed set, + // the theme's icon font answers it. The §4-compatible form of what + // full translation showed as the largest single "cannot say" + // (2,564 dropped icon instances across one kit's screens). + "Icon", + &[("name", Token(ICON)), ("size", Token(ICON_SIZE))], + ), + ( + // A person as initials in a tinted circle — the avatar every list + // design carries, WITHOUT an image: kits render exactly this when a + // photo is absent, so the no-photo form is a real design element, + // not a degraded one. The card passes the INITIALS ("TC"), because + // deriving them from "Tom Castle" needs string ops L0 does not have + // and the author writing the card already knows the name. + "Avatar", + &[("text", Text)], + ), ( "Chip", &[ @@ -3043,15 +3642,21 @@ pub mod catalog { /// able to NAME a capability it was not granted. pub const SOURCES: &[(&str, &[&str])] = &[ ("sys.geocode", &["name"]), + // LIVE topic/movie detail from Wikipedia. Declared `source d sys.wiki( + // query: state.title)`; a field access `d.extract` becomes the second + // arg — `sys.wiki(query, "extract")` — exactly like `now.temp` on + // `sys.weather`. So only the INPUT arg is listed here. + ("sys.wiki", &["query"]), ( "sys.weather", - &["lat", "lon", "days", "fields", "aggregate"], + &["lat", "lon", "days", "fields", "aggregate", "day"], ), ("sys.daylight", &["lat", "lon"]), ("sys.airquality", &["lat", "lon"]), ("sys.moonphase", &["lat", "lon"]), - ("sys.photo", &["query"]), + ("sys.photo", &["query", "cond"]), ("sys.locale", &[]), + ("sys.convert", &["amount", "from", "to", "direction", "fields"]), ("sys.gps", &[]), ("sys.search", &["query", "count", "fields"]), // COORDINATES, not places. A route needs four numbers and an argument @@ -3073,11 +3678,15 @@ pub mod catalog { ( "sys.step", &[ - "from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "fields", + "from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "via", "fields", ], ), ("sys.places", &["lat", "lon", "category", "count", "fields"]), ("sys.news", &["count", "offset", "fields"]), + ("sys.news_digest", &["query", "language", "count", "fields"]), + ("sys.news_status", &["query", "language", "fields"]), + ("sys.dataset", &["id", "fields"]), + ("sys.quakes", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), // `symbols` names the UNIVERSE to rank. Without it the only universe is // the market-wide day-gainers screener, so "top 10 AI movers" could only @@ -3110,7 +3719,7 @@ pub mod catalog { // The user's saved places. Same shape as `sys.watchlist`: no selector, // because it IS the list, and the host joins each stored place to a // live reading. - ("sys.cities", &["fields"]), + ("sys.cities", &["fields", "unit"]), ]; pub fn source(name: &str) -> Option<&'static [&'static str]> { @@ -3172,9 +3781,20 @@ pub mod catalog { ("sys.daylight", &["rise", "set", "now"]), ("sys.airquality", &["aqi", "pm25", "pm10", "ozone"]), ("sys.moonphase", &["phase", "illumination", "name"]), + // Live Wikipedia detail: the plot paragraph, the canonical title, the + // one-line description, a poster/still URL. Read off `source d + // sys.wiki(query: …)` as `d.extract`, `d.title`, … . + // `thumbnail` is NOT offered. The page-summary endpoint returns it as a + // nested object (`thumbnail.source`), and every lowering here reads a + // single flat field — so a card asking for `d.thumbnail` would have got + // an em dash with nothing able to warn it. Offering a field no backend + // answers is the one failure this vocabulary exists to prevent; adding + // it back needs a nested-path lowering first. + ("sys.wiki", &["title", "extract", "description"]), // A photo is a URL, not a record. No field is readable off it. ("sys.photo", &[]), ("sys.locale", &["lang", "temp_unit"]), + ("sys.convert", &["amount", "value"]), ("sys.gps", &["lat", "lon", "accuracy", "ok"]), // `label` is the secondary line — city, region, country. Without it a search // for "Stanford" renders five rows all reading "Stanford", which is what @@ -3194,6 +3814,16 @@ pub mod catalog { "sys.news", &["id", "title", "author", "points", "comments", "url"], ), + ("sys.news_digest", &["id", "title", "summary", "publisher", "url", "published_at"]), + ("sys.dataset", &["title", "subtitle", "summary", "coverage", "status", "as_of", + "metric1_label", "metric1_value", "metric2_label", "metric2_value", + "pick1_title", "pick1_body", "pick1_source", "url1", "pick2_title", "pick2_body", "pick2_source", "url2", + "pick3_title", "pick3_body", "pick3_source", "url3", "evidence_title", "evidence_body"]), + ("sys.news_status", &["status", "message", "count"]), + ( + "sys.quakes", + &["id", "mag", "place", "depth", "ago", "lat", "lon"], + ), ( "sys.news_item", &["id", "title", "author", "points", "comments", "url"], @@ -3275,7 +3905,7 @@ pub mod catalog { ( "sys.cities", &[ - "name", "lat", "lon", "temp", "feels", "hi", "lo", "cond", "humidity", "wind", + "name", "lat", "lon", "temp", "feels", "feels_delta", "hi", "lo", "cond", "humidity", "wind", ], ), ]; @@ -5084,6 +5714,8 @@ pub struct UiNode { pub bindings: Vec<(String, SourceBinding)>, /// For each argument that is an L1 expression, its resolved shape. pub exprs: Vec<(String, ExprPart)>, + /// Host-authenticated origins, retained through props and state. + pub origins: Vec<(String, ValueOrigin)>, } /// Where an argument's value came from, with the source's own arguments already @@ -5098,6 +5730,25 @@ pub struct SourceBinding { pub helper: String, /// Its arguments, resolved to values. pub args: Vec<(String, String)>, + /// Which of those arguments hold a CALL this lowering generated rather than + /// a value. + /// + /// A source argument that names another source resolves to the callee's own + /// live call, and once both are strings a backend cannot tell the two apart. + /// A numeric slot could guess — a coordinate is a number or it is a `sys.` + /// call — but a TEXT slot cannot, and it has to quote a value and not quote a + /// call. `sys.photo(query: place.name)` lowered to + /// `sys.photo("sys.geocode(\"kyoto\", \"name\")")`, so the wallpaper was + /// generated from the TEXT of the call. It looked right, which is why it + /// lived so long: the service is a generative image model and the place name + /// is inside the string it was handed, so a card asking for Kyoto got a + /// picture of Kyoto anyway. + /// + /// Guessing from the string is not good enough here, because a text + /// argument can carry what a person TYPED — `sys.video(query: state.q)` is + /// the search box — and "starts with `sys.`" would make a typed query + /// executable. This is the lowering stating what it built. + pub nested: Vec, /// The path WITHIN the source's result: `last` for `quote.last`, empty when /// the source itself is named. pub field: String, @@ -5178,7 +5829,8 @@ pub struct RealizeReport { /// 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 — + /// After [`Self::complete_root`] succeeds, 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, @@ -5188,7 +5840,8 @@ pub struct RealizeReport { /// Card state whose value came from a declared `initial: ` this /// realization — the first answer a source gave for it. /// - /// A HOST should write these into the store, and that write is what makes the + /// After [`Self::complete_root`] succeeds, a host should write these into + /// the store. That write is what makes the /// capture a capture. Left unwritten, an `initial:` re-resolves on every /// realization and the state follows its source: an origin declared as "where I /// am" then chases the device, and a route declared from it is re-fetched before @@ -5201,6 +5854,27 @@ pub struct RealizeReport { pub captured: Vec<(String, serde_json::Value)>, } +impl RealizeReport { + /// Only complete, diagnostic-free realizations may be mounted or used to + /// prune instance state. A root alone can represent a partial result. + pub fn complete_root(&self) -> Result<&UiNode, String> { + if self.truncated { + return Err("card realization exceeded its resource limits".into()); + } + if !self.diagnostics.is_empty() { + return Err(self + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect::>() + .join("; ")); + } + self.root + .as_ref() + .ok_or_else(|| "card realization produced no root".into()) + } +} + /// Realize a card against resolved data. /// /// `data` carries what the runtime fetched and holds: source results, state @@ -5232,8 +5906,8 @@ fn realize_inner( }; } - let tokens = match lex(source, &mut sink) { - Some(t) => t, + let card = match parsed_card(source, &mut sink) { + Some(card) => card, None => { return RealizeReport { root: None, @@ -5246,8 +5920,6 @@ fn realize_inner( } } }; - 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 { @@ -5293,14 +5965,22 @@ fn realize_inner( // a state that follows its source is not an initial value. match state.initial_path.as_ref().and_then(|p| data_path(data, p)) { Some(v) => { - captured.push((state.path.clone(), v.clone())); + if initial_origin(state, &card) == ValueOrigin::Source { + captured.push((state.path.clone(), v.clone())); + } v } None => initial_for(&state.shape), } } }; - frames.push((state.path.clone(), value, None)); + frames.push(( + state.path.clone(), + value, + None, + None, + card_state_origin(state, store, data, &card), + )); } let mut scope = ValueScope { frames, @@ -5330,7 +6010,13 @@ fn realize_inner( /// /// Named rather than left as a bare tuple because it grew a third element and /// clippy was right that four nested types in a signature stop being readable. -type Frame = (String, serde_json::Value, Option); +type Frame = ( + String, + serde_json::Value, + Option, + Option, + ValueOrigin, +); /// Which collection a loop binder iterates, and at what index. /// @@ -5408,8 +6094,8 @@ impl ValueScope<'_> { return Some(serde_json::Value::String(text.1.clone())); } - let mut current = match self.frames.iter().rev().find(|(n, _, _)| n == root) { - Some((_, v, _)) => v, + let mut current = match self.frames.iter().rev().find(|(n, _, _, _, _)| n == root) { + Some((_, v, _, _, _)) => v, None => self.data.get(root)?, }; for segment in segments { @@ -5553,16 +6239,33 @@ impl Realizer<'_> { children: Vec::new(), bindings: Vec::new(), exprs: Vec::new(), + origins: Vec::new(), }; for arg in &element.args { let value = self.value(&element.name, &arg.name, &arg.value, scope); + let origin = scope.operand_origin(&arg.value, self.card); + node.origins.push((arg.name.clone(), origin)); + if needs_data_origin(&element.name, &arg.name, &value) && !origin.permits_data() { + // Suppress the binding/expression too, or live lowering could + // resurrect the value that the renderer refused. + node.args.push((arg.name.clone(), NodeValue::Missing)); + continue; + } if let Operand::Path(path) = &arg.value { if let Some(binding) = self.source_binding(path, scope) { node.bindings.push((arg.name.clone(), binding)); } } - if matches!(arg.value, Operand::Expr { .. }) { + if matches!(arg.value, Operand::Expr { .. }) + || matches!(&arg.value, Operand::Path(p) if scope.frames.iter().rev() + .find(|(n, _, _, _, _)| n == p).is_some_and(|(_, _, _, e, _)| e.is_some())) + { if let Some(shape) = self.expr_shape(&arg.value, scope) { + if let ExprPart::Call(binding) = &shape { + if !node.bindings.iter().any(|(n, _)| n == &arg.name) { + node.bindings.push((arg.name.clone(), binding.clone())); + } + } node.exprs.push((arg.name.clone(), shape)); } } @@ -5595,6 +6298,7 @@ impl Realizer<'_> { // routing; and an enum token bound with its leading dot, so every // comparison against it inside the component was false. let mut bound = 0usize; + let mut param_frames = Vec::new(); for param in &component.params { let supplied = call.args.iter().find(|a| a.name == param.name); let value = match (supplied, ¶m.shape) { @@ -5610,10 +6314,7 @@ impl Realizer<'_> { 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), + other => scope_value(scope, other).unwrap_or(serde_json::Value::Null), }, // Omitted: the declared default, or nothing. Binding a frame // either way is what stops lookup falling through to injected @@ -5637,14 +6338,23 @@ impl Realizer<'_> { .frames .iter() .rev() - .find(|(n, _, prov)| n == root && prov.is_some()) - .and_then(|(_, _, prov)| prov.clone()) + .find(|(n, _, prov, _, _)| n == root && prov.is_some()) + .and_then(|(_, _, prov, _, _)| prov.clone()) } _ => None, }; - scope.frames.push((param.name.clone(), value, provenance)); + let expression = if param.shape == Shape::Event { + None + } else { + supplied.and_then(|a| self.expr_shape(&a.value, scope)) + }; + let origin = supplied + .map(|a| scope.operand_origin(&a.value, self.card)) + .unwrap_or(ValueOrigin::Authored); + param_frames.push((param.name.clone(), value, provenance, expression, origin)); bound += 1; } + scope.frames.extend(param_frames); let instance_key = format!("{key}/{}", component.name); self.live.push(instance_key.clone()); self.slots.push(split_slots(&call.children)); @@ -5666,15 +6376,32 @@ impl Realizer<'_> { *k == schema || state.keep && s.shape_unchanged(component, state) }) }) - .and_then(|s| s.get(&instance_key, &state.path)) - .cloned(); + .and_then(|s| { + s.get(&instance_key, &state.path) + .cloned() + .map(|v| (v, s.origin(&instance_key, &state.path).unwrap_or_default())) + }); let from_path = state.initial_path.as_ref().and_then(|p| scope.lookup(p)); + let origin = live.as_ref().map(|(_, o)| *o).unwrap_or_else(|| { + if state.initial.is_none() && state.initial_path.is_some() && from_path.is_none() { + return ValueOrigin::Unknown; + } + state + .initial_path + .as_ref() + .filter(|_| state.initial.is_none()) + .map(|p| scope.path_origin(p, self.card)) + .unwrap_or_else(|| initial_origin(state, self.card)) + }); scope.frames.push(( state.path.clone(), - live.or_else(|| state.initial.clone()) + live.map(|(v, _)| v) + .or_else(|| state.initial.clone()) .or(from_path) .unwrap_or_else(|| initial_for(&state.shape)), None, + None, + origin, )); bound += 1; } @@ -5758,11 +6485,10 @@ impl Realizer<'_> { }) .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) { + // A recovery suffix is still in the input key namespace: A, A, + // A#1 used to give two rows the same cell. Omit the duplicate and + // keep a diagnostic; complete_root refuses this partial result. + if seen_keys.contains(&item_key) { self.sink.push( element.line, element.column, @@ -5771,11 +6497,9 @@ impl Realizer<'_> { instances share one state cell (profile §5.7)" ), ); - format!("{item_key}#{index}") - } else { - seen_keys.push(item_key.clone()); - item_key - }; + continue; + } + seen_keys.push(item_key.clone()); let mut bound = 0usize; if let Some(binder) = element.binders.first() { @@ -5786,6 +6510,8 @@ impl Realizer<'_> { binder.clone(), item.clone(), Some((path.to_string(), index)), + None, + scope.path_origin(path, self.card), )); bound += 1; } @@ -5794,6 +6520,8 @@ impl Realizer<'_> { index_binder.clone(), serde_json::Value::from(index + 1), None, + None, + ValueOrigin::Vocabulary, )); bound += 1; } @@ -5846,14 +6574,39 @@ impl Realizer<'_> { /// The live call for `.`, when the backend answers that /// source itself. Used for a source argument that depends on another /// source, which a live card cannot resolve from data. + /// How deep a chain of source arguments may resolve. + /// + /// One level was not enough. `sys.photo(cond: now.cond)` needs `now`, whose + /// own `lat:` names `place` — so the inner resolver hit a path it would not + /// follow, fell to a scope lookup that a live card cannot answer, and + /// returned None. That None propagates: the OUTER binding fails too, so the + /// photo lowered to the realized em dash and the page drew no image at all. + /// Nothing said so, because a card with no wallpaper looks like a card whose + /// wallpaper has not loaded. + /// + /// Capped rather than unbounded: a card's sources form a DAG, but the checker + /// does not prove acyclicity of ARGUMENTS, and a cycle here would recurse + /// until the stack ran out. + const MAX_SOURCE_CHAIN: usize = 4; + fn nested_source_call(&self, path: &str, scope: &ValueScope) -> Option { + self.nested_source_call_at(path, scope, 0) + } + + fn nested_source_call_at( + &self, + path: &str, + scope: &ValueScope, + depth: usize, + ) -> Option { let (owner, field) = path.split_once('.')?; let declaration = self.card.sources.iter().find(|s| s.name == owner)?; let mut args = Vec::new(); for (name, arg) in &declaration.args { let resolved = match arg { + // Shortest round-trip, NOT `trim_num` — see `source_binding`. + SourceArg::Number(n) => format!("{n}"), SourceArg::Text(t) => t.clone(), - SourceArg::Number(n) => makepad::trim_num(*n), SourceArg::List(_) if name == "fields" => continue, // A list of PATHS resolves item by item, each to its own live // call, joined by a separator no call can contain. @@ -5891,7 +6644,17 @@ impl Realizer<'_> { // resolved here, which is what keeps this from recursing. SourceArg::Path(p) => { let key = p.strip_prefix("state.").unwrap_or(p); - json_to_key(&scope.lookup(key)?) + // THE SOURCE FIRST, then the scope — the same precedence + // `source_binding` argues for, and for the same reason: a + // live card carries no blob, so a scope-first resolution + // silently produces a different lowering than a previewed one. + match (depth + 1 < Self::MAX_SOURCE_CHAIN) + .then(|| self.nested_source_call_at(key, scope, depth + 1)) + .flatten() + { + Some(call) => call, + None => json_to_key(&scope.lookup(key)?), + } } }; args.push((name.clone(), resolved)); @@ -5899,6 +6662,10 @@ impl Realizer<'_> { makepad::vm_call(&SourceBinding { helper: declaration.helper.clone(), args, + // Empty by construction: this is the ONE-LEVEL resolver, and the + // comment above says why — a path naming yet another source is not + // followed here, so no argument of this call is itself a call. + nested: Vec::new(), field: field.to_string(), }) } @@ -5953,19 +6720,74 @@ impl Realizer<'_> { /// value realization already resolved. Returning `None` for an operand that /// resolves to nothing keeps a half-resolved expression from lowering — it /// renders as the missing binding it is. - fn expr_shape(&self, operand: &Operand, scope: &ValueScope) -> Option { + fn expr_shape(&mut self, operand: &Operand, scope: &ValueScope) -> Option { + self.expr_shape_inner(operand, scope, 0) + } + + fn charge_expression(&mut self, depth: usize) -> bool { + if depth >= DEFAULT_MAX_SYNTAX_NESTING || self.work >= self.limits.max_work { + self.truncated = true; + return false; + } + self.work += 1; + true + } + + // Substituting props can expand a small AST into an exponential tree. + // Charge every copied node and bound depth before allocating or recursing. + fn copy_expr(&mut self, expr: &ExprPart, depth: usize) -> Option { + if !self.charge_expression(depth) { + return None; + } + Some(match expr { + ExprPart::Bin(a, op, b) => ExprPart::Bin( + Box::new(self.copy_expr(a, depth + 1)?), + op.clone(), + Box::new(self.copy_expr(b, depth + 1)?), + ), + leaf => leaf.clone(), + }) + } + + fn expr_shape_inner( + &mut self, + operand: &Operand, + scope: &ValueScope, + depth: usize, + ) -> Option { + if !self.charge_expression(depth) { + return None; + } + if let Operand::Path(p) = operand { + if let Some((_, _, _, Some(expr), _)) = + scope.frames.iter().rev().find(|(n, _, _, _, _)| n == p) + { + return self.copy_expr(expr, depth); + } + } match operand { Operand::Expr { lhs, op, rhs } => Some(ExprPart::Bin( - Box::new(self.expr_shape(lhs, scope)?), + Box::new(self.expr_shape_inner(lhs, scope, depth + 1)?), op.clone(), - Box::new(self.expr_shape(rhs, scope)?), + Box::new(self.expr_shape_inner(rhs, scope, depth + 1)?), )), - Operand::Num(n) => Some(ExprPart::Const(makepad::trim_num(*n))), + // FULL PRECISION, not `trim_num`. `trim_num` is a DISPLAY rule — one + // decimal, because a temperature reads as 21.4 and not 21.437 — and an + // operand is not a display. Rounding one silently changes the + // arithmetic: measured on device, a converter card declaring + // `factor 0.621371` lowered to `(42 * 0.6)` and drew 42 km as 25.2 + // miles instead of 26.1. The card was right, the checker accepted it, + // and the number on screen was wrong — which is the whole failure + // class §1.1 exists to prevent. + // + // `{}` on an f64 is the shortest representation that round-trips, so + // 42.0 still emits `42` and nothing gains spurious digits. + Operand::Num(n) => Some(ExprPart::Const(format!("{n}"))), Operand::Path(p) => match self.source_binding(p, scope) { Some(binding) => Some(ExprPart::Call(binding)), None => { let v = scope.lookup(p)?; - Some(ExprPart::Const(makepad::trim_num(v.as_f64()?))) + Some(ExprPart::Const(format!("{}", v.as_f64()?))) } }, _ => None, @@ -5985,8 +6807,8 @@ impl Realizer<'_> { .frames .iter() .rev() - .find(|(n, _, prov)| n == root && prov.is_some()) - .and_then(|(_, _, prov)| prov.as_ref()) + .find(|(n, _, prov, _, _)| n == root && prov.is_some()) + .and_then(|(_, _, prov, _, _)| prov.as_ref()) .map(|(collection, index)| { if rest.is_empty() { format!("{collection}.{index}") @@ -6006,6 +6828,7 @@ impl Realizer<'_> { .max_by_key(|s| s.name.len())?; let mut args = Vec::new(); + let mut nested: Vec = Vec::new(); for (name, arg) in &declaration.args { let resolved = match arg { SourceArg::Path(p) => { @@ -6032,12 +6855,23 @@ impl Realizer<'_> { // forever. The seeded fix is the one value a navigation card // must never be allowed to keep. match self.nested_source_call(key, scope) { - Some(call) => call, + Some(call) => { + // Remembered, because a backend cannot tell a call + // from a value once both are strings — and a TEXT + // slot has to quote one and not the other. See + // `SourceBinding::nested`. + nested.push(name.clone()); + call + } None => json_to_key(&scope.lookup(key)?), } } SourceArg::Text(t) => t.clone(), - SourceArg::Number(n) => makepad::trim_num(*n), + // Shortest round-trip, NOT `trim_num`. That is a one-decimal + // DISPLAY rule, and an ARGUMENT is not a display: it rounded + // `sys.weather(lat: 37.7749)` to `37.8`, which is a different + // city. Same class as the L1 operand it rounded to 0.6. + SourceArg::Number(n) => format!("{n}"), // `fields:` is structural — which keys the card wants back — and // is not passed on. Any OTHER list is a value: `symbols: [NVDA, // AMD]` is the universe to rank, and the model writes it as a @@ -6111,6 +6945,7 @@ impl Realizer<'_> { Some(SourceBinding { helper: declaration.helper.clone(), args, + nested, field, }) } @@ -6138,7 +6973,22 @@ impl Realizer<'_> { }; } } - match operand { + // `.on`/`.off` are the authored selected-state tokens used by the + // beauty-card pipeline. On a Boolean argument they must become a + // Boolean, otherwise Chip's lowerers silently draw every chip off. + if catalog::lookup(ctor) + .and_then(|args| args.iter().find(|(n, _)| *n == arg)) + .is_some_and(|(_, kind)| matches!(kind, catalog::ArgKind::Bool)) + { + if let Operand::Token(token) = operand { + match token.trim_start_matches('.') { + "on" => return NodeValue::Bool(true), + "off" => return NodeValue::Bool(false), + _ => {} + } + } + } + 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), @@ -6292,8 +7142,8 @@ pub mod makepad { text_of(arg(node, arg_name)) } - /// An `ExprPart` as backend arithmetic. Parenthesised at every join, so the - /// tree's shape survives regardless of the target VM's precedence rules. + /// An `ExprPart` as finite-or-missing backend arithmetic. Each helper call + /// preserves the tree shape and applies the same checks as `apply_op`. pub(super) fn render_expr(part: &ExprPart) -> Option { match part { ExprPart::Const(v) => Some(v.clone()), @@ -6310,7 +7160,7 @@ pub mod makepad { // capability and the helpers cannot all change shape for it. ExprPart::Call(binding) => vm_call(binding).map(|c| format!("sys.num({c})")), ExprPart::Bin(lhs, op, rhs) => Some(format!( - "({} {op} {})", + "sys.l0_math({op:?}, {}, {})", render_expr(lhs)?, render_expr(rhs)? )), @@ -6576,6 +7426,54 @@ pub mod makepad { /// Public so a conformance test can ask, per capability and field, whether /// this backend answers at all — the check §4 says is owed and that no test /// comparing Octoscript with itself can perform. + /// The condition as the ICON's NUMBER, not as the reader's word. + /// + /// `cond` has two consumers and they need different things. A `TextRow` wants + /// "Rain"; `WeatherIcon` wants the WMO code, and the kit says so in as many + /// words — "`cond` is a NUMBER — the WMO code the forecast returns". The field + /// lowered to `sys.weatherword` for both, so the icon was handed a string, + /// coerced it to 0, and drew the SUN. Every icon, in every weather card, over + /// a Beijing forecast reading 92 % rain. + /// + /// `vm_call` cannot tell them apart — a `SourceBinding` carries no consumer — + /// but the ROLE does, and the kit lowering knows the role. Same path as + /// `weatherword` deliberately: a card may show the word beside the icon, and + /// two readings of one sky that disagree is worse than either being wrong. + pub(super) fn icon_call(binding: &SourceBinding) -> Option { + if binding.helper != "sys.weather" { + return None; + } + let (row, field) = match binding.field.split_once('.') { + Some((i, f)) if i.parse::().is_ok() => (i, f), + _ => ("0", binding.field.as_str()), + }; + if field != "cond" { + return None; + } + // The same day shift the weather arm applies — one sky, described once. + let day: u32 = binding + .args + .iter() + .find(|(n, _)| n == "day") + .and_then(|(_, v)| v.parse().ok()) + .unwrap_or(0); + let row = &format!("{}", row.parse::().unwrap_or(0) + day); + let num = |name: &str| -> Option { + let v = binding + .args + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone())?; + (v.starts_with("sys.") || v.trim().parse::().is_ok()).then_some(v) + }; + let lat = num("lat")?; + let lon = num("lon")?; + Some(format!( + "sys.weathercond({lat}, {lon}, {:?})", + format!("daily.weather_code.{row}") + )) + } + pub fn vm_call(binding: &SourceBinding) -> Option { let arg = |name: &str| { binding @@ -6604,7 +7502,47 @@ pub mod makepad { let v = arg(name)?; (v.starts_with("sys.") || v.trim().parse::().is_ok()).then_some(v) }; + // A TEXT slot, and the other half of `num`. A string slot is NOT safe by + // construction after all: `{:?}` quotes whatever it is handed, and what + // it was handed may be a nested CALL. `sys.photo(query: place.name)` + // lowered to `sys.photo("sys.geocode(\"kyoto\", \"name\")")`, so the + // wallpaper was generated from the text of the call rather than from the + // place. Measured on the 6T — the fetched URL was + // `image.pollinations.ai/prompt/sys.geocode%28%22ushuaia%22…`. + // + // It survived because it LOOKED right: the service is a generative image + // model and the place name is inside the string it was given, so a card + // asking for Kyoto still got a picture of Kyoto. + // + // `binding.nested` decides, not the shape of the string. A text argument + // can carry what a person typed — `sys.video(query: state.q)` is the + // search box — so "starts with `sys.`" would make a typed query + // executable, which is the one thing this must not do. + let text = |name: &str| -> Option { + let v = arg(name)?; + Some(if binding.nested.iter().any(|n| n == name) { + v + } else { + format!("{v:?}") + }) + }; match binding.helper.as_str() { + "sys.convert" => { + if !matches!(binding.field.as_str(), "amount" | "value") { + return None; + } + let amount = arg("amount")?; + if !binding.nested.iter().any(|name| name == "amount") + && !amount.parse::().is_ok_and(f64::is_finite) + { + return None; + } + let from = text("from")?; + let to = text("to")?; + let direction = text("direction").unwrap_or_else(|| "\"fwd\"".into()); + let field = if binding.field == "amount" { ", \"amount\"" } else { "" }; + Some(format!("sys.convert({amount}, {from}, {to}, {direction}{field})")) + } // THE FOUR THAT ANSWERED NOTHING. Each is in the catalog, so a card may // declare it and the checker accepts it — and each fell through to // `None`, which means the realized literal, which on this host is an @@ -6660,7 +7598,7 @@ pub mod makepad { Some(format!("sys.stockrange({ticker}, {range}, {key:?})")) } "sys.quote" => { - let symbol = arg("ticker")?; + let symbol = text("ticker")?; // What this helper can actually answer, verified against a live // response rather than against its documentation. // @@ -6687,7 +7625,7 @@ pub mod makepad { f @ ("name" | "change" | "changemoney" | "high" | "low" | "open") => f, _ => return None, }; - Some(format!("sys.stock({symbol:?}, {key:?})")) + Some(format!("sys.stock({symbol}, {key:?})")) } // A mover, by index. `sys.movers` takes the row's position rather // than a ticker, so the loop index has to reach here — which it does @@ -6701,12 +7639,31 @@ pub mod makepad { // drew `$—` where the seeded blob held a real price. // A place NAME resolved to a fact. `geocodenum` for the numbers the // other helpers take as arguments, `geocode` for the words. + // Live Wikipedia detail. The card names a FIELD (`d.extract`); this + // lowers it to a self-contained OCTOSCRIPT EXPRESSION evaluated inside + // the widget: concat the URL, fetch via the brokered `sys.fetch` + // primitive, `parse_json` (a Octoscript string method), then a static + // field access. The retrieval LOGIC runs as Octoscript; only the raw + // socket (`sys.fetch`) is Rust. (A shared `wiki_fetch` helper in a + // .octoscript file would be cleaner, but the VM isolates a widget's + // expression from a top-level `let`, so the expression must stand + // alone; and `vm.eval` from a capability is not re-entrant.) + "sys.wiki" => { + let query = text("query")?; + match binding.field.as_str() { + "title" | "extract" | "description" => Some(format!( + "sys.fetch(\"https://en.wikipedia.org/api/rest_v1/page/summary/\" + {query}).parse_json().{}", + binding.field + )), + _ => None, + } + } "sys.geocode" => { - let name = arg("name")?; + let name = text("name")?; match binding.field.as_str() { - "lat" | "lon" => Some(format!("sys.geocodenum({name:?}, {:?})", binding.field)), + "lat" | "lon" => Some(format!("sys.geocodenum({name}, {:?})", binding.field)), "name" | "country" | "admin1" | "timezone" => { - Some(format!("sys.geocode({name:?}, {:?})", binding.field)) + Some(format!("sys.geocode({name}, {:?})", binding.field)) } _ => None, } @@ -6716,10 +7673,24 @@ pub mod makepad { "sys.weather" => { let lat = num("lat")?; let lon = num("lon")?; + // Which day the DAILY fields describe. 0 is today; a forecast + // row's own index rides on top, so `day: 1` shifts the whole + // week loop too. + let day: u32 = arg("day").and_then(|v| v.parse().ok()).unwrap_or(0); let (row, field) = match binding.field.split_once('.') { Some((i, f)) if i.parse::().is_ok() => (i, f), _ => ("0", binding.field.as_str()), }; + let row = &format!("{}", row.parse::().unwrap_or(0) + day); + // There is no "current" tomorrow. A current-conditions field + // under a future day gets NO translation — the seeded value or + // an em dash, a visible absence — never today's reading served + // beneath a label that says otherwise. Measured motive: a card + // whose eyebrow said TOMORROW over `current.temperature_2m`, + // which no screen could tell from the real thing. + if day > 0 && matches!(field, "temp" | "feels" | "humidity" | "wind" | "pressure") { + return None; + } let path = match field { "temp" => "current.temperature_2m".to_string(), "feels" => "current.apparent_temperature".to_string(), @@ -6787,6 +7758,25 @@ pub mod makepad { }; Some(format!("sys.airquality({lat}, {lon}, {path:?})")) } + "sys.dataset" => { + let id = text("id")?; + if !crate::catalog::answers("sys.dataset")?.contains(&binding.field.as_str()) { return None; } + Some(format!("sys.dataset({id}, {:?})", binding.field)) + } + "sys.news_digest" | "sys.news_status" => { + let query = text("query")?; + let language = text("language").unwrap_or_else(|| "\"en\"".into()); + let field = if binding.helper == "sys.news_status" { + if !matches!(binding.field.as_str(), "status" | "message" | "count") { return None; } + binding.field.clone() + } else { + let (row, field) = binding.field.split_once('.')?; + let row: usize = row.parse().ok()?; + if row >= 3 || !matches!(field, "id" | "title" | "summary" | "publisher" | "url" | "published_at") { return None; } + format!("items.{row}.{field}") + }; + Some(format!("sys.news_digest({query}, {language}, {field:?})")) + } // A headline feed, indexed like the movers list. "sys.news" => { let (index, field) = binding.field.split_once('.')?; @@ -6802,9 +7792,41 @@ pub mod makepad { }; Some(format!("sys.news({index}, {key:?})")) } + // The quake feed. Same row shape as `sys.news`, same `offset` reason: + // the card shows the newest event large and the rest beneath it, and + // without honouring the offset row 0 of the list is the lead again. + "sys.quakes" => { + let (index, field) = binding.field.split_once('.')?; + let row: u32 = index.parse().ok()?; + let offset: u32 = arg("offset").and_then(|v| v.parse().ok()).unwrap_or(0); + let index = row + offset; + let key = match field { + // The backend spells the humanized age `ago`; `id` has no + // backend field — the row index IS the identity, which is all + // a `for` key needs. + "ago" => "ago", + f @ ("mag" | "place" | "depth" | "lat" | "lon") => f, + "id" => return Some(format!("\"{index}\"")), + _ => return None, + }; + Some(format!("sys.quakes({index}, {key:?})")) + } "sys.photo" => { - let query = arg("query")?; - Some(format!("sys.photo({query:?})")) + let query = text("query")?; + // The CONDITION, when the card passes one. `cond: now.cond` + // resolves to the forecast's own word call, so the host folds a + // live reading into the prompt and a rainy city stops sitting + // under a sunlit backdrop. + // + // Only when it is a generated call. A literal here would be the + // card stating the weather, which is the one thing §4 forbids — + // and a mood is only honest while the card is not asserting it. + match binding.args.iter().find(|(n, _)| n == "cond") { + Some((_, c)) if binding.nested.iter().any(|n| n == "cond") => { + Some(format!("sys.photo({query}, {c})")) + } + _ => Some(format!("sys.photo({query})")), + } } // The activity app's whole data surface. Missing from this table, a // declared `sys.places` source fell to the seeded blob -- which a @@ -6909,11 +7931,16 @@ pub mod makepad { let p = num("to_lon")?; let at_lat = num("at_lat")?; let at_lon = num("at_lon")?; - let along = format!("sys.navprog({a}, {o}, {b}, {p}, {at_lat}, {at_lon})"); + let via = via_string(&arg("via").unwrap_or_default()) + .map(|v| format!(", {v}")) + .unwrap_or_default(); + let along = format!("sys.navprog({a}, {o}, {b}, {p}, {at_lat}, {at_lon}{via})"); if key == "progress" { return Some(along); } - Some(format!("sys.navstep({a}, {o}, {b}, {p}, {along}, {key:?})")) + Some(format!( + "sys.navstep({a}, {o}, {b}, {p}, {along}, {key:?}{via})" + )) } "sys.gps" => { let key = match binding.field.as_str() { @@ -6941,9 +7968,9 @@ pub mod makepad { Some((i, f)) if i.parse::().is_ok() => (i, f), _ => ("0", binding.field.as_str()), }; - let query = arg("query")?; + let query = text("query")?; match field { - "lat" | "lon" => Some(format!("sys.searchnum({query:?}, {index}, {field:?})")), + "lat" | "lon" => Some(format!("sys.searchnum({query}, {index}, {field:?})")), // `label` is the secondary line the helper has always answered — // city, region, country. Five results named "Stanford" are what // Photon returns for "Stanford", and without this they render as @@ -6951,10 +7978,10 @@ pub mod makepad { // `query` is the text that finds this hit again — what a // results row must carry, or picking the third "Stanford" sets // state to "Stanford" and routes to the first. - "name" | "label" | "query" => { - Some(format!("sys.search({query:?}, {index}, {field:?})")) + "id" | "name" | "label" | "query" => { + Some(format!("sys.search({query}, {index}, {field:?})")) } - // `id` and `distance` have no answer in the helper, so they + // `distance` has no answer in the helper, so it must // fall back rather than emitting a call that returns "". _ => None, } @@ -7044,8 +8071,8 @@ pub mod makepad { | "embed") => f, _ => return None, }; - let query = arg("query")?; - Some(format!("sys.video({query:?}, {index}, {key:?})")) + let query = text("query")?; + Some(format!("sys.video({query}, {index}, {key:?})")) } // One country's reading, by row index. The countries argument is the // card's own list, so row 0 is the first country it named. @@ -7103,24 +8130,27 @@ pub mod makepad { let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; let key = match field { - f @ ("name" | "lat" | "lon" | "temp" | "feels" | "hi" | "lo" | "cond" + f @ ("name" | "lat" | "lon" | "temp" | "feels" | "feels_delta" | "hi" | "lo" | "cond" | "humidity" | "wind") => f, _ => return None, }; - Some(format!("sys.cities({index}, {key:?})")) + Some(match text("unit") { + Some(unit) => format!("sys.cities({index}, {key:?}, {unit})"), + None => format!("sys.cities({index}, {key:?})"), + }) } // The query is a path into declared state, so it reaches the helper // as whatever the user committed — the card never builds it. "sys.symbol_search" => { let (index, field) = binding.field.split_once('.')?; index.parse::().ok()?; - let query = arg("query")?; + let query = text("query")?; let key = match field { "ticker" => "symbol", f @ ("name" | "exchange" | "kind") => f, _ => return None, }; - Some(format!("sys.symbol_search({query:?}, {index}, {key:?})")) + Some(format!("sys.symbol_search({query}, {index}, {key:?})")) } _ => None, } @@ -7233,6 +8263,32 @@ pub mod makepad { format: Option, } + /// The literal `text:` of a node that ALSO carries a value, or `None`. + /// + /// Only a literal: a live `text:` and a live `value:` would each be a call + /// this side never resolves, and joining two of them is a decision for + /// whoever writes such a card, not for the lowering to guess at. + fn label_beside_value(node: &UiNode) -> Option<&str> { + // Not a HERO. A hero is one large number and its label belongs above + // it, not inside it: prepended, "card balance" turned $235.00 into a + // two-line "card balance $235.00" at 43pt and shrank the figure the + // screen exists to show. The small roles are the ones that read as + // "label value" on one line. + if node.kind == "TextHero" { + return None; + } + let has_value = arg(node, "value").is_some() + || node.bindings.iter().any(|(n, _)| n == "value") + || node.exprs.iter().any(|(n, _)| n == "value"); + if !has_value { + return None; + } + match arg(node, "text") { + Some(NodeValue::Text(t)) if !t.is_empty() => Some(t), + _ => None, + } + } + fn decoration_of(node: &UiNode) -> Decoration { Decoration { unit: match token_arg(node, "unit") { @@ -7256,9 +8312,23 @@ pub mod makepad { Some("pressure") => " hPa", _ => "", }, - glyph: match arg(node, "glyph") { - Some(NodeValue::Text(g)) => g.clone(), - _ => String::new(), + // A `text:` beside a `value:` is that value's LABEL, and it goes in + // front of the number for the same reason `glyph` does. + // + // `valued` prefers `value:` and only falls back to `text:`, so a + // node carrying both dropped the label silently: the weather card's + // `TextCaption(text: "最高", value: now.hi)` trio rendered as three + // bare temperatures with nothing to tell them apart, on every rail. + // A card that states a label wants it drawn. + glyph: { + let mark = match arg(node, "glyph") { + Some(NodeValue::Text(g)) => g.clone(), + _ => String::new(), + }; + match label_beside_value(node) { + Some(label) => format!("{label} {mark}"), + None => mark, + } }, // `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 @@ -7314,6 +8384,19 @@ pub mod makepad { } } } + seeded_body(node, value, &glyph, unit, &suffix, format_kind) + } + + /// The seeded tail shared by emission and measurement: the realized + /// literal with every decoration applied, never a live call. + fn seeded_body( + node: &UiNode, + value: Option<&NodeValue>, + glyph: &str, + unit: &str, + suffix: &str, + format_kind: Option, + ) -> String { match value { Some(NodeValue::Missing) => "\"—\"".into(), Some(v) => { @@ -7330,7 +8413,7 @@ pub mod makepad { // "300 m away · quiet green space". Nothing caught it because every // `suffix` in the original three cards pairs with `value:`, and only // that path applied the decoration. - None => decorate(expr_of(node, "text"), &glyph, unit, &suffix), + None => decorate(expr_of(node, "text"), glyph, unit, suffix), } } @@ -7425,12 +8508,22 @@ pub mod makepad { /// The realized value with every decoration applied — exactly what would /// have been drawn had nothing gone live. /// - /// This is `valued`'s literal path, reached directly. Reconstructing the - /// decoration here instead would give two formatters to keep in step, and - /// the one used for measuring would drift from the one used for drawing - /// without anything failing. + /// This is `valued_seeded`'s literal tail reached DIRECTLY, skipping its + /// live-binding branch. That branch exists for EMISSION — a bound value + /// must go live on the wire — but measuring the emitted call is how a + /// three-character "23°" was sized as the ~120 characters of + /// `sys.weather(sys.geocodenum(…))` and every live hero fell to the ramp's + /// bottom step: 17pt, on the canonical weather fixture too. Measurement + /// wants what will be DRAWN; the seeded literal — or its "—" placeholder — + /// is the sound proxy for that even when the wire form is a call. fn valued_literal(node: &UiNode) -> String { - valued_seeded(node) + let Decoration { + glyph, + unit, + suffix, + format: format_kind, + } = decoration_of(node); + seeded_body(node, arg(node, "value"), &glyph, unit, &suffix, format_kind) } /// A `value` argument as a live call plus its decoration, or `None` to use @@ -7447,7 +8540,8 @@ pub mod makepad { /// | `money` | yes | `sys.stock` returns two decimals, so `"$"` prefixes it | /// | `signed_pct` | yes | the VM already returns `"+0.63%"` — applying it again would double the sign | /// | `signed_money` | no | the sign goes OUTSIDE the currency symbol; not a prefix | - /// | `compact`, `ratio` | no | need the number to divide or round | + /// | `ratio` | yes | the runtime formats the live number to one decimal | + /// | `compact` | no | needs the number to divide or round | /// /// Returning `None` is the safe direction: the seeded value is stale but /// correct, where a wrong concatenation is neither. @@ -7473,12 +8567,16 @@ pub mod makepad { } else { binding.clone() }; - let call = vm_call(binding)?; + let mut call = vm_call(binding)?; let prefix = match format_kind { // Already whole: the helper returned the sign and the symbol in the // right order, so nothing may be prepended. None | Some("signed_pct") | Some("signed_money") => String::new(), Some("money") => "$".to_owned(), + Some("ratio") => { + call = format!("sys.l0_ratio({call})"); + String::new() + } Some(_) => return None, }; let mut expr = String::new(); @@ -7557,6 +8655,15 @@ pub mod makepad { fn element_body(node: &UiNode, depth: usize, out: &mut String) { let p = pad(depth); match node.kind.as_str() { + "Kit" => { + // The direct dialect backend has no host pack registry. Fail + // visibly; the registered kit lowering is the supported path. + let _ = writeln!( + out, + "{p}Label{{text: {:?}}}", + "Kit requires registered component lowering" + ); + } // A card holding a MAP is laid out the way the shipping nav card lays // one out: the map is the BOTTOM layer of an overlay and everything // else floats above it. Any other card keeps the ordinary column. @@ -7847,6 +8954,95 @@ pub mod makepad { "{p}SolidView{{ width: Fill height: 1 draw_bg.color: {HAIRLINE} }}" ); } + "Space" => { + let _ = writeln!(out, "{p}View{{ width: Fill height: Fill }}"); + } + "Bubble" => { + let me = matches!(arg(node, "side"), Some(NodeValue::Token(t)) if t == "me"); + let (fill, ink) = if me { (ACTIVE, TEXT) } else { (PANEL, SOFT) }; + let _ = writeln!( + out, + "{p}View{{ width: Fill height: Fit flow: Right align: Align{{x: {}}}", + if me { "1.0" } else { "0.0" } + ); + let _ = writeln!( + out, + "{p} RoundedView{{ width: Fit height: Fit draw_bg.color: {fill} \ + draw_bg.border_radius: 12.0 \ + padding: Inset{{left: 12 right: 12 top: 7 bottom: 7}}" + ); + let _ = writeln!( + out, + "{p} TextBody{{ text: {} draw_text.color: {ink} }}", + expr_of(node, "text") + ); + let _ = writeln!(out, "{p} }}"); + let _ = writeln!(out, "{p}}}"); + } + "Fab" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: 54 height: 54 draw_bg.color: {ACTIVE} \ + draw_bg.border_radius: 27.0 align: {{x: 0.5, y: 0.5}} }}" + ); + } + "Tab" => { + // A tab is only ever emitted by TabBar's own arm; this stands in + // when one appears alone so every admitted role draws. + let g = match arg(node, "icon") { + Some(NodeValue::Token(t)) => crate::icon_glyph(t), + _ => crate::icon_glyph("home"), + }; + let _ = writeln!( + out, + "{p}TextCaption{{ text: {} draw_text.color: {DIM} }}", + text_of(arg(node, "label")).replace('"', &format!("{g} ")) + ); + let _ = g; + } + "TabBar" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fill height: Fit flow: Right draw_bg.color: {PANEL} \ + draw_bg.border_radius: 16.0 padding: Inset{{left: 8 right: 8 top: 10 bottom: 10}}" + ); + for tab in &node.children { + let g = match arg(tab, "icon") { + Some(NodeValue::Token(t)) => crate::icon_glyph(t), + _ => crate::icon_glyph("home"), + }; + let on = matches!(arg(tab, "active"), Some(NodeValue::Token(t)) if t == "on"); + let ink = if on { ACTIVE } else { DIM }; + let _ = writeln!(out, + "{p} View{{ width: Fill height: Fit flow: Down align: {{x: 0.5}} spacing: 3"); + let _ = writeln!(out, + "{p} Label{{ height: Fit text: {g:?} draw_text.color: {ink} \ + draw_text.text_style: TextStyle{{ font_family: FontFamily{{ latin := \ + FontMember{{ res: crate_resource(\"makepad_widgets:resources/fa-solid-900.ttf\") \ + asc: 0.0 desc: 0.0 }} }} font_size: 9 }} }}"); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {ink} }}", + text_of(arg(tab, "label")) + ); + let _ = writeln!(out, "{p} }}"); + } + let _ = writeln!(out, "{p}}}"); + } + "Band" => { + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: Fit draw_bg.color: {TEXT} \ + padding: Inset{{left: 17 right: 17 top: 9 bottom: 9}} \ + margin: Inset{{left: -17 right: -17}}" + ); + let _ = writeln!( + out, + "{p} TextTitle{{ text: {} draw_text.color: {PANEL} }}", + expr_of(node, "text") + ); + let _ = writeln!(out, "{p}}}"); + } "Tile" => { let _ = writeln!( out, @@ -7937,10 +9133,16 @@ pub mod makepad { ); } "Thumb" => { - // A fixed 16:9 tile, the size a list row wants beside its text. + // A fixed 16:9 tile, the size a list row wants beside its text — + // or a square mosaic cell when the card says so. + let (w, h) = match arg(node, "shape") { + Some(NodeValue::Token(t)) if t == "square" => ("102", "102"), + Some(NodeValue::Token(t)) if t == "hero" => ("Fill", "210"), + _ => ("108", "61"), + }; let _ = writeln!( out, - "{p}Image{{ width: 108 height: 61 fit: ImageFit.CropToFill \ + "{p}Image{{ width: {w} height: {h} fit: ImageFit.CropToFill \ src: http_resource({}) }}", expr_of(node, "src") ); @@ -7957,6 +9159,21 @@ pub mod makepad { expr_of(node, "years"), ); } + "Avatar" => { + // A tinted initials circle; this backend's fixed palette stands + // in for the theme's accent tint. + let _ = writeln!( + out, + "{p}RoundedView{{ width: 36 height: 36 draw_bg.color: {ACTIVE} \ + draw_bg.border_radius: 18.0 align: {{x: 0.5, y: 0.5}}" + ); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {TEXT} }}", + expr_of(node, "text") + ); + let _ = writeln!(out, "{p}}}"); + } "Chip" => { // `active` selects the fill. Dropping it made every range chip // render identically, so a card could not show which was @@ -8113,7 +9330,7 @@ pub const CARD_STATE_KEY: &str = "@card"; #[derive(Clone, Debug, Default)] pub struct InstanceStore { /// `instance-key` → field → value. - cells: BTreeMap>, + 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. @@ -8124,21 +9341,32 @@ pub struct InstanceStore { impl InstanceStore { pub fn get(&self, key: &str, field: &str) -> Option<&serde_json::Value> { - self.cells.get(key)?.get(field) + self.cells.get(key)?.get(field).map(|(v, _)| v) } /// Write a cell. Public because a HOST owns when a captured initial becomes /// durable — see `RealizeReport::captured`. The realizer decides what was /// captured; only the host can decide it is now the state's value. pub fn set_cell(&mut self, key: &str, field: &str, value: serde_json::Value) { - self.set(key, field, value); + self.set_cell_with_origin(key, field, value, ValueOrigin::Host); + } + + pub fn origin(&self, key: &str, field: &str) -> Option { + self.cells.get(key)?.get(field).map(|(_, o)| *o) } - fn set(&mut self, key: &str, field: &str, value: serde_json::Value) { + /// Trusted host API. Never take `origin` from card text or event JSON. + pub fn set_cell_with_origin( + &mut self, + key: &str, + field: &str, + value: serde_json::Value, + origin: ValueOrigin, + ) { self.cells .entry(key.to_string()) .or_default() - .insert(field.to_string(), value); + .insert(field.to_string(), (value, origin)); } /// Whether `state` has the shape the store's live cells were written under. @@ -8313,7 +9541,15 @@ pub fn dispatch_with_data( payload: Option<&serde_json::Value>, data: &serde_json::Value, ) -> bool { - let (changed, durable) = dispatch_writes(source, store, instance_key, event, payload, data); + let (changed, durable) = dispatch_writes( + source, + store, + instance_key, + event, + payload, + data, + ValueOrigin::Authored, + ); !changed.is_empty() || !durable.is_empty() } @@ -8379,7 +9615,36 @@ pub fn dispatch_reporting( payload: Option<&serde_json::Value>, data: &serde_json::Value, ) -> DispatchOutcome { - let (changed, writes) = dispatch_writes(source, store, instance_key, event, payload, data); + dispatch_reporting_with_origin( + source, + store, + instance_key, + event, + payload, + data, + ValueOrigin::Authored, + ) +} + +/// Dispatch a native event with its origin established by the host. +pub fn dispatch_reporting_with_origin( + source: &str, + store: &mut InstanceStore, + instance_key: &str, + event: &str, + payload: Option<&serde_json::Value>, + data: &serde_json::Value, + payload_origin: ValueOrigin, +) -> DispatchOutcome { + let (changed, writes) = dispatch_writes( + source, + store, + instance_key, + event, + payload, + data, + payload_origin, + ); if changed.is_empty() && writes.is_empty() { return DispatchOutcome::default(); } @@ -8412,12 +9677,12 @@ fn dispatch_writes( event: &str, payload: Option<&serde_json::Value>, data: &serde_json::Value, + payload_origin: ValueOrigin, ) -> (Vec, Vec) { let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { + let Some(card) = parsed_card(source, &mut sink) else { return (Vec::new(), Vec::new()); }; - 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 @@ -8470,7 +9735,13 @@ fn dispatch_writes( // Stage every write first, commit only if the whole batch resolves. // (target, next, effective current) — the third is what decides whether this // transition is a change at all. - let mut staged: Vec<(String, serde_json::Value, serde_json::Value)> = Vec::new(); + let mut staged: Vec<( + String, + serde_json::Value, + serde_json::Value, + ValueOrigin, + ValueOrigin, + )> = Vec::new(); // §5.12 writes are staged alongside the cells, so §3's atomicity covers both: // a batch that sets a preference AND appends to a list must do neither if the // preference cannot be resolved. @@ -8528,8 +9799,8 @@ fn dispatch_writes( let current = staged .iter() .rev() - .find(|(t, _, _)| *t == transition.target) - .map(|(_, v, _)| v.clone()) + .find(|(t, ..)| *t == transition.target) + .map(|(_, v, ..)| v.clone()) .or_else(|| store.get(instance_key, &transition.target).cloned()) // The HOST-SEEDED value, and it has to be here because it is here in // the renderer. @@ -8627,7 +9898,49 @@ fn dispatch_writes( }, _ => return (Vec::new(), Vec::new()), }; - staged.push((transition.target.clone(), next, current)); + let previous_origin = staged + .iter() + .rev() + .find(|(t, ..)| *t == transition.target) + .map(|(_, _, _, o, _)| *o) + .or_else(|| store.origin(instance_key, &transition.target)) + .unwrap_or_else(|| { + if instance_key == CARD_STATE_KEY { + card_state_origin(state, None, data, &card) + } else { + initial_origin(state, &card) + } + }); + let origin = match &transition.form { + Form::Set(SetSource::Payload) => payload_origin, + Form::Set(SetSource::Path(path)) => ValueScope { + frames: Vec::new(), + data, + copies: &card.copies, + } + .path_origin(path, &card), + Form::Next(_) | Form::Prev(_) => ValueOrigin::Source, + Form::Toggle | Form::Cycle | Form::Set(SetSource::Token(_) | SetSource::Bool(_)) => { + ValueOrigin::Vocabulary + } + Form::Clear if declared_initial.is_none() && state.initial_path.is_some() => { + ValueOrigin::Unknown + } + Form::Clear => initial_origin(state, &card), + _ => ValueOrigin::Authored, + }; + let origin = if matches!(state.shape, Shape::Bool | Shape::Enum(_)) { + ValueOrigin::Vocabulary + } else { + origin + }; + staged.push(( + transition.target.clone(), + next, + current, + origin, + previous_origin, + )); } // Commit, and report what CHANGED. The targets are what a host needs to know @@ -8651,9 +9964,10 @@ fn dispatch_writes( // its transitions moved. A batch that sets one cell to a new value and // another to the value it holds reports the first and rebuilds once. let mut written = Vec::new(); - for (target, value, previous) in staged { - let unchanged = value == previous; - store.set(instance_key, &target, value); + for (target, value, previous, origin, previous_origin) in staged { + let unchanged = + value == previous && origin.permits_data() == previous_origin.permits_data(); + store.set_cell_with_origin(instance_key, &target, value, origin); if unchanged || written.contains(&target) { continue; } @@ -8704,25 +10018,419 @@ pub struct SourcePlan { /// against injected data at realization, which is after this is useful. pub fn state_initials(source: &str) -> std::collections::BTreeMap { let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { + let Some(card) = parsed_card(source, &mut sink) else { return std::collections::BTreeMap::new(); }; - let card = Parser::new(&tokens, &mut sink).parse_card(); card.states .iter() .filter_map(|st| st.initial.clone().map(|v| (st.path.clone(), v))) .collect() } +/// The pastel pair a card tint token resolves to — first stop, second stop. +/// One table, lower-time, the same shape as `icon_glyph`: the card names a +/// closed HUE ROLE, this is where the numbers live. +pub fn card_tint(t: &str) -> (u32, u32) { + match t { + // Sampled off the kit's own stat cards — mid-saturation, not pastel; + // `inkdark` keeps the text readable on them in every mood. + "green" => (0xff37_d7a3, 0xff2a_c6b1), + "pink" => (0xfff2_92c1, 0xffea_79b2), + "blue" => (0xff6f_a8f5, 0xff4f_8fee), + "amber" => (0xfff5_b84f, 0xffef_a53a), + "violet" => (0xffb5_7df0, 0xffc3_8cea), + "cyan" => (0xff4f_c8dd, 0xff36_b8d0), + "red" => (0xfff0_7a6e, 0xffe9_604f), + _ => (0xffba_c1cb, 0xffa5_aeba), + } +} + +/// The theme's default answer for each semantic icon name — Font Awesome +/// solid codepoints, resolved AT LOWER TIME so the kit never carries a string +/// table and the card never carries a glyph. +pub fn icon_glyph(name: &str) -> &'static str { + match name { + "activity" => "\u{f201}", + "alert" => "\u{f071}", + "arrow_down" => "\u{f063}", + "arrow_left" => "\u{f060}", + "arrow_right" => "\u{f061}", + "arrow_up" => "\u{f062}", + "bell" => "\u{f0f3}", + "bookmark" => "\u{f02e}", + "calendar" => "\u{f073}", + "camera" => "\u{f030}", + "chat" => "\u{f075}", + "check" => "\u{f00c}", + "chevron_down" => "\u{f078}", + "chevron_left" => "\u{f053}", + "chevron_right" => "\u{f054}", + "chevron_up" => "\u{f077}", + "clock" => "\u{f017}", + "close" => "\u{f00d}", + "cloud" => "\u{f0c2}", + "edit" => "\u{f304}", + "filter" => "\u{f0b0}", + "heart" => "\u{f004}", + "home" => "\u{f015}", + "image" => "\u{f03e}", + "info" => "\u{f129}", + "location" => "\u{f3c5}", + "lock" => "\u{f023}", + "mail" => "\u{f0e0}", + "map" => "\u{f279}", + "menu" => "\u{f0c9}", + "mic" => "\u{f130}", + "minus" => "\u{f068}", + "moon" => "\u{f186}", + "more" => "\u{f141}", + "phone" => "\u{f095}", + "play" => "\u{f04b}", + "plus" => "\u{f067}", + "refresh" => "\u{f021}", + "search" => "\u{f002}", + "send" => "\u{f1d8}", + "settings" => "\u{f013}", + "share" => "\u{f064}", + "star" => "\u{f005}", + "sun" => "\u{f185}", + "trash" => "\u{f1f8}", + "user" => "\u{f007}", + "users" => "\u{f0c0}", + "video" => "\u{f03d}", + "wifi" => "\u{f1eb}", + "zap" => "\u{f0e7}", + _ => "\u{f128}", // question — unreachable behind the Token set + } +} + +/// The theme axes a card declares beside its mood, in source order. +/// +/// Read before realize for the same reason [`card_theme`] is: a host choosing +/// a palette needs the card's stated intent, and that is a fact about the +/// source rather than about a realized tree. +pub fn card_theme_axes(source: &str) -> Vec<(String, String)> { + let mut sink = Diagnostics::default(); + let Some(card) = parsed_card(source, &mut sink) else { + return Vec::new(); + }; + card.theme_axes.clone() +} + +/// The theme this card declares, or `None` for the default. +/// +/// The card names a MOOD; resolving it into colours is the component kit's job +/// (§1.1's middle layer), so this hands the host a catalogued name and nothing +/// else. A name absent from [`catalog::THEMES`] never reaches here — the parser +/// refuses it — so a host may treat whatever it gets as answerable, and treat +/// `None` as "the kit's default". +/// +/// Read before realize, like [`state_initials`]: the palette has to be chosen to +/// build the source the kit is concatenated into, which is earlier than a tree. +pub fn card_theme(source: &str) -> Option { + let mut sink = Diagnostics::default(); + parsed_card(source, &mut sink)? + .theme + .as_ref() + .map(|(name, _)| name.clone()) +} + +/// The ROLE the card's root view names — `Surface`, `Photo`, `Map`. +/// +/// Read before realize, like [`card_theme`], and for the same reason: a host +/// choosing a palette has to know whether the page is a PHOTOGRAPH, and that is a +/// fact about the source rather than about a realized tree. The pairing matters +/// because a mood with dark ink is unreadable over an arbitrary image, so a kit +/// may legitimately answer `light` + `Photo` with its photo palette. +/// +/// `None` when the card declares no `root` view — which the checker refuses, so a +/// caller seeing `None` has a card that was not going to render anyway. +pub fn card_root_role(source: &str) -> Option { + let mut sink = Diagnostics::default(); + parsed_card(source, &mut sink)? + .views + .iter() + .find(|v| v.name == "root") + .map(|v| v.body.name.clone()) +} + +/// The (source, field) pairs a card GUARDS on — `when now.precip >= 40` gives +/// `("now", "precip")`. +/// +/// Guards are evaluated at realize time against injected data, and a host injects +/// list rows and little else, so a guard on a live scalar compares against +/// nothing and BOTH complementary branches are false. That renders a card with a +/// correct header and no answer, which is the §1.1 failure. A host can close it +/// by resolving exactly these before realize — exactly these, because resolving +/// every field of every source would fire a request per field per render. +/// +/// NOT SUFFICIENT ON ITS OWN, and the attempt that produced this says why. A +/// guarded field is reached by a call built from its source's ARGUMENTS, and +/// those are frequently paths into another source: weather-activity guards +/// `now.precip`, `now` takes `lat: place.lat`, and `place` is a `sys.geocode` +/// scalar that is not in the blob either. Resolving guarded fields therefore +/// needs the scalar sources resolved in DEPENDENCY ORDER first — geocode, then +/// weather — which is most of what realize does. This accessor is the part that +/// is cheap and knowable; the ordering is the work. +/// +/// `$state` fields are excluded: the lifecycle is already observed separately. +/// State paths are excluded because only a declared SOURCE needs fetching. +/// Guards inside a `component` are not walked — no shipped card has one, and a +/// component reads its props rather than a card-level source. +pub fn guarded_source_fields(source: &str) -> Vec<(String, String)> { + let mut sink = Diagnostics::default(); + let Some(card) = parsed_card(source, &mut sink) else { + return Vec::new(); + }; + guarded_fields_of(&card) +} + +fn guarded_fields_of(card: &Card) -> Vec<(String, String)> { + let sources: std::collections::BTreeSet = + card.sources.iter().map(|s| s.name.clone()).collect(); + let mut out: Vec<(String, String)> = Vec::new(); + fn walk( + el: &Element, + sources: &std::collections::BTreeSet, + out: &mut Vec<(String, String)>, + ) { + if el.name == "when" { + if let Some(a) = el.args.first() { + if let Operand::Path(p) = &a.value { + if let Some((head, field)) = p.split_once('.') { + if sources.contains(head) && !field.starts_with('$') && !field.contains('.') + { + let pair = (head.to_owned(), field.to_owned()); + if !out.contains(&pair) { + out.push(pair); + } + } + } + } + } + } + for c in &el.children { + walk(c, sources, out); + } + } + for v in &card.views { + walk(&v.body, &sources, &mut out); + } + out +} + +/// One value a guard reads, and the call that answers it. +#[derive(Clone, Debug)] +pub struct GuardBinding { + /// The source the guard names — `now`. + pub source: String, + /// The field within it — `precip`. + pub field: String, + /// What to ask, resolved exactly as it would be for a DISPLAY binding of the + /// same path — so the branch is decided on the number the screen shows. + pub binding: SourceBinding, +} + +/// What a host must resolve BEFORE realize, so the card's value guards have +/// something to compare against. +/// +/// Guards are decided during realize, against injected data. A fetched scalar is +/// not in that data — the kit resolves it lazily at draw time — so a +/// `when now.precip >= 40` compared against nothing, and so did its complement +/// `when now.precip < 40`. Both false: measured on a 6T, `weather-activity` drew +/// a correct header, a correct rain tile reading 100 %, and no verdict at all. +/// +/// The FETCH POLICY this answers is the card's own: a `when` on a value is the +/// card saying it needs that value early. A card with no value guards asks for +/// nothing here and pays nothing, which is why this can be unconditional in the +/// render path where resolving every scalar of every source could not. +/// +/// Dependencies come out resolved because `source_binding` already emits a +/// source argument as the callee's own live call — `now` takes `lat: place.lat`, +/// and `place` is a geocode this never has to fetch separately. +pub fn guard_bindings( + source: &str, + data: &serde_json::Value, + store: &InstanceStore, +) -> Vec { + resolved_bindings(source, data, store, guarded_fields_of) +} + +/// A probe binding for each source whose LIFECYCLE a `when` reads. +/// +/// `$state` was observed by walking the realized tree for bindings, which makes +/// it a property of what RENDERED rather than of the fetch. A card that gates its +/// rows on the state it is waiting for is then a closed loop: +/// +/// ```text +/// when parks.$state == .pending { TextBody(text: copy.loading) } +/// when parks.$state == .ready { Panel { for p, i in parks … } } +/// ``` +/// +/// no data -> pending -> the rows do not realize -> nothing binds `parks` -> +/// no status is written -> pending. Forever. Measured on the 6T: the activity +/// card for Beijing drew "Finding places nearby…" and nothing else, with +/// `L0 $state:` logging empty on every realize. It is the shipped EXEMPLAR's +/// shape, so every generated activity card inherited it, and the two apps that +/// escape do so by accident — `weather-activity` puts its `for` beside the +/// pending guard rather than behind a ready one, and `quake`'s gated feed shares +/// a helper with an ungated lead. +/// +/// The field is left EMPTY: which one answers is the backend's business, and a +/// lifecycle is a property of the fetch, so any field the helper answers reports +/// the same thing. +pub fn guarded_state_bindings( + source: &str, + data: &serde_json::Value, + store: &InstanceStore, +) -> Vec { + resolved_bindings(source, data, store, guarded_states_of) +} + +/// One probe binding per DECLARED source — so a host can observe each source's +/// lifecycle independently. +/// +/// The alternative was observing per HELPER and stamping the worst state onto +/// every source that names it, and the nav card is why that cannot stand: it +/// declares five `sys.route` trips, and the one that is UNANSWERABLE by design +/// (`trip`, whose origin is the empty string on a from-here journey) held its +/// four siblings at `.pending` forever. `trip_here` had a real origin, a real +/// destination and a 200 route on the wire — and Go never appeared, because the +/// helper's merged state was decided by a sibling that asks a question nobody +/// posed. Measured: "Finding a route…", indefinitely, with zero failed fetches. +pub fn source_state_bindings( + source: &str, + data: &serde_json::Value, + store: &InstanceStore, +) -> Vec { + fn all_sources_of(card: &Card) -> Vec<(String, String)> { + card.sources + .iter() + .map(|s| (s.name.clone(), String::new())) + .collect() + } + resolved_bindings(source, data, store, all_sources_of) +} + +/// Sources whose `$state` a `when` reads, as `(name, "")` — the empty field is +/// what makes each a probe rather than a value. +fn guarded_states_of(card: &Card) -> Vec<(String, String)> { + let sources: std::collections::BTreeSet = + card.sources.iter().map(|s| s.name.clone()).collect(); + let mut out: Vec<(String, String)> = Vec::new(); + fn walk( + el: &Element, + sources: &std::collections::BTreeSet, + out: &mut Vec<(String, String)>, + ) { + if el.name == "when" { + if let Some(a) = el.args.first() { + if let Operand::Path(p) = &a.value { + if let Some(name) = p.strip_suffix(".$state") { + if sources.contains(name) { + let pair = (name.to_owned(), String::new()); + if !out.contains(&pair) { + out.push(pair); + } + } + } + } + } + } + for c in &el.children { + walk(c, sources, out); + } + } + for v in &card.views { + walk(&v.body, &sources, &mut out); + } + out +} + +fn resolved_bindings( + source: &str, + data: &serde_json::Value, + store: &InstanceStore, + select: fn(&Card) -> Vec<(String, String)>, +) -> Vec { + let mut sink = Diagnostics::default(); + let Some(card) = parsed_card(source, &mut sink) else { + return Vec::new(); + }; + let wanted = select(&card); + if wanted.is_empty() { + return Vec::new(); + } + + // The same resolution order realize uses for card state, because a source + // argument reaches state — `sys.geocode(name: state.city)` — and resolving + // it differently here would ask about a different place than the card draws. + let mut frames: Vec = Vec::new(); + for state in &card.states { + let value = store + .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(|| { + if state.initial_path.is_some() { + serde_json::Value::Null + } else { + initial_for(&state.shape) + } + }); + frames.push(( + state.path.clone(), + value, + None, + None, + card_state_origin(state, Some(store), data, &card), + )); + } + let scope = ValueScope { + frames, + data, + copies: &card.copies, + }; + let ctx = Realizer { + depth: 0, + work: 0, + card: &card, + limits: RealizeLimits::default(), + nodes: 0, + truncated: false, + sink: &mut sink, + store: Some(store), + live: Vec::new(), + slots: Vec::new(), + }; + wanted + .into_iter() + .filter_map(|(name, field)| { + let path = if field.is_empty() { + name.clone() + } else { + format!("{name}.{field}") + }; + let binding = ctx.source_binding(&path, &scope)?; + Some(GuardBinding { + source: name, + field, + binding, + }) + }) + .collect() +} + pub fn source_plan(source: &str) -> SourcePlan { let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { + let Some(card) = parsed_card(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 @@ -8841,10 +10549,9 @@ pub struct RecordDeps { /// 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 { + let Some(card) = parsed_card(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(); @@ -8912,10 +10619,9 @@ pub fn record_dependencies(source: &str) -> Vec { /// stale forecast. pub fn stale_sources(source: &str, changed: &[&str]) -> Vec { let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { + let Some(card) = parsed_card(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() @@ -8987,8 +10693,10 @@ pub fn realize_patch( 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); + if report.complete_root().is_ok() { + if let Some(root) = report.root.as_mut() { + report.reused = carry_over(root, previous, &dirty); + } } report } @@ -9022,6 +10730,7 @@ fn carry_over(next: &mut UiNode, previous: &UiNode, dirty: &[String]) -> usize { if next.key == previous.key && next.kind == previous.kind && subtree_is_clean(next, &touches_dirty) + && next == previous { *next = previous.clone(); return count_nodes(previous); @@ -9042,10 +10751,9 @@ fn count_nodes(n: &UiNode) -> usize { pub fn patch_points(source: &str, changed: &[&str]) -> Vec { let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { + let Some(card) = parsed_card(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(); @@ -9086,11 +10794,8 @@ pub fn dirty_records(source: &str, changed: &[&str]) -> Vec { // of a disagreement between two functions answering one question: the coarse // one is what a host reaches for first. let mut sink = Diagnostics::default(); - let invalidated = match lex(source, &mut sink) { - Some(tokens) => { - let card = Parser::new(&tokens, &mut sink).parse_card(); - invalidated_by(&card, changed) - } + let invalidated = match parsed_card(source, &mut sink) { + Some(card) => invalidated_by(&card, changed), None => changed.iter().map(root_of).collect(), }; record_dependencies(source) @@ -9220,6 +10925,12 @@ fn dsl_kind(role: &str) -> Option<&'static str> { "Grid" => "grid", "Panel" | "Card" => "card", "Rule" => "divider", + "Space" => "column", + "Band" => "card", + "Bubble" => "card", + "Fab" => "card", + "TabBar" => "row", + "Tab" => "column", "Tile" => "listitem", "Chip" => "chip", "Photo" => "image", @@ -9337,10 +11048,21 @@ pub mod kit { "Col" => "l0_col", "Row" => "l0_row", "Grid" => "l0_grid", - "Panel" | "Card" => "l0_panel", + "Panel" => "l0_panel", + // Card lowers separately: a CONTENT card may take the pack's + // signature gradient (`l0_card_1/2`); a section Panel never does. + "Card" => "l0_card", "Rule" => "l0_rule", + "Space" => "l0_space", + "Band" => "l0_band", + "Bubble" => "l0_bubble_them", + "Fab" => "l0_fab", + "TabBar" => "l0_tabbar", + "Tab" => "l0_tab", "Tile" => "l0_tile", "Chip" => "l0_chip", + "Avatar" => "l0_avatar", + "Icon" => "l0_icon", "Photo" => "l0_photo", "Thumb" => "l0_thumb", "WeatherIcon" => "l0_weathericon", @@ -9356,6 +11078,7 @@ pub mod kit { "TextBody" => "l0_body", "TextRow" => "l0_row_text", "TextCaption" => "l0_caption", + "TextEyebrow" => "l0_eyebrow", "TextValue" => "l0_value", "TextStat" => "l0_stat", _ => return None, @@ -9448,6 +11171,21 @@ pub mod kit { let _ = write!(out, "{}]", " ".repeat(depth)); } + /// A `[...]` list from an explicit slice — the Surface layer split hands + /// segments of children rather than a node. + fn children_list(nodes: &[&UiNode], depth: usize, out: &mut String) { + out.push_str("[\n"); + for (i, child) in nodes.iter().enumerate() { + let _ = write!(out, "{}", " ".repeat(depth + 1)); + element(child, depth + 1, out); + if i + 1 < nodes.len() { + out.push(','); + } + out.push('\n'); + } + let _ = write!(out, "{}]", " ".repeat(depth)); + } + /// A tap, as the string the backend carries. /// /// JSON rather than a delimited form, because every field is data: an @@ -9476,9 +11214,9 @@ pub mod kit { if let Some(call) = makepad::vm_call(binding) { let head = serde_json::json!({ "e": event, "k": node.key }); let head = head.to_string(); - // `{"e":…,"k":…}` → `l0:{"e":…,"k":…,"v":"` + + `"}` - let open = format!("l0:{},\"v\":\"", head.trim_end_matches('}')); - return Some(format!("{open:?} + {call} + {:?}", "\"}")); + // Encode the complete JSON value, including quotes, at runtime. + let open = format!("l0:{},\"v\":", head.trim_end_matches('}')); + return Some(format!("{open:?} + sys.json_string({call}) + {:?}", "}")); } } let value = match arg(node, "value") { @@ -9616,6 +11354,14 @@ pub mod kit { } fn element(node: &UiNode, depth: usize, out: &mut String) { + if node.kind == "Kit" { + out.push_str("l0_kit_component("); + out.push_str(&super::kit_pack::arguments(node)); + out.push_str(", "); + children(node, depth, out); + out.push(')'); + return; + } // A `Field` carries its own commit target and must NOT be wrapped in a // tap: a hit target over a text input eats the focus, and the payload // here is what was typed rather than what the row was bound to. @@ -9847,9 +11593,68 @@ pub mod kit { out.push(')'); } "Surface" => { - let _ = write!(out, "{f}("); - children(node, depth, out); - out.push(')'); + // A `Fab` child floats OVER the page corner; a `TabBar` pins + // to the page floor. Both hoisted here so the author writes + // them as plain siblings. + let fab = node.children.iter().find(|c| c.kind == "Fab"); + let tabbar = node.children.iter().find(|c| c.kind == "TabBar"); + let body: Vec<&UiNode> = node + .children + .iter() + .filter(|c| c.kind != "Fab" && c.kind != "TabBar") + .collect(); + if tabbar.is_some() { + out.push_str("l0_surface_tabbar("); + } + if fab.is_some() { + out.push_str("l0_surface_fab("); + } + // Top-level `Space()` children split the page into ALIGNED + // layers: [pre] Space [post] pins post to the page floor; a + // second Space centers the middle segment. Deferred fills are + // greedy in the app's layout fork — anything walked after one + // never fits — so the pin is alignment, not fill. + let n_spaces = body.iter().filter(|c| c.kind == "Space").count(); + let n_real = body.len() - n_spaces; + if (1..=2).contains(&n_spaces) && n_real > 0 { + let mut segs: Vec> = vec![Vec::new()]; + for c in &body { + if c.kind == "Space" { + segs.push(Vec::new()); + } else { + segs.last_mut().expect("seeded non-empty").push(c); + } + } + let fname = if segs.len() == 2 { + "l0_surface_pin2" + } else { + "l0_surface_pin3" + }; + let _ = write!(out, "{fname}("); + for (i, seg) in segs.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + children_list(seg, depth, out); + } + out.push(')'); + } else { + let _ = write!(out, "{f}("); + children_list(&body, depth, out); + out.push(')'); + } + if let Some(fabnode) = fab { + let name = match arg(fabnode, "name") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "plus".to_owned(), + }; + let _ = write!(out, ", l0_fab({:?}))", crate::icon_glyph(&name)); + } + if let Some(bar) = tabbar { + out.push_str(", "); + element(bar, depth, out); + out.push(')'); + } } "Col" | "Row" => { // `gap` is a declared spacing, so it stays a semantic argument @@ -9883,10 +11688,23 @@ pub mod kit { _ => 2, }; let pad = " ".repeat(depth + 1); - let _ = writeln!(out, "l0_col(["); + // A grid of nothing but SQUARE tiles is a calendar: it spreads + // at the day pitch instead of the list gutter. + let all_sq = !node.children.is_empty() + && node.children.iter().all(|c| { + c.kind == "Tile" + && matches!(arg(c, "shape"), + Some(NodeValue::Token(t)) if t == "square") + }); + let (rows_fn, row_fn) = if all_sq { + ("l0_day_rows", "l0_day_row") + } else { + ("l0_grid_rows", "l0_row") + }; + let _ = writeln!(out, "{rows_fn}(["); let rows: Vec<&[UiNode]> = node.children.chunks(cols).collect(); for (r, row) in rows.iter().enumerate() { - let _ = writeln!(out, "{pad}l0_row(["); + let _ = writeln!(out, "{pad}{row_fn}(["); for (i, cell) in row.iter().enumerate() { let _ = write!(out, "{pad} "); element(cell, depth + 2, out); @@ -9903,22 +11721,91 @@ pub mod kit { } let _ = write!(out, "{}])", " ".repeat(depth)); } + "Card" if arg(node, "tint").is_some() => { + let (c1, c2) = match arg(node, "tint") { + Some(NodeValue::Token(t)) => crate::card_tint(t), + _ => crate::card_tint("neutral"), + }; + let _ = write!(out, "l0_card_tinted({c1}, {c2}, "); + children(node, depth, out); + out.push(')'); + } "Panel" | "Card" => { let _ = write!(out, "{f}("); children(node, depth, out); out.push(')'); } - "Rule" => { - let _ = write!(out, "{f}()"); + "Bubble" => { + let me = matches!(arg(node, "side"), Some(NodeValue::Token(t)) if t == "me"); + // A literal longer than a line gets the capped wrapping form; + // an unmeasurable live value is assumed long, which only costs + // a short message some air. + let long = match arg(node, "text") { + Some(NodeValue::Text(t)) => t.chars().count() > 40, + _ => true, + }; + let f = match (me, long) { + (true, true) => "l0_bubble_me_long", + (true, false) => "l0_bubble_me", + (false, true) => "l0_bubble_them_long", + (false, false) => "l0_bubble_them", + }; + let _ = write!(out, "{f}({})", makepad::expr_of(node, "text")); } - "Tile" => { + "Fab" => { + let name = match arg(node, "name") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "plus".to_owned(), + }; + let _ = write!(out, "l0_fab({:?})", crate::icon_glyph(&name)); + } + "TabBar" => { + let _ = write!(out, "l0_tabbar("); + children(node, depth, out); + out.push(')'); + } + "Tab" => { + let g = match arg(node, "icon") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "home".to_owned(), + }; + let on = i32::from( + matches!(arg(node, "active"), Some(NodeValue::Token(t)) if t == "on"), + ); let _ = write!( out, - "{f}({}, {})", - makepad::expr_of(node, "label"), - makepad::valued(node) + "l0_tab({:?}, {}, {on})", + crate::icon_glyph(&g), + makepad::expr_of(node, "label") ); } + "Rule" | "Space" => { + let _ = write!(out, "{f}()"); + } + "Band" => { + let _ = write!(out, "{f}({})", makepad::expr_of(node, "text")); + } + "Tile" => { + let square = matches!(arg(node, "shape"), + Some(NodeValue::Token(t)) if t == "square"); + if square { + // A calendar day: fixed square, flat, bordered. The dot is + // a GLYPH (a literal mark), not a value — values render data. + let label = makepad::expr_of(node, "label"); + if let Some(NodeValue::Text(g)) = arg(node, "glyph") { + let _ = write!(out, "l0_tile_sq2({label}, {g:?})"); + } else { + let _ = write!(out, "l0_tile_sq1({label})"); + } + } else { + let _ = write!( + out, + "{f}({}, {})", + makepad::expr_of(node, "label"), + makepad::valued(node) + ); + } + } "Chip" => { // `.danger` is a different role in the kit, not a parameter: it is // full width and centred as well as red, and threading three @@ -9968,9 +11855,19 @@ pub mod kit { children(node, depth, out); out.push(')'); } - "Photo" | "Thumb" => { + "Photo" => { let _ = write!(out, "{f}({})", makepad::expr_of(node, "src")); } + "Thumb" => { + // The shape token becomes a number: the kit picks with arithmetic, + // not string compares. + let sq = match arg(node, "shape") { + Some(NodeValue::Token(t)) if t == "square" => 1, + Some(NodeValue::Token(t)) if t == "hero" => 2, + _ => 0, + }; + let _ = write!(out, "{f}({}, {sq})", makepad::expr_of(node, "src")); + } // The trip, as the kit takes it: which member of the map family, how // close, where to centre, and the route already resolved. // @@ -10000,6 +11897,21 @@ pub mod kit { makepad::map_badge(node).unwrap_or_else(|| "\"\"".to_owned()), ); } + "Icon" => { + // The MEANING token resolves to the theme's glyph HERE, at + // lower time — the kit never carries a string table and the + // card never carries a codepoint. Size names WHERE it sits; + // the kit's type scale decides how big that is. + let name = match arg(node, "name") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "info".to_owned(), + }; + let size = match arg(node, "size") { + Some(NodeValue::Token(t)) => t.clone(), + _ => "row".to_owned(), + }; + let _ = write!(out, "{f}({:?}, {size:?})", crate::icon_glyph(&name)); + } // `cond` is a NUMBER — the WMO code the forecast returns — and this // matched only `Text` and `Token`, so every one of them fell through // to `""`. All seven forecast rows drew the same default icon over a @@ -10014,7 +11926,16 @@ pub mod kit { Some(NodeValue::Token(t)) => t.clone(), _ => "row".to_owned(), }; - let _ = write!(out, "{f}({}, {size:?})", scalar_of(node, "cond")); + // The ICON's reading of `cond`, falling back to whatever the + // card bound. `scalar_of` alone gave it the WORD, which is not a + // number, which is 0, which is the sun — always. + let cond = node + .bindings + .iter() + .find(|(n, _)| n == "cond") + .and_then(|(_, b)| makepad::icon_call(b)) + .unwrap_or_else(|| scalar_of(node, "cond")); + let _ = write!(out, "{f}({cond}, {size:?})"); } "TextStat" => { // `l0_stat` takes the direction as a parameter rather than @@ -10052,7 +11973,13 @@ pub mod kit { scalar_num_of(node, "years") ); } - "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "StockPlot" | "Satellite" => { + "StockPlot" => { + // A ticker is text even when it comes from a live source. + // Numeric coercion turned a mover's "BLTE" into a missing + // symbol, while literal state-selected tickers still worked. + let _ = write!(out, "{f}({}, {})", scalar_of(node, "symbol"), scalar_of(node, "range")); + } + "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "Satellite" => { let params: &[&str] = match node.kind.as_str() { "TempBar" => &["lo", "hi", "min", "max"], "SunArc" => &["rise", "set", "now"], @@ -10082,3 +12009,81 @@ pub mod kit { } } } + +#[cfg(test)] +mod guarded_fields_tests { + /// A guard on a live scalar is reported; a guard on state or `$state` is not. + #[test] + fn only_source_fields_a_card_guards_on_are_reported() { + let card = "source now sys.weather(lat: 1, lon: 2, fields: [temp, precip])\n\ + state mode { shape: enum[a, b], initial: .a }\n\ + view root Surface {\n\ + when now.$state == .pending { Rule() }\n\ + when mode == .a { Rule() }\n\ + when now.precip >= 40 { Rule() }\n\ + when now.precip < 40 { inner }\n\ + }\n\ + view inner Col { when now.temp < 12 { Rule() } }\n"; + let got = super::guarded_source_fields(card); + assert!( + got.contains(&("now".to_owned(), "precip".to_owned())), + "{got:?}" + ); + // Nested inside another guard's body, and inside a named view. + assert!( + got.contains(&("now".to_owned(), "temp".to_owned())), + "{got:?}" + ); + // Deduped: `precip` is guarded twice. + assert_eq!( + got.len(), + 2, + "state, $state and duplicates must not appear: {got:?}" + ); + } +} + +#[cfg(test)] +mod source_cache_tests { + use super::*; + + #[test] + fn source_cache_reuses_syntax_but_never_freezes_data_or_validation() { + let source = "state title { shape: text, initial: \"first\" } view root Surface { TextBody(text: title) }"; + let first = parsed_card(source, &mut Diagnostics::default()).unwrap(); + let second = parsed_card(source, &mut Diagnostics::default()).unwrap(); + assert!(std::rc::Rc::ptr_eq(&first, &second)); + for title in ["first", "second"] { + let report = realize( + source, + &serde_json::json!({"title": title}), + RealizeLimits::default(), + ); + let lowered = kit::lower(report.complete_root().unwrap()); + assert!(lowered.contains(title), "{lowered}"); + } + let valid = check_ui_l0(source); + assert!(valid.valid, "{valid:?}"); + let invalid = format!("{source}\nsource forbidden sys.shell()"); + let refused = check_ui_l0(&invalid); + assert!(!refused.valid); + assert_eq!(refused, check_ui_l0(&invalid)); + assert_eq!(valid, check_ui_l0(source)); + } + + #[test] + fn source_cache_evicts_old_entries_and_does_not_retain_oversized_inputs() { + let mut cache = SourceCache { + entries: std::collections::VecDeque::new(), + }; + for i in 0..SOURCE_CACHE_ENTRIES { + cache.insert(&i.to_string(), i); + } + assert_eq!(cache.get("0"), Some(0)); + cache.insert("next", 99); + assert_eq!(cache.get("1"), None); + assert_eq!(cache.get("0"), Some(0)); + cache.insert(&"x".repeat(DEFAULT_MAX_SOURCE_BYTES + 1), 100); + assert_eq!(cache.entries.len(), SOURCE_CACHE_ENTRIES); + } +} diff --git a/crates/octoscript-ui-l0/src/value_origin.rs b/crates/octoscript-ui-l0/src/value_origin.rs new file mode 100644 index 0000000..fdbc42b --- /dev/null +++ b/crates/octoscript-ui-l0/src/value_origin.rs @@ -0,0 +1,153 @@ +//! Runtime origins are supplied by the host and propagated by the realizer. +//! No spelling in a card or JSON event envelope can grant an origin. + +use crate::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ValueOrigin { + #[default] + Authored, + Vocabulary, + Source, + UserInput, + Host, + Derived, + Unknown, +} + +impl ValueOrigin { + pub fn permits_data(self) -> bool { + matches!( + self, + Self::Vocabulary | Self::Source | Self::UserInput | Self::Host | Self::Derived + ) + } + + fn combine(self, other: Self) -> Self { + if self == Self::Unknown || other == Self::Unknown { + Self::Unknown + } else if self == other { + self + } else if self.permits_data() || other.permits_data() { + Self::Derived + } else { + Self::Authored + } + } +} + +impl ValueScope<'_> { + pub(super) fn path_origin(&self, path: &str, card: &Card) -> ValueOrigin { + let root = path.split('.').next().unwrap_or(path); + if let Some((_, _, _, _, origin)) = self.frames.iter().rev().find(|(n, ..)| n == root) { + return *origin; + } + if let Some(name) = path.strip_prefix("copy.") { + return match self + .copies + .iter() + .find(|c| c.name == name) + .map(|c| c.provenance) + { + Some(Provenance::Vocabulary) => ValueOrigin::Vocabulary, + // Copy class is authored metadata, not evidence of a native input. + _ => ValueOrigin::Authored, + }; + } + if card + .sources + .iter() + .any(|s| path == s.name || path.starts_with(&format!("{}.", s.name))) + { + ValueOrigin::Source + } else { + ValueOrigin::Unknown + } + } + + pub(super) fn operand_origin(&self, operand: &Operand, card: &Card) -> ValueOrigin { + match operand { + Operand::Path(p) => self.path_origin(p, card), + Operand::Expr { lhs, rhs, .. } => self + .operand_origin(lhs, card) + .combine(self.operand_origin(rhs, card)), + Operand::Predicate { path, rhs, .. } => self + .path_origin(path, card) + .combine(self.operand_origin(rhs, card)), + Operand::Token(_) => ValueOrigin::Vocabulary, + _ => ValueOrigin::Authored, + } + } +} + +pub(super) fn initial_origin(state: &StateDecl, card: &Card) -> ValueOrigin { + if state.initial.is_none() && state.initial_path.is_some() { + ValueScope { + frames: Vec::new(), + data: &serde_json::Value::Null, + copies: &card.copies, + } + .path_origin(state.initial_path.as_ref().unwrap(), card) + } else if matches!(state.shape, Shape::Bool | Shape::Enum(_)) { + ValueOrigin::Vocabulary + } else { + ValueOrigin::Authored + } +} + +pub(super) fn card_state_origin( + state: &StateDecl, + store: Option<&InstanceStore>, + data: &serde_json::Value, + card: &Card, +) -> ValueOrigin { + store + .and_then(|s| s.origin(CARD_STATE_KEY, &state.path)) + .unwrap_or_else(|| { + if data.get(&state.path).is_some() { + ValueOrigin::Host + } else if state.initial.is_none() + && state + .initial_path + .as_ref() + .is_some_and(|p| data_path(data, p).is_none()) + { + ValueOrigin::Unknown + } else { + initial_origin(state, card) + } + }) +} + +/// Infer an event's payload origin from the realized control, never its JSON. +/// Call only for events delivered by the mounted native widget tree. +pub fn event_payload_origin(root: &UiNode, key: &str, event: &str) -> Option { + if root.key == key { + let bound = root.args.iter().find(|(name, value)| { + name.starts_with("on_") && matches!(value, NodeValue::Event(e) if e == event) + })?; + if root.kind == "Field" && matches!(bound.0.as_str(), "on_commit" | "on_change") { + return Some(ValueOrigin::UserInput); + } + return Some( + root.origins + .iter() + .find(|(n, _)| n == "value") + .map(|(_, o)| *o) + .unwrap_or(ValueOrigin::Authored), + ); + } + root.children + .iter() + .find_map(|n| event_payload_origin(n, key, event)) +} + +pub(super) fn needs_data_origin(kind: &str, argument: &str, value: &NodeValue) -> bool { + // A Field's initial text and placeholder are editable interface defaults. + if kind == "Field" { + return false; + } + let slot = catalog::lookup(kind).and_then(|args| args.iter().find(|(n, _)| *n == argument)); + slot.is_some_and(|(_, k)| matches!(k, catalog::ArgKind::Data)) + || (argument == "text" && matches!(value, NodeValue::Number(_))) +} diff --git a/crates/octoscript-ui-l0/tests/fixtures/nav.card b/crates/octoscript-ui-l0/tests/fixtures/nav.card index 9579b3c..3e3a10f 100644 --- a/crates/octoscript-ui-l0/tests/fixtures/nav.card +++ b/crates/octoscript-ui-l0/tests/fixtures/nav.card @@ -38,7 +38,7 @@ # the turn instruction, the distance remaining — moves because the device did. source here sys.gps() -source found sys.search(query: state.query, count: 5, fields: [name, label, query, lat, lon]) +source found sys.search(query: state.query, count: 5, fields: [id, name, label, query, lat, lon]) # Both endpoints, each resolved by the search that found it. source origin_place sys.search(query: state.origin, count: 1, fields: [id, name, lat, lon]) @@ -68,11 +68,22 @@ source trip_here sys.route(from_lat: state.from_lat, from_lon: state.from_lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) +source trip_here_via sys.route(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + mode: state.mode, via: [stop_place.0.lat, stop_place.0.lon], + fields: [duration, distance]) + source step_here sys.step(from_lat: state.from_lat, from_lon: state.from_lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, at_lat: here.lat, at_lon: here.lon, fields: [instruction, remaining, eta]) +source step_here_via sys.step(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + at_lat: here.lat, at_lon: here.lon, + via: [stop_place.0.lat, stop_place.0.lon], + fields: [instruction, remaining, eta]) + source trip sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) @@ -87,6 +98,9 @@ source trip_via sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0 source leg_a sys.route(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: stop_place.0.lat, to_lon: stop_place.0.lon, mode: state.mode, fields: [duration, distance]) +source leg_here_a sys.route(from_lat: state.from_lat, from_lon: state.from_lon, + to_lat: stop_place.0.lat, to_lon: stop_place.0.lon, + mode: state.mode, fields: [duration, distance]) source leg_b sys.route(from_lat: stop_place.0.lat, from_lon: stop_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, mode: state.mode, fields: [duration, distance]) @@ -105,6 +119,12 @@ source step sys.step(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, at_lat: here.lat, at_lon: here.lon, fields: [instruction, remaining, eta]) + +source step_via sys.step(from_lat: origin_place.0.lat, from_lon: origin_place.0.lon, + to_lat: dest_place.0.lat, to_lon: dest_place.0.lon, + at_lat: here.lat, at_lon: here.lon, + via: [stop_place.0.lat, stop_place.0.lon], + fields: [instruction, remaining, eta]) source env.locale sys.locale() # BOTH endpoints are editable state, and both are always on screen. @@ -248,18 +268,8 @@ state stop_row { shape: enum[hidden, shown], initial: .hidden } event edit_origin { editing: set(.origin), query: clear } event edit_dest { editing: set(.dest), query: clear } event set_origin { origin: set($value), query: clear, editing: set(.none) } -# `query` is SET, not cleared — this is the line that made search reachable. -# -# Every event here cleared `query` and nothing ever set it, so `found` — which is -# `sys.search(query: state.query)` — always ran on "" and the results list was -# permanently empty. The card rendered a search field, a results panel and five -# tappable rows, and could not search. A review caught it; nothing on screen could, -# because an empty list looks exactly like a query with no matches. -# -# Committing "Stanford" now does both things it plainly means: it is the destination -# (so the route draws to the best match immediately) and it is the query (so the -# alternatives are listed underneath, and one tap refines the choice). -event set_dest { dest: set($value), query: set($value), editing: set(.none) } +# Committing chooses the best match and closes the find list. +event set_dest { dest: set($value), query: clear, editing: set(.none) } # What is being typed, per keystroke. Separate from `set_dest` because the two mean # different things: a query is a question and a destination is an answer, and until # you commit, only the question exists. @@ -296,8 +306,11 @@ copy save_work_lbl { class: vocabulary, en: "☆ WORK" } copy from { class: vocabulary, en: "FROM" } copy to { class: vocabulary, en: "TO" } copy here_now { class: vocabulary, en: "Starting from…" } +copy cur_loc { class: vocabulary, en: "Current location", zh: "当前位置" } copy away { class: vocabulary, en: "away" } copy seeking { class: vocabulary, en: "Finding a route…" } +copy gps_wait { class: vocabulary, en: "Waiting for location…" } +copy route_error { class: vocabulary, en: "No route found. Check the places and try again." } copy start { class: vocabulary, en: "Go" } copy stop { class: vocabulary, en: "End" } copy begin { class: vocabulary, en: "Start" } @@ -365,7 +378,11 @@ view root Surface { when editing != .origin { Row(align: .center, gap: 10, on_tap: edit_origin, value: "1") { TextCaption(text: copy.from, width: .label) - TextRow(text: origin, width: .fill) + # An empty origin is not "unset" — it IS the trip's meaning: from + # here. The row says so, the way every wayfinding surface does, + # instead of a blank that reads as a question nobody answered. + when origin == "" { TextRow(text: copy.cur_loc, width: .fill) } + when origin != "" { TextRow(text: origin, width: .fill) } } } Rule() @@ -398,9 +415,11 @@ view root Surface { } } when editing != .stop { - Row(align: .center, gap: 10, on_tap: edit_stop, value: "1") { - TextCaption(text: copy.via_lbl, width: .label) - TextRow(text: stop, width: .fill) + Row(align: .center, gap: 10) { + Row(align: .center, gap: 10, width: .fill, on_tap: edit_stop, value: "1") { + TextCaption(text: copy.via_lbl, width: .label) + TextRow(text: stop, width: .fill) + } Chip(text: copy.remove, on_tap: drop_stop) } } @@ -471,10 +490,10 @@ view root Surface { # on device the first time. L0 has no predicate for "this list has rows", so the # container has to be the thing that cannot render empty: no rows, no nodes. when editing == .origin { - for f, i in found key f.label { Hit(f: f, pick: choose_origin) } + for f, i in found key f.id { Hit(f: f, pick: choose_origin) } } when editing == .stop { - for f, i in found key f.label { Hit(f: f, pick: choose_stop) } + for f, i in found key f.id { Hit(f: f, pick: choose_stop) } } # And NOT while the origin or the stop is the row being edited — their own # lists are rendering for the same query, and two lists answering one question @@ -482,11 +501,8 @@ view root Surface { when editing != .origin { when editing != .stop { when query != "" { - # KEYED ON THE NAME, not on `id`. `sys.search` answers name/label/lat/lon and - # the backend has no translation for `id` at all, so a row keyed on it had no - # identity and a tap carried nothing. The name is what the search returns and - # what `sys.search(query: state.dest)` can find again. - for f, i in found key f.label { Hit(f: f, pick: choose_dest) } + # Provider IDs keep distinct places selectable even when addresses match. + for f, i in found key f.id { Hit(f: f, pick: choose_dest) } } } } @@ -520,6 +536,7 @@ view root Surface { # origin starts from where you are, which is what `from_lat` froze. when origin != "" { when trip.$state == .pending { TextBody(text: copy.seeking) } + when trip.$state == .failed { TextBody(text: copy.route_error) } when trip.$state == .ready { # The decision, and the one button that acts on it. iOS Maps puts the # duration and distance on the left and GO on the right, and that is the @@ -539,7 +556,9 @@ view root Surface { } } when origin == "" { - when trip_here.$state == .pending { TextBody(text: copy.seeking) } + when here.ok == 0 { TextBody(text: copy.gps_wait) } + when here.ok == 1 { when trip_here.$state == .pending { TextBody(text: copy.seeking) } + when trip_here.$state == .failed { TextBody(text: copy.route_error) } } when trip_here.$state == .ready { # The decision, and the one button that acts on it. iOS Maps puts the # duration and distance on the left and GO on the right, and that is the @@ -578,8 +597,8 @@ view root Surface { # From the captured position: there is no place to name, so the map is given # the same two numbers the route was. when origin == "" { - Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, - zoom: 16, controls: .all) + when here.ok == 1 { Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, + zoom: 16, controls: .all, summary: trip_here) } } } @@ -589,7 +608,9 @@ view root Surface { # legs are beneath the total and sum to it — R6.2, and the reason two more # sources exist: a leg is a trip, so it is routed like one. when stop != "" { + when origin != "" { when trip_via.$state == .pending { TextBody(text: copy.seeking) } + when trip_via.$state == .failed { TextBody(text: copy.route_error) } when trip_via.$state == .ready { Row(align: .center, gap: 12) { TextValue(value: trip_via.duration) @@ -606,6 +627,28 @@ view root Surface { } Map(mode: .plan, from: origin_place, to: dest_place, via: stop_place, zoom: 16, controls: .all, summary: trip_via) + } + when origin == "" { + when here.ok == 0 { TextBody(text: copy.gps_wait) } + when here.ok == 1 { when trip_here_via.$state == .pending { TextBody(text: copy.seeking) } + when trip_here_via.$state == .failed { TextBody(text: copy.route_error) } } + when trip_here_via.$state == .ready { + Row(align: .center, gap: 12) { + TextValue(value: trip_here_via.duration) + TextCaption(value: trip_here_via.distance, suffix: copy.away) + Chip(text: copy.start, on_tap: go) + } + Row(align: .center, gap: 8) { + TextCaption(value: leg_here_a.duration) + TextCaption(value: leg_here_a.distance) + TextCaption(text: copy.via_lbl, width: .label) + TextCaption(value: leg_b.duration) + TextCaption(value: leg_b.distance) + } + } + when here.ok == 1 { Map(mode: .plan, from_lat: from_lat, from_lon: from_lon, to: dest_place, via: stop_place, zoom: 16, + controls: .all, summary: trip_here_via) } + } } } } @@ -621,8 +664,13 @@ view root Surface { # WRAPS. A manoeuvre is a whole street name and "Continue onto South De Anza # Boulevard" was clipped mid-word at the card's edge — the one thing a driver # reads at a glance, truncated. `TextBody` is the role that wraps. - when origin != "" { TextBody(text: step.instruction, width: .fill) } - when origin == "" { TextBody(text: step_here.instruction, width: .fill) } + when here.ok == 0 { TextBody(text: copy.gps_wait, width: .fill) } + when here.ok == 1 { + when stop == "" { when origin != "" { TextBody(text: step.instruction, width: .fill) } } + when stop != "" { when origin != "" { TextBody(text: step_via.instruction, width: .fill) } } + when stop == "" { when origin == "" { TextBody(text: step_here.instruction, width: .fill) } } + when stop != "" { when origin == "" { TextBody(text: step_here_via.instruction, width: .fill) } } + } } # The view switch, ON THE MAP rather than in the banner — the banner holds the @@ -649,11 +697,15 @@ view root Surface { # "when do I arrive" is the question, and the distance is how it is answered. # `unit: .duration` because "34" beside "26.8 km" reads as another distance; the # theme supplies the word. - when origin != "" { TextHero(value: step.eta, unit: .duration) } - when origin == "" { TextHero(value: step_here.eta, unit: .duration) } + when stop == "" { when origin != "" { TextHero(value: step.eta, unit: .duration) } } + when stop != "" { when origin != "" { TextHero(value: step_via.eta, unit: .duration) } } + when stop == "" { when origin == "" { TextHero(value: step_here.eta, unit: .duration) } } + when stop != "" { when origin == "" { TextHero(value: step_here_via.eta, unit: .duration) } } # The distance, small, beneath it — the shipping card's `remrest`. - when origin != "" { TextCaption(value: step.remaining) } - when origin == "" { TextCaption(value: step_here.remaining) } + when stop == "" { when origin != "" { TextCaption(value: step.remaining) } } + when stop != "" { when origin != "" { TextCaption(value: step_via.remaining) } } + when stop == "" { when origin == "" { TextCaption(value: step_here.remaining) } } + when stop != "" { when origin == "" { TextCaption(value: step_here_via.remaining) } } Reveal { # `.danger` says what the action MEANS. Ending navigation is destructive, and # the theme renders that red, full-width and centred — the shipping card's @@ -681,6 +733,7 @@ view root Surface { # `view: view` — the STATE, not a token. A camera the card states is a camera a # card can let the user choose, so R8.3's toggle costs a state and two chips # rather than a whole `Map` per branch it would otherwise multiply with. + when stop == "" { when origin != "" { Map(mode: .drive, from: origin_place, to: dest_place, at: here, view: view, zoom: 15, controls: .all) @@ -689,5 +742,16 @@ view root Surface { Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, at: here, view: view, zoom: 15, controls: .all) } + } + when stop != "" { + when origin != "" { + Map(mode: .drive, from: origin_place, to: dest_place, via: stop_place, at: here, + view: view, zoom: 15, controls: .all) + } + when origin == "" { + Map(mode: .drive, from_lat: from_lat, from_lon: from_lon, to: dest_place, via: stop_place, + at: here, view: view, zoom: 15, controls: .all) + } + } } } diff --git a/crates/octoscript-ui-l0/tests/layers_review.rs b/crates/octoscript-ui-l0/tests/layers_review.rs new file mode 100644 index 0000000..48be575 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/layers_review.rs @@ -0,0 +1,146 @@ +//! Regressions for the completed L0–L3 boundary review. +use serde_json::json; +use octoscript_ui_l0::*; + +fn card(expr: &str) -> String { + format!("# level: L1\nsource q sys.quote(ticker: \"N\", fields: [last, open])\nview root Surface {{ TextHero(value: {expr}) }}") +} + +#[test] +fn flat_expression_trees_are_bounded_before_all_consumers() { + for op in ["+", "*"] { + let src = card(&vec!["q.last"; 20_000].join(op)); + let check = check_ui_l0(&src); + assert!(!check.valid); + assert!(check.diagnostics.iter().any(|d| d.message.contains("deep"))); + assert!( + realize(&src, &json!({"q":{"last":3}}), RealizeLimits::default()) + .complete_root() + .is_err() + ); + } +} + +#[test] +fn constant_analysis_does_not_sample_inputs() { + assert!(check_ui_l0(&card("(q.last - 2) * (q.last - 5) * (q.last - 11)")).valid); + assert!(!check_ui_l0(&card("(q.last - 2) / (q.last - 2) * 1547")).valid); + assert!(check_ui_l0(&card("q.last - q.open")).valid); +} + +#[test] +fn component_arguments_preserve_values_and_live_arithmetic() { + let inline = card("q.last * 2"); + let component = inline + .replace( + "view root", + "component Hero(v: number) { view TextHero(value: v) }\nview root", + ) + .replace("TextHero(value: q.last * 2)", "Hero(v: q.last * 2)"); + let data = json!({"q":{"last":4}}); + let a = realize(&inline, &data, RealizeLimits::default()); + let b = realize(&component, &data, RealizeLimits::default()); + let a = &a.complete_root().unwrap().children[0]; + let b = &b.complete_root().unwrap().children[0]; + assert_eq!(a.args, b.args); + assert_eq!(a.exprs, b.exprs); + assert_eq!(kit::lower(a), kit::lower(b)); +} + +#[test] +fn component_predicates_resolve_both_operands() { + let src = "state n { shape: number, initial: 2 }\ncomponent Flag(v: bool) { view Surface { when v { Rule() } } }\nview root Surface { Flag(v: n == 2) }"; + let report = realize(src, &json!({}), RealizeLimits::default()); + assert_eq!( + report.complete_root().unwrap().children[0].children[0].kind, + "Rule" + ); + let report = realize( + &src.replace("n == 2", "n == 3"), + &json!({}), + RealizeLimits::default(), + ); + assert!(report.complete_root().unwrap().children[0] + .children + .is_empty()); +} + +#[test] +fn duplicates_never_collide_with_authored_suffixes() { + let src = "source q sys.movers(fields: [name, last])\ncomponent Item(v: number) { state expanded { shape: bool, initial: false } event toggle { expanded: toggle } view Surface { TextRow(text: v) when expanded { Rule() } } }\nview root Surface { for x in q key x.name { Item(v: x.last) } }"; + let report = realize( + src, + &json!({"q":[{"name":"A","last":1},{"name":"A","last":2},{"name":"A#1","last":3}]}), + RealizeLimits::default(), + ); + assert!(report.complete_root().is_err()); + let children = &report.root.as_ref().unwrap().children; + assert_eq!(children.len(), 2); + assert_ne!(children[0].key, children[1].key); +} + +#[test] +fn patch_cannot_restore_a_tree_past_its_budget_or_overwrite_edits() { + let src = "view root Surface { Rule() Rule() Rule() }"; + let previous = realize(src, &json!({}), RealizeLimits::default()); + let previous = previous.complete_root().unwrap(); + let patch = realize_patch( + src, + &json!({}), + None, + previous, + &[], + RealizeLimits { + max_nodes: 1, + ..RealizeLimits::default() + }, + ); + assert!(patch.complete_root().is_err()); + assert_eq!(patch.reused, 0); + assert!(patch.root.as_ref().is_none_or(|n| n.children.is_empty())); + let patch = realize_patch( + "view root Surface { TextRow(text: \"changed\") }", + &json!({}), + None, + previous, + &[], + RealizeLimits::default(), + ); + assert_eq!(patch.complete_root().unwrap().children[0].kind, "TextRow"); +} + +#[test] +fn fingerprints_cover_keys_and_transitive_definitions() { + let src = "source q sys.movers(fields: [ticker, name, last])\ncomponent Items() { view Surface { for x in q key x.ticker { TextRow(text: x.last) } } }\nview root Items()"; + let digest = |s: &str| { + let r = check_ui_l0(s); + assert!(r.valid, "{:?}", r.diagnostics); + r.closure + }; + assert_ne!( + digest(src), + digest(&src.replace("key x.ticker", "key x.name")) + ); + let src = "view leaf TextRow(text: \"a\")\ncomponent Child() { view leaf }\ncomponent Parent() { view Child() }\nview root Parent()"; + assert_ne!( + digest(src), + digest(&src.replace("text: \"a\"", "text: \"b\"")) + ); + assert_eq!(digest(src), digest(&format!("\n\n{src}\n"))); +} + +#[test] +fn component_expression_substitution_charges_its_expansion() { + let mut src = String::from("# level: L1\nsource q sys.quote(ticker: \"N\", fields: [last])\n"); + for i in 0..24 { + src.push_str(&format!( + "component C{i}(v: number) {{ view C{}(v: v + v) }}\n", + i + 1 + )); + } + src.push_str("component C24(v: number) { view TextHero(value: v) }\nview root C0(v: q.last)\n"); + assert!(check_ui_l0(&src).valid); + let report = realize(&src, &json!({"q":{"last":1}}), RealizeLimits::default()); + assert!(report.truncated); + assert!(report.complete_root().is_err()); +} diff --git a/crates/octoscript-ui-l0/tests/live_news.rs b/crates/octoscript-ui-l0/tests/live_news.rs new file mode 100644 index 0000000..2e7b586 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/live_news.rs @@ -0,0 +1,52 @@ +use octoscript_ui_l0::{check_ui_l0_named, makepad, SourceBinding}; + +#[test] +fn live_news_card_is_l0_and_query_changes_invalidate_both_sources() { + let card = include_str!("../../../../tools/octoscript-research/live-news.card"); + let report = check_ui_l0_named("live-news", card); + assert!(report.valid, "{:?}", report.diagnostics); + for field in ["query", "language"] { + assert_eq!(octoscript_ui_l0::stale_sources(card, &[field]), vec!["stories", "progress"]); + } + assert!(!check_ui_l0_named("live-news", &card.replace("count: 3,", "endpoint: \"https://untrusted.invalid\", count: 3,")).valid); +} + +#[test] +fn news_binding_quotes_user_text_and_bounds_rows_and_fields() { + let mut binding = SourceBinding { + helper: "sys.news_digest".into(), field: "0.summary".into(), nested: vec![], + args: vec![("query".into(), "Apple \" OR sys.stock(1)".into()), ("language".into(), "zh-CN".into())], + }; + let call = makepad::vm_call(&binding).unwrap(); + assert_eq!(call, "sys.news_digest(\"Apple \\\" OR sys.stock(1)\", \"zh-CN\", \"items.0.summary\")"); + for field in ["3.summary", "0.password", "../../url"] { + binding.field = field.into(); + assert!(makepad::vm_call(&binding).is_none()); + } +} + +#[test] +fn chinese_search_uses_the_real_field_and_preserves_news_sources() { + use octoscript_ui_l0::{NodeValue, UiNode, InstanceStore, RealizeLimits, ValueOrigin}; + fn find<'a>(node: &'a UiNode, prop: &str, event: &str) -> Option<&'a UiNode> { + if node.args.iter().any(|(p,v)| p==prop && matches!(v, NodeValue::Event(e) if e==event)) { return Some(node); } + node.children.iter().find_map(|c| find(c, prop, event)) + } + let card = include_str!("../../../../tools/octoscript-research/live-news.card"); + let mut store = InstanceStore::default(); + let data = serde_json::json!({}); + for (prop, event, value) in [("on_tap", "edit_search", ""), ("on_commit", "search", "美国伊朗最新冲突")] { + let report = octoscript_ui_l0::realize_with_state(card, &data, &store, RealizeLimits::default()); + let root = report.complete_root().unwrap(); + let control = find(root, prop, event).unwrap(); + let origin = octoscript_ui_l0::event_payload_origin(root, &control.key, event).unwrap(); + if event == "search" { assert_eq!(origin, ValueOrigin::UserInput); } + let outcome = octoscript_ui_l0::dispatch_reporting_with_origin(card, &mut store, &control.key, + event, Some(&serde_json::json!(value)), &data, origin); + assert!(outcome.applied); + if event == "search" { assert!(outcome.stale.iter().any(|s| s == "stories")); } + } + let report = octoscript_ui_l0::realize_with_state(card, &data, &store, RealizeLimits::default()); + assert!(report.complete_root().is_ok()); + assert!(octoscript_ui_l0::source_plan(card).requests.iter().any(|s| s.helper == "sys.news_digest")); +} diff --git a/crates/octoscript-ui-l0/tests/nav_waypoint.rs b/crates/octoscript-ui-l0/tests/nav_waypoint.rs new file mode 100644 index 0000000..9ab24e9 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/nav_waypoint.rs @@ -0,0 +1,31 @@ +use octoscript_ui_l0::{check_ui_l0_named, kit, realize, RealizeLimits}; + +#[test] +fn waypoint_progress_and_instructions_use_the_same_route() { + let card = r#" +source here sys.gps() +source origin sys.search(query: "Saratoga High School", count: 1, fields: [name, lat, lon]) +source dest sys.search(query: "NVIDIA", count: 1, fields: [name, lat, lon]) +source stop sys.search(query: "Apple Park", count: 1, fields: [name, lat, lon]) +source step sys.step(from_lat: origin.0.lat, from_lon: origin.0.lon, + to_lat: dest.0.lat, to_lon: dest.0.lon, at_lat: here.lat, at_lon: here.lon, + via: [stop.0.lat, stop.0.lon], fields: [instruction, remaining, eta]) +view root Surface { + TextBody(text: step.instruction) + TextCaption(value: step.remaining) + TextHero(value: step.eta) + Map(mode: .drive, from: origin, to: dest, via: stop, at: here) +} +"#; + let checked = check_ui_l0_named("nav", card); + assert!(checked.valid, "{:?}", checked.diagnostics); + let report = realize(card, &serde_json::json!({}), RealizeLimits::default()); + let lowered = kit::lower(&report.complete_root().unwrap()); + assert!(lowered.contains("sys.navprog(")); + assert!(lowered.contains("sys.navstep(")); + // Each live instruction asks navstep and navprog about the same waypoint; + // map geometry needs it too. A direct-route progress value cannot be mixed + // with a route through the stop. + assert!(lowered.matches("sys.searchnum(\"Apple Park\", 0, \"lat\")").count() >= 7, "{lowered}"); + assert!(lowered.matches("sys.searchnum(\"Apple Park\", 0, \"lon\")").count() >= 7, "{lowered}"); +} diff --git a/crates/octoscript-ui-l0/tests/origins_approval.rs b/crates/octoscript-ui-l0/tests/origins_approval.rs new file mode 100644 index 0000000..1a49afa --- /dev/null +++ b/crates/octoscript-ui-l0/tests/origins_approval.rs @@ -0,0 +1,160 @@ +use serde_json::json; +use octoscript_ui_l0::{approval::ArtifactApproval, *}; + +fn tree(source: &str, data: serde_json::Value, store: &InstanceStore) -> UiNode { + realize_with_state(source, &data, store, RealizeLimits::default()) + .complete_root() + .unwrap() + .clone() +} +fn value(node: &UiNode) -> &NodeValue { + &node.args.iter().find(|(n, _)| n == "value").unwrap().1 +} + +const FORM: &str = r#" +state amount { shape: text, initial: "1547" } +event input { amount: set($value) } +event reset { amount: clear } +view root Surface { + Field(text: amount, on_commit: input, on_change: input) + TextHero(value: amount) + Row(on_tap: input, value: "1547") { TextRow(text: "Use default") } +} +"#; + +#[test] +fn defaults_remain_editable_but_cannot_become_data_through_a_tap() { + let mut store = InstanceStore::default(); + let root = tree(FORM, json!({}), &store); + assert_eq!(*value(&root.children[1]), NodeValue::Missing); + assert!(root.children[0] + .args + .contains(&("text".into(), NodeValue::Text("1547".into())))); + let row = &root.children[2]; + let origin = event_payload_origin(&root, &row.key, "input").unwrap(); + assert_eq!(origin, ValueOrigin::Authored); + dispatch_reporting_with_origin( + FORM, + &mut store, + &row.key, + "input", + Some(&json!("1547")), + &json!({}), + origin, + ); + assert_eq!( + *value(&tree(FORM, json!({}), &store).children[1]), + NodeValue::Missing + ); + assert_eq!(event_payload_origin(&root, &row.key, "invented"), None); +} + +#[test] +fn typing_the_same_default_is_a_change_of_origin_and_reset_revokes_it() { + let mut store = InstanceStore::default(); + let root = tree(FORM, json!({}), &store); + let field = &root.children[0]; + let origin = event_payload_origin(&root, &field.key, "input").unwrap(); + let outcome = dispatch_reporting_with_origin( + FORM, + &mut store, + &field.key, + "input", + Some(&json!("1547")), + &json!({}), + origin, + ); + assert!(outcome.applied); + assert_eq!( + store.origin(CARD_STATE_KEY, "amount"), + Some(ValueOrigin::UserInput) + ); + assert_eq!( + *value(&tree(FORM, json!({}), &store).children[1]), + NodeValue::Text("1547".into()) + ); + assert!(dispatch(FORM, &mut store, "root", "reset")); + assert_eq!( + *value(&tree(FORM, json!({}), &store).children[1]), + NodeValue::Missing + ); +} + +#[test] +fn origin_survives_nested_props_and_l1_formulas_without_live_bypass() { + let source = "# level: L1\nstate n { shape: number, initial: 1547 }\ncomponent Inner(v: number) { view TextHero(value: v) }\ncomponent Outer(v: number) { view Inner(v: v * 2) }\nview root Surface { Outer(v: n) }"; + let mut store = InstanceStore::default(); + let root = tree(source, json!({}), &store); + let hero = &root.children[0]; + assert_eq!(*value(hero), NodeValue::Missing); + assert!(hero.exprs.is_empty() && hero.bindings.is_empty()); + assert!(!kit::lower(&root).contains("1547")); + store.set_cell(CARD_STATE_KEY, "n", json!(3)); + assert_eq!( + *value(&tree(source, json!({}), &store).children[0]), + NodeValue::Number(6.0) + ); +} + +#[test] +fn source_captures_and_loop_props_keep_their_origin() { + let source = "source q sys.quote(ticker: \"N\", fields: [last])\nstate n { shape: number, initial: q.last }\nview root Surface { TextHero(value: n) }"; + let mut store = InstanceStore::default(); + assert_eq!( + *value(&tree(source, json!({}), &store).children[0]), + NodeValue::Missing + ); + let report = realize_with_state( + source, + &json!({"q":{"last":5}}), + &store, + RealizeLimits::default(), + ); + assert_eq!( + *value(&report.complete_root().unwrap().children[0]), + NodeValue::Number(5.0) + ); + for (path, value) in report.captured { + store.set_cell_with_origin(CARD_STATE_KEY, &path, value, ValueOrigin::Source); + } + assert_eq!( + *value(&tree(source, json!({"q":{"last":9}}), &store).children[0]), + NodeValue::Number(5.0) + ); + let source = "source rows sys.movers(fields: [name, last])\ncomponent Hero(row: record) { view TextHero(value: row.last) }\nview root Surface { for row in rows key row.name { Hero(row: row) } }"; + let root = tree( + source, + json!({"rows":[{"name":"N","last":7}]}), + &InstanceStore::default(), + ); + assert_eq!(*value(&root.children[0]), NodeValue::Number(7.0)); +} + +#[test] +fn approval_pins_source_level_kit_and_runtime() { + let source = "view root Surface { Rule() }"; + let approval = ArtifactApproval::admit(source, "runtime-a", "kit-a").unwrap(); + let restored = ArtifactApproval::from_json(&approval.to_json()).unwrap(); + restored.verify(source, "runtime-a", "kit-a").unwrap(); + for (source, runtime, kit) in [ + ("view root Surface { Gap(size: 2) }", "runtime-a", "kit-a"), + (source, "runtime-b", "kit-a"), + (source, "runtime-a", "kit-b"), + ( + "# level: L1\nview root Surface { Rule() }", + "runtime-a", + "kit-a", + ), + ] { + assert!(restored.verify(source, runtime, kit).is_err()); + } + assert!(ArtifactApproval::admit( + "# level: L2\nview root Surface { Rule() }", + "runtime-a", + "kit-a" + ) + .is_err()); + let mut bad = approval.to_json(); + bad["version"] = json!(2); + assert!(ArtifactApproval::from_json(&bad).is_err()); +} diff --git a/crates/octoscript-ui-l0/tests/profile.rs b/crates/octoscript-ui-l0/tests/profile.rs index 2d58652..d0838d8 100644 --- a/crates/octoscript-ui-l0/tests/profile.rs +++ b/crates/octoscript-ui-l0/tests/profile.rs @@ -10,6 +10,63 @@ const WEATHER: &str = include_str!("fixtures/weather.card"); const NEWS: &str = include_str!("fixtures/news.card"); const STOCK: &str = include_str!("fixtures/stock.card"); +#[test] +fn l0_migration_conversion_binding_tracks_amount_pair_and_direction() { + let card = "state amount { shape: number, initial: 20 }\n\ + state from { shape: text, initial: \"c\" }\n\ + state to { shape: text, initial: \"f\" }\n\ + state dir { shape: enum[fwd, rev], initial: .fwd }\n\ + source result sys.convert(amount: state.amount, from: state.from, to: state.to, direction: state.dir, fields: [value])\n\ + view root TextHero(value: result.value, format: .ratio)\n"; + let checked = check_ui_l0_named("convert", card); + assert!(checked.valid, "{:?}", checked.diagnostics); + assert_eq!(checked.level, Level::L0); + for (amount, direction) in [(20, "fwd"), (68, "rev")] { + let data = serde_json::json!({"amount": amount, "from":"c", "to":"f", "dir": direction}); + let realized = octoscript_ui_l0::realize(card, &data, Default::default()); + let lowered = octoscript_ui_l0::kit::lower(realized.complete_root().unwrap()); + assert!(lowered.contains(&format!("sys.convert({amount}, \"c\", \"f\", \"{direction}\")")), "{lowered}\n{:?}", realized.complete_root().unwrap()); + } + for changed in ["amount", "from", "to", "dir"] { + assert_eq!(octoscript_ui_l0::stale_sources(card, &[changed]), vec!["result"]); + } +} + +#[test] +fn l0_migration_conversion_rejects_undeclared_results_and_executable_arguments() { + let card = "source result sys.convert(amount: 20, from: \"c\", to: \"f\", fields: [value])\nview root TextHero(value: result.value)"; + assert!(check_ui_l0_named("convert", card).valid); + assert!(!check_ui_l0_named("convert", &card.replace("result.value)", "result.value + 1)")).valid); + assert!(!check_ui_l0_named("convert", &card.replace("result.value)", "result.factor)")).valid); + for bad in ["1 + 1", "sys.navsecs(1)", "NaN", "inf"] { + let binding = octoscript_ui_l0::SourceBinding { + helper: "sys.convert".into(), field: "value".into(), nested: vec![], + args: vec![("amount".into(), bad.into()), ("from".into(), "c".into()), ("to".into(), "f".into())], + }; + assert_eq!(octoscript_ui_l0::makepad::vm_call(&binding), None, "{bad}"); + } +} + +#[test] +fn l0_migration_city_difference_is_l0_and_unit_changes_rebind_the_source() { + let card = "state units { shape: enum[c, f], initial: .c }\n\ + source picks sys.cities(fields: [name, temp, feels_delta], unit: state.units)\n\ + view root Col { for c in picks key c.name { TextValue(value: c.temp, unit: units) TextCaption(value: c.feels_delta, unit: units) } }"; + let checked = check_ui_l0_named("cities", card); + assert!(checked.valid, "{:?}", checked.diagnostics); + assert_eq!(checked.level, Level::L0); + for unit in ["c", "f"] { + let data = serde_json::json!({"units":unit,"picks":[{"name":"Test","temp":20,"feels_delta":2}]}); + let realized = octoscript_ui_l0::realize(card, &data, Default::default()); + let lowered = octoscript_ui_l0::kit::lower(realized.complete_root().unwrap()); + assert!(lowered.contains(&format!("sys.cities(0, \"feels_delta\", \"{unit}\")")), "{lowered}"); + assert!(lowered.contains(&format!("sys.cities(0, \"temp\", \"{unit}\")")), "{lowered}"); + } + assert_eq!(octoscript_ui_l0::stale_sources(card, &["units"]), vec!["picks"]); + let empty = octoscript_ui_l0::realize(card, &serde_json::json!({"picks":[]}), Default::default()); + assert!(empty.complete_root().is_ok()); +} + fn accepts(name: &str, source: &str) { let report = check_ui_l0_named(name, source); assert!( @@ -689,6 +746,7 @@ fn an_unmapped_constructor_is_visible_rather_than_dropped() { children: vec![], bindings: vec![], exprs: vec![], + origins: vec![], }; let dsl = makepad::lower(&node); assert!(dsl.contains("no makepad lowering for Hologram"), "{dsl}"); @@ -1583,8 +1641,8 @@ view root Panel { Framed(title: "Details", rank: 3) } 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" + NodeValue::Missing, + "a numeric authored prop is not a sourced reading" ); } @@ -3777,13 +3835,17 @@ fn duplicate_loop_keys_are_reported_and_do_not_share_state() { report.diagnostics ); + assert!( + report.complete_root().is_err(), + "a host must refuse duplicate keys" + ); 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" + assert_eq!( + rows.len(), + 1, + "the duplicate is omitted, never renamed into a collision" ); } @@ -4797,9 +4859,14 @@ view root Surface { // icon is the code that is true when the card draws rather than the one the // host happened to seed. The size must still travel with it: that half was // the original defect and is unrelated to where the value comes from. + // + // `weathercond`, NOT `weatherword`: this pinned the word for two releases, + // and the word is a STRING, which the icon coerces to 0, which is the sun. + // The test agreed with the emitter and neither of them was looking at a + // screen — see `the_weather_icon_is_given_a_number_and_the_text_a_word`. assert!( - dsl.contains("l0_weathericon(sys.weatherword(") && dsl.contains(", \"hero\")"), - "the hero's cond must go live and keep its size:\n{dsl}" + dsl.contains("l0_weathericon(sys.weathercond(") && dsl.contains(", \"hero\")"), + "the hero's cond must go live as a CODE and keep its size:\n{dsl}" ); // And the per-item conditions must DIFFER — one shared value for every row // is exactly what the bug produced, and a test that only checked "a number @@ -4906,7 +4973,9 @@ fn the_nav_trip_planner_is_expressible_at_l0() { // comment above names is 400 — the point at which declarations stop being the // cheaper answer — and this is well inside it. assert!( - lines < 300, + // Both origin modes now retain a stop in the drive screen, and expose + // loading/location failures. Keep the documented 400-line budget. + lines < 400, "the point is that it is small; this is {lines} lines" ); } @@ -5919,7 +5988,7 @@ fn changing_a_declared_attribute_must_change_the_lowering() { format!("{head}view root Surface {{ {role}{arglist}{body} }}\n") } }; - let data = serde_json::json!({ "env": { "locale": {} }, "copy": {} }); + let data = serde_json::json!({ "env": { "locale": {} }, "copy": {}, "sa": 11, "sb": 4242, "sc": -7 }); let lower = |card: &str| -> Option { if !check_ui_l0_named("probe", card).valid { return None; @@ -6547,9 +6616,8 @@ view root Surface { Chip(text: "c", active: k == 3 * 4) TextRow(text: q.last) } /// The third is the one that needed an argument rather than a patch. §9.3 asked /// an expression to READ something, which stops `1547 * 3.2` and does not stop /// `quote.last * 0 + 1547` — one real reading laundering a fabricated number. -/// The argument: a formula is a formula because its answer MOVES when its inputs -/// move, so evaluate it under several assignments and refuse an answer that never -/// changes. +/// A limited structural analysis rejects recognized constants. Unknown formulas +/// remain accepted; sampling cannot prove dependence on inputs. #[test] fn an_expression_must_depend_on_what_it_reads() { let ok = |body: &str| { @@ -6599,7 +6667,8 @@ fn an_expression_must_depend_on_what_it_reads() { } // The probe must not condemn a difference. Binding every read to the SAME - // number would make `a - b` constant, which is why the assignments differ + // number would make `a - b` constant; structural equality distinguishes them. + // The former sampling check required different assignments // per path as well as per round. assert!( ok("TextHero(value: q.last - q.open)"), @@ -6638,11 +6707,11 @@ fn grouping_overrides_precedence() { // The DSL carries the EXPRESSION, so the tree's shape is the evidence: the // backend evaluates it against data that arrives later. assert!( - tree("a + b * c").contains("(2 + (3 * 4))"), + tree("a + b * c").contains(r#"sys.l0_math("+", 2, sys.l0_math("*", 3, 4))"#), "multiplication binds tighter" ); assert!( - tree("(a + b) * c").contains("((2 + 3) * 4)"), + tree("(a + b) * c").contains(r#"sys.l0_math("*", sys.l0_math("+", 2, 3), 4)"#), "and grouping overrides that" ); } @@ -6961,7 +7030,6 @@ fn every_offered_field_has_a_translation() { ("sys.watchlist", "currency"), ("sys.watchlist", "exchange"), ("sys.places", "id"), - ("sys.search", "id"), ("sys.search", "distance"), // `days` is the COLLECTION a forecast loops over, not a value read off // it, so no single call answers it. The loop is what consumes it. @@ -6982,6 +7050,9 @@ fn every_offered_field_has_a_translation() { ("lat".into(), "1".into()), ("lon".into(), "2".into()), ("ticker".into(), "N".into()), + ("amount".into(), "20".into()), + ("from".into(), "c".into()), + ("to".into(), "f".into()), ("query".into(), "q".into()), ("countries".into(), "CHN".into()), ("indicator".into(), "NY.GDP.MKTP.KD.ZG".into()), @@ -7007,6 +7078,9 @@ fn every_offered_field_has_a_translation() { ("at_lat".into(), "5".into()), ("at_lon".into(), "6".into()), ], + // The probe hands VALUES, not calls — a text slot quotes + // what it is given unless the lowering says it built it. + nested: Vec::new(), field: f.clone(), }) .is_some() @@ -8833,3 +8907,536 @@ fn a_map_labels_the_route_with_what_it_costs() { "the badge and the summary must come from one trip:\n{dsl}" ); } + +// ─── a value guard gets a number to compare against ────────────────────────── +// +// The defect class in one sentence: guards are decided at REALIZE against +// injected data, and a fetched scalar is not in that data — the kit resolves it +// lazily at DRAW time. So `when now.precip >= 40` compared against nothing, and +// so did its complement, and both were false. Measured on a 6T: the +// `weather-activity` card drew a correct header and a rain tile reading 100 %, +// with no verdict under either. +// +// `guard_bindings` is what a host resolves BEFORE realize so the branch is taken +// on the number the tile shows. + +/// The decision tree from `weather-activity`, cut to the guards. +const GUARDED: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, precip])\n", + "source air sys.airquality(lat: place.lat, lon: place.lon)\n", + "state city { shape: text, initial: \"Kyoto\" }\n", + "copy wet { class: vocabulary, en: \"stay in\" }\n", + "copy dry { class: vocabulary, en: \"go out\" }\n", + "view root Surface {\n", + " when now.precip >= 40 { TextTitle(text: copy.wet) }\n", + " when now.precip < 40 {\n", + " when air.aqi >= 100 { TextTitle(text: copy.wet) }\n", + " when air.aqi < 100 {\n", + " when now.temp < 12 { TextTitle(text: copy.wet) }\n", + " when now.temp >= 12 { TextTitle(text: copy.dry) }\n", + " }\n", + " }\n", + "}\n" +); + +#[test] +fn a_value_guard_reports_the_call_that_answers_it() { + let checked = check_ui_l0_named("weather-activity", GUARDED); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let store = octoscript_ui_l0::InstanceStore::default(); + let bindings = octoscript_ui_l0::guard_bindings(GUARDED, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), &store); + + // Every field under a `when`, once each — and nothing else. `place.lat` is + // read by a source ARGUMENT, not by a guard, so it is not asked for + // separately: it arrives inside the call below. + let mut pairs: Vec<(String, String)> = bindings + .iter() + .map(|g| (g.source.clone(), g.field.clone())) + .collect(); + pairs.sort(); + assert_eq!( + pairs, + vec![ + ("air".to_string(), "aqi".to_string()), + ("now".to_string(), "precip".to_string()), + ("now".to_string(), "temp".to_string()), + ], + "the three values the tree branches on" + ); + + // THE POINT: the dependency is already resolved. `now` takes `lat: place.lat` + // and `place` is a geocode of the card's own state, so the emitted call + // carries the place the card names — the host fetches nothing extra and + // resolves nothing in an order of its own invention. + let precip = bindings + .iter() + .find(|g| g.field == "precip") + .expect("precip is guarded"); + let call = octoscript_ui_l0::makepad::vm_call(&precip.binding).expect("this backend answers it"); + assert_eq!( + call, + "sys.weather(sys.geocodenum(\"Kyoto\", \"lat\"), sys.geocodenum(\"Kyoto\", \"lon\"), \ + \"daily.precipitation_probability_max.0\")", + "the guard's call is the display binding's call" + ); +} + +#[test] +fn a_card_with_no_value_guards_asks_for_nothing() { + // The fetch policy, and why this can sit unconditionally in the render path: + // a `when` on a value is the CARD saying it needs that value early. A card + // that branches only on state and `$state` pays nothing. + const PLAIN: &str = concat!( + "# level: L0\n", + "source now sys.weather(lat: 1, lon: 2, fields: [temp])\n", + "state units { shape: enum[c, f], initial: .c }\n", + "copy t { class: vocabulary, en: \"t\" }\n", + "view root Surface {\n", + " when now.$state == .pending { TextBody(text: copy.t) }\n", + " when units == .c { TextHero(value: now.temp, unit: units) }\n", + "}\n" + ); + let checked = check_ui_l0_named("weather", PLAIN); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let store = octoscript_ui_l0::InstanceStore::default(); + assert!( + octoscript_ui_l0::guard_bindings(PLAIN, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), &store).is_empty(), + "`$state` is already injected and `units` is card state — neither is a fetch" + ); +} + +#[test] +fn a_source_argument_is_not_rounded_to_one_decimal() { + // `trim_num` is a one-decimal DISPLAY rule and it was emitting source + // ARGUMENTS. A coordinate is the case that shows it: 37.7749 became 37.8, + // which is thirty kilometres away and still renders a perfectly plausible + // forecast. Same class as the L1 operand it rounded from 0.621371 to 0.6. + const PINNED: &str = concat!( + "# level: L0\n", + "source now sys.weather(lat: 37.7749, lon: -122.4194, fields: [temp])\n", + "copy t { class: vocabulary, en: \"t\" }\n", + "view root Surface { TextHero(value: now.temp) }\n" + ); + let checked = check_ui_l0_named("weather", PINNED); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(PINNED, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + dsl.contains("sys.weather(37.7749, -122.4194,"), + "the coordinate the card declared, not one rounded for display:\n{dsl}" + ); +} + +#[test] +fn a_text_argument_that_names_another_source_stays_a_call() { + // A string slot is not safe by construction. `{:?}` quotes whatever it is + // handed, and a source argument that names ANOTHER SOURCE resolves to that + // source's own live call — so `sys.photo(query: place.name)` lowered to + // `sys.photo("sys.geocode(\"kyoto\", \"name\")")` and the wallpaper was + // generated from the TEXT of the call. + // + // Measured on the 6T: the URL fetched was + // `image.pollinations.ai/prompt/sys.geocode%28%22kyoto%22%2C%20%22name%22%29`. + // It survived because it LOOKED right — the service is a generative image + // model and the place name is inside the string it was given, so a card + // asking for Kyoto got a picture of Kyoto. [[the L0 defect class]] exactly: + // the request is wrong and only the wire shows it. + const CARD: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source scene sys.photo(query: place.name)\n", + "state city { shape: text, initial: \"kyoto\" }\n", + "view root Photo(src: scene) { TextTitle(text: place.name) }\n" + ); + let checked = check_ui_l0_named("weather", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + dsl.contains("sys.photo(sys.geocode(\"kyoto\", \"name\"))"), + "the photo is asked for the PLACE, not for the text of the call:\n{dsl}" + ); + assert!( + !dsl.contains("sys.photo(\"sys."), + "a nested call must not be quoted into a string literal:\n{dsl}" + ); +} + +#[test] +fn a_typed_query_is_never_executable() { + // The other half, and the reason this is decided by `binding.nested` rather + // than by whether the string starts with `sys.`. A text argument can carry + // what a PERSON TYPED — a youtube card's `q` is a search box — and a typed + // query must stay a query however it is spelled. + const CARD: &str = concat!( + "# level: L0\n", + "source hits sys.video(query: state.q, count: 1, fields: [id, title])\n", + "state q { shape: text, initial: \"sys.gps(lat)\" }\n", + "view root Surface { for v, i in hits key v.id { TextRow(text: v.title) } }\n" + ); + let checked = check_ui_l0_named("youtube", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + dsl.contains("sys.video(\"sys.gps(lat)\""), + "a typed query stays a QUOTED string, whatever it looks like:\n{dsl}" + ); +} + +#[test] +fn a_card_that_gates_its_rows_on_a_state_can_still_learn_that_state() { + // The shape the shipped `activity` exemplar had, and the deadlock it caused: + // the ONLY reference to `parks` sits inside `when parks.$state == .ready`, and + // `$state` was observed by walking the realized tree for bindings. No data -> + // pending -> the rows do not realize -> nothing binds `parks` -> no status is + // written -> pending. Forever. + // + // Measured on the 6T with "what can I do in beijing tomorrow": the card drew + // "Finding places nearby…" and nothing else, and `L0 $state:` logged empty on + // every realize while the Kyoto card beside it logged all seven ready. + // + // A host cannot break that loop from the tree, so the card has to say what it + // is waiting on — which its own `when` already does. + const GATED: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source parks sys.places(lat: place.lat, lon: place.lon, category: \"park\",\n", + " count: 3, fields: [id, name, distance])\n", + "state city { shape: text, initial: \"Beijing\" }\n", + "copy loading { class: vocabulary, en: \"Finding places nearby…\" }\n", + "view root Surface {\n", + " when parks.$state == .pending { TextBody(text: copy.loading) }\n", + " when parks.$state == .ready {\n", + " Panel { for p, i in parks key p.id { TextRow(text: p.name) } }\n", + " }\n", + "}\n" + ); + let checked = check_ui_l0_named("activity", GATED); + assert!(checked.valid, "{:#?}", checked.diagnostics); + + let store = octoscript_ui_l0::InstanceStore::default(); + let probes = octoscript_ui_l0::guarded_state_bindings(GATED, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), &store); + assert_eq!(probes.len(), 1, "one source is gated: {probes:#?}"); + assert_eq!(probes[0].source, "parks"); + // EMPTY, deliberately: a lifecycle is a property of the fetch, so which field + // answers is the backend's to choose — `probe_call` walks the catalog. + assert!(probes[0].field.is_empty(), "a probe names no field"); + // And the argument chain is already resolved, so the host asks about Beijing + // rather than about a source it would have to fetch in order first. + // Asked as a ROW — `sys.places` is a list, so `0.name` is the shape that + // translates and a bare `name` answers nothing. That is exactly what a host's + // probe has to try, and the one that only tried the bare field could not + // probe a list source at all. + let call = octoscript_ui_l0::makepad::vm_call(&octoscript_ui_l0::SourceBinding { + field: "0.name".to_owned(), + ..probes[0].binding.clone() + }) + .expect("this backend answers it"); + assert!( + call.contains("sys.geocodenum(\"Beijing\", \"lat\")"), + "the probe carries the place the card names:\n{call}" + ); + + // The discriminating half: a card that merely DISPLAYS a source is not gated, + // and asks for no probe. Its bindings are in the tree, where the walk finds + // them, so this stays as narrow as `resolve_guards`. + const PLAIN: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source parks sys.places(lat: place.lat, lon: place.lon, category: \"park\",\n", + " count: 3, fields: [id, name, distance])\n", + "state city { shape: text, initial: \"Beijing\" }\n", + "view root Surface {\n", + " Panel { for p, i in parks key p.id { TextRow(text: p.name) } }\n", + "}\n" + ); + let checked = check_ui_l0_named("activity", PLAIN); + assert!(checked.valid, "{:#?}", checked.diagnostics); + assert!( + octoscript_ui_l0::guarded_state_bindings(PLAIN, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), &store).is_empty(), + "an ungated source needs no probe — the tree walk already answers it" + ); +} + +#[test] +fn the_weather_icon_is_given_a_number_and_the_text_a_word() { + // `cond` has two consumers that need different readings of the same field. + // Both lowered to `sys.weatherword`, so the icon was handed "Rain", coerced + // it to 0, and drew the SUN. Measured on the 6T over a Beijing forecast + // reading 92 % rain — and a wrong icon looks exactly like a right one, which + // is why it survived every card that shipped. + const CARD: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, cond])\n", + "state city { shape: text, initial: \"Beijing\" }\n", + "view root Surface {\n", + " WeatherIcon(cond: now.cond, size: .hero)\n", + " TextRow(text: now.cond)\n", + "}\n" + ); + let checked = check_ui_l0_named("weather", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + // The icon gets the CODE. + assert!( + dsl.contains("l0_weathericon(sys.weathercond("), + "the icon must be given the WMO code, not a word:\n{dsl}" + ); + // And the reader still gets the word — one field, two readings. + assert!( + dsl.contains("sys.weatherword("), + "the text must still read as words:\n{dsl}" + ); + // Both from the same forecast path, or the sky is described twice and + // disagrees with itself. + assert!( + dsl.matches("\"daily.weather_code.0\"").count() == 2, + "the icon and the word must read the same day:\n{dsl}" + ); +} + +#[test] +fn a_source_argument_follows_a_chain_of_sources() { + // `sys.photo(cond: now.cond)` needs `now`, whose own `lat:` names `place`. + // The inner resolver stopped at one level, fell to a scope lookup a LIVE card + // cannot answer, and returned None — and that None propagated outward, so the + // whole photo binding failed and the surface lowered to the realized em dash. + // + // Measured on the 6T: the page drew no wallpaper at all, black behind the + // card, and nothing said so — a card with no image looks exactly like a card + // whose image has not loaded yet. + const CARD: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, cond])\n", + "state city { shape: text, initial: \"Beijing\" }\n", + "state mood { shape: text, initial: \"hutong rooftops, cinematic\" }\n", + "source scene sys.photo(query: state.mood, cond: now.cond)\n", + "view root Photo(src: scene, pad: .page) { TextHero(value: now.temp) }\n" + ); + let checked = check_ui_l0_named("weather-activity", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + // Three levels deep: photo <- weather <- geocode <- state. + assert!( + dsl.contains( + "l0_surface_photo(sys.photo(\"hutong rooftops, cinematic\", \ + sys.weatherword(sys.geocodenum(\"Beijing\", \"lat\"), \ + sys.geocodenum(\"Beijing\", \"lon\"), \"daily.weather_code.0\"))" + ), + "the whole chain must resolve, or the outer binding fails silently:\n{dsl}" + ); + // And the failure this replaced: never the realized placeholder. + assert!( + !dsl.contains("l0_surface_photo(\"\u{2014}\""), + "an unresolved chain used to lower as an em dash:\n{dsl}" + ); +} + +#[test] +fn a_future_day_shifts_the_daily_fields_and_refuses_the_current_ones() { + // Seen in the wild: a weather-activity card whose eyebrow said TOMORROW over + // `current.temperature_2m` — nothing in the lowering could read daily.1, so + // only the LABEL could claim tomorrow, and it did. The screen asserted the + // wrong day and no stage could see it. + // + // `day: 1` is the capability that makes the label honest. Daily fields shift + // by the day; current-conditions fields have no future form and get NO + // translation — a visible absence, never today's reading under tomorrow's + // heading. + const CARD: &str = concat!( + "# level: L0\n", + "source place sys.geocode(name: state.city)\n", + "source next sys.weather(lat: place.lat, lon: place.lon, day: 1,\n", + " fields: [hi, lo, precip, cond, temp])\n", + "state city { shape: text, initial: \"Kyoto\" }\n", + "copy eyebrow { class: vocabulary, en: \"TOMORROW\" }\n", + "view root Surface {\n", + " TextCaption(text: copy.eyebrow)\n", + " WeatherIcon(cond: next.cond, size: .hero)\n", + " TextHero(value: next.hi)\n", + " TextValue(value: next.lo)\n", + " TextValue(value: next.precip)\n", + " TextValue(value: next.temp)\n", + "}\n" + ); + let checked = check_ui_l0_named("weather-activity", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + // The daily fields read tomorrow's row… + for path in [ + "daily.temperature_2m_max.1", + "daily.temperature_2m_min.1", + "daily.precipitation_probability_max.1", + ] { + assert!(dsl.contains(path), "{path} must be read for day 1:\n{dsl}"); + } + // …the icon describes the same sky… + assert!( + dsl.contains("sys.weathercond(") && dsl.contains("\"daily.weather_code.1\""), + "the icon must shift with the day:\n{dsl}" + ); + // …and `temp` — a current-conditions field — is NOT served under a future + // day. No current.* call anywhere on this card. + assert!( + !dsl.contains("current.temperature_2m"), + "there is no 'current' tomorrow:\n{dsl}" + ); +} + +#[test] +fn a_forecast_loop_rides_on_top_of_the_day() { + // `day` shifts the whole week: row 0 of a `for` over the forecast is the + // named day, row 1 the day after it. + const CARD: &str = concat!( + "# level: L0\n", + "source week sys.weather(lat: 35, lon: 135, days: 3, day: 1,\n", + " fields: [days, hi, dayname])\n", + "view root Surface {\n", + " for d, i in week.days key d.dayname {\n", + " TextRow(text: d.dayname)\n", + " TextValue(value: d.hi)\n", + " }\n", + "}\n" + ); + let checked = check_ui_l0_named("weather", CARD); + assert!(checked.valid, "{:#?}", checked.diagnostics); + let dsl = octoscript_ui_l0::kit::lower( + &realize(CARD, &serde_json::json!({"sa":11,"sb":4242,"sc":-7}), RealizeLimits::default()) + .root + .expect("realizes"), + ); + assert!( + dsl.contains("daily.temperature_2m_max.1") && dsl.contains("daily.temperature_2m_max.3"), + "rows 0..2 under day:1 must read daily 1..3:\n{dsl}" + ); +} + +#[test] +fn every_source_answers_for_its_own_lifecycle() { + // The nav card declares five `sys.route` trips, and exactly one of them — + // `trip`, whose origin is the empty string on a from-here journey — is + // unanswerable BY DESIGN. Observed per HELPER, its eternal `.pending` was + // stamped onto `trip_here`, which had a real origin, a real destination and + // a 200 on the wire: "Finding a route…" forever, Go never shown, zero + // failed fetches to point at. Measured on a 6T with a simulated drive. + // + // `source_state_bindings` is the cure: one probe binding per DECLARED + // source, each carrying its own resolved arguments. + const CARD: &str = include_str!("fixtures/nav.card"); + let store = octoscript_ui_l0::InstanceStore::default(); + let data = serde_json::json!({ + "here": {"lat": 37.4487, "lon": -122.1593, "ok": 1}, + "dest": "stanford university, stanford, ca", + "env": {"locale": {}}, + }); + let probes = octoscript_ui_l0::source_state_bindings(CARD, &data, &store); + + let call_of = |name: &str| -> String { + let b = probes + .iter() + .find(|p| p.source == name) + .unwrap_or_else(|| panic!("{name} has a probe")); + // Any answered field stands in for the source's lifecycle; `duration` + // is what the host's probe walks to first for a route. + octoscript_ui_l0::makepad::vm_call(&octoscript_ui_l0::SourceBinding { + field: "duration".into(), + ..b.binding.clone() + }) + .unwrap_or_else(|| panic!("{name} lowers")) + }; + + // trip_here: the from-here trip — REAL captured origin, real destination. + let trip_here = call_of("trip_here"); + assert!( + trip_here.contains("37.4487") && trip_here.contains("stanford university"), + "trip_here probes its own trip:\n{trip_here}" + ); + // trip: the named-origin trip, whose origin is "" here — its probe embeds + // the empty search and can only ever be pending. That is ITS answer, and + // after this test it is nobody else's. + let trip = call_of("trip"); + assert!( + trip.contains("searchnum(\"\""), + "trip's own probe carries the empty origin:\n{trip}" + ); + assert_ne!( + trip, trip_here, + "two trips, two probes — never one merged state" + ); +} + +// ───────────────────────────────────────────────── theme portability ── + +/// An emoji is painted from the colour-font with its colours baked in, so +/// `draw_text.color` never reaches it. A card using one as an icon renders in +/// one polarity and vanishes in the other — measured across the corpus at 81 +/// cards, and named in four separate judge verdicts before the cause was found. +#[test] +fn an_emoji_used_as_an_icon_is_flagged_but_does_not_refuse_the_card() { + const CARD: &str = "\ +source place sys.geocode(name: \"Kyoto\") + +view root Col { + Row(align: .center, gap: 10) { + TextRow(text: \"🍽\") + TextRow(text: place.name) + } +} +"; + let r = octoscript_ui_l0::check_ui_l0_named("card.l0", CARD); + assert!( + r.valid, + "the card is well-formed; a lint must not refuse it" + ); + assert_eq!(r.lints.len(), 1, "one emoji, one lint"); + assert_eq!(r.lints[0].line, 5); + assert!( + r.lints[0].message.contains("colour-font"), + "the lint should say WHY it cannot be themed: {}", + r.lints[0].message + ); +} + +/// Latin, CJK and punctuation are monochrome and take the theme's ink. Only the +/// pictographic blocks carry their own colour, and a lint that fired on ordinary +/// text would be noise on every card in the corpus. +#[test] +fn ordinary_text_carries_no_portability_lint() { + const CARD: &str = "\ +view root Col { + TextRow(text: \"12 km NW of La Romana\") + TextRow(text: \"今天天氣很好\") + TextRow(text: \"Café naïve — em dash… ellipsis\") +} +"; + let r = octoscript_ui_l0::check_ui_l0_named("card.l0", CARD); + assert!(r.valid); + assert!(r.lints.is_empty(), "unexpected lints: {:?}", r.lints); +} diff --git a/docs/authoring-for-llms.md b/docs/authoring-for-llms.md new file mode 100644 index 0000000..8ee212d --- /dev/null +++ b/docs/authoring-for-llms.md @@ -0,0 +1,116 @@ +# Authoring Octoscript as an LLM + +You were trained on an enormous amount of JavaScript and Python. **Octoscript is +neither.** Under load you will reach for their idioms by reflex, and the result +either fails to parse or fails to run. This page is the antidote: every rule +below was *measured* against `octoscript check` (syntax) and the runtime (semantics) +on the canonical **workflow profile** — not inferred from the grammar. The exact +diagnostic the tools emit is quoted so you recognize it in the check→fix loop. + +> This is the workflow/tool-orchestration profile. The generated-UI (card) +> profile is a sibling with **different** rules — do not carry rules between +> them. When unsure, run `octoscript check `; it is effect-free and authoritative. + +## Rule 0 — the one that bites hardest: blocks are newline-delimited + +A one-line block `{ statement }` is **invalid**. The statement inside a block +must end with a newline (or a `;`) before the closing `}`. + +``` +// REJECTED -> expected a newline or `;` after a statement (at the `}`) +if x == 1 { x = 10 } +fn add(a, b) { return a + b } + +// OK +if x == 1 { + x = 10 +} +fn add(a, b) { + return a + b +} +// OK (explicit terminator) +if x == 1 { x = 10; } +``` + +This applies to every block: `fn`, `if`, `elif`, `else`, `for`, `while`, `loop`, +`try`, `catch`. It is the single most common JS reflex that breaks Octoscript. + +## Rule 1 — syntax the parser rejects outright + +Each of these stops at `octoscript check` with the quoted message. + +| You will write (JS/Python) | Diagnostic | Write instead | +| --- | --- | --- | +| `cond ? a : b` | ``operator `?` is not part of the canonical Octoscript profile`` | `if` is an expression: `let x = if cond { a } else { b }` | +| `if(cond, a, b)` (ternary-as-call) | `expected ) after a parenthesized expression` | same — use an `if` expression | +| `[...a, b]` | ``operator `...` is not part of the canonical Octoscript profile`` | `array.concat(a, [b])`, or `array.push(a, b)` | +| `x => x + 1` | ``operator `=>` is not part of the canonical Octoscript profile`` | `|x| x + 1` (bar-delimited lambda) | +| `a === b` / `a !== b` | ``operator `===` is not part of the canonical Octoscript profile`` | `a == b` / `a != b` | +| `` `hi ${x}` `` (template) | `unsupported character ...` | `"hi " + x` (string `+`) | +| `else if` (two words) | `expected a newline or ; after a statement` | `elif` | +| `const x` / `var x` | rejected | `let x` (reassign with `=`, `+=`, `-=`) | +| `new Date()` | rejected | no `new`; get time/etc. from a host tool | +| `function f() {}` | rejected | `fn f() { ... }` | +| `/ab+c/` (regex) | `expected an expression` | no regex; use `mod.std.text` literal ops | +| `for (let i = 0; i < n; i++)` | `expected a binding identifier after for` | `for i in array.range(0, n)` | +| `import x` / `from x import y` | `expected a newline or ; after a statement` | `use mod.tool` — effects come from registered tools | +| `try:` / `except:` (Python) | `expected an expression` | `try { ... } catch { ... }` | + +## Rule 2 — parses, but dies at runtime (the sneakier class) + +`octoscript check` will **not** catch these — they are syntactically valid. They +fail only when evaluated, so they surface at execution with a runtime message. +The mental model to fix them is at the bottom. + +| You will write | Runtime diagnostic | Write instead | +| --- | --- | --- | +| `Math.max(a, b)` | `variable Math not found in scope` | `use mod.std.math` → `math.max(a, b)` | +| `JSON.parse(s)` | `variable JSON not found in scope` | `use mod.std.json` → `json.parse(s)`, or `s.parse_json()` | +| `console.log(x)` | `variable console not found in scope` | there is no stdout — a step's final value **is** its output | +| `null` / `undefined` | `variable null not found in scope` | `nil` | +| `require("x")` | `variable require not found in scope` | `use mod.tool`; capabilities are host-registered | +| `arr.push(x)` | `method push not found on array` | `use mod.std.array` → `array.push(arr, x)` | +| `s.trim()` | `method trim not found on string` | `use mod.std.text` → `text.trim(s)` | +| `s.toUpperCase()` | `method toUpperCase not found on string` | `text.upper(s)` | + +**The mental shift:** collection, text, and math operations are **module +functions that take the value as their first argument**, not methods hanging off +the value. It is `array.push(list, x)`, `text.trim(s)`, `math.max(a, b)` — never +`list.push(x)`, `s.trim()`, `Math.max(...)`. The *only* methods that exist on a +value are `.parse_json()`, `.to_json()`, `.to_bytes()`, and `.await()` (on a +promise). Everything else is a module function. + +## Rule 3 — how you actually do things + +- **Effects / tools.** `use mod.tool`, then `tool.call("name", "text-arg")` or + `tool.call_json("name", {field: value})`. Async: `tool.start("name", arg).await()`. + A tool exists only if the host registered it and the workflow step was granted + a lease for it; otherwise the call is denied. There is no ambient I/O, no file + system, no network except through a granted tool. +- **Standard library** (frozen, no I/O): `mod.std.math`, `mod.std.json`, + `mod.std.text`, `mod.std.array`, `mod.std.object`, `mod.std.assert`. Import with + `use mod.std.array` and call `array.(value, ...)`. +- **`if`/`elif`/`else` and `try`/`catch` produce a value** — you can assign them: + `let x = if c { 1 } else { 2 }`. **`for`/`while`/`loop` do NOT** produce a value; + put an explicit `nil` (or a result expression) after the loop. +- **`&&` and `||` work** (unlike some earlier profiles — measured valid here). +- **`+` is string concatenation whenever either operand is a string.** `3 + "5"` + is `"35"`, not `8`. Arithmetic `+` requires both operands numeric. +- **Dataflow between steps.** A step reads host input as `workflow.input.` + and a prior step's returned value as `workflow.outputs..`. Its + own final expression becomes its output. + +## Rule 4 — verify before you hand it over + +Never ship generated source unchecked; the host will reject it anyway. + +```sh +octoscript check # JSON diagnostics with line + column, nonzero on any violation +octoscript workflow-review # per-step syntax status for a multi-step plan +octoscript format # canonical spelling +``` + +Run `check`, read each `line`/`column`/`message`, fix it, regenerate, re-check +until `valid` is `true`. Rule 2's runtime errors survive `check`; they appear +when the host executes the step and show up in the audit as a failed call — feed +that message back and fix it the same way. diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 191d3cf..d3c06e9 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -26,6 +26,23 @@ # ─── containers ─────────────────────────────────────────────────────────────── +# A registered theme-kit component. The host supplies its checked definition; +# an instance names a source-linked placement, not a widget implementation. +[Kit] +component = { kind = "text" } +instance = { kind = "text" } +part = { kind = "text" } +text = { kind = "text" } +placeholder = { kind = "text" } +enabled = { kind = "bool" } +checked = { kind = "bool" } +selected = { kind = "bool" } +index = { kind = "number" } +value = { kind = "number" } +value2 = { kind = "number" } +focused = { kind = "bool" } +password = { kind = "bool" } + [Surface] pad = { kind = "token", tokens = ["page", "tight", "none"] } @@ -108,6 +125,7 @@ dock = { kind = "token", tokens = ["top", "bottom", "right"] } [Card] on_tap = { kind = "event" } value = { kind = "any" } +tint = { kind = "token" } # `width` says which column absorbs the leftover space. A row of columns has to # divide the line somehow, and only the card knows which one should yield. @@ -143,6 +161,33 @@ cols = { kind = "number" } [Rule] +# A flexible blank: absorbs the leftover height of the column it sits in. +# Before a bottom bar it pins the bar to the bottom; around a centered stack it +# centers the stack vertically. Zero arguments — its whole meaning is "the +# height that is left". +[Space] + +# A full-bleed inverse section band — a month strip, a dark app-bar stripe. +[Band] +text = { kind = "text" } + +# One chat message; the side is who said it, the theme styles the parties. +[Bubble] +text = { kind = "text" } +side = { kind = "token" } + +# The floating round action button, pinned over the page corner. +[Fab] +name = { kind = "token" } + +# The bottom tab bar and its tabs, pinned to the page floor. +[TabBar] + +[Tab] +icon = { kind = "token" } +label = { kind = "text" } +active = { kind = "token" } + # ─── 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 @@ -177,6 +222,11 @@ tint = { kind = "path" } text = { kind = "text" } width = { kind = "width" } +# The small tracked-caps line above a hero — "T O K Y O". The card supplies +# the words; the caps and the tracking are the theme's. +[TextEyebrow] +text = { kind = "text" } + [TextCaption] text = { kind = "text" } value = { kind = "data" } @@ -196,6 +246,15 @@ label = { kind = "text" } value = { kind = "data" } unit = { kind = "unit" } format = { kind = "format" } +glyph = { kind = "text" } +shape = { kind = "token" } + +[Icon] +name = { kind = "token" } +size = { kind = "token" } + +[Avatar] +text = { kind = "text" } [Chip] text = { kind = "text" } @@ -273,7 +332,8 @@ span = { kind = "number" } # A list row's image: a fixed 16:9 tile. `Photo` fills its width (it is a # backdrop); a row needs a thumbnail beside its text. [Thumb] -src = { kind = "path" } +src = { kind = "path" } +shape = { kind = "token" } # One YouTube search result. `embed` is a player url ready for sys.link — a # card cannot build one, because L0 has no string concatenation. @@ -347,8 +407,21 @@ tokens = ["fill", "fit", "day", "rank", "temp", "label"] args = ["name"] answers = ["lat", "lon", "name", "country", "admin1", "timezone", "population"] +# Live topic detail from Wikipedia's page-summary endpoint. Declared with the +# subject only — `source d sys.wiki(query: "Dune")` — and read as `d.extract`, +# `d.title`, the same shape as any other record source. +[sources."sys.wiki"] +args = ["query"] +answers = ["title", "extract", "description"] + [sources."sys.weather"] -args = ["lat", "lon", "days", "fields", "aggregate"] +# `day` selects which day the DAILY fields describe: 0 (default) is today, +# 1 is tomorrow. The current-conditions fields (temp, feels, humidity, wind, +# pressure) have no future form — there is no "current" tomorrow — so a card +# that names day: 1 must read `hi`/`lo` instead of `temp`, and the lowering +# refuses to translate a current field under a future day rather than serve +# today's reading beneath a label that says otherwise. +args = ["lat", "lon", "days", "fields", "aggregate", "day"] answers = ["temp", "feels", "hi", "lo", "cond", "humidity", "wind", "pressure", "uv", "visibility", "precip", "dayname", "days"] aggregates = ["min_lo", "max_hi"] @@ -365,7 +438,17 @@ args = ["lat", "lon"] answers = ["phase", "illumination", "name"] [sources."sys.photo"] -args = ["query"] +# `query` is a SCENE, not a place. Asked for "Beijing" a generative image model +# invents a city, and it invented one that reads as Taipei — a fabricated +# depiction of a real place, rendering as a plausible photograph, with nothing on +# screen saying it is synthetic. A card that names a place in its title must not +# name it here. +# +# `cond` is what keeps the backdrop honest about the day: pass the forecast's +# condition and the host folds it into the prompt, so a card reading 92 % rain +# does not sit under a sunlit wallpaper. The SCENE is the card's words; the +# WEATHER is the host's fact, and neither writes the other's half. +args = ["query", "cond"] answers = [] [sources."sys.locale"] @@ -414,7 +497,7 @@ answers = ["duration", "distance", "steps"] # Progress here is measured: the position is projected onto the route, so an # instruction only advances because the device did. [sources."sys.step"] -args = ["from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "fields"] +args = ["from_lat", "from_lon", "to_lat", "to_lon", "at_lat", "at_lon", "via", "fields"] answers = ["instruction", "remaining", "progress", "eta"] [sources."sys.places"] @@ -425,6 +508,16 @@ answers = ["id", "name", "distance", "lat", "lon", "category"] args = ["count", "offset", "fields"] answers = ["id", "title", "author", "points", "comments", "url"] +[sources."sys.quakes"] +# The USGS M2.5+ feed of the last 24 hours, newest first. Row-shaped like +# `sys.news`, and for the same reason: a feed is a list, and `offset` is what +# lets a card show a lead event above the rest without repeating it. +# +# `id` is the row's index as a string. USGS ids are opaque and the card only +# needs identity for a `key`, which is what a `for` requires. +args = ["count", "offset", "fields"] +answers = ["id", "mag", "place", "depth", "ago", "lat", "lon"] + [sources."sys.news_item"] args = ["id", "fields"] answers = ["id", "title", "author", "points", "comments", "url"] @@ -538,6 +631,12 @@ answers = ["ticker", "name", "exchange", "kind"] # produce that: L0 has no way to build a composite value, which is exactly the # point of it having no expression form. The language was right. [sources."sys.cities"] -args = ["fields"] -answers = ["name", "lat", "lon", "temp", "feels", "hi", "lo", "cond", "humidity", "wind"] +args = ["fields", "unit"] +answers = ["name", "lat", "lon", "temp", "feels", "feels_delta", "hi", "lo", "cond", "humidity", "wind"] writes = ["append", "remove"] + +# Pure conversion; supported pairs and coefficients are owned by the runtime. +# direction is fwd (default) or rev. Unsupported pairs have no value. +[sources."sys.convert"] +args = ["amount", "from", "to", "direction", "fields"] +answers = ["amount", "value"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 1749e7e..6a97ff9 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -505,12 +505,11 @@ source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, hi, lo, 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. +**Selected data arguments must be bound rather than supplied as direct literals.** The +checker enforces this syntactic rule, declared names and declared copy classes. It does +not establish the provenance or truth of every value reachable through a binding. -**What is actually enforced, and what is not.** Both halves matter, and only one is complete: +**What is actually enforced, and what is not:** | Position | Rule | |---|---| @@ -527,13 +526,31 @@ real ergonomic cost and buys less than it appears to: of the eleven literals in 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. +State values now retain a runtime origin: `Authored`, `Vocabulary`, `Source`, +`UserInput`, `Host`, `Derived` or `Unknown`. The realizer carries this through loops, +component parameters, local state, source captures, schema retention and transitions. +Authored defaults remain legal in controls. In `Data` catalog arguments and numeric +`text` arguments they resolve to `Missing`; their live bindings and expressions are +suppressed too. Thus `state n { shape: number, initial: 1547 }` with +`TextHero(value: n)` checks successfully but displays an em dash until an accepted +host or user value arrives. Passing that state through components does not change +its origin. Path arguments used as query selectors (for example a stock symbol) +keep their existing binding contract. + +The native host derives event origins from the realized control and its declared +event: Field commits/changes are user input; a Row carrying an authored value +retains `Authored`. JSON payloads cannot declare an origin. Legacy dispatch APIs +conservatively treat unattributed payloads as authored; trusted hosts use +`dispatch_reporting_with_origin`. A reset or literal assignment restores the +initial/authored origin. Changing only the eligibility of an origin still triggers +a redraw, even when the value is identical. Empty native text is a payload, not an +absent event. `set_cell` is a trusted host write; source captures use +`set_cell_with_origin(..., Source)`. + +L1 expressions propagate origins and permit authored coefficients combined with +source/host/user values. This traces inputs, not the truth of the formula. Authored +copy classes remain declarations, and inline labels remain accepted. Neither L0 +acceptance nor L1 expression validation is a proof of factual correctness. Implicit dependencies are declared like any other source: @@ -545,6 +562,49 @@ 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. +### 4.1 `theme` — the one presentation statement, and why it is not one + +``` +theme dark +``` + +At most one per card, and the name must be catalogued (`catalog::THEMES`: `dark`, `light`, +`glass`, `photo`, `vibrant`, `minimal`). An uncatalogued name is refused at parse time, with +the known set named. + +This does not weaken the no-presentation rule, because a card declaring `theme glass` has said +nothing about what glass *is*. The kit answers it, by assembling itself in four parts: + +``` +_palette_dark.octoscript the BASE — knobs (space_factor, radius_factor, font_base, font_step) + and every colour +_palette_.octoscript the DELTA — only what this mood changes; may move a knob +_derive.octoscript every SIZE, computed from the knobs — AFTER the delta, so a mood + that moves a knob moves everything following from it +_kit.octoscript the roles, which read those tokens and never a literal +``` + +So the page colour, the panel fill, the hairline, the ink, the paddings, the radii and the type +scale are all decided one layer down, and `glass` is expressed as "dark, but these fourteen +colours and `radius_factor: 1.5`". A card still cannot write `#ffffff`, a font size or a radius — +those are refused exactly as before. + +The order is load-bearing and its failure is silent: `let` evaluates at its own line, so deriving +sizes in the base would leave a delta's `radius_factor` with nothing left to change, and an +assembly that omits `_derive.octoscript` leaves every size an undefined name — which is 0, so the +tree still builds with no padding and no type scale at all. + +**Declaring a theme is not the same as reading one.** The `source env.theme sys.theme()` sketched +above is the opposite direction — a card ASKING what the ambient theme is — and it is *not +implemented*: `sys.theme` has never been in the catalog, so that line is a sketch and a card +naming it is refused. The declaration is the direction that exists, because the case that +motivated it is a user asking for a look ("dark weather tokyo"): the request names a mood, the +agent declares it, the kit renders it. + +**A card must not declare a mood nobody asked for.** Choosing one unprompted is deciding +presentation by the back door. The host tells the agent when a request named a look; absent that, +the card declares nothing and gets the kit's default. + --- ## 5. Components and instance identity @@ -1223,28 +1283,35 @@ rejected until the level is explicitly raised; escalation is never silent. **Com 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. +The check returns a **closure digest** per declared component, sorted by name. Each digest +covers its parameters, state (including shape and initial capture path), events, and view +body, plus the transitive component and view definitions it references. Loop key paths and +whether an element is a view reference are included. Definition order at file scope and +source locations are excluded; order within a definition remains significant. A component +edit that affects a dependency therefore also changes its callers' digests. + +The closure digests are versioned 64-bit FNV-1a change detectors. Every declared +component receives an entry, even unused ones. They are diagnostic/cache metadata. + +`approval::ArtifactApproval` separately pins the complete source, admitted level, +policy version, profile implementation, assembled kit, and a host runtime bundle +using length-delimited BLAKE3 hashes. The Octos native host builds that bundle from +its local source/resources, patched Makepad tree (including the VM, widget layer and +capability bindings), dependency lockfile, compiler version and build configuration. +It stores each policy approval under `l0-approvals/.json`, beneath its +configuration directory. New L0/L1 artifacts may be admitted by the installed host +policy; this is not a claim that a person manually reviewed each card. + +Existing approvals are verified before source resolution or mounting, and live +session approvals are verified before dispatch or durable effects. A source, +level, kit or runtime change cannot inherit a mismatching approval. Invalid records +and I/O failures fail closed; the host never silently overwrites a mismatching pin. +Reapproval is a host operation after reviewing the changed artifact. The native app +locks compiled script sources before widget registration, so file watching or Studio +live edits cannot change the approved runtime in a running process. Layout refreshes +remain available. Runtime source data is intentionally outside the code approval: +it changes without changing the card's authority. The host and its approval storage +are trusted; this is not a signature scheme against a compromised host. 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. @@ -1307,6 +1374,11 @@ visualisations have kit functions and all six are in the consumer's tag table. device path calls only `kit::lower`. A verification done through `makepad::lower` therefore proves nothing about what a phone renders, which is a mistake this repository has made before. +The review's “L0–L3” names the four implementation layers: profile/realization, +kit lowering, VM/node translation, and native widget mounting/interaction. The fourth +layer is native rendering. There is no `# level: L3` language capability profile; +that header remains rejected. + L1 and L2 **as capability levels** — the levels this document's §7 classifies, not the layers above — are a separate matter from the kit work; the two senses of "L1" are easy to conflate and this paragraph previously did. L2 remains specified only as "what L0 excludes", is unimplemented, @@ -1430,8 +1502,7 @@ defect and is recorded in §9.8. ### 9.3 §4's no-facts rule, one level up -L0 keeps §4 structurally: a literal in a value position is refused outright, which is decidable -because a `view` is a typed tree. **L1 cannot use that check**, because at L1 a literal in a value +L0 rejects a direct literal in a value position, as described in §4. **L1 cannot use that check**, because at L1 a literal in a value position is often legitimate — a coefficient. `temp * 9 / 5 + 32` is a formula, and `9`, `5` and `32` are not claims about the world. @@ -1444,27 +1515,16 @@ fact wearing arithmetic, and is refused. Every path an expression reads — at a checked against declared names exactly as a bare binding is, so an expression cannot launder an undeclared name past the check that a plain path would fail. -That rule alone bounds what an expression is made OF and not what it may PRODUCE, and the gap is -real: `quote.last * 0 + 1547` reads a source, computes, and yields a fabricated number. So there -is a second rule. - -> **An expression's answer must MOVE when its inputs move.** +The checker also rejects constants recognized by a limited structural analysis: finite +literal arithmetic, identical subexpressions subtracted or reduced modulo themselves, +identical numerator and denominator, and multiplication by a known zero. These identities +are interpreted wherever the expression is defined; missing values remain missing at +runtime. Thus `last * 0 + 1547` and `(last - 2) / (last - 2) * 1547` are refused. -A formula is a formula because it depends on what it reads. So the expression is evaluated under -several assignments of its reads, and an answer that never changes is a constant the model wrote -with extra steps — refused. `temp * 9 / 5 + 32` moves; `last * 0 + 1547`, `last - last + 99` and -`(last - last) * k + 5` do not. - -The assignments differ **per path** as well as per round, because binding every read to one number -would make `a - b` constant and condemn a correct formula. Three rounds of coprime-ish values: an -expression constant across all three and not constant in general is not something five arithmetic -operators can express. Unresolvable in every round — a division by a probed zero — is *not* -degenerate; that is a partial expression, and §9.4 already renders it as missing. - -The two rules together are still not L0's *structural* guarantee, which is that no position admits -a fabricated number at all. They are a pair of decidable checks that between them refuse the -constructions a fabrication has available. The remaining distance is that L1 must ASK these -questions where L0 has no question to ask. +An unrecognized expression is **unknown**, not proven to depend on its inputs. There is no +sampling rule: `(last - 2) * (last - 5) * (last - 11)` must be accepted even though it is zero +at all three assignments used by the former implementation. This analysis is deliberately +incomplete and does not prove factual correctness or rule out every disguised constant. ### 9.4 Evaluation @@ -1487,8 +1547,9 @@ or the host. A backend receives the **shape** of an expression, not the number realization computed: each operand is either a live capability call or a constant, and each join carries its operator. -Every join is parenthesised on the way out, so the target VM's own precedence rules cannot -change what the card meant. +Every join lowers to `sys.l0_math(operator, left, right)`, preserving grouping and checking +finite operands, zero divisors and finite results at each operation. Missing propagates as +an em dash through further operations. Both renderer lineages install these pure helpers. This is not an optimisation. A value computed at realization is the answer for whatever data the host happened to seed, and a live card is seeded with nothing — so lowering the computed number @@ -1545,10 +1606,8 @@ Recorded rather than patched over, in the order they would bite. refused too. - ~~**No grouping and no unary minus.**~~ **Fixed**, per §9.2. `(a + b) * c` and `n * -1` are both admitted, and a bare `value: -1` is still refused by §4's original rule. -- ~~**The no-facts rule bounds operands, not results.**~~ **Fixed**, per §9.3's second rule: an - expression's answer must move when its inputs move, so `last * 0 + 1547` is refused. This was - recorded as needing an argument rather than a patch, and the argument is that a formula depends - on what it reads. +- **Constant analysis is incomplete.** §9.3 rejects structurally recognized constants and + leaves other expressions unknown. Neither source/state reads nor arithmetic prove truth. - ~~**A state's `initial:` was a third position, and it discarded the expression.**~~ **Fixed.** §9.2 admits an expression in one position; §9.8 already recorded a guard as a second. `initial:` was a third, and the worst of the three: the parser scanned the @@ -1563,3 +1622,17 @@ Recorded rather than patched over, in the order they would bite. this section admits, which is the reason it was left alone. - **`Form` is unchanged**, so a transition still cannot compute. `set(shares * 2)` is not admitted, and §6's condition 3 continues to hold for the reason it did at L0. + +### Complete rendering and runtime limits + +A host must call `RealizeReport::complete_root()` before mounting, capturing state or pruning +instances. Diagnostics, duplicate keys and truncation are failures even if a partial root +exists. Duplicate loop items are omitted from that diagnostic tree and are never renamed. +Patch reuse cannot replace a truncated result or overwrite a changed subtree. + +Both kit walkers admit at most 128 edges of depth and 65,536 nodes. Unknown or malformed +children reject the entire tree. Kit evaluation uses a 2,000,000-instruction budget and +rejects uncaught runtime errors even if a later expression returns a node. That instruction +budget covers VM execution, not native capability work or parsing; these trusted host and +theme operations need their own resource policy. L0/L1 source parsing additionally bounds +the resulting expression AST, including flat operator chains. diff --git a/examples/media_input.json b/examples/media_input.json new file mode 100644 index 0000000..4ac36e4 --- /dev/null +++ b/examples/media_input.json @@ -0,0 +1,3 @@ +{ + "prompt": "A serene retro science-fiction paperback cover: a lone astronaut standing on a teal dune sea beneath an enormous amber ringed planet, painterly brushwork, warm rim light, generous empty sky, no text or lettering anywhere" +} diff --git a/examples/media_workflow_draft.json b/examples/media_workflow_draft.json new file mode 100644 index 0000000..9c387f2 --- /dev/null +++ b/examples/media_workflow_draft.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "steps": [ + { + "id": "generate", + "source": "use mod.tool\nlet response = tool.call_json(\"image.generate\", {prompt: workflow.input.prompt, size: \"1024x1024\", quality: \"low\"})\nresponse.parse_json()" + }, + { + "id": "edit", + "source": "use mod.tool\nlet handle = workflow.outputs.generate.handle\nlet response = tool.call_json(\"media.transcode\", {src: handle, vf: \"eq=saturation=0.62:brightness=-0.06,vignette\"})\nresponse.parse_json()" + } + ] +} diff --git a/examples/media_workflow_draft_naive.json b/examples/media_workflow_draft_naive.json new file mode 100644 index 0000000..e4679d3 --- /dev/null +++ b/examples/media_workflow_draft_naive.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "steps": [ + { + "id": "generate", + "source": "use mod.tool\nlet size = workflow.input.wide ? \"1536x1024\" : \"1024x1024\"\nlet response = tool.call_json(\"image.generate\", {prompt: workflow.input.prompt, size: size, quality: \"low\"})\nresponse.parse_json()" + }, + { + "id": "edit", + "source": "use mod.tool\nlet handle = workflow.outputs.generate.handle\nlet vf = \"vignette\"\nif workflow.input.dark == 1 { vf = \"eq=brightness=-0.1,vignette\" }\nlet response = tool.call_json(\"media.transcode\", {src: handle, vf: vf})\nresponse.parse_json()" + } + ] +} diff --git a/examples/movie_boxoffice_draft.json b/examples/movie_boxoffice_draft.json new file mode 100644 index 0000000..4f2e35b --- /dev/null +++ b/examples/movie_boxoffice_draft.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "steps": [ + { + "id": "enrich", + "source": "use mod.tool\nuse mod.std.array\nuse mod.std.object\n\nfn pick_movie(items) {\n for k in array.range(0, array.len(items)) {\n let it = items[k]\n if object.get(it, \"qid\", \"\") == \"movie\" {\n return it\n }\n }\n return items[0]\n}\n\nlet films = workflow.input.films\nlet rows = []\nfor i in array.range(0, array.len(films)) {\n let f = films[i]\n let url = \"https://v3.sg.media-imdb.com/suggestion/x/\" + f.q + \".json\"\n let raw = tool.call_json(\"net.imdb\", {origin: \"imdb\", url: url})\n let data = raw.parse_json()\n let hit = pick_movie(data.d)\n let img = object.get(hit, \"i\", {})\n let row = {rank: i + 1, title: object.get(hit, \"l\", \"\"), year: object.get(hit, \"y\", 0), gross: f.gross, stars: object.get(hit, \"s\", \"\"), poster: object.get(img, \"imageUrl\", \"\"), imdb: object.get(hit, \"id\", \"\")}\n array.push(rows, row)\n}\nlet out = {films: rows}\nout\n" + } + ] +} diff --git a/examples/movie_boxoffice_input.json b/examples/movie_boxoffice_input.json new file mode 100644 index 0000000..3b506ab --- /dev/null +++ b/examples/movie_boxoffice_input.json @@ -0,0 +1,44 @@ +{ + "films": [ + { + "q": "avatar%202009", + "gross": "$2.923B" + }, + { + "q": "avengers%20endgame", + "gross": "$2.799B" + }, + { + "q": "avatar%20the%20way%20of%20water", + "gross": "$2.320B" + }, + { + "q": "titanic%201997", + "gross": "$2.257B" + }, + { + "q": "star%20wars%20the%20force%20awakens", + "gross": "$2.068B" + }, + { + "q": "avengers%20infinity%20war", + "gross": "$2.052B" + }, + { + "q": "spider-man%20no%20way%20home", + "gross": "$1.922B" + }, + { + "q": "inside%20out%202", + "gross": "$1.699B" + }, + { + "q": "jurassic%20world%202015", + "gross": "$1.671B" + }, + { + "q": "the%20lion%20king%202019", + "gross": "$1.663B" + } + ] +}