git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit3a256784d2b77061b2e05aff55fd016639bd7808
parent0e3f99f4e0
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 20:17
feat: typed parameter values (typed values, phases 3-4)

Phase 3: ParamDef moves to src/param.rs and keeps its value two ways,
together and private: the text exactly as written, and the ParamSlot it
parses to under its kind (a Number, Int, Vec3, Bool, Choice or Text value,
an Expr, or Invalid with the reason). Every setter re-parses, so the two
cannot disagree. Serde goes through a mirror of the old struct, field for
field and in order, so saves are byte-identical and sim_solve_key's hash of
a simnet's JSON does not move. A text that does not fit its kind is kept
verbatim and read exactly as before, and a load reports it on the status
line. The template merge re-parses under the template's kind
(adopt_ui_from). Readers take the parsed value first.

Phase 4: every write goes through set_text / set_value / bake / set_expr.
Expressions evaluate to a typed value converted by the row
(value_from_expr, which replaces format_for_param). The params pane and
MCP set_param/add_param refuse a value that does not fit: nothing is
written, and the pane puts the kept text back and says why. A value that
reads as an expression goes through as one. Reset to Default now restores
the template's expression flag with its text.

Checked: the designer_default_file project (128 params) and both bundled
projects round-trip with identical content and have no invalid values.
The project exports identical geometry at frames 1, 10 and 30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                  |  55 ++++-
 src/app.rs                 | 328 +++++++++------------------
 src/curve_tool.rs          |   2 +-
 src/dialog.rs              |   4 +-
 src/geometry.rs            | 392 ++++++++++-----------------------
 src/main.rs                | 525 ++++++++++++++++++++++++-------------------
 src/param.rs               | 538 +++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs             |  31 ++-
 src/render.rs              |   2 +-
 src/soft_transform_tool.rs |   2 +-
 src/window.rs              |  60 ++---
 11 files changed, 1173 insertions(+), 766 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 339a388..bd4d841 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -217,6 +217,9 @@ gone from cce-ui with the wgpu path).
   its top edge, so a mode line there lands under the collapsed stubs. It exists
   because a viewer state changes what every click does and snapping silently
   changes what a drag does.
+- `src/param.rs` — node parameters: `ParamDef` (text and parsed value kept
+  together, both private), `ParamKind`, `ParamValue`, `ParamSlot`. See
+  "Parameter kinds and typed values".
 - `src/project.rs` — save/load. A project is a **directory containing `state.json`**
   (`Project { name, root: FsNode, view_state }`); `default_project.json` in the crate
   root is special-cased as a single file and doubles as the detached-window sync channel.
@@ -475,16 +478,50 @@ diagnosis is in the git history of this section (commit `8fd0c29`) if the
 pattern ever recurs with another driver: a `read` returning EBADF on a file
 nothing is wrong with, in a process that has loaded a vendor ICD.
 
-### Parameter kinds
+### Parameter kinds and typed values
 
-A `ParamDef`'s `type` string names a `ParamKind` (`app.rs`) —
+`src/param.rs` owns `ParamDef`, `ParamKind`, `ParamValue` and `ParamSlot`
+(re-exported from `app`). A `type` string names a `ParamKind` —
 `ParamKind::parse` reads the head before the first `:` (`slider:-2:2` is a
 Slider, `choice:A,B` a Choice; `string`, what an absent type deserializes to,
-is Text). `ParamDef::kind()` is the one place the string is interpreted;
-`param_display`, `format_for_param`, `param_number`, the float3 expression
-split, Paste Reference's `ch`/`chs` choice and the pane write-back all
-dispatch on it. The VALUE is still the `default` string — kinds are phases
-0–2 of the typed-value migration, and storage (phase 3) has not moved.
+is Text) — and `ParamDef::kind()` is the one place the string is interpreted.
+
+**A parameter keeps its TEXT and what that text parses to, together.** Both
+fields are private; `text()` is the value as written (`"0.50"` stays
+`"0.50"`), `slot()` is `Value(ParamValue)`, `Expr` or `Invalid(why)`, and
+every setter re-parses, so the two cannot disagree. `set_text` is the one
+way a value changes (it keeps the expression flag), `set_value` writes a
+typed value, `bake` writes a value and clears the flag (an evaluated
+expression, Delete Expression), `set_expr` flips the flag, `set_type` /
+`adopt_ui_from` change the kind and re-parse — the template merge goes
+through `adopt_ui_from`, so an old save's text wire is a node wire and an
+old text Center a float3 from the load on. Tests build parameters with
+`ParamDef::new(name, type, text)` and the `with_*` builders.
+
+- **The file format did not move.** Serde goes through `ParamDefRepr`, the
+  old struct field for field and in order, so a re-save is byte-identical
+  and `sim_solve_key` — a hash of the simnet's JSON — does not restart
+  cached simulations. `params_serialize_as_they_always_did` walks every
+  template and both bundled projects; the user's own project was checked
+  the same way when this landed (128 parameters, identical).
+- **A text that does not fit is kept, never coerced.** It loads as
+  `Invalid`, readers fall back exactly as they did when every read parsed
+  the string (`node_param_f32` on `4.5` in a spinbox is still 4.5), and a
+  load says so on the status line (`report_invalid_params`, only over the
+  plain success message). What REFUSES one is the two places a person types:
+  the params pane (`sync_parameters_to_project`: nothing written, the row
+  shows the kept text again, "Not applied — Threshold: 'abc' is not a
+  number") and MCP's `set_param` / `add_param`. A value that reads as an
+  expression goes through as one and is checked when it evaluates.
+- **Readers take the parsed value** (`node_param_f32` / `_vec3` / `_bool`,
+  `param_number`) and fall back to the text for a slot that is not the
+  kind they want — so a text row holding `12` still reads 12.
+- **Expressions evaluate to a typed value.** `eval_param_value` returns
+  `Evaluated`: `value_from_expr` converts by the row (a number into a toggle
+  is its truth, into a choice the option at that index, into a spinbox its
+  whole part), and a result that fits nothing is stored as its text and
+  flagged, which is what the old string write-back did.
+  `resolve_param_refs` stores either with `set_value` / `bake`.
 
 - **A type naming no kind is refused**, not read as text: `load_fs_tree`
   drops the template with a message (as it does an unparseable one), MCP's
@@ -513,8 +550,8 @@ dispatch on it. The VALUE is still the `default` string — kinds are phases
 
 Saved projects need no migration for any of this: `merge_template_defs`
 hands every instance its template's type along with the rest of the UI
-metadata, so an old save's `"type": "text"` wire loads as `node`
-(`a_saved_text_wire_loads_as_a_node_wire`).
+metadata (`adopt_ui_from`, which re-parses), so an old save's
+`"type": "text"` wire loads as `node` (`a_saved_text_wire_loads_as_a_node_wire`).
 
 ### Conditional parameter rows
 
diff --git a/src/app.rs b/src/app.rs
index d7dce1e..ee427c7 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -74,150 +74,7 @@ pub const MIN_COLUMN: f32 = 120.0;
 pub const BREADCRUMB_H: f32 = 24.0;
 pub const PLAYBAR_H: f32 = 36.0;
 
-#[derive(Clone, Deserialize, Serialize)]
-pub struct ParamDef {
-    pub name: String,
-    #[serde(default)]
-    pub label: String,
-    #[serde(rename = "type")]
-    #[serde(default = "default_param_type")]
-    pub param_type: String,
-    #[serde(default)]
-    pub default: String,
-    #[serde(default)]
-    pub options: Vec<String>,
-    #[serde(default)]
-    pub min: Option<f32>,
-    #[serde(default)]
-    pub max: Option<f32>,
-    #[serde(default)]
-    pub step: Option<f32>,
-    /// When this parameter should be SHOWN, as a condition over its siblings'
-    /// current values. Empty means always.
-    ///
-    /// Grammar, deliberately tiny: `Mode == Twist`, `Mode == Twist|Bend` for
-    /// any-of, `Mode != Bleed` for unless, and ` && ` between clauses. It
-    /// exists because collapsing fifty operators into ten traded node count
-    /// for parameter count — Attribute reached sixteen parameters, of which
-    /// four matter at any moment — and a pane showing twelve irrelevant rows
-    /// is worse than the twelve nodes it replaced.
-    ///
-    /// Houdini calls this `hideWhen`. Phrased the positive way round here
-    /// because a template author is describing when a control APPLIES, and
-    /// stating that directly is easier to get right than stating its negation.
-    #[serde(default)]
-    pub show_when: String,
-    /// Whether `default` is an EXPRESSION to evaluate rather than a value —
-    /// `ch("../sphere1/Radius") * 2`, `$F / 24` (see `expr.rs`). A flag and
-    /// not a guess about the text, because a kernel's Code contains `chf(`,
-    /// a node name is an identifier and `0.5` parses as an expression too;
-    /// Houdini makes the same choice. The instance owns it with the value:
-    /// the template merge never touches it.
-    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
-    pub expr: bool,
-}
-
-/// What a parameter HOLDS, parsed from its `type` string — the one place
-/// that string is interpreted. The value is still the `default` string;
-/// the kind says how to read it and which control the params pane draws.
-///
-/// The head before the first `:` names the kind; what follows is the
-/// kind's own detail (`slider:-2:2` a range, `choice:UV,Icosphere,Cube`
-/// the options), read by the pane and by `choice_options`. A type naming
-/// no kind is a template bug: `load_fs_tree` drops the template and says
-/// so, and `every_shipped_template_param_has_a_known_kind` walks the
-/// shipped ones.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum ParamKind {
-    /// Free text: a name, a path, a group or attribute name. `string` is
-    /// the same kind — it is what an ABSENT type deserializes to.
-    Text,
-    /// A number with no range — a threshold, a scale factor, a manual
-    /// ramp end — shown as a text row, because the pane's slider holds a
-    /// fraction of its range and would clamp anything outside it.
-    Float,
-    /// A number over a range (`min`/`max`, or inline `slider:lo:hi`).
-    Slider,
-    /// An integer, stepped.
-    Spin,
-    /// Three numbers, `x:y:z`.
-    Float3,
-    /// One of a fixed set of options, stored as the option's text.
-    Choice,
-    /// `true` / `false`.
-    Toggle,
-    /// A press, not a value: the pane writes `clicked` and the app clears it.
-    Button,
-    /// A program (a wrangle's script). Never an expression.
-    Code,
-    /// The NAME of another node, resolved sibling-first by
-    /// `geometry::find_input_node` — an `Input` wire, a Boolean's `With`,
-    /// a Relax's `Rest`. Empty means unconnected.
-    Node,
-}
-
-impl ParamKind {
-    /// The type-string heads [`ParamKind::parse`] accepts, for messages.
-    /// `string` is left out: it is an alias, not something to ask for.
-    pub const NAMES: &'static [&'static str] =
-        &["text", "float", "slider", "spinbox", "float3", "choice", "toggle", "button", "code", "node"];
-
-    /// The kind a `type` string names, or `None` when it names none.
-    pub fn parse(ty: &str) -> Option<Self> {
-        let head = ty.split(':').next().unwrap_or("").trim();
-        Some(match head {
-            "text" | "string" => Self::Text,
-            "float" => Self::Float,
-            "slider" => Self::Slider,
-            "spinbox" => Self::Spin,
-            "float3" => Self::Float3,
-            "choice" => Self::Choice,
-            "toggle" => Self::Toggle,
-            "button" => Self::Button,
-            "code" => Self::Code,
-            "node" => Self::Node,
-            _ => return None,
-        })
-    }
-}
-
-impl ParamDef {
-    /// This parameter's kind. A type that names none reads as text — the
-    /// row stays editable and its value survives — but a shipped template
-    /// cannot carry one (see [`ParamKind`]).
-    pub fn kind(&self) -> ParamKind {
-        ParamKind::parse(&self.param_type).unwrap_or(ParamKind::Text)
-    }
-
-    /// Whether a value that READS as a reference should become an
-    /// expression here. Not for a code parameter: a kernel or a wrangle
-    /// script is a program, and one whose whole text happens to be
-    /// `ch("../a/Radius")` is a one-line program, not a channel — flagging
-    /// it would evaluate the script to a number before it ever ran.
-    pub fn takes_expressions(&self) -> bool {
-        !(self.kind() == ParamKind::Code || self.name == "Code")
-    }
-}
-
-/// Every parameter in `node`'s tree whose type names no [`ParamKind`], as
-/// `(path, parameter, type)`. Empty for a well-formed template.
-pub fn unknown_param_kinds(node: &FsNode) -> Vec<(String, String, String)> {
-    fn walk(node: &FsNode, path: &str, out: &mut Vec<(String, String, String)>) {
-        for p in &node.params {
-            if ParamKind::parse(&p.param_type).is_none() {
-                out.push((path.to_string(), p.name.clone(), p.param_type.clone()));
-            }
-        }
-        for c in &node.children {
-            walk(c, &format!("{path}/{}", c.name), out);
-        }
-    }
-    let mut out = Vec::new();
-    walk(node, &node.name, &mut out);
-    out
-}
-
-fn default_param_type() -> String { "string".to_string() }
+pub use crate::param::{invalid_params, unknown_param_kinds, ParamDef, ParamKind, ParamSlot, ParamValue};
 
 /// Expand a leading `~` to the home directory. A path typed into a text field
 /// is typed by a person, and `~/models/thing.stl` is what a person writes.
@@ -259,7 +116,7 @@ pub fn param_visible(params: &[ParamDef], cond: &str) -> bool {
         };
         let matches = wanted
             .split('|')
-            .any(|w| w.trim().eq_ignore_ascii_case(sibling.default.trim()));
+            .any(|w| w.trim().eq_ignore_ascii_case(sibling.text().trim()));
         matches != negated
     })
 }
