From 904dc3381ba94071fc7b817f370efb3bba4e84e2 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:49:51 -0400 Subject: [PATCH] UI: form problems name fields by their labels, and OK waits for required fields Every problem reads with the field's label ("Material: choose a material") beside its field, once it was edited or OK was tried; a click on the greyed OK shows them all, and its tooltip lists them. A parameter the command refuses shows beside its field, by its label, in the form and status bar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/context.rs | 42 +++++- crates/ui-egui/src/form.rs | 242 +++++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 71 ++++++++-- 3 files changed, 318 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 67d540c..c19a570 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -120,6 +120,12 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { }); let mut invalid = 0; let mut create = None; + // What is missing or wrong, by the fields' labels: OK waits for it (in either view). + let problems = match (spec.form, params.as_object()) { + (Some(fields), Some(obj)) => crate::form::messages(fields, obj), + _ => Vec::new(), + }; + let missing = problems.iter().map(|(_, m)| m.as_str()).collect::>().join("\n"); // The typed fields edit the JSON text's object; text that is not an object stays in the // JSON view until it is. let object = params.as_object().cloned(); @@ -134,14 +140,25 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { d.advanced = advanced; match (spec.form, object) { (Some(fields), Some(mut obj)) if !d.advanced => { - let cx = crate::form::Context { units, names: &|c| session.names(c), salt: form_id }; + let refused = d.refused.as_ref().map(|(k, p)| (k.as_str(), p.as_str())); + let cx = crate::form::Context { + units, + names: &|c| session.names(c), + salt: form_id, + touched: &d.touched, + attempted: d.attempted, + refused, + problems: &problems, + }; let out = crate::form::show(ui, t, &cx, fields, &mut obj); invalid = out.invalid; create = out.create; if out.changed { d.text = Value::Object(obj).to_string(); d.error = None; + d.refused = None; } + d.touched.extend(out.touched); } _ => { ui.add(egui::TextEdit::multiline(&mut d.text).id(form_id.with("json")).code_editor().desired_rows(6).desired_width(f32::INFINITY)); @@ -149,23 +166,34 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { } if let Some(e) = &d.error { ui.label(RichText::new(e).color(t.error)); + } else if d.attempted && !missing.is_empty() { + ui.label(RichText::new(&missing).color(t.error)); } ui.collapsing(RichText::new("What it takes").size(12.0).color(t.text_dim), |ui| { ui.label(RichText::new(spec.params).small().color(t.text_dim)); }); ui.add_space(4.0); + let ready = invalid == 0 && missing.is_empty(); + let not_ready = + || if invalid > 0 { format!("{missing}\nA field holds something that is not a value").trim().to_string() } else { missing.clone() }; + let mut attempted = false; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { cancel |= ui.button("Cancel").clicked(); - run = ui - .add_enabled( - invalid == 0, - egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)), - ) - .on_disabled_hover_text("A field holds something that is not a value") + let ok = + ui.add_enabled(ready, egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0))); + // A click on the greyed OK shows every problem, beside its field. + attempted = !ready && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked()); + run = ok + .on_disabled_hover_ui(|ui| { + ui.label(not_ready()); + }) .clicked(); }); }); + if attempted && let Some(d) = app.dialog.as_mut() { + d.attempted = true; + } if ui.input(|i| i.key_pressed(egui::Key::Escape)) && app.ui.menu.is_none() && !app.ui.palette { cancel = true; } diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index f7118f6..16caeb4 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -2,7 +2,7 @@ //! with units, drop-downs of the model's items, groups and one-of variants. The form edits the //! command's JSON parameters in place; it never builds anything the command does not take. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use egui::{Id, RichText, Ui}; use fieldcraft_engine::schema::{self, Collection, Field, Kind, MAX_DEPTH, MAX_ROWS, join}; @@ -27,6 +27,14 @@ pub struct Context<'a> { /// A salt for the widget ids: the open form (its command and the opening), so no widget /// state is shared with another form. pub salt: Id, + /// The fields the user has edited: their problems show beside them; the others wait. + pub touched: &'a BTreeSet, + /// OK was tried: every problem shows. + pub attempted: bool, + /// The command refused a parameter: its path and the problem, shown beside the field. + pub refused: Option<(&'a str, &'a str)>, + /// What is missing or wrong in the parameters ([`messages`]). + pub problems: &'a [(String, String)], } /// What a frame of the form did. @@ -37,11 +45,14 @@ pub struct Outcome { pub invalid: usize, /// A reference field's "Create …" was clicked: the collection to add to and the field's path. pub create: Option<(Collection, String)>, + /// The paths of the fields edited this frame. + pub touched: Vec, } struct Form<'a> { cx: &'a Context<'a>, style: Style, + /// Each field's problem, by path, phrased with its label ([`messages`]). problems: BTreeMap, names: BTreeMap<&'static str, Vec>, out: Outcome, @@ -52,8 +63,8 @@ pub fn style(t: &Tokens) -> Style { } /// Draw `fields` editing `params` (an object). -pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &[Field], params: &mut Map) -> Outcome { - let problems = schema::object_problems(fields, params).into_iter().filter(|p| p.message != "required").map(|p| (p.path, p.message)).collect(); +pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &'static [Field], params: &mut Map) -> Outcome { + let problems = cx.problems.iter().cloned().collect(); let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() }; ui.spacing_mut().item_spacing.y = 3.0; f.fields(ui, fields, fields, params, "", 0); @@ -61,6 +72,23 @@ pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &[Field], params: &mu } impl Form<'_> { + /// The problem with the field at `path`, under it: the command's refusal at once, the + /// form's own once the field was edited or OK was tried. + fn problem_note(&self, ui: &mut Ui, path: &str, label: &str) { + let message = match self.cx.refused { + Some((key, problem)) if key == path => Some(format!("{label}: {problem}")), + _ => self.problems.get(path).filter(|_| self.cx.attempted || self.cx.touched.contains(path)).cloned(), + }; + if let Some(message) = message { + fields::note(ui, &self.style, &message, self.style.error); + } + } + + fn touch(&mut self, path: &str) { + self.out.changed = true; + self.out.touched.push(path.to_string()); + } + fn id(&self, path: &str) -> Id { Id::new(("fc_form", self.cx.salt, path)) } @@ -89,10 +117,12 @@ impl Form<'_> { && f.key.is_empty() { let current = obj.get(tag).and_then(Value::as_str).map(str::to_string); - if let Some(t) = self.variant_choice(ui, f, variants, current.as_deref(), &join(prefix, tag)) { + let path = join(prefix, tag); + if let Some(t) = self.variant_choice(ui, f, variants, current.as_deref(), &path) { *obj = schema::switch_variant(f, common, obj, &t); - self.out.changed = true; + self.touch(&path); } + self.problem_note(ui, &path, f.label); if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) { self.fields(ui, v.fields, common, obj, prefix, depth + 1); } @@ -124,16 +154,14 @@ impl Form<'_> { Value::Null => obj.remove(f.key), v => obj.insert(f.key.into(), v), }; - self.out.changed = true; + self.touch(&path); } if let Kind::Either(options) = f.kind { self.either_value(ui, f, options, obj, &path); } } } - if let Some(message) = self.problems.get(&path).cloned() { - fields::note(ui, &self.style, &message, self.style.error); - } + self.problem_note(ui, &path, f.label); } /// A one-row field; returns its new value (`Null` removes it). @@ -210,7 +238,7 @@ impl Form<'_> { let new = fields::row(ui, &style, "", false, |ui| self.scalar(ui, option, Some(&value), &sub)); if let Some(new) = new { obj.insert(f.key.into(), json!({key: new})); - self.out.changed = true; + self.touch(path); } } @@ -239,7 +267,8 @@ impl Form<'_> { self.json_only(ui); return None; } - _ => vec![if nullable { Value::Null } else { json!(0.0) }; labels.len()], + // Empty slots show their placeholder (x, y, z), not a 0 nobody typed. + _ => vec![Value::Null; labels.len()], }; let mut next = current.clone(); let mut changed = false; @@ -252,10 +281,10 @@ impl Form<'_> { for &i in chunk { let hint = labels.get(i).copied().unwrap_or(""); let slot = current.get(i).cloned().unwrap_or(Value::Null); - let shown = if nullable && slot.is_null() { None } else { Some(&slot) }; + let shown = (!slot.is_null()).then_some(&slot); if let Some(v) = self.number(ui, id.with(i), shown, hint, false, width) { if let Some(n) = next.get_mut(i) { - *n = if v.is_null() && !nullable { json!(0.0) } else { v }; + *n = v; } changed = true; } @@ -267,7 +296,8 @@ impl Form<'_> { } }); // A vector left empty is not written until edited (a required one shows as missing). - changed.then_some(Value::Array(next)) + // The slots left empty of a vector that takes no blanks are 0. + changed.then(|| Value::Array(next.into_iter().map(|x| if x.is_null() && !nullable { json!(0.0) } else { x }).collect())) } /// A drop-down of the collection's items; never free text. With none, the drop-down is @@ -373,7 +403,7 @@ impl Form<'_> { } else { obj.remove(f.key); } - self.out.changed = true; + self.touch(path); } if !on { return; @@ -393,10 +423,12 @@ impl Form<'_> { }; let current = child.get(tag).and_then(Value::as_str).map(str::to_string); let row = Field { label: "Type", required: true, ..*f }; - if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &join(path, tag)) { + let tag_path = join(path, tag); + if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &tag_path) { *child = schema::switch_variant(f, &[], child, &t); - self.out.changed = true; + self.touch(&tag_path); } + self.problem_note(ui, &tag_path, row.label); if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) { self.fields(ui, v.fields, v.fields, child, path, depth + 1); } @@ -427,15 +459,95 @@ impl Form<'_> { } if let Some(i) = remove { list.remove(i); - self.out.changed = true; + self.touch(path); } if list.len() < max && ui.small_button("+ Add").clicked() { list.push(schema::prefill(fields, &json!({}))); - self.out.changed = true; + self.touch(path); } } } +/// What is missing or wrong in `params`, in the form's order: (field path, message), each +/// phrased with the field's label as the form shows it ("Material: choose a material"), never +/// its JSON key. +pub fn messages(fields: &'static [Field], params: &Map) -> Vec<(String, String)> { + schema::object_problems(fields, params) + .into_iter() + .map(|p| { + let message = field_at(fields, &p.path).map_or_else(|| format!("{}: {}", p.path, p.message), |f| phrase(f, &p.message)); + (p.path, message) + }) + .collect() +} + +/// The field at `path` (`elastic/young`, `rows/0/name`). A variant's tag gives its +/// [`Kind::Variants`] field; an [`Kind::Either`] option's value gives the option. +pub fn field_at(fields: &'static [Field], path: &str) -> Option<&'static Field> { + let mut segments = path.split('/'); + let mut field = find(fields, segments.next()?)?; + for (depth, segment) in segments.enumerate() { + if depth > MAX_DEPTH { + return None; + } + field = match field.kind { + Kind::Rows { .. } if segment.parse::().is_ok() => field, + Kind::Group(inner) | Kind::Rows { fields: inner, .. } => find(inner, segment)?, + Kind::Variants { tag, .. } if tag == segment => field, + Kind::Variants { variants, .. } => variants.iter().find_map(|v| find(v.fields, segment))?, + Kind::Either(options) => options.iter().find(|o| o.key == segment)?, + _ => return None, + }; + } + Some(field) +} + +/// The field of `fields` with key `key`, looking into flattened variants. +fn find(fields: &'static [Field], key: &str) -> Option<&'static Field> { + fields.iter().find_map(|f| match f.kind { + Kind::Variants { tag, variants } if f.key.is_empty() => { + if tag == key { + Some(f) + } else { + variants.iter().find_map(|v| find(v.fields, key)) + } + } + _ => (f.key == key).then_some(f), + }) +} + +/// A schema problem ([`schema::Problem::message`]) of field `f`, as the form says it. +fn phrase(f: &Field, problem: &str) -> String { + let required = problem == "required"; + let text = match f.kind { + Kind::Ref(c) if required => format!("choose {}", article(c.label())), + Kind::RefList(c) if required => format!("choose at least one {}", c.label()), + Kind::Either(options) => format!("choose {}", options.iter().map(|o| article(&lower(o.label))).collect::>().join(" or ")), + Kind::Choice(options) => format!("choose one of {}", options.iter().map(|(_, l)| *l).collect::>().join(", ")), + Kind::Variants { .. } => format!("choose {}", article(&lower(f.label))), + Kind::Path { .. } if required => "choose a file".into(), + Kind::Text if required && f.key.ends_with("name") => "enter a name".into(), + _ if required => "enter a value".into(), + _ => format!("must be {problem}"), + }; + format!("{}: {text}", f.label) +} + +/// "a material", "an element". +fn article(noun: &str) -> String { + let an = noun.starts_with(['a', 'e', 'i', 'o', 'u']); + format!("{} {noun}", if an { "an" } else { "a" }) +} + +/// A label lower-cased, but not its acronyms ("Reference point" → "reference point", "RBE2" stays). +fn lower(label: &str) -> String { + let mut chars = label.chars(); + match (chars.next(), chars.next()) { + (Some(c), Some(d)) if d.is_lowercase() => c.to_lowercase().chain(label.chars().skip(1)).collect(), + _ => label.to_string(), + } +} + /// A collection's label in the plural: "materials", "point masses", "bodies". fn plural(label: &str) -> String { if let Some(stem) = label.strip_suffix('y') { @@ -512,7 +624,16 @@ mod tests { let width = crate::context::WIDTH - 16.0; let mut used = 0.0; let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let cx = Context { units: UnitSystem::MmTonneS, names, salt: Id::new(c.id) }; + let touched = BTreeSet::new(); + let cx = Context { + units: UnitSystem::MmTonneS, + names, + salt: Id::new(c.id), + touched: &touched, + attempted: true, + refused: None, + problems: &messages(form, ¶ms), + }; ui.vertical(|ui| { ui.set_max_width(width); outcome = show(ui, &Tokens::get(false), &cx, form, &mut params); @@ -527,4 +648,85 @@ mod tests { } } } + + /// In every form, each required field left empty is a problem, so OK waits, and the message + /// names the field by its label, never by a JSON key. + #[test] + fn every_required_field_left_empty_is_named_by_its_label() { + let mut checked = 0; + for c in fieldcraft_engine::commands() { + let Some(form) = c.form else { continue }; + let full = schema::prefill(form, &json!({})); + let Some(full) = full.as_object() else { continue }; + for (path, message) in messages(form, full) { + assert!(!message.contains('`'), "{}: {path}: {message}", c.id); + } + for f in form.iter().filter(|f| f.required) { + let key = match f.kind { + Kind::Variants { tag, .. } if f.key.is_empty() => tag, + _ => f.key, + }; + let mut params = full.clone(); + params.remove(key); + let found: BTreeMap = messages(form, ¶ms).into_iter().collect(); + let message = found.get(key).unwrap_or_else(|| panic!("{}: `{key}` empty is no problem: {found:?}", c.id)); + assert!(message.starts_with(&format!("{}: ", f.label)), "{}: {message}", c.id); + for m in found.values() { + assert!(!m.contains('`'), "{}: {m}", c.id); + } + checked += 1; + } + } + assert!(checked > 50, "{checked}"); + } + + #[test] + fn messages_read_as_the_form_shows_them() { + let plasticity = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap(); + let m = messages(plasticity, &Map::new()); + assert_eq!(m.first(), Some(&("name".to_string(), "Material: choose a material".to_string())), "{m:?}"); + let load = fieldcraft_engine::find_command("model.load.create").and_then(|c| c.form).unwrap(); + let m: Vec = messages(load, &json!({"type": "force"}).as_object().cloned().unwrap_or_default()).into_iter().map(|(_, m)| m).collect(); + assert_eq!(m, ["Name: enter a name", "Target: choose a set or a reference point", "Force: enter a value"]); + } + + /// A problem waits until its field is edited or OK is tried; a refused parameter shows at once. + #[test] + fn problems_show_once_touched_or_attempted() { + let form = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap(); + let ctx = egui::Context::default(); + let draw = |touched: &BTreeSet, attempted: bool, refused: Option<(&str, &str)>| { + let mut shown = String::new(); + let mut params = Map::new(); + let names = |_: Collection| Vec::new(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let cx = Context { + units: UnitSystem::MmTonneS, + names: &names, + salt: Id::new("t"), + touched, + attempted, + refused, + problems: &messages(form, ¶ms), + }; + show(ui, &Tokens::get(false), &cx, form, &mut params); + }); + out.textures_delta.clear(); + // The text drawn this frame. + for clipped in &out.shapes { + if let egui::Shape::Text(t) = &clipped.shape { + shown.push_str(t.galley.text()); + shown.push('\n'); + } + } + shown + }; + let none = BTreeSet::new(); + assert!(!draw(&none, false, None).contains("choose a material")); + assert!(draw(&none, true, None).contains("Material: choose a material")); + let touched: BTreeSet = ["name".to_string()].into(); + assert!(draw(&touched, false, None).contains("Material: choose a material")); + let refused = draw(&none, false, Some(("name", "names no material"))); + assert!(refused.contains("Material: names no material") && !refused.contains("choose a material"), "{refused}"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 35c37ba..33839dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,8 +50,8 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; -use fieldcraft_engine::Session; use fieldcraft_engine::catalogue::{self, Tab, Workspace}; +use fieldcraft_engine::{EngineError, Session}; use fieldcraft_render::glyphs::Glyphs; use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView}; use serde::{Deserialize, Serialize}; @@ -158,6 +158,12 @@ pub struct ParamsDialog { /// The parameters as JSON text. pub text: String, pub error: Option, + /// The command refused one parameter: its path and the problem, shown beside its field. + pub refused: Option<(String, String)>, + /// The paths of the fields edited: their problems show; the others wait for OK. + pub touched: std::collections::BTreeSet, + /// OK was tried with something missing: every problem shows. + pub attempted: bool, /// The JSON text is shown instead of the typed fields. pub advanced: bool, /// Which opening of a form this is (counts up): a form opened over another one, even of the @@ -167,6 +173,12 @@ pub struct ParamsDialog { pub back: Option>, } +/// A command that failed: its message, and the parameter it refused (path, problem). +struct Failure { + message: String, + refused: Option<(String, String)>, +} + /// The form a "Create …" button left, and where the item made goes in it. #[derive(Clone, Debug, PartialEq)] pub struct Back { @@ -453,10 +465,25 @@ impl ModelApp { /// Run a command with JSON parameters: what every panel, the control channel and scripts /// do. The result or the error goes to the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { - self.ensure_idle()?; + self.run_form(id, params, None).map_err(|f| f.message) + } + + /// [`Self::run`] from a form with `fields`: a refused parameter is named by its field's label + /// (the engine's message names its key, which agents need). + fn run_form(&mut self, id: &str, params: Value, fields: Option<&'static [fieldcraft_engine::schema::Field]>) -> Result { + self.ensure_idle().map_err(|message| Failure { message, refused: None })?; let revision = self.session.revision(); let label = command_label(id); - let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + let r = self.session.execute(id, ¶ms).map_err(|e| match e { + EngineError::Param { key, problem } => { + let message = match fields.and_then(|f| form::field_at(f, &key)) { + Some(f) => format!("{}: {problem}", f.label), + None => format!("`{key}` {problem}"), + }; + Failure { message, refused: Some((key, problem)) } + } + e => Failure { message: e.to_string(), refused: None }, + }); let changed = self.session.revision() != revision; if changed { self.model_changed(); @@ -478,7 +505,7 @@ impl ModelApp { } self.set_status(line, false) } - Err(e) => self.set_status(format!("{label}: {e}"), true), + Err(e) => self.set_status(format!("{label}: {}", e.message), true), } r } @@ -579,6 +606,9 @@ impl ModelApp { command: spec.id, text: params.to_string(), error: None, + refused: None, + touched: Default::default(), + attempted: false, advanced: spec.form.is_none(), opening: self.forms_opened, back: None, @@ -626,16 +656,20 @@ impl ModelApp { /// Run the open parameter form; it closes when the command succeeds. pub fn run_dialog(&mut self) -> Result { let (command, params) = self.dialog_params()?; - let r = self.run(command, params); - match &r { - Ok(_) => self.dialog_done(), - Err(e) => { + let fields = fieldcraft_engine::find_command(command).and_then(|c| c.form); + match self.run_form(command, params, fields) { + Ok(v) => { + self.dialog_done(); + Ok(v) + } + Err(Failure { message, refused }) => { if let Some(open) = self.dialog.as_mut() { - open.error = Some(e.clone()); + open.error = Some(message.clone()); + open.refused = refused; } + Err(message) } } - r } /// The open form's command and parameters; parameters that are not JSON are the form's error. @@ -1445,6 +1479,23 @@ mod tests { assert!(app.start("no.such").is_err()); } + /// A parameter the command refuses is named by its field's label in the form and the status + /// bar; the command itself (agents) still names the key. + #[test] + fn a_refused_parameter_is_named_by_its_label() { + let mut app = ModelApp::new(Session::default()); + app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap(); + app.start("model.material.plasticity").unwrap(); + app.dialog.as_mut().unwrap().text = r#"{"name": 7}"#.into(); + let e = app.run_dialog().unwrap_err(); + assert_eq!(e, "Material: must be a name"); + let d = app.dialog.as_ref().unwrap(); + assert_eq!(d.refused, Some(("name".to_string(), "must be a name".to_string()))); + assert!(app.status.as_ref().is_some_and(|s| s.text == "Plasticity: Material: must be a name")); + let raw = app.session.execute("model.material.plasticity", &json!({"name": 7})).unwrap_err(); + assert_eq!(raw.to_string(), "`name` must be a name"); + } + /// A required reference with one candidate starts picked (the browser's selection wins); with /// none, "Create …" opens the providing command over the form, which comes back with the new /// item picked (or as it was, when that command is cancelled).