@@ -702,38 +559,38 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
     params.iter().filter(|p| param_visible(params, &p.show_when)).map(|p| {
         let key = if p.label.is_empty() { &p.name } else { &p.label };
         let kind = p.kind();
-        let value = if p.param_type == "choice" && !p.options.is_empty() && p.default.is_empty() {
-            p.options[0].clone()
+        let value = if p.ty() == "choice" && !p.options().is_empty() && p.text().is_empty() {
+            p.options()[0].clone()
         } else {
-            p.default.clone()
+            p.text().to_string()
         };
         // An expression (`ch("../sphere1/Radius") * 2`) is shown as the text
         // it is: a slider cannot hold it, and a spinbox would show zero and
         // then write zero back over it.
-        let ptype = if p.expr {
+        let ptype = if p.is_expr() {
             "text".to_string()
         } else if matches!(kind, ParamKind::Text | ParamKind::Float | ParamKind::Node) {
             // The pane has no numeric-text or node-picker row; both are a
             // text box there. `string` (an absent type) is one too — the
             // pane does not know that word and would draw nothing.
             "text".to_string()
-        } else if p.param_type == "slider" {
+        } else if p.ty() == "slider" {
             let min = p.min.unwrap_or(0.0);
             let max = p.max.unwrap_or(2.0);
             format!("slider:{}:{}", min, max)
-        } else if p.param_type == "float3" {
+        } else if p.ty() == "float3" {
             let min = p.min.unwrap_or(-10.0);
             let max = p.max.unwrap_or(10.0);
             format!("float3:{}:{}", min, max)
-        } else if p.param_type == "spinbox" {
+        } else if p.ty() == "spinbox" {
             let min = p.min.unwrap_or(1.0) as i32;
             let max = p.max.unwrap_or(10000.0) as i32;
             let step = p.step.unwrap_or(1.0) as i32;
             format!("spinbox:{}:{}:{}", min, max, step)
-        } else if p.param_type == "choice" {
-            format!("choice:{}", p.options.join(","))
+        } else if p.ty() == "choice" {
+            format!("choice:{}", p.options().join(","))
         } else {
-            p.param_type.clone()
+            p.ty().to_string()
         };
         (key.clone(), value, ptype)
     }).collect()
@@ -889,8 +746,8 @@ impl Project {
             if !map.is_empty() {
                 for child in &mut dir.children {
                     for p in &mut child.params {
-                        if let Some((_, new)) = map.iter().find(|(old, _)| *old == p.default) {
-                            p.default = new.clone();
+                        if let Some((_, new)) = map.iter().find(|(old, _)| *old == p.text()) {
+                            p.set_text(new.clone());
                         }
                     }
                 }
@@ -923,10 +780,10 @@ impl Project {
         }
         fn walk(node: &mut FsNode) {
             for p in &mut node.params {
-                if !p.expr {
-                    if let Some(new) = crate::expr::migrate_legacy_ref(&p.default) {
-                        p.default = new;
-                        p.expr = true;
+                if !p.is_expr() {
+                    if let Some(new) = crate::expr::migrate_legacy_ref(p.text()) {
+                        p.set_text(new);
+                        p.set_expr(true);
                     }
                 }
             }
@@ -946,8 +803,8 @@ impl Project {
 /// one a typed or scripted value gets — arithmetic alone is not enough.
 pub fn infer_template_exprs(node: &mut FsNode) {
     for p in &mut node.params {
-        if !p.expr && p.takes_expressions() && crate::expr::looks_like_expression(&p.default) {
-            p.expr = true;
+        if !p.is_expr() && p.takes_expressions() && crate::expr::looks_like_expression(p.text()) {
+            p.set_expr(true);
         }
     }
     for c in &mut node.children {
@@ -996,7 +853,7 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                     fresh.geometry_visible = c.geometry_visible;
                     for p in &c.params {
                         if let Some(fp) = fresh.params.iter_mut().find(|fp| fp.name == p.name) {
-                            fp.default = p.default.clone();
+                            fp.set_text(p.text().to_string());
                         }
                     }
                     *c = fresh;
@@ -1072,19 +929,15 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
         for tp in &template.params {
             if let Some(i) = node.params.iter().position(|p| p.name == tp.name) {
                 cursor = i + 1;
-                let ip = &mut node.params[i];
-                ip.param_type = tp.param_type.clone();
-                ip.label = tp.label.clone();
-                ip.options = tp.options.clone();
-                ip.min = tp.min;
-                ip.max = tp.max;
-                ip.step = tp.step;
-                // The condition is UI metadata like the rest: the template
-                // owns when a control applies, the instance owns its value.
-                // Without this a saved project keeps the pane it had on the
-                // day it was made, and a node that later learned to hide its
-                // irrelevant rows would not hide them there.
-                ip.show_when = tp.show_when.clone();
+                // Type, label, options, range, step and the show-when
+                // condition — the condition is UI metadata like the rest:
+                // the template owns when a control applies, the instance
+                // owns its value. Without it a saved project keeps the pane
+                // it had on the day it was made, and a node that later
+                // learned to hide its irrelevant rows would not hide them
+                // there. The value is re-parsed under the template's kind,
+                // so an old save's text wire is a node wire from here on.
+                node.params[i].adopt_ui_from(tp);
             } else {
                 node.params.insert(cursor, tp.clone());
                 cursor += 1;
@@ -1108,7 +961,7 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                             .expect("children_match checked above");
                         if let Some(t_code) = tc.params.iter().find(|p| p.name == "Code") {
                             if let Some(i_code) = ic.params.iter_mut().find(|p| p.name == "Code") {
-                                i_code.default = t_code.default.clone();
+                                i_code.set_text(t_code.text().to_string());
                             }
                         }
                         merge_params(ic, tc);
@@ -1205,11 +1058,11 @@ pub fn load_fs_tree() -> FsNode {
                     // Merge parameters
                     for override_p in &child.params {
                         if let Some(base_p) = resolved_child.params.iter_mut().find(|p| p.name == override_p.name) {
-                            base_p.default = override_p.default.clone();
+                            base_p.set_text(override_p.text().to_string());
                             // The flag travels with the value: an override
                             // that is a reference (the Embryo's sphere1
                             // reading `chf("../Radius")`) stays one.
-                            base_p.expr = override_p.expr;
+                            base_p.set_expr(override_p.is_expr());
                         }
                     }
                     if depth < 8 {
@@ -3489,6 +3342,11 @@ impl State {
                     // action firing (the Open dropdown snapping back to
                     // "- Select -" — its action is file_to_open's, below).
                     let mut display_resets: Vec<String> = Vec::new();
+                    // Rows whose new text does not fit the parameter's kind
+                    // (`abc` typed into a threshold): (display key, the text
+                    // kept, why). Nothing is written; the row shows the kept
+                    // text again and the status line says why.
+                    let mut rejected: Vec<(String, String, String)> = Vec::new();
                     let mut pane_actions = Vec::new();
                     for (u_name, u_val, _) in &updated_params {
                         // The params pane reports its display key (label when
@@ -3498,21 +3356,31 @@ impl State {
                             let key = if p.label.is_empty() { &p.name } else { &p.label };
                             key == u_name
                         }) {
-                            if p.default != *u_val {
-                                p.default = u_val.clone();
-                                param_changed = true;
+                            if p.text() != *u_val {
                                 // A reference typed into a plain row becomes
                                 // an expression — the one way to make one
-                                // without the row menu.
-                                if !p.expr && p.takes_expressions() && crate::expr::looks_like_expression(&p.default) {
-                                    p.expr = true;
+                                // without the row menu — and an expression is
+                                // checked when it evaluates. Anything else
+                                // must fit the kind.
+                                let as_expr = p.is_expr()
+                                    || (p.takes_expressions() && crate::expr::looks_like_expression(u_val));
+                                if !as_expr {
+                                    if let Err(why) = p.check(u_val) {
+                                        rejected.push((u_name.clone(), p.text().to_string(), format!("{}: {why}", p.name)));
+                                        continue;
+                                    }
                                 }
-                                if p.kind() == ParamKind::Button && p.default == "clicked" {
+                                p.set_text(u_val.clone());
+                                param_changed = true;
+                                if as_expr {
+                                    p.set_expr(true);
+                                }
+                                if p.kind() == ParamKind::Button && p.text() == "clicked" {
                                     triggered_buttons.push(p.name.clone());
-                                    p.default = "".to_string();
+                                    p.set_text("".to_string());
                                 }
                                 if p.kind() == ParamKind::Toggle {
-                                    let desired = p.default == "true";
+                                    let desired = p.text() == "true";
                                     let cur = match p.name.as_str() {
                                         "Show Network Pane" => Some(cur_show.0),
                                         "Show Viewport Pane" => Some(cur_show.1),
@@ -3527,9 +3395,9 @@ impl State {
                                         pane_actions.push(p.name.clone());
                                     }
                                 }
-                                if p.name == "Open" && p.default != "- Select -" && !p.default.is_empty() {
-                                    file_to_open = Some(p.default.clone());
-                                    p.default = "- Select -".to_string();
+                                if p.name == "Open" && p.text() != "- Select -" && !p.text().is_empty() {
+                                    file_to_open = Some(p.text().to_string());
+                                    p.set_text("- Select -".to_string());
                                     // Display reset ONLY — never into
                                     // triggered_buttons, whose entries get
                                     // executed as menu actions: "Open" there
@@ -3541,15 +3409,23 @@ impl State {
                         }
                     }
 
-                    if !triggered_buttons.is_empty() || !display_resets.is_empty() {
+                    if !triggered_buttons.is_empty() || !display_resets.is_empty() || !rejected.is_empty() {
                         let mut disp_params = self.param().node_params();
                         for btn_name in triggered_buttons.iter().chain(display_resets.iter()) {
                             if let Some(pos) = disp_params.iter().position(|p| p.0 == *btn_name) {
                                 disp_params[pos].1 = if btn_name == "Open" { "- Select -".to_string() } else { "".to_string() };
                             }
                         }
+                        for (key, kept, _) in &rejected {
+                            if let Some(pos) = disp_params.iter().position(|p| p.0 == *key) {
+                                disp_params[pos].1 = kept.clone();
+                            }
+                        }
                         self.param_mut().set_display_params(&disp_params);
                     }
+                    if let Some((_, _, why)) = rejected.first() {
+                        self.update_status_text(&format!("Not applied — {why}"));
+                    }
 
                     if param_changed {
                         self.sync_grid_settings();
@@ -3935,7 +3811,7 @@ impl State {
             .find(|c| c.node_type == "camera" && c.name == active)
         {
             if let Some(p) = cam.params.iter_mut().find(|p| p.name == name) {
-                p.default = if val { "true" } else { "false" }.to_string();
+                p.set_value(crate::app::ParamValue::Bool(val));
             }
         }
     }
@@ -4377,10 +4253,10 @@ impl State {
                     None
                 };
                 let pos = node.params.iter().find(|p| p.name == "Position")
-                    .and_then(|p| parse3(&p.default))
+                    .and_then(|p| parse3(p.text()))
                     .unwrap_or(Vec3::new(2.5, 1.8, 2.5));
                 let piv = node.params.iter().find(|p| p.name == "Pivot")
-                    .and_then(|p| parse3(&p.default))
+                    .and_then(|p| parse3(p.text()))
                     .unwrap_or(Vec3::ZERO);
                 let offset = pos - piv;
                 let dir_unit = if offset.length() > 1e-4 {
@@ -4391,10 +4267,10 @@ impl State {
                 let new_pos = center + dir_unit * dist;
                 let fmt3 = |v: Vec3| format!("{:.2}:{:.2}:{:.2}", v.x, v.y, v.z);
                 if let Some(p) = node.params.iter_mut().find(|p| p.name == "Pivot") {
-                    p.default = fmt3(center);
+                    p.set_text(fmt3(center));
                 }
                 if let Some(p) = node.params.iter_mut().find(|p| p.name == "Position") {
-                    p.default = fmt3(new_pos);
+                    p.set_text(fmt3(new_pos));
                 }
                 self.viewport_mut().zoom = 1.0;
                 self.viewport_mut().reset_velocity();
@@ -4473,16 +4349,16 @@ impl State {
                     None
                 };
                 let pos = node.params.iter().find(|p| p.name == "Position")
-                    .and_then(|p| parse3(&p.default))
+                    .and_then(|p| parse3(p.text()))
                     .unwrap_or(Vec3::new(2.5, 1.8, 2.5));
                 let piv = node.params.iter().find(|p| p.name == "Pivot")
-                    .and_then(|p| parse3(&p.default))
+                    .and_then(|p| parse3(p.text()))
                     .unwrap_or(Vec3::ZERO);
                 let offset = pos - piv;
                 let dir_unit = if offset.length() > 1e-4 { offset.normalize() } else { Vec3::new(2.5, 1.8, 2.5).normalize() };
                 let new_pos = piv + dir_unit * dist;
                 if let Some(p) = node.params.iter_mut().find(|p| p.name == "Position") {
-                    p.default = format!("{:.3}:{:.3}:{:.3}", new_pos.x, new_pos.y, new_pos.z);
+                    p.set_text(format!("{:.3}:{:.3}:{:.3}", new_pos.x, new_pos.y, new_pos.z));
                 }
                 self.viewport_mut().zoom = 1.0;
                 self.viewport_mut().reset_velocity();
@@ -4538,7 +4414,7 @@ impl State {
     fn open_param_context_menu(&mut self, slot: usize, pname: String) {
         let (node_id, is_expr) = {
             let child = &self.param_editor_dir().children[slot];
-            (child.id.clone(), child.params.iter().find(|p| p.name == pname).is_some_and(|p| p.expr))
+            (child.id.clone(), child.params.iter().find(|p| p.name == pname).is_some_and(|p| p.is_expr()))
         };
         let mut options = vec!["Copy Parameter".to_string()];
         let mut actions = vec![ParamMenuAction::CopyParameter];
@@ -4639,8 +4515,8 @@ impl State {
                 if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
                     .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
                 {
-                    p.default = value.clone();
-                    p.expr = true;
+                    p.set_text(value.clone());
+                    p.set_expr(true);
                 }
                 self.update_status_text(&format!("{pname} = {value}"));
             }
@@ -4648,7 +4524,7 @@ impl State {
                 if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
                     .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
                 {
-                    p.expr = true;
+                    p.set_expr(true);
                 }
                 self.update_status_text(&format!("{pname} is an expression — type ch(\"../node/Param\"), $F, arithmetic."));
             }
@@ -4660,16 +4536,16 @@ impl State {
                 let evaluated = crate::viewer_state::find_node_by_id(&self.fs_root, node_id).and_then(|node| {
                     let mut err = None;
                     let resolved = crate::geometry::resolve_param_refs(&self.fs_root, node, frame, &mut err)?;
-                    resolved.params.into_iter().find(|p| p.name == pname).map(|p| (p.default, err))
+                    resolved.params.into_iter().find(|p| p.name == pname).map(|p| (p.text().to_string(), err))
                 });
                 let mut note = None;
                 if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
                     .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
                 {
-                    p.expr = false;
+                    p.set_expr(false);
                     match evaluated {
                         Some((value, None)) => {
-                            p.default = value.clone();
+                            p.bake(value.clone());
                             note = Some(format!("{pname} = {value}, no longer an expression."));
                         }
                         Some((_, Some(e))) => note = Some(format!("{pname} kept as text — it did not evaluate: {e}")),
@@ -5252,10 +5128,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 None
             };
             let pos = node.params.iter().find(|p| p.name == "Position")
-                .and_then(|p| parse3(&p.default))
+                .and_then(|p| parse3(p.text()))
                 .unwrap_or(Vec3::new(2.5, 1.8, 2.5));
             let piv = node.params.iter().find(|p| p.name == "Pivot")
-                .and_then(|p| parse3(&p.default))
+                .and_then(|p| parse3(p.text()))
                 .unwrap_or(Vec3::ZERO);
             let offset = pos - piv;
             let pitch0_deg = (offset.y / offset.length().max(1e-5)).asin().to_degrees();
@@ -5265,7 +5141,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 let mut rx = 0.0f32;
                 let mut ry = 0.0f32;
                 let mut rz = 0.0f32;
-                if let Some(v) = parse3(&p.default) {
+                if let Some(v) = parse3(p.text()) {
                     rx = v.x;
                     ry = v.y;
                     rz = v.z;
@@ -5277,7 +5153,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     .clamp(-max_pitch_deg - pitch0_deg, max_pitch_deg - pitch0_deg);
                 while ry > 180.0 { ry -= 360.0; }
                 while ry < -180.0 { ry += 360.0; }
-                p.default = format!("{:.2}:{:.2}:{:.2}", rx, ry, rz);
+                p.set_text(format!("{:.2}:{:.2}:{:.2}", rx, ry, rz));
 
                 self.sync_nodes();
                 // Through the one pane-sync path, so the pick-list upgrade
@@ -5298,7 +5174,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let dir = self.current_dir_mut();
         if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
             if let Some(p) = node.params.iter_mut().find(|p| p.name == "Rotation") {
-                p.default = "0.00:0.00:0.00".to_string();
+                p.set_text("0.00:0.00:0.00".to_string());
                 self.sync_nodes();
                 // Through the one pane-sync path, so the pick-list upgrade
                 // (textpick rows) survives this rebuild.
@@ -5473,7 +5349,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
         if let Some(node) = selected_node {
             current_name = Some(node.id.clone());
-            current_params = Some(node.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>());
+            current_params = Some(node.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect::<Vec<_>>());
             if self.last_spreadsheet_node_name == current_name && self.last_spreadsheet_node_params == current_params {
                 cache_hit = true;
             }
@@ -5518,7 +5394,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let group_key = selected_node.filter(|n| n.node_type.eq_ignore_ascii_case("group")).map(|n| {
             (
                 n.id.clone(),
-                n.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>(),
+                n.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect::<Vec<_>>(),
                 self.rt_geometry_version,
             )
         });
@@ -5583,7 +5459,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let key = node.as_ref().map(|n| {
             (
                 n.id.clone(),
-                n.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>(),
+                n.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect::<Vec<_>>(),
                 self.rt_geometry_version,
             )
         });
@@ -7155,7 +7031,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     .params
                     .iter()
                     .find(|p| p.name.eq_ignore_ascii_case("input"))
-                    .map(|p| p.default.clone()),
+                    .map(|p| p.text().to_string()),
                 position: c.position,
                 // Utility trees stay where they were put; see the module doc.
                 pinned: false,
@@ -9238,7 +9114,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     let dir = self.current_dir_mut();
                     if let Some(child) = dir.children.iter_mut().find(|c| c.id == input_node_id) {
                         if let Some(p) = child.params.iter_mut().find(|p| p.name == "Input") {
-                            p.default = output_node_name;
+                            p.set_text(output_node_name);
                             self.sync_nodes();
                             self.rebuild_scene_geometry();
                             self.sync_parameters_pane();
@@ -9269,12 +9145,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     if let (Some(mid_name), true) = (mid_name, both_rewirable) {
                         if let Some(mid) = dir.children.iter_mut().find(|c| c.id == mid_id) {
                             if let Some(p) = mid.params.iter_mut().find(|p| p.name == "Input") {
-                                p.default = src_name;
+                                p.set_text(src_name);
                             }
                         }
                         if let Some(dest) = dir.children.iter_mut().find(|c| c.id == dest_id) {
                             if let Some(p) = dest.params.iter_mut().find(|p| p.name == "Input") {
-                                p.default = mid_name;
+                                p.set_text(mid_name);
                             }
                         }
                         self.sync_nodes();
@@ -9307,7 +9183,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         let dir = self.dir_at_mut(&p2);
                         if let Some(child) = dir.children.iter_mut().find(|c| c.id == input_node_id) {
                             if let Some(p) = child.params.iter_mut().find(|p| p.name == "Input") {
-                                p.default = output_node_name;
+                                p.set_text(output_node_name);
                                 self.sync_nodes();
                                 self.rebuild_scene_geometry();
                                 self.sync_parameters_pane();
@@ -9335,12 +9211,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         if let (Some(mid_name), true) = (mid_name, both) {
                             if let Some(mid) = dir.children.iter_mut().find(|c| c.id == mid_id) {
                                 if let Some(p) = mid.params.iter_mut().find(|p| p.name == "Input") {
-                                    p.default = src_name;
+                                    p.set_text(src_name);
                                 }
                             }
                             if let Some(dest) = dir.children.iter_mut().find(|c| c.id == dest_id) {
                                 if let Some(p) = dest.params.iter_mut().find(|p| p.name == "Input") {
-                                    p.default = mid_name;
+                                    p.set_text(mid_name);
                                 }
                             }
                             self.sync_nodes();
@@ -10108,7 +9984,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         let mut cz = 2.5f32;
                         for p in &node.params {
                             if p.name == "Position" {
-                                let parts: Vec<&str> = p.default
+                                let parts: Vec<&str> = p.text()
                                     .split(|c| c == ':' || c == ',' || c == ' ')
                                     .filter(|s| !s.is_empty())
                                     .collect();
@@ -10120,7 +9996,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                     }
                                 }
                             } else if p.name == "Rotation" {
-                                let parts: Vec<&str> = p.default
+                                let parts: Vec<&str> = p.text()
                                     .split(|c| c == ':' || c == ',' || c == ' ')
                                     .filter(|s| !s.is_empty())
                                     .collect();
@@ -10132,7 +10008,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                     }
                                 }
                             } else if p.name == "Pivot" {
-                                let parts: Vec<&str> = p.default
+                                let parts: Vec<&str> = p.text()
                                     .split(|c| c == ':' || c == ',' || c == ' ')
                                     .filter(|s| !s.is_empty())
                                     .collect();
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index bf39cc7..89edbe3 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -50,7 +50,7 @@ impl HandleSource for CurveHandles {
     fn write(&self, node: &mut FsNode, handles: &[Vec3]) {
         let formatted = format_curve_points(handles);
         if let Some(p) = node.params.iter_mut().find(|p| p.name == "Points") {
-            p.default = formatted;
+            p.set_text(formatted);
         }
     }
 
diff --git a/src/dialog.rs b/src/dialog.rs
index 6fbc50a..21c4405 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1726,7 +1726,7 @@ impl State {
                             .params
                             .iter()
                             .find(|p| p.name == name)
-                            .map(|p| p.default.clone())
+                            .map(|p| p.text().to_string())
                     })
                     .flatten();
                 // No camera node behind the Default Camera: the live field
@@ -1890,7 +1890,7 @@ impl State {
                         .and_then(|c| c.params.iter_mut().find(|p| p.name == name))
                     {
                         Some(p) => {
-                            p.default = value.to_string();
+                            p.set_text(value.to_string());
                             true
                         }
                         None => false,
diff --git a/src/geometry.rs b/src/geometry.rs
index 2533630..18b2288 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -508,35 +508,46 @@ pub fn curve_geometry(node: &FsNode) -> Geometry {
     detail_to_soup(&curve_detail(node))
 }
 
+fn find_param<'a>(node: &'a FsNode, name: &str) -> Option<&'a ParamDef> {
+    node.params.iter().find(|p| p.name.eq_ignore_ascii_case(name))
+}
+
+/// A number: the parsed value of a number, whole-number or toggle
+/// parameter, else the text as a number (what every read was before the
+/// value was typed — so a text row holding `12` still reads 12), else
+/// `fallback`.
 pub fn node_param_f32(node: &FsNode, name: &str, fallback: f32) -> f32 {
-    node.params.iter()
-        .find(|p| p.name.eq_ignore_ascii_case(name))
-        .and_then(|p| p.default.parse::<f32>().ok())
-        .unwrap_or(fallback)
+    use crate::app::ParamValue;
+    let Some(p) = find_param(node, name) else { return fallback };
+    match p.value() {
+        Some(ParamValue::Number(n)) => *n,
+        Some(ParamValue::Int(i)) => *i as f32,
+        _ => p.text().parse::<f32>().unwrap_or(fallback),
+    }
 }
 
+/// The text as written — for a choice the option as the row shows it, for
+/// an expression the expression.
 pub fn node_param_str(node: &FsNode, name: &str, fallback: &str) -> String {
-    node.params.iter()
-        .find(|p| p.name.eq_ignore_ascii_case(name))
-        .map(|p| p.default.clone())
-        .unwrap_or_else(|| fallback.to_string())
+    find_param(node, name).map(|p| p.text().to_string()).unwrap_or_else(|| fallback.to_string())
 }
 
+/// Three numbers: a float3's parsed value, else `x:y:z` read from the text
+/// (a text row holding a vector, like the Attribute node's Value), else
+/// `fallback`.
 pub fn node_param_vec3(node: &FsNode, name: &str, fallback: Vec3) -> Vec3 {
-    node.params.iter()
-        .find(|p| p.name.eq_ignore_ascii_case(name))
-        .and_then(|p| {
-            let parts: Vec<&str> = p.default.split(':').collect();
-            if parts.len() == 3 {
-                let x = parts[0].parse::<f32>().ok()?;
-                let y = parts[1].parse::<f32>().ok()?;
-                let z = parts[2].parse::<f32>().ok()?;
-                Some(Vec3::new(x, y, z))
-            } else {
-                None
-            }
-        })
-        .unwrap_or(fallback)
+    use crate::app::ParamValue;
+    let Some(p) = find_param(node, name) else { return fallback };
+    if let Some(ParamValue::Vec3(v)) = p.value() {
+        return Vec3::from(*v);
+    }
+    let parts: Vec<&str> = p.text().split(':').collect();
+    if let [x, y, z] = parts[..] {
+        if let (Ok(x), Ok(y), Ok(z)) = (x.parse::<f32>(), y.parse::<f32>(), z.parse::<f32>()) {
+            return Vec3::new(x, y, z);
+        }
+    }
+    fallback
 }
 
 /// A toggle's value. `true` / `false` in any case — and `1` / `0`, `on` /
@@ -545,10 +556,11 @@ pub fn node_param_vec3(node: &FsNode, name: &str, fallback: Vec3) -> Vec3 {
 /// the hand-rolled `== "true"` and `!= "false"` this replaced disagreed about
 /// garbage depending on which way round each site had been written.
 pub fn node_param_bool(node: &FsNode, name: &str, fallback: bool) -> bool {
-    let Some(p) = node.params.iter().find(|p| p.name.eq_ignore_ascii_case(name)) else {
-        return fallback;
-    };
-    match p.default.trim().to_ascii_lowercase().as_str() {
+    let Some(p) = find_param(node, name) else { return fallback };
+    if let Some(crate::app::ParamValue::Bool(b)) = p.value() {
+        return *b;
+    }
+    match p.text().trim().to_ascii_lowercase().as_str() {
         "true" | "1" | "on" => true,
         "false" | "0" | "off" => false,
         _ => fallback,
@@ -631,19 +643,12 @@ pub use crate::expr::{ChKind, Value};
 /// Whether any of `node`'s parameters holds an expression — the cheap test
 /// that lets evaluation skip the clone for the common node.
 pub fn has_param_refs(node: &FsNode) -> bool {
-    node.params.iter().any(|p| p.expr)
+    node.params.iter().any(|p| p.is_expr())
 }
 
 /// A choice's options: the `options` list, else the `choice:A,B` type.
 pub fn choice_options(p: &ParamDef) -> Vec<String> {
-    if !p.options.is_empty() {
-        p.options.clone()
-    } else {
-        p.param_type
-            .strip_prefix("choice:")
-            .map(|o| o.split(',').map(|x| x.trim().to_string()).collect())
-            .unwrap_or_default()
-    }
+    p.choice_options()
 }
 
 /// A parameter's value as a NUMBER — what a kernel's `chf` / `chi` / `chb`
@@ -654,11 +659,17 @@ pub fn choice_options(p: &ParamDef) -> Vec<String> {
 /// text parses as nothing, and until 2026-09-24 the kernel path parsed it
 /// anyway, so every choice read as 0 from inside a kernel.
 pub fn param_number(p: &ParamDef) -> f32 {
-    let raw = p.default.trim();
-    if p.kind() == crate::app::ParamKind::Choice {
-        choice_options(p).iter().position(|o| o.eq_ignore_ascii_case(raw)).map_or(0.0, |i| i as f32)
-    } else {
-        number_of_str(raw)
+    use crate::app::ParamValue;
+    match p.value() {
+        Some(ParamValue::Number(n)) => *n,
+        Some(ParamValue::Int(i)) => *i as f32,
+        Some(ParamValue::Bool(b)) => f32::from(u8::from(*b)),
+        Some(ParamValue::Choice(o)) => choice_options(p).iter().position(|x| x == o).map_or(0.0, |i| i as f32),
+        // Text, a vector, an expression or a value that does not fit: the
+        // text as a number, which is what every read was before the value
+        // was typed.
+        _ if p.kind() == crate::app::ParamKind::Choice => 0.0,
+        _ => number_of_str(p.text().trim()),
     }
 }
 
@@ -785,7 +796,7 @@ impl<'a> crate::expr::Scope for TreeScope<'a> {
         let node = walk_ref_path(self.root, start, &segs).map_err(|e| format!("{}: ch(\"{path}\"): {e}", self.node.name))?;
         let (p, comp) = find_ref_param(node, pname)
             .ok_or_else(|| format!("{}: ch(\"{path}\") names no parameter {pname} on {}", self.node.name, if node.id == self.root.id { "/" } else { &node.name }))?;
-        let raw = if p.expr {
+        let raw = if p.is_expr() {
             let key = (node.id.clone(), p.name.clone());
             if self.stack.contains(&key) {
                 return Err(format!("{}: ch(\"{path}\") is a circular reference", self.node.name));
@@ -798,18 +809,17 @@ impl<'a> crate::expr::Scope for TreeScope<'a> {
             let r = eval_param_value(&mut inner, p);
             self.stack = inner.stack;
             self.stack.pop();
-            r?
+            r?.text()
         } else {
-            p.default.clone()
+            p.text().to_string()
         };
         let value = match comp {
             Some(i) => Value::Num(raw.split(':').nth(i).and_then(|c| c.trim().parse::<f64>().ok()).unwrap_or(0.0)),
             None => {
                 let mut lit = p.clone();
-                lit.default = raw;
-                lit.expr = false;
+                lit.bake(raw);
                 match kind {
-                    ChKind::Str => Value::Str(lit.default.trim().to_string()),
+                    ChKind::Str => Value::Str(lit.text().trim().to_string()),
                     _ => Value::Num(param_number(&lit) as f64),
                 }
             }
@@ -829,60 +839,53 @@ impl<'a> crate::expr::Scope for TreeScope<'a> {
     }
 }
 
-/// An evaluated value written back in the parameter's own vocabulary: a
-/// toggle's `true` / `false`, a choice's option NAME (an index picks one), a
-/// spinbox's integer, anything else the value's text.
-pub fn format_for_param(v: &Value, p: &ParamDef) -> String {
-    use crate::app::ParamKind;
-    let kind = p.kind();
-    if kind == ParamKind::Toggle {
-        return v.truthy().to_string();
-    }
-    if kind == ParamKind::Choice {
-        return match v {
-            Value::Num(n) => {
-                let options = choice_options(p);
-                if options.is_empty() {
-                    crate::expr::fmt_num(*n)
-                } else {
-                    let i = (n.round().max(0.0) as usize).min(options.len() - 1);
-                    options[i].clone()
-                }
-            }
-            Value::Str(s) => s.clone(),
-        };
-    }
-    if kind == ParamKind::Spin {
-        if let Value::Num(n) = v {
-            return crate::expr::fmt_num(n.trunc());
+/// What an expression evaluated to, in the parameter's own terms: a value
+/// that fits its kind ([`ParamDef::value_from_expr`] — a toggle's truth, a
+/// choice's option, a spinbox's whole part), or the value's text when it
+/// fits nothing (a string into a slider), which is stored and flagged
+/// exactly as the old write-back stored it and a reader then fell back from.
+#[derive(Debug)]
+pub enum Evaluated {
+    Value(crate::app::ParamValue),
+    Unfit(String),
+}
+
+impl Evaluated {
+    pub fn text(&self) -> String {
+        match self {
+            Evaluated::Value(v) => v.to_text(),
+            Evaluated::Unfit(s) => s.clone(),
         }
     }
-    v.as_str()
 }
 
-/// One parameter's expression evaluated to its value string. A float3 is
-/// three expressions separated by `:` — each component its own, as
-/// Houdini's channels are — so `chf("../a/Size.x"):0:0` reads naturally.
-fn eval_param_value(scope: &mut TreeScope, p: &ParamDef) -> Result<String, String> {
+/// One parameter's expression evaluated. A float3 is three expressions
+/// separated by `:` — each component its own, as Houdini's channels are —
+/// so `chf("../a/Size.x"):0:0` reads naturally.
+fn eval_param_value(scope: &mut TreeScope, p: &ParamDef) -> Result<Evaluated, String> {
     if p.kind() == crate::app::ParamKind::Float3 {
-        let parts: Vec<&str> = p.default.split(':').collect();
+        let parts: Vec<&str> = p.text().split(':').collect();
         if parts.len() == 3 {
-            let mut out = Vec::with_capacity(3);
-            for part in parts {
+            let mut out = [0.0f32; 3];
+            for (c, part) in out.iter_mut().zip(parts) {
                 let t = part.trim();
-                if t.parse::<f64>().is_ok() {
-                    out.push(t.to_string());
-                } else {
-                    let e = crate::expr::parse(t).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
-                    out.push(crate::expr::fmt_num(e.eval(scope)?.as_num()));
-                }
+                *c = match t.parse::<f32>() {
+                    Ok(n) => n,
+                    Err(_) => {
+                        let e = crate::expr::parse(t).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
+                        e.eval(scope)?.as_num() as f32
+                    }
+                };
             }
-            return Ok(out.join(":"));
+            return Ok(Evaluated::Value(crate::app::ParamValue::Vec3(out)));
         }
     }
-    let e = crate::expr::parse(&p.default).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
+    let e = crate::expr::parse(p.text()).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
     let v = e.eval(scope)?;
-    Ok(format_for_param(&v, p))
+    Ok(match p.value_from_expr(&v) {
+        Some(value) => Evaluated::Value(value),
+        None => Evaluated::Unfit(v.as_str()),
+    })
 }
 
 /// `target` with every expression replaced by the value it evaluates to at
@@ -898,15 +901,13 @@ pub fn resolve_param_refs(root: &FsNode, target: &FsNode, frame: i32, error: &mu
     }
     let mut out = target.clone();
     for p in &mut out.params {
-        if !p.expr {
+        if !p.is_expr() {
             continue;
         }
         let mut scope = TreeScope { root, node: target, frame, stack: vec![(target.id.clone(), p.name.clone())] };
         match eval_param_value(&mut scope, p) {
-            Ok(v) => {
-                p.default = v;
-                p.expr = false;
-            }
+            Ok(Evaluated::Value(v)) => p.set_value(v),
+            Ok(Evaluated::Unfit(text)) => p.bake(text),
             Err(e) => {
                 if error.is_none() {
                     *error = Some(e);
@@ -954,10 +955,10 @@ pub fn rename_node_in_tree(root: &mut FsNode, id: &str, new_name: &str) -> bool
     let mut edits: Vec<(String, String, String)> = Vec::new();
     fn collect(root: &FsNode, node: &FsNode, id: &str, new_name: &str, edits: &mut Vec<(String, String, String)>) {
         for p in &node.params {
-            if !p.expr {
+            if !p.is_expr() {
                 continue;
             }
-            let rewritten = crate::expr::rewrite_paths(&p.default, |path| {
+            let rewritten = crate::expr::rewrite_paths(p.text(), |path| {
                 let (absolute, segs, pname) = split_ref_path(path);
                 let mut cur = if absolute { root } else { node };
                 let mut out: Vec<String> = Vec::new();
@@ -988,7 +989,7 @@ pub fn rename_node_in_tree(root: &mut FsNode, id: &str, new_name: &str) -> bool
                 out.push(pname.to_string());
                 Some(format!("{}{}", if absolute { "/" } else { "" }, out.join("/")))
             });
-            if rewritten != p.default {
+            if rewritten != p.text() {
                 edits.push((node.id.clone(), p.name.clone(), rewritten));
             }
         }
@@ -1002,15 +1003,15 @@ pub fn rename_node_in_tree(root: &mut FsNode, id: &str, new_name: &str) -> bool
     for (nid, pname, value) in edits {
         if let Some(n) = crate::viewer_state::find_node_by_id_mut(root, &nid) {
             if let Some(p) = n.params.iter_mut().find(|p| p.name == pname) {
-                p.default = value;
+                p.set_text(value);
             }
         }
     }
     if let Some(parent) = crate::viewer_state::find_node_by_id_mut(root, &parent_id) {
         for sibling in &mut parent.children {
             for p in &mut sibling.params {
-                if !p.expr && p.default == old_name {
-                    p.default = new_name.to_string();
+                if !p.is_expr() && p.text() == old_name {
+                    p.set_text(new_name.to_string());
                 }
             }
         }
@@ -5982,17 +5983,7 @@ mod tests {
             children: vec![],
             params: [("Points", "0 0 0; 1 0 0"), ("Segments", "2"), ("Thickness", "0.02")]
                 .into_iter()
-                .map(|(name, default)| ParamDef {
-                    name: name.to_string(),
-                    label: String::new(),
-                    param_type: "text".to_string(),
-                    default: default.to_string(),
-                    options: Vec::new(),
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                })
+                .map(|(name, default)| crate::app::ParamDef::new(name.to_string(), "text".to_string(), default.to_string()))
                 .collect(),
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6024,28 +6015,8 @@ mod tests {
             node_type: "points".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Points".to_string(),
-                    label: String::new(),
-                    param_type: "spinbox".to_string(),
-                    default: "5".to_string(),
-                    options: vec![],
-                    min: Some(1.0),
-                    max: Some(10.0),
-                    step: Some(1.0),
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Shape".to_string(),
-                    label: String::new(),
-                    param_type: "choice:None,Spiral,Line,Circle,Grid".to_string(),
-                    default: shape.to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
+                crate::app::ParamDef::new("Points".to_string(), "spinbox".to_string(), "5".to_string()).with_range(Some(1.0), Some(10.0)).with_step(Some(1.0)),
+                crate::app::ParamDef::new("Shape".to_string(), "choice:None,Spiral,Line,Circle,Grid".to_string(), shape.to_string()),
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6099,17 +6070,7 @@ mod tests {
             node_type: "sphere".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Radius".to_string(),
-                    label: String::new(),
-                    param_type: "slider".to_string(),
-                    default: "0.5".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.5".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6122,28 +6083,8 @@ mod tests {
             node_type: "transform".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Input".to_string(),
-                    label: String::new(),
-                    param_type: "text".to_string(),
-                    default: "Sphere 1".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Translation".to_string(),
-                    label: String::new(),
-                    param_type: "float3".to_string(),
-                    default: "1.00:2.00:3.00".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Input".to_string(), "text".to_string(), "Sphere 1".to_string()),
+                crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "1.00:2.00:3.00".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6180,28 +6121,8 @@ mod tests {
             node_type: "transform".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Input".to_string(),
-                    label: String::new(),
-                    param_type: "text".to_string(),
-                    default: "Transform 1".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Translation".to_string(),
-                    label: String::new(),
-                    param_type: "float3".to_string(),
-                    default: "-1.00:-1.00:-1.00".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Input".to_string(), "text".to_string(), "Transform 1".to_string()),
+                crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "-1.00:-1.00:-1.00".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6235,28 +6156,8 @@ mod tests {
             node_type: "transform".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Input".to_string(),
-                    label: String::new(),
-                    param_type: "text".to_string(),
-                    default: "Transform Loop".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Translation".to_string(),
-                    label: String::new(),
-                    param_type: "float3".to_string(),
-                    default: "1.00:1.00:1.00".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Input".to_string(), "text".to_string(), "Transform Loop".to_string()),
+                crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "1.00:1.00:1.00".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6287,17 +6188,7 @@ mod tests {
             node_type: "sphere".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Radius".to_string(),
-                    label: String::new(),
-                    param_type: "slider".to_string(),
-                    default: "0.5".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.5".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6311,39 +6202,9 @@ mod tests {
             node_type: "scatter".to_string(),
             children: vec![],
             params: vec![
-                ParamDef {
-                    name: "Input".to_string(),
-                    label: String::new(),
-                    param_type: "text".to_string(),
-                    default: "Sphere 1".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Points".to_string(),
-                    label: String::new(),
-                    param_type: "spinbox".to_string(),
-                    default: "15".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                },
-                ParamDef {
-                    name: "Radius".to_string(),
-                    label: String::new(),
-                    param_type: "slider".to_string(),
-                    default: "0.02".to_string(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                }
+                crate::app::ParamDef::new("Input".to_string(), "text".to_string(), "Sphere 1".to_string()),
+                crate::app::ParamDef::new("Points".to_string(), "spinbox".to_string(), "15".to_string()),
+                crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.02".to_string())
             ],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -6602,17 +6463,7 @@ mod simnet_tests {
     use crate::app::{FsNode, ParamDef};
 
     fn param(name: &str, value: &str) -> ParamDef {
-        ParamDef {
-            name: name.to_string(),
-            label: String::new(),
-            param_type: "text".to_string(),
-            default: value.to_string(),
-            options: vec![],
-            min: None,
-            max: None,
-            step: None,
-            show_when: String::new(), expr: false,
-        }
+        crate::app::ParamDef::new(name.to_string(), "text".to_string(), value.to_string())
     }
 
     fn node(id: &str, name: &str, node_type: &str, params: Vec<ParamDef>, children: Vec<FsNode>) -> FsNode {
@@ -6635,7 +6486,7 @@ mod simnet_tests {
         let mut ps = vec![param("Input", "In"), param("Attribute Name", "mass")];
         for (k, v) in params {
             match ps.iter_mut().find(|p| p.name == *k) {
-                Some(p) => p.default = v.to_string(),
+                Some(p) => p.set_text(v.to_string()),
                 None => ps.push(param(k, v)),
             }
         }
@@ -6914,7 +6765,7 @@ mod simnet_tests {
         let mut ps = vec![param("Input", "In"), param("Attribute", "mass")];
         for (k, v) in params {
             match ps.iter_mut().find(|p| p.name == *k) {
-                Some(p) => p.default = v.to_string(),
+                Some(p) => p.set_text(v.to_string()),
                 None => ps.push(param(k, v)),
             }
         }
@@ -7246,7 +7097,7 @@ mod simnet_tests {
         ];
         for (k, v) in extra {
             match params.iter_mut().find(|p| p.name == *k) {
-                Some(p) => p.default = v.to_string(),
+                Some(p) => p.set_text(v.to_string()),
                 None => params.push(param(k, v)),
             }
         }
@@ -7276,7 +7127,7 @@ mod simnet_tests {
         let mut params_vec = vec![param("Input", "In"), param("Attribute", "mass")];
         for (k, v) in params {
             match params_vec.iter_mut().find(|p| p.name == *k) {
-                Some(p) => p.default = v.to_string(),
+                Some(p) => p.set_text(v.to_string()),
                 None => params_vec.push(param(k, v)),
             }
         }
@@ -8196,8 +8047,7 @@ mod simnet_tests {
         {
             let sim_mut = root.children.iter_mut().find(|c| c.node_type == "simnet").unwrap();
             let step = sim_mut.children.iter_mut().find(|c| c.name == "step1").unwrap();
-            step.params.iter_mut().find(|p| p.name == "Translation").unwrap().default =
-                "2.00:0.00:0.00".to_string();
+            step.params.iter_mut().find(|p| p.name == "Translation").unwrap().set_text("2.00:0.00:0.00".to_string());
         }
         let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap().clone();
         let base = min_x(&solve_at(&root, 1));
diff --git a/src/main.rs b/src/main.rs
index c9ab1e3..65e7fa0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 
 pub mod app;
+pub mod param;
 pub mod application;
 pub mod curve_tool;
 pub mod soft_transform_tool;
@@ -326,9 +327,9 @@ mod tests {
         assert_eq!(node.node_type, "points");
         assert_eq!(node.name, "Add 3", "instance name is the wire identity — never rewritten");
         let points = node.params.iter().find(|p| p.name == "Points").unwrap();
-        assert_eq!(points.default, "250", "instance owns its values");
+        assert_eq!(points.text(), "250", "instance owns its values");
         let shape = node.params.iter().find(|p| p.name == "Shape").expect("Shape appended");
-        assert_eq!(shape.default, "None");
+        assert_eq!(shape.text(), "None");
     }
 
     /// Set As Default is reachable. It was a button on the Main utility
@@ -580,7 +581,7 @@ mod tests {
     /// the wires, and the active camera in the view state.
     #[test]
     fn loading_a_project_strips_spaces_and_rewires_references() {
-        use crate::app::{ParamDef, Project};
+        use crate::app::Project;
         let content = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/default_project.json")).unwrap();
         let mut proj: Project = serde_json::from_str(&content).unwrap();
         // Age the file: put the spaces back, add a consumer wired to the
@@ -595,11 +596,11 @@ mod tests {
         group.name = "My Region".into();
         group.node_type = "group".into();
         group.children.clear();
-        group.params = vec![ParamDef { name: "Input".into(), label: "Input".into(), param_type: "text".into(), default: "Sphere 1".into(), options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false }];
+        group.params = vec![crate::app::ParamDef::new("Input", "text", "Sphere 1").with_label("Input")];
         let mut clash = group.clone();
         clash.id = "c".into();
         clash.name = "sphere1".into();
-        clash.params[0].default = "Camera 1".into();
+        clash.params[0].set_text("Camera 1");
         proj.root.children.push(group);
         proj.root.children.push(clash);
 
@@ -613,8 +614,8 @@ mod tests {
         assert!(names.contains(&"sphere1"), "the hand-named sibling keeps its name");
         assert!(names.contains(&"sphere1_2"), "the migrated sphere steps aside from it: {names:?}");
         let by_name = |n: &str| proj.root.children.iter().find(|c| c.name == n).unwrap();
-        assert_eq!(by_name("my_region").params[0].default, "sphere1_2", "the wire followed the rename");
-        assert_eq!(by_name("sphere1").params[0].default, "camera1");
+        assert_eq!(by_name("my_region").params[0].text(), "sphere1_2", "the wire followed the rename");
+        assert_eq!(by_name("sphere1").params[0].text(), "camera1");
         assert_eq!(proj.view_state.active_camera, "camera1");
         // The sphere is the native node the bundled file now holds.
         assert_eq!(by_name("sphere1_2").node_type, "sphere");
@@ -805,17 +806,7 @@ mod tests {
         let mut state = State::new(false);
         // A pre-removal save: the four utility subnets flat at the root,
         // Main carrying its retired Style section.
-        let style = |name: &str, ty: &str, val: &str| crate::app::ParamDef {
-            name: name.to_string(),
-            label: String::new(),
-            param_type: ty.to_string(),
-            default: val.to_string(),
-            options: Vec::new(),
-            min: None,
-            max: None,
-            step: None,
-            show_when: String::new(), expr: false,
-        };
+        let style = |name: &str, ty: &str, val: &str| crate::app::ParamDef::new(name.to_string(), ty.to_string(), val.to_string());
         state.fs_root.children.push(crate::app::FsNode {
             id: "legacy-main".to_string(),
             name: "main".to_string(),
@@ -925,17 +916,7 @@ mod tests {
 
         // An old save: a "session"-typed container with the four subnets,
         // carrying values that are not the defaults.
-        let p = |name: &str, ty: &str, val: &str| crate::app::ParamDef {
-            name: name.to_string(),
-            label: String::new(),
-            param_type: ty.to_string(),
-            default: val.to_string(),
-            options: Vec::new(),
-            min: None,
-            max: None,
-            step: None,
-            show_when: String::new(), expr: false,
-        };
+        let p = |name: &str, ty: &str, val: &str| crate::app::ParamDef::new(name.to_string(), ty.to_string(), val.to_string());
         let subnet = |name: &str, params: Vec<crate::app::ParamDef>| crate::app::FsNode {
             id: format!("legacy-{name}"),
             name: name.to_string(),
@@ -1076,17 +1057,7 @@ mod tests {
             name: "guides".to_string(),
             node_type: "utility".to_string(),
             children: vec![],
-            params: vec![crate::app::ParamDef {
-                name: "Show Grid Guide".to_string(),
-                label: String::new(),
-                param_type: "toggle".to_string(),
-                default: "false".to_string(),
-                options: vec![],
-                min: None,
-                max: None,
-                step: None,
-                show_when: String::new(), expr: false,
-            }],
+            params: vec![crate::app::ParamDef::new("Show Grid Guide".to_string(), "toggle".to_string(), "false".to_string())],
             geometry_visible: true,
             position: (0.0, 4.0),
             inputs: 1,
@@ -2682,13 +2653,13 @@ mod tests {
         assert!(sphere1.children.is_empty(), "a native node has no children");
         assert!(sphere1.params.iter().any(|p| p.name == "Method"), "the base template's params arrive");
         let radius = sphere1.params.iter().find(|p| p.name == "Radius").unwrap();
-        assert!(radius.expr && radius.default.contains("Radius"), "the override is the reference: {} (expr {})", radius.default, radius.expr);
-        assert_eq!(sphere1.params.iter().find(|p| p.name == "Center Y").unwrap().default, "0.0");
+        assert!(radius.is_expr() && radius.text().contains("Radius"), "the override is the reference: {} (expr {})", radius.text(), radius.is_expr());
+        assert_eq!(sphere1.params.iter().find(|p| p.name == "Center Y").unwrap().text(), "0.0");
 
         let output1 = embryo.children.iter().find(|c| c.name == "output1").unwrap();
         assert_eq!(output1.node_type, "output");
         let output_input = output1.params.iter().find(|p| p.name == "Input").unwrap();
-        assert_eq!(output_input.default, "normal1");
+        assert_eq!(output_input.text(), "normal1");
     }
 
     /// The raster pipeline culls back faces with CCW fronts (the wgpu
@@ -2818,8 +2789,8 @@ mod tests {
             .find(|t| t.name == template_name)
             .unwrap_or_else(|| panic!("{template_name} template should be loaded"));
         let color = template.params.iter().find(|p| p.name == "Color").expect("a Color param");
-        assert_eq!(color.param_type, "toggle");
-        assert_eq!(color.default, "true", "coloured by default, as it always was");
+        assert_eq!(color.ty(), "toggle");
+        assert_eq!(color.text(), "true", "coloured by default, as it always was");
 
         let generate = |on: &str| {
             let mut inst = template.clone();
@@ -2827,7 +2798,7 @@ mod tests {
             for child in &mut inst.children {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
-            inst.params.iter_mut().find(|p| p.name == "Color").unwrap().default = on.to_string();
+            inst.params.iter_mut().find(|p| p.name == "Color").unwrap().set_text(on.to_string());
             let root = FsNode {
                 id: "root".to_string(),
                 name: "root".to_string(),
@@ -2973,8 +2944,7 @@ mod tests {
 
         // Two points: one span, 8 boxes. The scene walk agrees with the
         // single-node path.
-        instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
-            "0 0 0; 1 0 0".to_string();
+        instance.params.iter_mut().find(|p| p.name == "Points").unwrap().set_text("0 0 0; 1 0 0".to_string());
         let root = make_root(instance.clone());
         assert_eq!(eval(&root).num_points(), 8 * 8);
         assert_eq!(
@@ -2984,8 +2954,7 @@ mod tests {
         );
 
         // No parseable points: empty geometry, not a panic.
-        instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
-            "not points".to_string();
+        instance.params.iter_mut().find(|p| p.name == "Points").unwrap().set_text("not points".to_string());
         assert_eq!(eval(&make_root(instance)).num_points(), 0);
     }
 
@@ -3375,7 +3344,7 @@ mod tests {
         group_instance.id = "group_inst".to_string();
         group_instance.name = "Group 1".to_string();
         let set = |inst: &mut FsNode, name: &str, val: &str| {
-            inst.params.iter_mut().find(|p| p.name == name).unwrap().default = val.to_string();
+            inst.params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
         };
         set(&mut group_instance, "Input", "Sphere 1");
         set(&mut group_instance, "Center", "0.00:0.80:0.00");
@@ -3491,8 +3460,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
-                    val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             inst
         };
@@ -3682,8 +3650,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
-                    val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             inst
         };
@@ -3888,8 +3855,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
-                    val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             let root = FsNode {
                 id: "root".to_string(),
@@ -3942,6 +3908,209 @@ mod tests {
     /// KERNEL SUBNET saved while Sphere was one becomes the native node with
     /// its values intact and its children gone, and non-template lookalikes
     /// are left alone.
+    /// Phase 3: a parameter's value is parsed by its kind and its TEXT is
+    /// kept verbatim, so `0.50` is the number 0.5 and still reads, saves and
+    /// hashes as `0.50`. A text that does not fit is kept too, flagged, and
+    /// read the way every read used to be — never coerced or dropped.
+    #[test]
+    fn a_value_parses_by_its_kind_and_keeps_its_text() {
+        use crate::app::{ParamDef, ParamSlot, ParamValue as V};
+        let v = |ty: &str, text: &str| ParamDef::new("P", ty, text).slot().clone();
+        assert_eq!(v("slider", "0.50"), ParamSlot::Value(V::Number(0.5)));
+        assert_eq!(ParamDef::new("P", "slider", "0.50").text(), "0.50");
+        assert_eq!(v("float", " -3 "), ParamSlot::Value(V::Number(-3.0)));
+        assert_eq!(v("spinbox", "16"), ParamSlot::Value(V::Int(16)));
+        assert_eq!(v("float3", "0.00:0.20:-1"), ParamSlot::Value(V::Vec3([0.0, 0.2, -1.0])));
+        assert_eq!(v("toggle", "TRUE"), ParamSlot::Value(V::Bool(true)));
+        assert_eq!(v("choice:UV,Icosphere,Cube", "icosphere"), ParamSlot::Value(V::Choice("Icosphere".into())));
+        assert_eq!(v("choice:UV,Icosphere,Cube", ""), ParamSlot::Value(V::Choice("UV".into())), "empty is the first option");
+        assert_eq!(v("node", "sphere1"), ParamSlot::Value(V::Text("sphere1".into())));
+        for (ty, text) in [("slider", "abc"), ("float", ""), ("spinbox", "4.5"), ("float3", "1:2"), ("toggle", "maybe"), ("choice:UV,Cube", "Torus")] {
+            let p = ParamDef::new("P", ty, text);
+            assert!(p.invalid().is_some(), "{ty} {text:?} should not fit");
+            assert_eq!(p.text(), text, "an invalid text is kept verbatim");
+            assert!(p.check(text).is_err());
+        }
+        // An invalid value reads as it always did: `4.5` in a spinbox is still 4.5.
+        let node = FsNode { params: vec![ParamDef::new("Count", "spinbox", "4.5")], ..crate::app::load_fs_tree() };
+        assert_eq!(crate::geometry::node_param_f32(&node, "Count", 0.0), 4.5);
+        // An expression is not parsed as a value, and is not flagged.
+        let e = ParamDef::new("P", "slider", "ch(\"../a/Radius\") * 2").as_expr();
+        assert!(e.is_expr() && e.invalid().is_none());
+    }
+
+    /// The file format did not move: a parameter serializes with the keys
+    /// the old derive wrote, in its order, with its values — which is what
+    /// keeps a re-save byte-identical and `sim_solve_key` (a hash of the
+    /// simnet's JSON) from restarting every cached simulation. Checked over
+    /// every shipped template and both bundled projects.
+    #[test]
+    fn params_serialize_as_they_always_did() {
+        use serde_json::Value;
+        let norm = |p: &Value| {
+            let mut o = p.as_object().unwrap().clone();
+            o.entry("label").or_insert("".into());
+            o.entry("type").or_insert("string".into());
+            o.entry("default").or_insert("".into());
+            o.entry("options").or_insert(serde_json::json!([]));
+            for k in ["min", "max", "step"] {
+                o.entry(k).or_insert(Value::Null);
+            }
+            o.entry("show_when").or_insert("".into());
+            if o.get("expr") == Some(&Value::Bool(false)) {
+                o.remove("expr");
+            }
+            Value::Object(o)
+        };
+        fn walk(v: &Value, f: &mut dyn FnMut(&Value)) {
+            if let Some(ps) = v.get("params").and_then(|p| p.as_array()) {
+                ps.iter().for_each(|p| f(p));
+            }
+            if let Some(cs) = v.get("children").and_then(|c| c.as_array()) {
+                cs.iter().for_each(|c| walk(c, f));
+            }
+            if let Some(r) = v.get("root") {
+                walk(r, f);
+            }
+        }
+        let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+        let mut files: Vec<_> = fs::read_dir(dir.join("nodes")).unwrap().flatten().map(|e| e.path()).collect();
+        files.push(dir.join("default_project.json"));
+        files.push(dir.join("project.json"));
+        let mut n = 0;
+        for path in files.iter().filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) {
+            let v: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
+            walk(&v, &mut |p| {
+                let def: crate::app::ParamDef = serde_json::from_value(p.clone()).unwrap();
+                // Through a STRING, as a save writes: `to_value` would widen
+                // the f32 range fields and disagree with the file about 0.05.
+                let back: Value = serde_json::from_str(&serde_json::to_string(&def).unwrap()).unwrap();
+                // By numeric VALUE: a hand-written template says `128` where
+                // an f32 field writes `128.0`, as the old derive did too.
+                fn num(v: Value) -> Value {
+                    match v {
+                        Value::Number(n) => serde_json::json!(n.as_f64()),
+                        Value::Object(o) => Value::Object(o.into_iter().map(|(k, v)| (k, num(v))).collect()),
+                        other => other,
+                    }
+                }
+                assert_eq!(num(back), num(norm(p)), "{}", path.display());
+                n += 1;
+            });
+        }
+        assert!(n > 300, "walked {n} parameters");
+        let written = serde_json::to_string(&crate::app::ParamDef::new("N", "slider", "0.50").as_expr()).unwrap();
+        assert_eq!(
+            written,
+            r#"{"name":"N","label":"","type":"slider","default":"0.50","options":[],"min":null,"max":null,"step":null,"show_when":"","expr":true}"#
+        );
+    }
+
+    /// A template that changes a parameter's kind re-parses the value an old
+    /// save carries: Grid's Center shipped as text and is a float3 now.
+    #[test]
+    fn the_template_kind_reparses_an_old_value() {
+        use crate::app::{ParamDef, ParamValue};
+        let mut old = ParamDef::new("Center", "text", "0.00:1.50:0.00");
+        assert_eq!(old.value(), Some(&ParamValue::Text("0.00:1.50:0.00".into())));
+        old.adopt_ui_from(&ParamDef::new("Center", "float3", "0:0:0"));
+        assert_eq!(old.value(), Some(&ParamValue::Vec3([0.0, 1.5, 0.0])));
+        assert_eq!(old.text(), "0.00:1.50:0.00", "the instance keeps its value");
+    }
+
+    /// Phase 4: an expression's result is converted by the row it lands in —
+    /// a number into a toggle is its truth, into a choice the option at that
+    /// index, into a spinbox its whole part — and a result that fits nothing
+    /// (a string into a slider) is stored as its text and flagged, which is
+    /// what the old string write-back did.
+    #[test]
+    fn an_expression_result_takes_the_rows_kind() {
+        use crate::app::{ParamDef, ParamValue as V};
+        use crate::expr::Value;
+        let p = |ty: &str| ParamDef::new("P", ty, "");
+        assert_eq!(p("toggle").value_from_expr(&Value::Num(2.0)), Some(V::Bool(true)));
+        assert_eq!(p("choice:UV,Icosphere,Cube").value_from_expr(&Value::Num(1.0)), Some(V::Choice("Icosphere".into())));
+        assert_eq!(p("choice:UV,Icosphere,Cube").value_from_expr(&Value::Str("cube".into())), Some(V::Choice("Cube".into())));
+        assert_eq!(p("spinbox").value_from_expr(&Value::Num(3.7)), Some(V::Int(3)));
+        assert_eq!(p("slider").value_from_expr(&Value::Num(0.25)), Some(V::Number(0.25)));
+        assert_eq!(p("text").value_from_expr(&Value::Num(0.5)), Some(V::Text("0.5".into())));
+        assert_eq!(p("slider").value_from_expr(&Value::Str("abc".into())), None);
+
+        // Through the resolver: the clone's parameter holds the typed value.
+        let mut node = crate::app::load_fs_tree().children.into_iter().find(|t| t.node_type == "cull").unwrap();
+        node.params.retain(|q| q.name != "Invert");
+        node.params.push(ParamDef::new("Invert", "toggle", "1 + 1").as_expr());
+        let root = FsNode { children: vec![node.clone()], ..crate::app::load_fs_tree() };
+        let mut err = None;
+        let resolved = crate::geometry::resolve_param_refs(&root, &root.children[0], 1, &mut err).unwrap();
+        let inv = resolved.params.iter().find(|q| q.name == "Invert").unwrap();
+        assert_eq!((inv.value(), inv.text(), inv.is_expr()), (Some(&V::Bool(true)), "true", false));
+        assert!(err.is_none(), "{err:?}");
+    }
+
+    /// Phase 4's entry points refuse a value that does not fit, and write
+    /// nothing: MCP's set_param returns why; the params pane puts the kept
+    /// text back in the row and says why on the status line. A value that
+    /// reads as an expression is let through as one.
+    #[test]
+    fn a_value_that_does_not_fit_is_refused_where_it_is_typed() {
+        let mut s = State::new(false);
+        let mut redraw = false;
+        s.apply_action(crate::app::McpAction::AddNode { template_name: "Cull".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw).unwrap();
+        let slot = s.current_dir().children.iter().position(|c| c.node_type == "cull").unwrap();
+        let threshold = |s: &State| crate::geometry::node_param_str(&s.current_dir().children[slot], "Threshold", "");
+        let before = threshold(&s);
+        let set = |s: &mut State, v: &str| {
+            s.apply_action(crate::app::McpAction::SetParam { slot, name: "Threshold".into(), value: v.into() }, &mut false)
+        };
+        let err = set(&mut s, "abc").unwrap_err();
+        assert!(err.contains("Threshold") && err.contains("not a number"), "{err}");
+        assert_eq!(threshold(&s), before, "nothing was written");
+        set(&mut s, "0.7").unwrap();
+        assert_eq!(threshold(&s), "0.7");
+        set(&mut s, "ch(\"../x/Radius\")").expect("an expression is not checked as a value");
+        assert!(s.current_dir().children[slot].params.iter().find(|p| p.name == "Threshold").unwrap().is_expr());
+        set(&mut s, "0.7").unwrap();
+        s.current_dir_mut().children[slot].params.iter_mut().find(|p| p.name == "Threshold").unwrap().set_expr(false);
+
+        // The pane.
+        s.apply_action(crate::app::McpAction::Select { slot }, &mut redraw).unwrap();
+        let rows: Vec<(String, String, String)> = s
+            .param()
+            .node_params()
+            .into_iter()
+            .map(|(n, v, t)| if n == "Threshold" { (n, "abc".into(), t) } else { (n, v, t) })
+            .collect();
+        s.param_mut().set_display_params(&rows);
+        s.sync_parameters_to_project();
+        assert_eq!(threshold(&s), "0.7", "the pane wrote nothing");
+        let shown = s.param().node_params().into_iter().find(|r| r.0 == "Threshold").unwrap().1;
+        assert_eq!(shown, "0.7", "the row shows the kept value again");
+        assert!(s.last_status_text.contains("Not applied") && s.last_status_text.contains("Threshold"), "{}", s.last_status_text);
+    }
+
+    /// A load says when a project holds a value that does not fit — kept,
+    /// but worth knowing about.
+    #[test]
+    fn a_load_reports_a_value_that_does_not_fit() {
+        let dir = std::env::temp_dir().join(format!("cce_designer_invalid_param_{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        fs::create_dir_all(&dir).unwrap();
+        let mut a = State::new(false);
+        let mut redraw = false;
+        a.apply_action(crate::app::McpAction::AddNode { template_name: "Cull".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw).unwrap();
+        let slot = a.current_dir().children.iter().position(|c| c.node_type == "cull").unwrap();
+        a.current_dir_mut().children[slot].params.iter_mut().find(|p| p.name == "Threshold").unwrap().set_text("half");
+        a.save_to_file(&dir).expect("save");
+
+        let mut b = State::new(false);
+        b.load_from_file(&dir).expect("load");
+        assert!(b.last_status_text.contains("Threshold") && b.last_status_text.contains("not a number"), "{}", b.last_status_text);
+        let kept = b.current_dir().children.iter().find(|c| c.node_type == "cull").unwrap();
+        assert_eq!(crate::geometry::node_param_str(kept, "Threshold", ""), "half", "kept verbatim");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     /// Phase 0 of typed parameters: a `type` string names a [`ParamKind`],
     /// read by its head, and anything else is refused rather than read as
     /// text and left to look like it worked.
@@ -3965,10 +4134,7 @@ mod tests {
         // Found anywhere in a template's tree, children included.
         let mut t = crate::app::load_fs_tree().children.into_iter().find(|t| t.name == "Embryo").unwrap();
         assert!(crate::app::unknown_param_kinds(&t).is_empty());
-        t.children[0].params.push(crate::app::ParamDef {
-            name: "Count".into(), label: String::new(), param_type: "int".into(), default: "1".into(),
-            options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-        });
+        t.children[0].params.push(crate::app::ParamDef::new("Count", "int", "1"));
         let bad = crate::app::unknown_param_kinds(&t);
         assert_eq!(bad.len(), 1);
         assert_eq!((bad[0].1.as_str(), bad[0].2.as_str()), ("Count", "int"));
@@ -4044,15 +4210,15 @@ mod tests {
         let templates = crate::app::flatten_node_templates(&templates_root);
         let mut relax = templates_root.children.iter().find(|t| t.node_type == "relax").unwrap().clone();
         for p in relax.params.iter_mut().filter(|p| p.name == "Input" || p.name == "Rest") {
-            p.param_type = "text".into();
-            p.default = "sphere1".into();
+            p.set_type("text");
+            p.set_text("sphere1");
         }
         let mut root = FsNode { children: vec![relax], ..templates_root.clone() };
         crate::app::merge_template_defs(&mut root, &templates);
         for name in ["Input", "Rest"] {
             let p = root.children[0].params.iter().find(|p| p.name == name).unwrap();
             assert_eq!(p.kind(), crate::app::ParamKind::Node, "{name}");
-            assert_eq!(p.default, "sphere1", "the value is the instance's");
+            assert_eq!(p.text(), "sphere1", "the value is the instance's");
         }
     }
 
@@ -4061,11 +4227,7 @@ mod tests {
     /// recognise at all.
     #[test]
     fn node_and_float_rows_show_as_text() {
-        use crate::app::ParamDef;
-        let row = |ty: &str| ParamDef {
-            name: "X".into(), label: String::new(), param_type: ty.into(), default: "1".into(),
-            options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-        };
+        let row = |ty: &str| crate::app::ParamDef::new("X", ty, "1");
         let shown = crate::app::param_display(&[row("node"), row("float"), row("string"), row("toggle")]);
         let types: Vec<&str> = shown.iter().map(|r| r.2.as_str()).collect();
         assert_eq!(types, vec!["text", "text", "text", "toggle"]);
@@ -4078,10 +4240,7 @@ mod tests {
     fn node_param_bool_reads_a_toggle_and_falls_back_on_anything_else() {
         use crate::geometry::node_param_bool;
         let node = |v: &str| FsNode {
-            params: vec![crate::app::ParamDef {
-                name: "On".into(), label: String::new(), param_type: "toggle".into(), default: v.into(),
-                options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-            }],
+            params: vec![crate::app::ParamDef::new("On", "toggle", v)],
             ..crate::app::load_fs_tree()
         };
         for v in ["true", "TRUE", " True ", "1", "on"] {
@@ -4127,10 +4286,7 @@ mod tests {
             name: "opencl1".to_string(),
             node_type: "opencl".to_string(),
             children: vec![],
-            params: vec![crate::app::ParamDef {
-                name: "Code".into(), label: String::new(), param_type: "code".into(), default: "OLD KERNEL".into(),
-                options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-            }],
+            params: vec![crate::app::ParamDef::new("Code", "code", "OLD KERNEL")],
             geometry_visible: true,
             position: (4.0, 2.0),
             inputs: 1,
@@ -4139,16 +4295,13 @@ mod tests {
         let mut output1 = output_t.clone();
         output1.id = "s_output1".to_string();
         output1.name = "output1".to_string();
-        output1.params.iter_mut().find(|p| p.name == "Input").unwrap().default = "opencl1".to_string();
+        output1.params.iter_mut().find(|p| p.name == "Input").unwrap().set_text("opencl1".to_string());
         let old_sphere = FsNode {
             id: "s".to_string(),
             name: "Sphere 3".to_string(),
             node_type: "node".to_string(),
             children: vec![opencl1, output1],
-            params: vec![crate::app::ParamDef {
-                name: "Radius".into(), label: String::new(), param_type: "slider".into(), default: "0.70".into(),
-                options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-            }],
+            params: vec![crate::app::ParamDef::new("Radius", "slider", "0.70")],
             geometry_visible: true,
             position: (3.0, 1.0),
             inputs: 0,
@@ -4160,8 +4313,7 @@ mod tests {
         old_group.id = "g".to_string();
         old_group.name = "My Region".to_string(); // renamed: native nodes match by TYPE
         old_group.params.retain(|p| p.name != "Highlight");
-        old_group.params.iter_mut().find(|p| p.name == "Center").unwrap().default =
-            "0.00:0.80:0.00".to_string();
+        old_group.params.iter_mut().find(|p| p.name == "Center").unwrap().set_text("0.00:0.80:0.00".to_string());
 
         // A hand-built subnet that happens to share the Sphere name.
         let lookalike = FsNode {
@@ -4198,15 +4350,15 @@ mod tests {
         assert!(s.children.is_empty(), "the opencl and output children go");
         let names: Vec<&str> = s.params.iter().map(|p| p.name.as_str()).collect();
         assert_eq!(names, ["Method", "Radius", "Rows", "Columns", "Frequency", "Resolution", "Center X", "Center Y", "Center Z", "Color"]);
-        assert_eq!(s.params.iter().find(|p| p.name == "Radius").unwrap().default, "0.70", "instance value survives");
+        assert_eq!(s.params.iter().find(|p| p.name == "Radius").unwrap().text(), "0.70", "instance value survives");
 
         // And the merged instance evaluates with the new controls live.
         let mut merged_sphere_root = root.clone();
         merged_sphere_root.children.truncate(1);
         merged_sphere_root.children[0].params.iter_mut()
-            .find(|p| p.name == "Rows").unwrap().default = "4".to_string();
+            .find(|p| p.name == "Rows").unwrap().set_text("4".to_string());
         merged_sphere_root.children[0].params.iter_mut()
-            .find(|p| p.name == "Columns").unwrap().default = "6".to_string();
+            .find(|p| p.name == "Columns").unwrap().set_text("6".to_string());
         let mut visited = Vec::new();
         let mut err = None;
         let mut cache = crate::geometry::SimCache::default();
@@ -4222,8 +4374,8 @@ mod tests {
 
         // Group (renamed, matched by type): Highlight restored, value kept.
         let g = &root.children[1];
-        assert!(g.params.iter().any(|p| p.name == "Highlight" && p.default == "true"));
-        assert_eq!(g.params.iter().find(|p| p.name == "Center").unwrap().default, "0.00:0.80:0.00");
+        assert!(g.params.iter().any(|p| p.name == "Highlight" && p.text() == "true"));
+        assert_eq!(g.params.iter().find(|p| p.name == "Center").unwrap().text(), "0.00:0.80:0.00");
 
         // Lookalike: untouched — no params gained, no children injected.
         let l = &root.children[2];
@@ -4247,8 +4399,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
-                    val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             let root = FsNode {
                 id: "root".to_string(),
@@ -4314,8 +4465,8 @@ mod tests {
         let templates_root = crate::app::load_fs_tree();
         let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
         let method = sphere_t.params.iter().find(|p| p.name == "Method").expect("a Method dropdown");
-        assert_eq!(method.param_type, "choice:UV,Icosphere,Cube");
-        assert_eq!(method.default, "UV", "the default stays the sphere every saved project was built with");
+        assert_eq!(method.ty(), "choice:UV,Icosphere,Cube");
+        assert_eq!(method.text(), "UV", "the default stays the sphere every saved project was built with");
         assert_eq!(sphere_t.params[0].name, "Method", "the method heads the pane, above the radius it governs");
         // And it heads the pane of a sphere SAVED before it existed too: the
         // bundled project's sphere1 gains it through the loader's merge, at
@@ -4331,7 +4482,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default = val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             let root = FsNode {
                 id: "root".to_string(),
@@ -4392,10 +4543,7 @@ mod tests {
     #[test]
     fn a_choice_reads_as_its_option_index_from_a_kernel() {
         use crate::geometry::param_number;
-        let p = |ty: &str, val: &str| crate::app::ParamDef {
-            name: "X".into(), label: String::new(), param_type: ty.into(), default: val.into(),
-            options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
-        };
+        let p = |ty: &str, val: &str| crate::app::ParamDef::new("X", ty, val);
         assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "Cube")), 2.0);
         assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "icosphere")), 1.0, "case-insensitive, like the reference path");
         assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "Nope")), 0.0, "an unknown option is the first");
@@ -4422,8 +4570,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (pname, val) in params {
-                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
-                    val.to_string();
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().set_text(val.to_string());
             }
             inst
         };
@@ -4499,7 +4646,7 @@ mod tests {
                 child.id = format!("{}_{}", inst.id, child.name);
             }
             for (name, value) in overrides {
-                inst.params.iter_mut().find(|p| p.name == *name).unwrap().default = value.to_string();
+                inst.params.iter_mut().find(|p| p.name == *name).unwrap().set_text(value.to_string());
             }
             let root = FsNode {
                 id: "root".to_string(),
@@ -5348,18 +5495,7 @@ mod tests {
     // ---- The parameter pane's conditional rows ----
 
     fn pd(name: &str, value: &str, show_when: &str) -> crate::app::ParamDef {
-        crate::app::ParamDef {
-            name: name.into(),
-            label: String::new(),
-            param_type: "text".into(),
-            default: value.into(),
-            options: vec![],
-            min: None,
-            max: None,
-            step: None,
-            show_when: show_when.into(),
-            expr: false,
-        }
+        crate::app::ParamDef::new(name, "text", value).with_show_when(show_when)
     }
 
     #[test]
@@ -5380,14 +5516,14 @@ mod tests {
         // for parameter count, and a pane showing twelve irrelevant rows is
         // worse than the twelve nodes it replaced.
         let mut bent = params.clone();
-        bent[0].default = "Bend".into();
+        bent[0].set_text("Bend");
         let shown: Vec<String> = param_display(&bent).into_iter().map(|r| r.0).collect();
         assert_eq!(shown, vec!["Mode", "Bend Axis", "Shared", "Not Bleed"]);
 
         // Bleed matches none of the conditions, so only the driving row is
         // left — which is a node with one relevant control showing one.
         let mut bleeding = params.clone();
-        bleeding[0].default = "Bleed".into();
+        bleeding[0].set_text("Bleed");
         let shown: Vec<String> = param_display(&bleeding).into_iter().map(|r| r.0).collect();
         assert_eq!(shown, vec!["Mode"]);
 
@@ -5480,10 +5616,10 @@ mod tests {
             pd("To Max", "7.5", "Operation == Remap"),
         ];
         assert_eq!(param_display(&params).len(), 2);
-        params[0].default = "Clip".into();
+        params[0].set_text("Clip");
         assert_eq!(param_display(&params).len(), 1, "the row hid");
-        assert_eq!(params[1].default, "7.5", "but the value is untouched");
-        params[0].default = "Remap".into();
+        assert_eq!(params[1].text(), "7.5", "but the value is untouched");
+        params[0].set_text("Remap");
         assert_eq!(param_display(&params)[1].1, "7.5", "and comes back as it was");
     }
 
@@ -5613,17 +5749,7 @@ mod tests {
                 children: vec![],
                 params: params
                     .iter()
-                    .map(|(n, v)| crate::app::ParamDef {
-                        name: n.to_string(),
-                        label: String::new(),
-                        param_type: "text".to_string(),
-                        default: v.to_string(),
-                        options: vec![],
-                        min: None,
-                        max: None,
-                        step: None,
-                        show_when: String::new(), expr: false,
-                    })
+                    .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
                     .collect(),
                 geometry_visible: true,
                 position: (0.0, 0.0),
@@ -6402,7 +6528,7 @@ mod tests {
             s.current_dir()
                 .children
                 .iter()
-                .map(|c| (c.name.clone(), c.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect()))
+                .map(|c| (c.name.clone(), c.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect()))
                 .collect()
         }
         let mut s = State::new(false);
@@ -6697,7 +6823,7 @@ mod tests {
             if let Some(p) =
                 dir.children[slot].params.iter_mut().find(|p| p.name.eq_ignore_ascii_case("input"))
             {
-                p.default = value.to_string();
+                p.set_text(value.to_string());
             }
         };
         set_input(&mut state, remesh, &names[curve]);
@@ -7156,7 +7282,6 @@ mod tests {
     // ----- Parameter references and the Switch node (src/geometry.rs) -----
 
     fn ref_node(id: &str, name: &str, node_type: &str, params: Vec<(&str, &str, &str)>, children: Vec<FsNode>) -> FsNode {
-        use crate::app::ParamDef;
         FsNode {
             id: id.into(),
             name: name.into(),
@@ -7169,7 +7294,7 @@ mod tests {
                         Some(o) => ("choice".to_string(), o.split(',').map(str::to_string).collect()),
                         None => (t.to_string(), Vec::new()),
                     };
-                    ParamDef { name: n.into(), label: String::new(), param_type: ptype, default: d.into(), options, min: None, max: None, step: None, show_when: String::new(), expr: crate::expr::looks_like_expression(d) }
+                    { let p = crate::app::ParamDef::new(n, ptype, d).with_options(options); if crate::expr::looks_like_expression(d) { p.as_expr() } else { p } }
                 })
                 .collect(),
             geometry_visible: true,
@@ -7318,16 +7443,16 @@ mod tests {
             view_state: Default::default(),
             format: 0,
         };
-        proj.root.children[0].children[0].params[0].expr = false;
+        proj.root.children[0].children[0].params[0].set_expr(false);
         proj.migrate_param_refs();
         let r = &proj.root.children[0].children[0].params[0];
-        assert_eq!(r.default, "chf(\"../Size\")");
-        assert!(r.expr);
+        assert_eq!(r.text(), "chf(\"../Size\")");
+        assert!(r.is_expr());
         assert_eq!(proj.format, crate::app::PROJECT_FORMAT);
         // A NEW file's bare name is the node's own parameter and stays.
-        proj.root.children[0].children[0].params[0].default = "chf(\"Radius\")".into();
+        proj.root.children[0].children[0].params[0].set_text("chf(\"Radius\")");
         proj.migrate_param_refs();
-        assert_eq!(proj.root.children[0].children[0].params[0].default, "chf(\"Radius\")");
+        assert_eq!(proj.root.children[0].children[0].params[0].text(), "chf(\"Radius\")");
     }
 
     #[test]
@@ -7351,7 +7476,7 @@ mod tests {
         let mut err = None;
         let resolved = resolve_param_refs(&root, probe, 0, &mut err).expect("it has references");
         assert!(err.is_none(), "{err:?}");
-        let get = |n: &str| resolved.params.iter().find(|p| p.name == n).unwrap().default.clone();
+        let get = |n: &str| resolved.params.iter().find(|p| p.name == n).unwrap().text().to_string();
         assert_eq!(get("Index"), "1", "chi on a choice is its option index");
         assert_eq!(get("Flag"), "1", "chb into a text row is 1 or 0");
         assert_eq!(get("Name"), "Scatter");
@@ -7375,7 +7500,7 @@ mod tests {
         let root2 = ref_node("root", "root", "node", vec![], vec![holder]);
         let mut err = None;
         let r = resolve_param_refs(&root2, &root2.children[0].children[0], 0, &mut err).unwrap();
-        assert_eq!(r.params[0].default, "ch(\"../Nope\")");
+        assert_eq!(r.params[0].text(), "ch(\"../Nope\")");
         assert!(err.as_deref().unwrap_or("").contains("names no parameter Nope on holder1"), "{err:?}");
         // Too many levels up, likewise.
         let far = ref_node("f", "far1", "sphere", vec![("Radius", "slider", "ch(\"../../../X\")")], vec![]);
@@ -7409,13 +7534,13 @@ mod tests {
         let root = ref_node("root", "root", "node", vec![("Top", "slider", "10")], vec![sub]);
         // The choice's value is an index written as an expression; flag it.
         let mut root = root;
-        root.children[0].children[1].params.iter_mut().find(|p| p.name == "Mode").unwrap().expr = true;
+        root.children[0].children[1].params.iter_mut().find(|p| p.name == "Mode").unwrap().set_expr(true);
 
         let b = &root.children[0].children[1];
         let mut err = None;
         let r = resolve_param_refs(&root, b, 12, &mut err).expect("b1 has expressions");
         assert!(err.is_none(), "{err:?}");
-        let get = |n: &str| r.params.iter().find(|p| p.name == n).unwrap().default.clone();
+        let get = |n: &str| r.params.iter().find(|p| p.name == n).unwrap().text().to_string();
         assert_eq!(get("Radius"), "0.5", "a sibling by path");
         assert_eq!(get("Rows"), "50", "a bare name is the node's OWN parameter, read through its expression");
         assert_eq!(get("Y"), "5", "components, relative and absolute");
@@ -7425,7 +7550,7 @@ mod tests {
         assert_eq!(get("On"), "true", "a number into a toggle is true or false");
         assert_eq!(get("Label"), "0.25 units");
         assert_eq!(get("Center"), "1:0:0.5", "a float3 is three expressions");
-        assert!(r.params.iter().all(|p| !p.expr), "the resolved clone holds values");
+        assert!(r.params.iter().all(|p| !p.is_expr()), "the resolved clone holds values");
 
         // A circle: two parameters reading each other.
         let x = ref_node("x", "x1", "sphere", vec![("Radius", "slider", "ch(\"../y1/Radius\")")], vec![]);
@@ -7434,7 +7559,7 @@ mod tests {
         let mut err = None;
         let r = resolve_param_refs(&ring, &ring.children[0], 0, &mut err).unwrap();
         assert!(err.as_deref().unwrap_or("").contains("circular"), "{err:?}");
-        assert_eq!(r.params[0].default, "ch(\"../y1/Radius\")", "left as written");
+        assert_eq!(r.params[0].text(), "ch(\"../y1/Radius\")", "left as written");
         // A parameter reading itself is the shortest circle.
         let me = ref_node("m", "me", "sphere", vec![("Radius", "slider", "ch(\"Radius\") + 1")], vec![]);
         let solo = ref_node("root", "root", "node", vec![], vec![me]);
@@ -7451,7 +7576,7 @@ mod tests {
         // A syntax error names the parameter.
         let broken = ref_node("k", "broken", "sphere", vec![("Radius", "slider", "1 +")], vec![]);
         let mut broken = broken;
-        broken.params[0].expr = true;
+        broken.params[0].set_expr(true);
         let root5 = ref_node("root", "root", "node", vec![], vec![broken]);
         let mut err = None;
         resolve_param_refs(&root5, &root5.children[0], 0, &mut err);
@@ -7490,7 +7615,7 @@ mod tests {
             for &i in path {
                 node = &node.children[i];
             }
-            node.params.iter().find(|p| p.name == n).unwrap().default.clone()
+            node.params.iter().find(|p| p.name == n).unwrap().text().to_string()
         };
         assert_eq!(root.children[0].children[0].name, "ball");
         assert_eq!(get(&root, &[0, 1], "Radius"), "ch( \"../ball/Radius\" ) * 2", "spacing kept");
@@ -7566,8 +7691,8 @@ mod tests {
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Right });
         state.run_param_action(&ball_id, "Radius", ParamMenuAction::PasteRelative);
         let radius = |state: &State, slot: usize| state.current_dir().children[slot].params.iter().find(|p| p.name == "Radius").unwrap().clone();
-        assert_eq!(radius(&state, ball).default, "ch(\"../sphere1/Radius\")");
-        assert!(radius(&state, ball).expr);
+        assert_eq!(radius(&state, ball).text(), "ch(\"../sphere1/Radius\")");
+        assert!(radius(&state, ball).is_expr());
         let rows = crate::app::param_display(&state.current_dir().children[ball].params);
         assert_eq!(rows.iter().find(|r| r.0 == "Radius").unwrap().2, "text", "the pane shows an expression as text");
 
@@ -7576,27 +7701,27 @@ mod tests {
         let mut err = None;
         let r = crate::geometry::resolve_param_refs(&state.fs_root, &state.current_dir().children[ball], 0, &mut err).unwrap();
         assert!(err.is_none(), "{err:?}");
-        assert_eq!(r.params.iter().find(|p| p.name == "Radius").unwrap().default, "0.9");
+        assert_eq!(r.params.iter().find(|p| p.name == "Radius").unwrap().text(), "0.9");
 
         // Absolute paste, then Delete Expression bakes the current value.
         state.run_param_action(&ball_id, "Radius", ParamMenuAction::PasteAbsolute);
-        assert_eq!(radius(&state, ball).default, "ch(\"/sphere1/Radius\")");
+        assert_eq!(radius(&state, ball).text(), "ch(\"/sphere1/Radius\")");
         state.run_param_action(&ball_id, "Radius", ParamMenuAction::DeleteExpression);
-        assert_eq!(radius(&state, ball).default, "0.9");
-        assert!(!radius(&state, ball).expr);
+        assert_eq!(radius(&state, ball).text(), "0.9");
+        assert!(!radius(&state, ball).is_expr());
         // Edit Expression flags without changing.
         state.run_param_action(&ball_id, "Radius", ParamMenuAction::EditExpression);
-        assert_eq!(radius(&state, ball).default, "0.9");
-        assert!(radius(&state, ball).expr);
+        assert_eq!(radius(&state, ball).text(), "0.9");
+        assert!(radius(&state, ball).is_expr());
 
         // And a reference typed straight into a row (or scripted) becomes one.
         state.apply_action(McpAction::SetParam { slot: ball, name: "Rows".into(), value: "chi(\"../sphere1/Rows\") * 2".into() }, &mut redraw).unwrap();
         let rows_p = state.current_dir().children[ball].params.iter().find(|p| p.name == "Rows").unwrap();
-        assert!(rows_p.expr);
+        assert!(rows_p.is_expr());
 
         // A rename carries the paste along.
         state.apply_action(McpAction::RenameNode { slot: sphere, new_name: "orb".into() }, &mut redraw).unwrap();
-        assert_eq!(state.current_dir().children[ball].params.iter().find(|p| p.name == "Rows").unwrap().default, "chi(\"../orb/Rows\") * 2");
+        assert_eq!(state.current_dir().children[ball].params.iter().find(|p| p.name == "Rows").unwrap().text(), "chi(\"../orb/Rows\") * 2");
     }
 
     /// A code parameter never becomes an expression, however its text reads:
@@ -7608,11 +7733,11 @@ mod tests {
         use crate::app::{infer_template_exprs, McpAction};
         let mut node = ref_node("w", "w1", "wrangle", vec![("Code", "code", "ch(\"../a/Radius\")"), ("Radius", "slider", "ch(\"../a/Radius\")")], vec![]);
         for p in &mut node.params {
-            p.expr = false;
+            p.set_expr(false);
         }
         infer_template_exprs(&mut node);
-        assert!(!node.params[0].expr, "the code stays a program");
-        assert!(node.params[1].expr, "the slider becomes an expression");
+        assert!(!node.params[0].is_expr(), "the code stays a program");
+        assert!(node.params[1].is_expr(), "the slider becomes an expression");
 
         let mut state = State::new(false);
         let mut redraw = false;
@@ -7620,8 +7745,8 @@ mod tests {
         let k = state.current_dir().children.iter().position(|c| c.name == "k").unwrap();
         state.apply_action(McpAction::SetParam { slot: k, name: "Code".into(), value: "chf(\"../sphere1/Radius\")".into() }, &mut redraw).unwrap();
         let code = state.current_dir().children[k].params.iter().find(|p| p.name == "Code").unwrap();
-        assert!(!code.expr);
-        assert_eq!(code.param_type, "code");
+        assert!(!code.is_expr());
+        assert_eq!(code.ty(), "code");
     }
 
     /// Inside the SECOND instance of a subnet, a child wired to a sibling by
@@ -7720,8 +7845,8 @@ mod tests {
             c.id = format!("sph_{}", c.name);
         }
         let radius = sphere.params.iter_mut().find(|p| p.name == "Radius").unwrap();
-        radius.default = "ch(\"../Radius\")".into();
-        radius.expr = true;
+        radius.set_text("ch(\"../Radius\")");
+        radius.set_expr(true);
         let out = ref_node("o", "output1", "output", vec![("Input", "text", "sphere1")], vec![]);
         let sub = ref_node("sub", "subnet1", "node", vec![("Input", "text", ""), ("Radius", "slider", "0.9")], vec![sphere, out]);
         let root = ref_node("root", "root", "node", vec![], vec![sub]);
@@ -7736,10 +7861,9 @@ mod tests {
     /// The params pane shows a referencing value as the text it is.
     #[test]
     fn param_display_shows_references_as_text() {
-        use crate::app::ParamDef;
         let params = vec![
-            ParamDef { name: "Radius".into(), label: String::new(), param_type: "slider".into(), default: "ch(\"../Radius\")".into(), options: vec![], min: Some(0.0), max: Some(2.0), step: None, show_when: String::new(), expr: true },
-            ParamDef { name: "Rows".into(), label: String::new(), param_type: "spinbox".into(), default: "16".into(), options: vec![], min: Some(2.0), max: Some(128.0), step: Some(1.0), show_when: String::new(), expr: false },
+            crate::app::ParamDef::new("Radius", "slider", "ch(\"../Radius\")").with_range(Some(0.0), Some(2.0)).as_expr(),
+            crate::app::ParamDef::new("Rows", "spinbox", "16").with_range(Some(2.0), Some(128.0)).with_step(Some(1.0)),
         ];
         let rows = crate::app::param_display(&params);
         assert_eq!(rows[0], ("Radius".to_string(), "ch(\"../Radius\")".to_string(), "text".to_string()));
@@ -7903,14 +8027,14 @@ mod tests {
         let sphere1 = t.children.iter().find(|c| c.name == "sphere1").unwrap();
         assert_eq!(sphere1.node_type, "sphere", "the nested sphere is the native Sphere");
         assert!(sphere1.params.iter().any(|p| p.name == "Method"), "with its template's whole surface");
-        assert!(sphere1.params.iter().find(|p| p.name == "Radius").unwrap().expr, "and the Embryo's reference on its Radius");
+        assert!(sphere1.params.iter().find(|p| p.name == "Radius").unwrap().is_expr(), "and the Embryo's reference on its Radius");
 
         let instance = |overrides: &[(&str, &str)], extra: Vec<FsNode>| {
             let mut inst = t.clone();
             crate::app::regenerate_node_ids(&mut inst);
             inst.name = "embryo1".into();
             for (n, v) in overrides {
-                inst.params.iter_mut().find(|p| p.name == *n).unwrap_or_else(|| panic!("param {n}")).default = v.to_string();
+                inst.params.iter_mut().find(|p| p.name == *n).unwrap_or_else(|| panic!("param {n}")).set_text(v.to_string());
             }
             let mut children = extra;
             children.push(inst);
@@ -7959,10 +8083,9 @@ mod tests {
     /// with its values, its identity and its meta child intact.
     #[test]
     fn a_native_embryo_recomposes_on_load() {
-        use crate::app::ParamDef;
         let templates_root = crate::app::load_fs_tree();
         let templates = crate::app::flatten_node_templates(&templates_root);
-        let param = |n: &str, v: &str| ParamDef { name: n.into(), label: String::new(), param_type: "text".into(), default: v.into(), options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false };
+        let param = |n: &str, v: &str| crate::app::ParamDef::new(n, "text", v);
         let meta = ref_node("m", "meta", "meta", vec![("Point Markers", "toggle", "true")], vec![]);
         let mut native = ref_node("old-id", "embryo1", "embryo", vec![], vec![meta]);
         native.params = vec![param("Input", ""), param("Method", "Scatter"), param("Scatter Count", "150"), param("Radius", "0.7"), param("Base Resolution", "16")];
@@ -7973,7 +8096,7 @@ mod tests {
         let e = &root.children[0];
         assert_eq!(e.node_type, "node", "recomposed as a subnet");
         assert_eq!((e.id.as_str(), e.name.as_str(), e.position, e.geometry_visible), ("old-id", "embryo1", (3.0, 4.0), true));
-        let get = |n: &str| e.params.iter().find(|p| p.name == n).unwrap().default.clone();
+        let get = |n: &str| e.params.iter().find(|p| p.name == n).unwrap().text().to_string();
         assert_eq!(get("Method"), "Scatter");
         assert_eq!(get("Scatter Count"), "150");
         assert_eq!(get("Radius"), "0.7");
@@ -8002,17 +8125,7 @@ mod tests {
                 children: vec![],
                 params: params
                     .iter()
-                    .map(|(n, v)| crate::app::ParamDef {
-                        name: n.to_string(),
-                        label: String::new(),
-                        param_type: "text".to_string(),
-                        default: v.to_string(),
-                        options: vec![],
-                        min: None,
-                        max: None,
-                        step: None,
-                        show_when: String::new(), expr: false,
-                    })
+                    .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
                     .collect(),
                 geometry_visible: true,
                 position: (0.0, 0.0),
@@ -8620,17 +8733,7 @@ mod tests {
         // lays them along X.
         root.children[1]
             .params
-            .push(crate::app::ParamDef {
-                name: "Shape".into(),
-                label: String::new(),
-                param_type: "text".into(),
-                default: "Line".into(),
-                options: vec![],
-                min: None,
-                max: None,
-                step: None,
-                show_when: String::new(), expr: false,
-            });
+            .push(crate::app::ParamDef::new("Shape", "text", "Line"));
 
         let (g, err) = eval_node(&root, "distance 1");
         assert!(err.is_none(), "{err:?}");
@@ -8768,7 +8871,7 @@ mod tests {
             .iter_mut()
             .find(|p| p.name == "Invert")
             .unwrap()
-            .default = "true".into();
+            .set_text("true");
         let (kept, _) = eval_node(&kept_root, "cull 1");
         assert_eq!(kept.num_points(), sphere_detail(Vec3::ZERO, 1.0, 16, 24).num_points());
         assert!(kept.num_prims() > 0, "the surface survived with its primitives");
@@ -8862,7 +8965,7 @@ mod tests {
             .iter_mut()
             .find(|p| p.name == "Maximum Distance")
             .unwrap()
-            .default = "0.01".into();
+            .set_text("0.01");
         let (g, _) = eval_node(&limited, "transfer 1");
         assert!(g.points().has("Norm"), "the column exists even where nothing was near");
         assert!(
@@ -9077,7 +9180,7 @@ mod tests {
             .iter_mut()
             .find(|p| p.name == "Scale")
             .unwrap()
-            .default = "0.50".into();
+            .set_text("0.50");
         let (small, _) = eval_node(&root, "copy 1");
         let small_size = small.bounds().map(|(a, b)| (b - a).length()).unwrap();
         assert!(small_size < plain_size, "{small_size} should be under {plain_size}");
@@ -9249,7 +9352,7 @@ mod tests {
             .iter_mut()
             .find(|p| p.name == "Rings")
             .unwrap()
-            .default = "-1".into();
+            .set_text("-1");
         let (shrunk, _) = eval_node(&shrunk_root, "group 2");
         assert!(shrunk.points().group_members("wider").len() < members.len());
     }
@@ -9454,17 +9557,7 @@ mod tests {
             children: vec![],
             params: [("Attribute", "growth"), ("Scale", "0.50"), ("Direction", "Normal")]
                 .into_iter()
-                .map(|(name, default)| crate::app::ParamDef {
-                    name: name.into(),
-                    label: String::new(),
-                    param_type: "text".into(),
-                    default: default.into(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                })
+                .map(|(name, default)| crate::app::ParamDef::new(name, "text", default))
                 .collect(),
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -9498,17 +9591,7 @@ mod tests {
             children: vec![],
             params: params
                 .iter()
-                .map(|(name, default)| crate::app::ParamDef {
-                    name: (*name).into(),
-                    label: String::new(),
-                    param_type: "text".into(),
-                    default: (*default).into(),
-                    options: vec![],
-                    min: None,
-                    max: None,
-                    step: None,
-                    show_when: String::new(), expr: false,
-                })
+                .map(|(name, default)| crate::app::ParamDef::new(*name, "text", *default))
                 .collect(),
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -11984,8 +12067,8 @@ mod tests {
         let templates = crate::app::load_fs_tree();
         let t = templates.children.iter().find(|n| n.node_type == "wrangle").expect("nodes/wrangle.json loads");
         assert_eq!(t.name, "Wrangle");
-        let code = t.params.iter().find(|p| p.name == "Code").map(|p| p.default.clone()).unwrap();
-        assert!(t.params.iter().all(|p| !p.expr), "no template parameter reads as an expression — least of all the Code");
+        let code = t.params.iter().find(|p| p.name == "Code").map(|p| p.text().to_string()).unwrap();
+        assert!(t.params.iter().all(|p| !p.is_expr()), "no template parameter reads as an expression — least of all the Code");
         let (before, g, err) = wrangle_over_sphere(&code);
         assert!(err.is_none(), "{err:?}");
         let g = g.unwrap();
diff --git a/src/param.rs b/src/param.rs
new file mode 100644
index 0000000..3c1ca91
--- /dev/null
+++ b/src/param.rs
@@ -0,0 +1,538 @@
+//! Node parameters: what a parameter holds, typed.
+//!
+//! A [`ParamDef`] keeps two things about its value and keeps them together:
+//! the TEXT, exactly as the user or the file wrote it (`"0.50"` stays
+//! `"0.50"`, so a save with nothing edited is byte-identical and the sim
+//! cache keys that hash a simnet's JSON do not move), and the [`ParamSlot`]
+//! that text parses to under the parameter's [`ParamKind`] — a number, a
+//! vector, a switch, an option, or an expression still to be evaluated. Both
+//! are private, and every setter re-parses, so the two cannot disagree: this
+//! is phases 3 and 4 of the typed-value migration (CLAUDE.md, "Parameter
+//! kinds").
+//!
+//! A text that does not fit its kind is kept verbatim as
+//! [`ParamSlot::Invalid`] rather than dropped or coerced: readers then fall
+//! back exactly as they did when every read parsed the string, the load says
+//! how many values are affected, and the text survives for a person to fix.
+//! What REFUSES a bad value is the entry points a person types into — the
+//! params pane and MCP's `set_param` — via [`ParamDef::check`]; a load never
+//! refuses anything.
+
+use crate::app::FsNode;
+use crate::expr::{fmt_num, Value};
+use serde::{Deserialize, Serialize};
+
+/// What a parameter HOLDS, parsed from its `type` string — the one place
+/// that string is interpreted. The kind says how the text parses and which
+/// control the params pane draws.
+///
+/// The head before the first `:` names the kind; what follows is the
+/// kind's own detail (`slider:-2:2` a range, `choice:UV,Icosphere,Cube`
+/// the options), read by the pane and by [`ParamDef::choice_options`]. A
+/// type naming no kind is a template bug: `load_fs_tree` drops the template
+/// and says so, and `every_shipped_template_param_has_a_known_kind` walks
+/// the shipped ones.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ParamKind {
+    /// Free text: a name, a path, a group or attribute name. `string` is
+    /// the same kind — it is what an ABSENT type deserializes to.
+    Text,
+    /// A number with no range — a threshold, a scale factor, a manual
+    /// ramp end — shown as a text row, because the pane's slider holds a
+    /// fraction of its range and would clamp anything outside it.
+    Float,
+    /// A number over a range (`min`/`max`, or inline `slider:lo:hi`).
+    Slider,
+    /// A whole number, stepped.
+    Spin,
+    /// Three numbers, `x:y:z`.
+    Float3,
+    /// One of a fixed set of options, stored as the option's text.
+    Choice,
+    /// `true` / `false`.
+    Toggle,
+    /// A press, not a value: the pane writes `clicked` and the app clears it.
+    Button,
+    /// A program (a wrangle's script). Never an expression.
+    Code,
+    /// The NAME of another node, resolved sibling-first by
+    /// `geometry::find_input_node` — an `Input` wire, a Boolean's `With`,
+    /// a Relax's `Rest`. Empty means unconnected.
+    Node,
+}
+
+impl ParamKind {
+    /// The type-string heads [`ParamKind::parse`] accepts, for messages.
+    /// `string` is left out: it is an alias, not something to ask for.
+    pub const NAMES: &'static [&'static str] =
+        &["text", "float", "slider", "spinbox", "float3", "choice", "toggle", "button", "code", "node"];
+
+    /// The kind a `type` string names, or `None` when it names none.
+    pub fn parse(ty: &str) -> Option<Self> {
+        let head = ty.split(':').next().unwrap_or("").trim();
+        Some(match head {
+            "text" | "string" => Self::Text,
+            "float" => Self::Float,
+            "slider" => Self::Slider,
+            "spinbox" => Self::Spin,
+            "float3" => Self::Float3,
+            "choice" => Self::Choice,
+            "toggle" => Self::Toggle,
+            "button" => Self::Button,
+            "code" => Self::Code,
+            "node" => Self::Node,
+            _ => return None,
+        })
+    }
+}
+
+/// A parameter's value, parsed.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParamValue {
+    /// Float and Slider.
+    Number(f32),
+    /// Spin: a whole number.
+    Int(i64),
+    Vec3([f32; 3]),
+    Bool(bool),
+    /// The option, spelled as the options list spells it (the text may
+    /// differ in case, and an empty text means the first option).
+    Choice(String),
+    /// Text, Node, Code and Button: the text is the value.
+    Text(String),
+}
+
+impl ParamValue {
+    /// The value as text. Numbers go through `expr::fmt_num` — the
+    /// formatting every evaluated expression has always been written back
+    /// with — so a typed write and the string write it replaced agree.
+    pub fn to_text(&self) -> String {
+        match self {
+            ParamValue::Number(n) => fmt_num(*n as f64),
+            ParamValue::Int(i) => i.to_string(),
+            ParamValue::Vec3(v) => v.iter().map(|c| fmt_num(*c as f64)).collect::<Vec<_>>().join(":"),
+            ParamValue::Bool(b) => b.to_string(),
+            ParamValue::Choice(s) | ParamValue::Text(s) => s.clone(),
+        }
+    }
+}
+
+/// What a parameter's text parsed to.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParamSlot {
+    Value(ParamValue),
+    /// An expression (`ch("../sphere1/Radius") * 2`), evaluated wherever the
+    /// node is — the text is the expression. See `expr.rs`.
+    Expr,
+    /// A text that does not fit the kind, and why. Kept, never coerced.
+    Invalid(String),
+}
+
+/// One node parameter: the template-owned UI metadata (label, type, range,
+/// options, condition) and the instance-owned value.
+#[derive(Clone)]
+pub struct ParamDef {
+    pub name: String,
+    pub label: String,
+    param_type: String,
+    text: String,
+    slot: ParamSlot,
+    options: Vec<String>,
+    pub min: Option<f32>,
+    pub max: Option<f32>,
+    pub step: Option<f32>,
+    /// When this parameter should be SHOWN, as a condition over its siblings'
+    /// current values. Empty means always.
+    ///
+    /// Grammar, deliberately tiny: `Mode == Twist`, `Mode == Twist|Bend` for
+    /// any-of, `Mode != Bleed` for unless, and ` && ` between clauses. It
+    /// exists because collapsing fifty operators into ten traded node count
+    /// for parameter count — Attribute reached sixteen parameters, of which
+    /// four matter at any moment — and a pane showing twelve irrelevant rows
+    /// is worse than the twelve nodes it replaced.
+    ///
+    /// Houdini calls this `hideWhen`. Phrased the positive way round here
+    /// because a template author is describing when a control APPLIES, and
+    /// stating that directly is easier to get right than stating its negation.
+    pub show_when: String,
+}
+
+/// The file shape of a [`ParamDef`] — the struct as it was before the value
+/// was typed, field for field and in the same order, so the JSON a save
+/// writes (and `sim_solve_key` hashes) is unchanged. `expr` is serialized
+/// only when set, as it always was.
+#[derive(Clone, Deserialize, Serialize)]
+struct ParamDefRepr {
+    name: String,
+    #[serde(default)]
+    label: String,
+    #[serde(rename = "type")]
+    #[serde(default = "default_param_type")]
+    param_type: String,
+    #[serde(default)]
+    default: String,
+    #[serde(default)]
+    options: Vec<String>,
+    #[serde(default)]
+    min: Option<f32>,
+    #[serde(default)]
+    max: Option<f32>,
+    #[serde(default)]
+    step: Option<f32>,
+    #[serde(default)]
+    show_when: String,
+    /// Whether `default` is an EXPRESSION to evaluate rather than a value.
+    /// A flag and not a guess about the text, because a script contains
+    /// `chf(`, a node name is an identifier and `0.5` parses as an
+    /// expression too; Houdini makes the same choice.
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    expr: bool,
+}
+
+fn default_param_type() -> String {
+    "string".to_string()
+}
+
+impl<'de> Deserialize<'de> for ParamDef {
+    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+        let r = ParamDefRepr::deserialize(d)?;
+        let mut p = ParamDef {
+            name: r.name,
+            label: r.label,
+            param_type: r.param_type,
+            text: r.default,
+            slot: ParamSlot::Expr,
+            options: r.options,
+            min: r.min,
+            max: r.max,
+            step: r.step,
+            show_when: r.show_when,
+        };
+        if !r.expr {
+            p.reparse();
+        }
+        Ok(p)
+    }
+}
+
+impl Serialize for ParamDef {
+    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+        ParamDefRepr {
+            name: self.name.clone(),
+            label: self.label.clone(),
+            param_type: self.param_type.clone(),
+            default: self.text.clone(),
+            options: self.options.clone(),
+            min: self.min,
+            max: self.max,
+            step: self.step,
+            show_when: self.show_when.clone(),
+            expr: self.is_expr(),
+        }
+        .serialize(s)
+    }
+}
+
+/// `text` parsed under `kind`, with `options` the choices a Choice takes.
+pub fn parse_value(kind: ParamKind, text: &str, options: &[String]) -> Result<ParamValue, String> {
+    let t = text.trim();
+    let number = |t: &str| t.parse::<f32>().ok().filter(|n| n.is_finite());
+    match kind {
+        ParamKind::Text | ParamKind::Node | ParamKind::Code | ParamKind::Button => Ok(ParamValue::Text(text.to_string())),
+        ParamKind::Float | ParamKind::Slider => {
+            number(t).map(ParamValue::Number).ok_or_else(|| format!("'{text}' is not a number"))
+        }
+        ParamKind::Spin => match t.parse::<f64>() {
+            Ok(n) if n.is_finite() && n.fract() == 0.0 => Ok(ParamValue::Int(n as i64)),
+            Ok(_) => Err(format!("'{text}' is not a whole number")),
+            Err(_) => Err(format!("'{text}' is not a number")),
+        },
+        ParamKind::Float3 => {
+            let parts: Vec<&str> = t.split(':').collect();
+            match parts[..] {
+                [x, y, z] => match (number(x.trim()), number(y.trim()), number(z.trim())) {
+                    (Some(x), Some(y), Some(z)) => Ok(ParamValue::Vec3([x, y, z])),
+                    _ => Err(format!("'{text}' is not three numbers")),
+                },
+                _ => Err(format!("'{text}' is not x:y:z")),
+            }
+        }
+        ParamKind::Toggle => match t.to_ascii_lowercase().as_str() {
+            "true" | "1" | "on" => Ok(ParamValue::Bool(true)),
+            "false" | "0" | "off" => Ok(ParamValue::Bool(false)),
+            _ => Err(format!("'{text}' is not true or false")),
+        },
+        ParamKind::Choice => {
+            if options.is_empty() {
+                return Ok(ParamValue::Choice(text.to_string()));
+            }
+            if t.is_empty() {
+                return Ok(ParamValue::Choice(options[0].clone()));
+            }
+            options
+                .iter()
+                .find(|o| o.eq_ignore_ascii_case(t))
+                .map(|o| ParamValue::Choice(o.clone()))
+                .ok_or_else(|| format!("'{text}' is not one of {}", options.join(", ")))
+        }
+    }
+}
+
+impl ParamDef {
+    /// A parameter of type `ty` holding `text`, parsed. The rest of the
+    /// metadata starts empty; the `with_*` builders fill it in.
+    pub fn new(name: impl Into<String>, ty: impl Into<String>, text: impl Into<String>) -> Self {
+        let mut p = ParamDef {
+            name: name.into(),
+            label: String::new(),
+            param_type: ty.into(),
+            text: text.into(),
+            slot: ParamSlot::Expr,
+            options: Vec::new(),
+            min: None,
+            max: None,
+            step: None,
+            show_when: String::new(),
+        };
+        p.reparse();
+        p
+    }
+
+    pub fn with_label(mut self, label: impl Into<String>) -> Self {
+        self.label = label.into();
+        self
+    }
+
+    pub fn with_range(mut self, min: Option<f32>, max: Option<f32>) -> Self {
+        self.min = min;
+        self.max = max;
+        self
+    }
+
+    pub fn with_step(mut self, step: Option<f32>) -> Self {
+        self.step = step;
+        self
+    }
+
+    pub fn with_options(mut self, options: Vec<String>) -> Self {
+        self.options = options;
+        self.reparse();
+        self
+    }
+
+    pub fn with_show_when(mut self, cond: impl Into<String>) -> Self {
+        self.show_when = cond.into();
+        self
+    }
+
+    /// This parameter holding its text as an expression.
+    pub fn as_expr(mut self) -> Self {
+        self.set_expr(true);
+        self
+    }
+
+    /// The value as written: the expression for an expression, the verbatim
+    /// text otherwise.
+    pub fn text(&self) -> &str {
+        &self.text
+    }
+
+    /// The `type` string, detail and all (`slider:-2:2`).
+    pub fn ty(&self) -> &str {
+        &self.param_type
+    }
+
+    /// The options list the template gave (a Choice may carry its options
+    /// in the type string instead — see [`ParamDef::choice_options`]).
+    pub fn options(&self) -> &[String] {
+        &self.options
+    }
+
+    pub fn slot(&self) -> &ParamSlot {
+        &self.slot
+    }
+
+    /// The parsed value, when the text is a value that fits the kind.
+    pub fn value(&self) -> Option<&ParamValue> {
+        match &self.slot {
+            ParamSlot::Value(v) => Some(v),
+            _ => None,
+        }
+    }
+
+    pub fn is_expr(&self) -> bool {
+        self.slot == ParamSlot::Expr
+    }
+
+    /// Why the text does not fit the kind, when it does not.
+    pub fn invalid(&self) -> Option<&str> {
+        match &self.slot {
+            ParamSlot::Invalid(why) => Some(why),
+            _ => None,
+        }
+    }
+
+    /// This parameter's kind. A type that names none reads as text — the
+    /// row stays editable and its value survives — but a shipped template
+    /// cannot carry one (see [`ParamKind`]).
+    pub fn kind(&self) -> ParamKind {
+        ParamKind::parse(&self.param_type).unwrap_or(ParamKind::Text)
+    }
+
+    /// A Choice's options: the list when there is one, else the ones the
+    /// type string names (`choice:UV,Icosphere,Cube`).
+    pub fn choice_options(&self) -> Vec<String> {
+        if !self.options.is_empty() {
+            self.options.clone()
+        } else {
+            self.param_type
+                .strip_prefix("choice:")
+                .map(|o| o.split(',').map(|x| x.trim().to_string()).collect())
+                .unwrap_or_default()
+        }
+    }
+
+    /// Whether a value that READS as a reference should become an
+    /// expression here. Not for a code parameter: a kernel or a wrangle
+    /// script is a program, and one whose whole text happens to be
+    /// `ch("../a/Radius")` is a one-line program, not a channel — flagging
+    /// it would evaluate the script to a number before it ever ran.
+    pub fn takes_expressions(&self) -> bool {
+        !(self.kind() == ParamKind::Code || self.name == "Code")
+    }
+
+    /// Whether `text` would be a valid VALUE here — what an entry point a
+    /// person types into asks before it writes. Nothing is stored.
+    pub fn check(&self, text: &str) -> Result<(), String> {
+        parse_value(self.kind(), text, &self.choice_options()).map(|_| ())
+    }
+
+    /// Replace the text, keeping whether it is an expression, and re-parse.
+    /// The one way a value changes: a text that does not fit is kept as
+    /// [`ParamSlot::Invalid`], so a writer never loses what it wrote — the
+    /// entry points that should refuse one ask [`ParamDef::check`] first.
+    pub fn set_text(&mut self, text: impl Into<String>) {
+        self.text = text.into();
+        if !self.is_expr() {
+            self.reparse();
+        }
+    }
+
+    /// Store a value, as text formatted by [`ParamValue::to_text`]. Clears
+    /// the expression flag: a value is not an expression.
+    pub fn set_value(&mut self, v: ParamValue) {
+        self.text = v.to_text();
+        self.reparse();
+    }
+
+    /// A value written as text, clearing the expression flag — what an
+    /// evaluated expression or Delete Expression leaves behind.
+    pub fn bake(&mut self, text: impl Into<String>) {
+        self.text = text.into();
+        self.reparse();
+    }
+
+    /// Mark the text as an expression, or as a plain value again.
+    pub fn set_expr(&mut self, on: bool) {
+        if on {
+            self.slot = ParamSlot::Expr;
+        } else if self.is_expr() {
+            self.reparse();
+        }
+    }
+
+    /// Change the type, re-parsing the value under the new kind.
+    pub fn set_type(&mut self, ty: impl Into<String>) {
+        self.param_type = ty.into();
+        if !self.is_expr() {
+            self.reparse();
+        }
+    }
+
+    /// Take the template's UI metadata — type, label, options, range, step,
+    /// condition — keeping this instance's value, which is re-parsed under
+    /// the (possibly new) kind. The template owns the surface, the instance
+    /// owns its value.
+    pub fn adopt_ui_from(&mut self, template: &ParamDef) {
+        self.param_type = template.param_type.clone();
+        self.label = template.label.clone();
+        self.options = template.options.clone();
+        self.min = template.min;
+        self.max = template.max;
+        self.step = template.step;
+        self.show_when = template.show_when.clone();
+        if !self.is_expr() {
+            self.reparse();
+        }
+    }
+
+    fn reparse(&mut self) {
+        self.slot = match parse_value(self.kind(), &self.text, &self.choice_options()) {
+            Ok(v) => ParamSlot::Value(v),
+            Err(why) => ParamSlot::Invalid(why),
+        };
+    }
+
+    /// An evaluated expression's result as the value this parameter holds —
+    /// the row decides: a number into a toggle is its truth, into a choice
+    /// the option at that index, into a spinbox the whole part; a string is
+    /// parsed as the row's text would be. `None` when it fits nothing (a
+    /// string into a slider that is not a number), which leaves the text
+    /// the old write-back produced to be stored and flagged.
+    pub fn value_from_expr(&self, v: &Value) -> Option<ParamValue> {
+        let kind = self.kind();
+        match (kind, v) {
+            (ParamKind::Toggle, _) => Some(ParamValue::Bool(v.truthy())),
+            (ParamKind::Choice, Value::Num(n)) => {
+                let options = self.choice_options();
+                if options.is_empty() {
+                    Some(ParamValue::Choice(fmt_num(*n)))
+                } else {
+                    let i = (n.round().max(0.0) as usize).min(options.len() - 1);
+                    Some(ParamValue::Choice(options[i].clone()))
+                }
+            }
+            (ParamKind::Spin, Value::Num(n)) if n.is_finite() => Some(ParamValue::Int(n.trunc() as i64)),
+            (ParamKind::Float | ParamKind::Slider, Value::Num(n)) => Some(ParamValue::Number(*n as f32)),
+            _ => parse_value(kind, &v.as_str(), &self.choice_options()).ok(),
+        }
+    }
+}
+
+/// Every parameter in `node`'s tree whose type names no [`ParamKind`], as
+/// `(path, parameter, type)`. Empty for a well-formed template.
+pub fn unknown_param_kinds(node: &FsNode) -> Vec<(String, String, String)> {
+    fn walk(node: &FsNode, path: &str, out: &mut Vec<(String, String, String)>) {
+        for p in &node.params {
+            if ParamKind::parse(&p.param_type).is_none() {
+                out.push((path.to_string(), p.name.clone(), p.param_type.clone()));
+            }
+        }
+        for c in &node.children {
+            walk(c, &format!("{path}/{}", c.name), out);
+        }
+    }
+    let mut out = Vec::new();
+    walk(node, &node.name, &mut out);
+    out
+}
+
+/// Every parameter in `node`'s tree whose text does not fit its kind, as
+/// `(path, parameter, why)` — what a load reports on the status line.
+pub fn invalid_params(node: &FsNode) -> Vec<(String, String, String)> {
+    fn walk(node: &FsNode, path: &str, out: &mut Vec<(String, String, String)>) {
+        for p in &node.params {
+            if let Some(why) = p.invalid() {
+                out.push((path.to_string(), p.name.clone(), why.to_string()));
+            }
+        }
+        for c in &node.children {
+            walk(c, &format!("{path}/{}", c.name), out);
+        }
+    }
+    let mut out = Vec::new();
+    for c in &node.children {
+        walk(c, &c.name, &mut out);
+    }
+    out
+}
diff --git a/src/project.rs b/src/project.rs
index 4c0cce6..12dc290 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -547,6 +547,7 @@ impl State {
         self.sync_parameters_pane();
 
         self.rebuild_scene_geometry();
+        self.report_invalid_params();
         self.rebuild_positions();
         self.apply_layout();
         self.update_panel_bounds();
@@ -556,6 +557,20 @@ impl State {
         Ok(())
     }
 
+    /// Say on the status line when a loaded project holds values that do not
+    /// fit their parameter's kind — kept verbatim, read as they always were,
+    /// but worth knowing about (`ParamSlot::Invalid`). Only over the plain
+    /// success message: a node error is the more urgent line.
+    pub(crate) fn report_invalid_params(&mut self) {
+        let bad = crate::app::invalid_params(&self.fs_root);
+        let Some((at, name, why)) = bad.first() else { return };
+        if !self.last_status_text.starts_with("Geometry updated") {
+            return;
+        }
+        let more = if bad.len() > 1 { format!(" (and {} more)", bad.len() - 1) } else { String::new() };
+        self.update_status_text(&format!("A value does not fit its parameter — {at} {name}: {why}{more}"));
+    }
+
     /// The Main node's "Set As Default": remember the currently-loaded project
     /// as what the app opens at startup. A pointer in state.kdl — NOT a rewrite
     /// of the bundled default_project.json, which is versioned and doubles as
@@ -716,8 +731,8 @@ impl State {
                 .map(|n| n.params.clone())
                 .unwrap_or_default()
         };
-        let as_bool = |p: &ParamDef| p.default.parse::<bool>().ok();
-        let as_f32 = |p: &ParamDef| p.default.parse::<f32>().ok();
+        let as_bool = |p: &ParamDef| p.text().parse::<bool>().ok();
+        let as_f32 = |p: &ParamDef| p.text().parse::<f32>().ok();
 
         for p in params("guides").iter().chain(params("main").iter()) {
             match p.name.as_str() {
@@ -733,11 +748,11 @@ impl State {
                 "Grid Thickness" => if let Some(v) = as_f32(p) { self.grid_thickness = v / 1000.0; },
                 "Origin Guide Size" => if let Some(v) = as_f32(p) { self.origin_size = v / 10.0; },
                 "Camera Pivot Size" => if let Some(v) = as_f32(p) { self.camera_pivot_size = v / 10.0; },
-                "Grid Color" => if let Some(c) = hex_to_color(&p.default) { self.viewport_mut().grid_color = c; },
-                "Background Color" => if let Some(c) = hex_to_color(&p.default) { self.viewport_mut().bg_color = c; },
+                "Grid Color" => if let Some(c) = hex_to_color(p.text()) { self.viewport_mut().grid_color = c; },
+                "Background Color" => if let Some(c) = hex_to_color(p.text()) { self.viewport_mut().bg_color = c; },
                 "Point Marker Size" => if let Some(v) = as_f32(p) { self.point_marker_size = v / 1000.0; },
-                "Point Marker Color" => if let Some(c) = hex_to_color(&p.default) { self.point_marker_color = c; },
-                "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(&p.default) { self.world_unit = u; },
+                "Point Marker Color" => if let Some(c) = hex_to_color(p.text()) { self.point_marker_color = c; },
+                "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(p.text()) { self.world_unit = u; },
                 "Circular Pane" => if let Some(v) = as_bool(p) { self.circular_network_pane = v; },
                 "Ray Traced Preview" => if let Some(v) = as_bool(p) { self.viewport_mut().rt_mode = v; },
                 _ => {}
@@ -748,7 +763,7 @@ impl State {
                 "Show Wireframe" => if let Some(v) = as_bool(&p) { self.wireframe = v; },
                 "Wire Single Color" => if let Some(v) = as_bool(&p) { self.wire_single_color = v; },
                 // Its alpha was the wire opacity until that was a setting.
-                "Wire Color" => if let Some([r, g, b, a]) = hex_to_rgba(&p.default) {
+                "Wire Color" => if let Some([r, g, b, a]) = hex_to_rgba(p.text()) {
                     self.wire_color = [r, g, b];
                     self.wire_opacity = a;
                 },
@@ -756,7 +771,7 @@ impl State {
                 "Opacity" => if let Some(v) = as_f32(&p) { self.geo_opacity = v.clamp(0.0, 1.0); },
                 "Render Points" => if let Some(v) = as_bool(&p) { self.render_points = v; },
                 "Point Size" => if let Some(v) = as_f32(&p) { self.point_size = v.clamp(0.0, 0.1); },
-                "Point Color" => if let Some(c) = hex_to_color(&p.default) { self.point_color = c; },
+                "Point Color" => if let Some(c) = hex_to_color(p.text()) { self.point_color = c; },
                 _ => {}
             }
         }
diff --git a/src/render.rs b/src/render.rs
index d893653..416bf60 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -594,7 +594,7 @@ impl State {
                 let is_expr = |key: &str| {
                     child.params.iter().any(|p| {
                         let k = if p.label.is_empty() { &p.name } else { &p.label };
-                        k == key && p.expr
+                        k == key && p.is_expr()
                     })
                 };
                 if rows.iter().any(|r| is_expr(&r.0)) {
diff --git a/src/soft_transform_tool.rs b/src/soft_transform_tool.rs
index 149eccb..c850706 100644
--- a/src/soft_transform_tool.rs
+++ b/src/soft_transform_tool.rs
@@ -62,7 +62,7 @@ fn set_triple(node: &mut FsNode, name: &str, v: Vec3) {
     // tool wrote and a value the pane wrote are indistinguishable.
     let formatted = format!("{:.2}:{:.2}:{:.2}", v.x, v.y, v.z);
     if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
-        p.default = formatted;
+        p.set_text(formatted);
     }
 }
 
diff --git a/src/window.rs b/src/window.rs
index 4911d4c..a1f148d 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -443,18 +443,22 @@ impl State {
                             if item_idx == 0 { // Default
                                 for template_param in &params_to_reset {
                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
-                                        p.default = template_param.default.clone();
+                                        // The template's text AND whether it is an
+                                        // expression: a default that is a reference
+                                        // is one again after a reset.
+                                        p.set_text(template_param.text().to_string());
+                                        p.set_expr(template_param.is_expr());
                                     }
                                 }
                             } else if item_idx == 1 { // Custom
                                 for template_param in &params_to_reset {
                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
-                                        if let Ok(v) = template_param.default.parse::<f32>() {
-                                            p.default = format!("{:.2}", v * 1.5);
-                                        } else if let Ok(v) = template_param.default.parse::<i32>() {
-                                            p.default = format!("{}", v * 2);
-                                        } else if template_param.default.contains(':') {
-                                            let parts: Vec<&str> = template_param.default.split(':').collect();
+                                        if let Ok(v) = template_param.text().parse::<f32>() {
+                                            p.set_text(format!("{:.2}", v * 1.5));
+                                        } else if let Ok(v) = template_param.text().parse::<i32>() {
+                                            p.set_text(format!("{}", v * 2));
+                                        } else if template_param.text().contains(':') {
+                                            let parts: Vec<&str> = template_param.text().split(':').collect();
                                             let custom_parts: Vec<String> = parts.iter().map(|p_str| {
                                                 if let Ok(v) = p_str.parse::<f32>() {
                                                     format!("{:.2}", v * 1.5)
@@ -462,9 +466,9 @@ impl State {
                                                     p_str.to_string()
                                                 }
                                             }).collect();
-                                            p.default = custom_parts.join(":");
+                                            p.set_text(custom_parts.join(":"));
                                         } else {
-                                            p.default = template_param.default.clone();
+                                            p.set_text(template_param.text().to_string());
                                         }
                                     }
                                 }
@@ -484,7 +488,11 @@ impl State {
                             if let Some(params_to_reset) = template_params {
                                 for template_param in &params_to_reset {
                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
-                                        p.default = template_param.default.clone();
+                                        // The template's text AND whether it is an
+                                        // expression: a default that is a reference
+                                        // is one again after a reset.
+                                        p.set_text(template_param.text().to_string());
+                                        p.set_expr(template_param.is_expr());
                                     }
                                 }
                                 state.sync_nodes();
@@ -719,11 +727,19 @@ impl State {
                 let dir = state.current_dir_mut();
                 if let Some(child) = dir.children.get_mut(slot) {
                     if let Some(p) = child.params.iter_mut().find(|p| p.name == name) {
-                        p.default = value;
                         // A value that reads as a reference becomes an
-                        // expression, as one typed into the pane does.
-                        if !p.expr && p.takes_expressions() && crate::expr::looks_like_expression(&p.default) {
-                            p.expr = true;
+                        // expression, as one typed into the pane does; an
+                        // expression is checked when it evaluates. Anything
+                        // else must fit the kind, or nothing is written.
+                        let as_expr = p.is_expr() || (p.takes_expressions() && crate::expr::looks_like_expression(&value));
+                        if !as_expr {
+                            if let Err(why) = p.check(&value) {
+                                return Err(format!("{name}: {why}"));
+                            }
+                        }
+                        p.set_text(value);
+                        if as_expr {
+                            p.set_expr(true);
                         }
                         // Same sequence as the interactive param-pane
                         // path, so settings params (viewport flags,
@@ -884,18 +900,10 @@ impl State {
                         "Unknown param_type '{param_type}'; expected one of: {}",
                         crate::app::ParamKind::NAMES.join(", ")
                     ))
+                } else if let Some(why) = ParamDef::new(name.clone(), param_type.clone(), default.clone()).invalid() {
+                    Err(format!("{name}: {why}"))
                 } else if slot < len {
-                    let param = ParamDef {
-                        name,
-                        label: String::new(),
-                        param_type,
-                        default,
-                        options: vec![],
-                        min: None,
-                        max: None,
-                        step: None,
-                        show_when: String::new(), expr: false,
-                    };
+                    let param = ParamDef::new(name, param_type, default);
                     state.current_dir_mut().children[slot].params.push(param);
                     state.sync_nodes();
                     // Params feed kernel evaluation and the param pane shows
@@ -968,7 +976,7 @@ impl State {
                 let Some(p) = child.params.iter_mut().find(|p| p.name == "Points") else {
                     return Err("Curve node has no Points param".to_string());
                 };
-                p.default = crate::geometry::format_curve_points(&pts);
+                p.set_text(crate::geometry::format_curve_points(&pts));
                 // Same resync sequence as SetParam / the viewer state.
                 state.sync_nodes();
                 state.rebuild_scene_geometry();