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

commitbe84093a9f4cc875deb25e218fe6fa3678fa6ff5
parent23e984b8ad
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:36
feat: a parameter's right-click menu says what the parameter does

Every template parameter carries a `description`, shown under its name in
the row menu, wrapped to 44 characters over dimmed header rows. It is the
template's, like the label: the merge hands it to each instance, and it is
read from the template file and never written, so saves and sim_solve_key
are unchanged. All 327 shipped parameters are described;
the_row_menu_says_what_a_parameter_does holds every template to it.

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

 CLAUDE.md                 |  12 ++++-
 nodes/analysis.json       |  12 +++--
 nodes/attribute.json      |  60 +++++++++++++++--------
 nodes/boolean.json        |   8 ++--
 nodes/bounds.json         |   6 +--
 nodes/box.json            |   9 ++--
 nodes/camera.json         |  15 ++++--
 nodes/collision.json      |  21 ++++----
 nodes/connectivity.json   |   4 +-
 nodes/copy.json           |  12 ++---
 nodes/cull.json           |  12 ++---
 nodes/curve.json          |   6 +--
 nodes/deform.json         |  10 ++--
 nodes/detangle.json       |  20 ++++----
 nodes/develop.json        |  12 ++---
 nodes/distance.json       |  12 ++---
 nodes/embryo.json         |  48 ++++++++++++-------
 nodes/export.json         |  10 ++--
 nodes/extrude.json        |   9 ++--
 nodes/grid.json           |  10 ++--
 nodes/group.json          |  45 ++++++++++++------
 nodes/hull.json           |   2 +-
 nodes/line.json           |   4 +-
 nodes/mold_shell.json     |  10 ++--
 nodes/neighbour.json      |  39 ++++++++++-----
 nodes/normal.json         |  10 ++--
 nodes/output.json         |   2 +-
 nodes/page.json           |  18 +++----
 nodes/page_border.json    |   8 ++--
 nodes/page_grid.json      |  12 ++---
 nodes/page_shape.json     |  32 ++++++-------
 nodes/page_text.json      |  20 ++++----
 nodes/plane.json          |  24 ++++++----
 nodes/points.json         |   9 ++--
 nodes/polygon.json        |  10 ++--
 nodes/relax.json          |  24 ++++++----
 nodes/remesh.json         |  42 ++++++++++------
 nodes/scatter.json        |  22 ++++-----
 nodes/simnet.json         |  12 +++--
 nodes/soft_transform.json |  14 +++---
 nodes/sphere.json         |  30 ++++++++----
 nodes/subdivide.json      |   4 +-
 nodes/subnet.json         |   2 +-
 nodes/suture.json         |  10 ++--
 nodes/switch.json         |  10 ++--
 nodes/time.json           |  10 ++--
 nodes/transfer.json       |  21 +++++---
 nodes/transform.json      |   4 +-
 nodes/valence.json        |   6 +--
 nodes/visualize.json      |  33 ++++++++-----
 nodes/volume.json         |  10 ++--
 nodes/wrangle.json        |   9 ++--
 src/app.rs                |  34 ++++++++++++-
 src/main.rs               | 119 ++++++++++++++++++++++++++++++++++++++++++----
 src/param.rs              |  14 ++++++
 55 files changed, 644 insertions(+), 339 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index adef8f8..d4b88a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -940,7 +940,17 @@ and paste still pastes the right path. **Header rows read the
 parameter out** (since 2026-09-28, `param_menu_rows`): `Name:` is the
 parameter's name, what a `ch()` path spells, with `Label:` under it only
 when the template gives one (the pane shows the name otherwise, and a
-Label row repeating it would claim there is one); `Control:` is the
+Label row repeating it would claim there is one), then **what the
+parameter does** (since 2026-09-30): the template's `description`, a
+sentence or two in prose, wrapped to `PARAM_DESCRIPTION_WIDTH` (44)
+characters over as many unprefixed rows as it takes, since the menu is as
+wide as its widest row. It is the TEMPLATE's, like the label: the merge
+hands it to every instance (`adopt_ui_from`), a subnet template's child
+takes its base template's, and it is read from a template file and never
+written, so a save carries none and `sim_solve_key` does not see it.
+Every parameter a template ships has one —
+`the_row_menu_says_what_a_parameter_does` reads the raw files — so a new
+parameter needs its description written with it. `Control:` is the
 control the pane DRAWS for the row (slider, spinbox, dropdown, toggle,
 text box, text box with picker, code editor, button) and `Type:` the type
 of value that control SETS, in a programmer's terms (float, float3,
diff --git a/nodes/analysis.json b/nodes/analysis.json
index 7c03c50..17d19fa 100644
--- a/nodes/analysis.json
+++ b/nodes/analysis.json
@@ -7,24 +7,28 @@
     {
       "name": "Input",
       "type": "node",
-      "default": ""
+      "default": "",
+      "description": "The node whose geometry is measured; the results are added as detail attributes and the geometry passed on."
     },
     {
       "name": "Source",
       "type": "choice:Attribute,Edge Lengths",
-      "default": "Attribute"
+      "default": "Attribute",
+      "description": "What is measured: a point attribute's values, or the lengths of every mesh edge."
     },
     {
       "name": "Attribute",
       "type": "attribute",
       "default": "mass",
-      "show_when": "Source == Attribute"
+      "show_when": "Source == Attribute",
+      "description": "The point attribute measured. Results go to detail attributes named after it with _min, _max, _sum, _average, _spread and _count."
     },
     {
       "name": "Group",
       "type": "group",
       "default": "",
-      "show_when": "Source == Attribute"
+      "show_when": "Source == Attribute",
+      "description": "A point group limiting which points are measured. Empty means every point."
     }
   ]
 }
diff --git a/nodes/attribute.json b/nodes/attribute.json
index f5cd4eb..9799f5a 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -7,35 +7,41 @@
     {
       "name": "Input",
       "type": "node",
-      "default": ""
+      "default": "",
+      "description": "The node whose geometry this reads; its point attributes are created, changed or deleted and the result passed on."
     },
     {
       "name": "Operation",
       "type": "choice:Create,Modify,Delete,Remap,Clip,Normalize,Composite,Promote",
-      "default": "Create"
+      "default": "Create",
+      "description": "What is done to the named point attribute: create it, modify it by Value, delete it, remap or clip its range, normalize it, combine it with Source B, or promote it between point and detail."
     },
     {
       "name": "Attribute Name",
       "type": "attribute",
-      "default": "attr1"
+      "default": "attr1",
+      "description": "The point attribute to work on. Pos and Col are the built-in position and colour; they can be modified but not created or deleted."
     },
     {
       "name": "Type",
       "type": "choice:Float,Float2,Float3,Float4",
       "default": "Float",
-      "show_when": "Operation == Create"
+      "show_when": "Operation == Create",
+      "description": "How many components the new attribute has: one number, or a vector of two, three or four."
     },
     {
       "name": "Value",
       "type": "text",
       "default": "1.00",
-      "show_when": "Operation == Create|Modify"
+      "show_when": "Operation == Create|Modify",
+      "description": "The number or numbers written, separated by spaces, commas or colons. One number fills every component; otherwise the count must match the attribute's width."
     },
     {
       "name": "Combine",
       "type": "choice:Set,Add,Multiply",
       "default": "Set",
-      "show_when": "Operation == Modify"
+      "show_when": "Operation == Modify",
+      "description": "How Value meets the current value: Set replaces it, Add adds to it, Multiply multiplies it, componentwise."
     },
     {
       "name": "Strength",
@@ -44,85 +50,99 @@
       "min": 0.0,
       "max": 2.0,
       "step": 0.05,
-      "show_when": "Operation == Modify"
+      "show_when": "Operation == Modify",
+      "description": "How much of the change lands: 1 is the full change, 0 none, 2 twice as much. Scales the difference the node makes under every Combine."
     },
     {
       "name": "Scale By",
       "type": "attribute",
       "default": "",
-      "show_when": "Operation == Modify"
+      "show_when": "Operation == Modify",
+      "description": "A point attribute whose value weights the change per point, multiplied with Strength. Empty weights every point equally; a name that is not an attribute is an error."
     },
     {
       "name": "Per Frame",
       "type": "toggle",
       "default": "true",
-      "show_when": "Operation == Modify && Combine != Set"
+      "show_when": "Operation == Modify && Combine != Set",
+      "description": "Inside a simulation, makes Add and Multiply a rate per frame, so the substep count does not change how far a frame moves. Off lands the full amount every substep."
     },
     {
       "name": "Group",
       "type": "group",
       "default": "",
-      "show_when": "Operation != Promote"
+      "show_when": "Operation != Promote",
+      "description": "A point group limiting which points are changed or measured. Empty means every point."
     },
     {
       "name": "From Min",
       "type": "float",
       "default": "0.00",
-      "show_when": "Operation == Remap|Clip"
+      "show_when": "Operation == Remap|Clip",
+      "description": "Remap: the input value that maps to To Min. Clip: the lower bound values are clamped to."
     },
     {
       "name": "From Max",
       "type": "float",
       "default": "1.00",
-      "show_when": "Operation == Remap|Clip"
+      "show_when": "Operation == Remap|Clip",
+      "description": "Remap: the input value that maps to To Max. Clip: the upper bound values are clamped to. Must differ from From Min for a remap."
     },
     {
       "name": "To Min",
       "type": "float",
       "default": "0.00",
-      "show_when": "Operation == Remap"
+      "show_when": "Operation == Remap",
+      "description": "The value that From Min maps to; values between map linearly, and values outside the From range extend past it."
     },
     {
       "name": "To Max",
       "type": "float",
       "default": "1.00",
-      "show_when": "Operation == Remap|Normalize"
+      "show_when": "Operation == Remap|Normalize",
+      "description": "Remap: the value that From Max maps to. Normalize: the value the chosen measure (sum, maximum or range) is scaled to hit."
     },
     {
       "name": "Source B",
       "type": "attribute",
       "default": "",
-      "show_when": "Operation == Composite"
+      "show_when": "Operation == Composite",
+      "description": "The second point attribute combined into this one, point by point. A single-number Source B applies to every component."
     },
     {
       "name": "Combine Op",
       "type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
       "default": "Add",
-      "show_when": "Operation == Composite"
+      "show_when": "Operation == Composite",
+      "description": "How Source B is folded in, componentwise. Dot, Distance and Length produce one number written into every component."
     },
     {
       "name": "To Class",
       "type": "choice:Detail,Point",
       "default": "Detail",
-      "show_when": "Operation == Promote"
+      "show_when": "Operation == Promote",
+      "description": "Where the attribute is moved: Detail reduces the per-point values to one number for the whole geometry; Point copies a detail value onto every point."
     },
     {
       "name": "Method",
       "type": "choice:Average,Sum,Minimum,Maximum,First",
       "default": "Average",
-      "show_when": "Operation == Promote"
+      "show_when": "Operation == Promote",
+      "description": "How point values are reduced to one detail value when promoting to Detail: their average, sum, minimum, maximum or the first point's."
     },
     {
       "name": "Target",
       "type": "choice:Sum,Maximum,Range",
       "default": "Maximum",
-      "show_when": "Operation == Normalize"
+      "show_when": "Operation == Normalize",
+      "description": "What is measured before scaling: the values' sum, their maximum, or their range (max minus min). Every value is scaled so this measure equals To Max."
     },
     {
       "name": "Kind",
       "type": "choice:Live,Derivative",
       "default": "Live",
-      "show_when": "Operation == Create"
+      "show_when": "Operation == Create",
+      "description": "Live keeps the attribute's values from one simulation step to the next; Derivative zeroes it at the start of every step, for values the chain rebuilds each step."
     }
   ]
 }
diff --git a/nodes/boolean.json b/nodes/boolean.json
index 2200af9..003e83b 100644
--- a/nodes/boolean.json
+++ b/nodes/boolean.json
@@ -4,9 +4,9 @@
  "inputs": 2,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "With", "type": "node", "default": "" },
-  { "name": "Operation", "type": "choice:Union,Intersect,Subtract", "default": "Union" },
-  { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005 }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is the first operand, the shape the second is combined with or cut from." },
+  { "name": "With", "type": "node", "default": "", "description": "The node whose geometry is the second operand: joined for Union, kept in common for Intersect, or cut away for Subtract." },
+  { "name": "Operation", "type": "choice:Union,Intersect,Subtract", "default": "Union", "description": "Union joins both shapes, Intersect keeps only where they overlap, and Subtract removes the With shape from the Input." },
+  { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005, "description": "Grid spacing in world units of the distance fields both shapes are sampled on. Smaller gives finer, cleaner edges but costs more." }
  ]
 }
diff --git a/nodes/bounds.json b/nodes/bounds.json
index fc4b632..a27408f 100644
--- a/nodes/bounds.json
+++ b/nodes/bounds.json
@@ -4,8 +4,8 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Prefix", "type": "text", "default": "bounds" },
-  { "name": "Group", "type": "group", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is measured; its bounding box is added as detail attributes and the geometry passed on." },
+  { "name": "Prefix", "type": "text", "default": "bounds", "description": "The start of the four detail attribute names written: <prefix>_min, _max, _size and _center. Empty writes nothing." },
+  { "name": "Group", "type": "group", "default": "", "description": "A point group whose points alone are measured. Empty measures every point." }
  ]
 }
diff --git a/nodes/box.json b/nodes/box.json
index 472dd5d..c4d589d 100644
--- a/nodes/box.json
+++ b/nodes/box.json
@@ -7,17 +7,20 @@
     {
       "name": "Scale",
       "default": "1.0",
-      "type": "slider"
+      "type": "slider",
+      "description": "Edge length of the cube in world units; the box extends half this distance from Center on every axis."
     },
     {
       "name": "Wireframe",
       "default": "false",
-      "type": "toggle"
+      "type": "toggle",
+      "description": "When on, builds only the twelve edges as thin bars and the eight corners as small cubes instead of a solid box, useful as a reference frame."
     },
     {
       "name": "Center",
       "type": "float3",
-      "default": "0.00:0.55:0.00"
+      "default": "0.00:0.55:0.00",
+      "description": "Position of the box's center in world units."
     }
   ]
 }
diff --git a/nodes/camera.json b/nodes/camera.json
index dacac67..eb168e4 100644
--- a/nodes/camera.json
+++ b/nodes/camera.json
@@ -9,31 +9,36 @@
       "type": "float3",
       "default": "2.50:1.80:2.50",
       "min": -10.0,
-      "max": 10.0
+      "max": 10.0,
+      "description": "Where the camera sits in world units. Its direction and distance from Pivot set the starting view; panning the view rewrites it."
     },
     {
       "name": "Rotation",
       "type": "float3",
       "default": "0.00:0.00:0.00",
       "min": -180.0,
-      "max": 180.0
+      "max": 180.0,
+      "description": "Extra orbit around the Pivot in degrees: X tilts up or down, Y turns around the vertical axis, Z rolls the view. Dragging the viewport writes it."
     },
     {
       "name": "Pivot",
       "type": "float3",
       "default": "0.00:0.00:0.00",
       "min": -10.0,
-      "max": 10.0
+      "max": 10.0,
+      "description": "The point in world units the camera looks at and orbits around. Panning and framing move it."
     },
     {
       "name": "Square Aspect",
       "type": "toggle",
-      "default": "false"
+      "default": "false",
+      "description": "Records the viewport's Square Aspect setting (a 1:1 view) for this camera; it is updated when that toggle is flipped while this camera is active."
     },
     {
       "name": "Show Camera Pivot",
       "type": "toggle",
-      "default": "false"
+      "default": "false",
+      "description": "Records the viewport's Show Camera Pivot setting for this camera; it is updated when that toggle is flipped while this camera is active."
     }
   ]
 }
diff --git a/nodes/collision.json b/nodes/collision.json
index f8246eb..7bc10e7 100644
--- a/nodes/collision.json
+++ b/nodes/collision.json
@@ -4,12 +4,13 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "node" },
-    { "name": "Collider", "default": "", "type": "node" },
+    { "name": "Input", "default": "", "type": "node", "description": "The geometry whose points or primitives are tested against the collider and grouped." },
+    { "name": "Collider", "default": "", "type": "node", "description": "The geometry to test against, read as triangles. With nothing connected, or a collider without faces, the input passes through unchanged." },
     {
       "name": "Method",
       "type": "choice:Inside,Proximity",
-      "default": "Inside"
+      "default": "Inside",
+      "description": "Inside marks elements enclosed by the collider, so it needs a closed collider. Proximity marks elements within Distance of the collider's surface."
     },
     {
       "name": "Distance",
@@ -17,23 +18,27 @@
       "default": "0.05",
       "min": 0.0,
       "max": 1.0,
-      "step": 0.005
+      "step": 0.005,
+      "description": "For Proximity, how near the collider's surface an element must be to count, in world units. Unused by Inside."
     },
     {
       "name": "Element Type",
       "type": "choice:Points,Primitives",
-      "default": "Points"
+      "default": "Points",
+      "description": "Points tests each point. Primitives tests each primitive's centroid and groups the hit primitives and their points."
     },
-    { "name": "Group Name", "default": "collisions", "type": "group" },
+    { "name": "Group Name", "default": "collisions", "type": "group", "description": "Name of the group written with the elements that hit. Also written as a primitive group when primitives are tested." },
     {
       "name": "Invert",
       "type": "toggle",
-      "default": "false"
+      "default": "false",
+      "description": "Groups the elements that did NOT hit the collider instead of those that did."
     },
     {
       "name": "Highlight",
       "type": "toggle",
-      "default": "true"
+      "default": "true",
+      "description": "Tints the grouped points red so the contact shows in the viewport. Off leaves colours untouched."
     }
   ]
 }
diff --git a/nodes/connectivity.json b/nodes/connectivity.json
index 327d9a9..1a48bd6 100644
--- a/nodes/connectivity.json
+++ b/nodes/connectivity.json
@@ -4,7 +4,7 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "piece" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is split into connected pieces; the piece number is written per point and the geometry passed on." },
+  { "name": "Attribute", "type": "attribute", "default": "piece", "description": "The integer point attribute holding each point's piece number: 0 is the largest piece, 1 the next, and so on. Empty writes nothing." }
  ]
 }
diff --git a/nodes/copy.json b/nodes/copy.json
index 2a31a8f..f5029f1 100644
--- a/nodes/copy.json
+++ b/nodes/copy.json
@@ -4,11 +4,11 @@
  "inputs": 2,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "To", "type": "node", "default": "" },
-  { "name": "Orient", "type": "choice:None,Normal", "default": "None" },
-  { "name": "Scale", "type": "float", "default": "1.00" },
-  { "name": "Scale Attribute", "type": "attribute", "default": "" },
-  { "name": "Group", "type": "group", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is copied; one copy is placed at each target point." },
+  { "name": "To", "type": "node", "default": "", "description": "The node whose points receive a copy of the Input geometry each. Unresolved, the Input passes through uncopied." },
+  { "name": "Orient", "type": "choice:None,Normal", "default": "None", "description": "None keeps every copy upright. Normal turns each copy so its +Y axis follows the target surface's normal at that point." },
+  { "name": "Scale", "type": "float", "default": "1.00", "description": "Uniform scale applied to every copy about its own origin before it is placed." },
+  { "name": "Scale Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute on the target whose value multiplies Scale per copy, so a field can drive their size. Empty uses Scale alone." },
+  { "name": "Group", "type": "group", "default": "", "description": "Point group on the target that receives copies; other points get none. Empty copies onto every target point." }
  ]
 }
diff --git a/nodes/cull.json b/nodes/cull.json
index 06244c0..b49d15c 100644
--- a/nodes/cull.json
+++ b/nodes/cull.json
@@ -4,11 +4,11 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Group", "type": "group", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "" },
-  { "name": "Comparison", "type": "choice:Below,Above", "default": "Below" },
-  { "name": "Threshold", "type": "float", "default": "0.50" },
-  { "name": "Invert", "type": "choice:false,true", "default": "false" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose points are culled; faces using a removed point go with it." },
+  { "name": "Group", "type": "group", "default": "", "description": "Point group to cull from; points outside it are kept. Empty considers every point. With Attribute also empty, nothing is removed." },
+  { "name": "Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute compared against Threshold to choose which points are removed. Empty removes the whole Group." },
+  { "name": "Comparison", "type": "choice:Below,Above", "default": "Below", "description": "Below removes points whose attribute value is less than Threshold; Above removes those whose value is greater." },
+  { "name": "Threshold", "type": "float", "default": "0.50", "description": "The value the Attribute is compared against to decide which points are removed." },
+  { "name": "Invert", "type": "choice:false,true", "default": "false", "description": "Reverse the selection: keep the points that would have been removed and remove all the others." }
  ]
 }
diff --git a/nodes/curve.json b/nodes/curve.json
index 18ca6d0..26973a5 100644
--- a/nodes/curve.json
+++ b/nodes/curve.json
@@ -4,8 +4,8 @@
   "inputs": 0,
   "outputs": 1,
   "params": [
-    { "name": "Points", "default": "-0.75 0.05 0; -0.25 1.05 0; 0.25 0.05 0; 0.75 1.05 0", "type": "text" },
-    { "name": "Segments", "default": "8", "type": "spinbox", "min": 1, "max": 64, "step": 1 },
-    { "name": "Thickness", "default": "0.02", "type": "slider" }
+    { "name": "Points", "default": "-0.75 0.05 0; -0.25 1.05 0; 0.25 0.05 0; 0.75 1.05 0", "type": "text", "description": "Control points as x y z triples in world coordinates, separated by semicolons. The curve passes through each one; Edit Handles lets you drag them in the viewport." },
+    { "name": "Segments", "default": "8", "type": "spinbox", "min": 1, "max": 64, "step": 1, "description": "Number of straight pieces sampled between each pair of control points. Higher values give a smoother curve." },
+    { "name": "Thickness", "default": "0.02", "type": "slider", "description": "Width of the curve's square cross-section in world units." }
   ]
 }
diff --git a/nodes/deform.json b/nodes/deform.json
index dc639dd..8415edb 100644
--- a/nodes/deform.json
+++ b/nodes/deform.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Mode", "type": "choice:Twist,Bend,Taper", "default": "Twist" },
-  { "name": "Axis", "type": "choice:X,Y,Z", "default": "Y" },
-  { "name": "Amount", "type": "slider", "default": "1.00", "min": -4.0, "max": 4.0, "step": 0.05 },
-  { "name": "Group", "type": "group", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is twisted, bent or tapered." },
+  { "name": "Mode", "type": "choice:Twist,Bend,Taper", "default": "Twist", "description": "Twist rotates slices about the axis, Bend curves the shape along the axis, and Taper scales slices so one end narrows and the other widens." },
+  { "name": "Axis", "type": "choice:X,Y,Z", "default": "Y", "description": "The axis the deformation runs along. The middle of the geometry's extent on this axis stays still and the two ends deform in opposite ways." },
+  { "name": "Amount", "type": "slider", "default": "1.00", "min": -4.0, "max": 4.0, "step": 0.05, "description": "Strength of the deformation over the geometry's full length: radians of rotation for Twist and Bend, change in scale for Taper. Negative reverses it." },
+  { "name": "Group", "type": "group", "default": "", "description": "Point group to deform; other points stay where they are. Empty deforms every point." }
  ]
 }
diff --git a/nodes/detangle.json b/nodes/detangle.json
index 5f8f251..12386de 100644
--- a/nodes/detangle.json
+++ b/nodes/detangle.json
@@ -4,15 +4,15 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Method", "type": "choice:Points,Surface", "default": "Points" },
-  { "name": "Thickness", "type": "slider", "default": "1.00", "min": 0.0, "max": 8.0, "step": 0.05 },
-  { "name": "Rings", "type": "spinbox", "default": "2", "min": 0.0, "max": 6.0, "step": 1.0 },
-  { "name": "Iterations", "type": "spinbox", "default": "4", "min": 1.0, "max": 32.0, "step": 1.0 },
-  { "name": "Edge Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface" },
-  { "name": "Fold Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface" },
-  { "name": "Step Limit", "type": "slider", "default": "0.00", "min": 0.0, "max": 2.0, "step": 0.05, "show_when": "Method == Surface" },
-  { "name": "Group", "type": "group", "default": "" },
-  { "name": "Tangled Group", "type": "group", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The geometry to untangle. Its points are pushed apart where the surface comes closer to itself than the Thickness." },
+  { "name": "Method", "type": "choice:Points,Surface", "default": "Points", "description": "Points pushes pairs of nearby points apart. Surface tests points against nearby triangles, and inside a simnet also puts back points that passed through the surface." },
+  { "name": "Thickness", "type": "slider", "default": "1.00", "min": 0.0, "max": 8.0, "step": 0.05, "description": "How close the surface may come to itself, in mean edge lengths, so the setting holds after a remesh. Larger keeps sheets further apart; 0 turns the node off." },
+  { "name": "Rings", "type": "spinbox", "default": "2", "min": 0.0, "max": 6.0, "step": 1.0, "description": "How many edge rings around each point are excluded from contact, since near neighbours are close by construction. More rings ignore more of the local surface." },
+  { "name": "Iterations", "type": "spinbox", "default": "4", "min": 1.0, "max": 32.0, "step": 1.0, "description": "Most separation passes per evaluation. More resolves deeper contacts; the solve stops early once a pass finds nothing to separate." },
+  { "name": "Edge Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Also tests edges against nearby edges, catching two edges that cross with no point near a triangle. Much more thorough on dense contact, and slower." },
+  { "name": "Fold Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Inside a simnet, also catches the surface folding through its own excluded neighbourhood since the step began, and puts it back. Has no effect outside a simnet." },
+  { "name": "Step Limit", "type": "slider", "default": "0.00", "min": 0.0, "max": 2.0, "step": 0.05, "show_when": "Method == Surface", "description": "Inside a simnet, caps how far each point may move in one step, in thicknesses, before contacts are resolved. 0 means no limit; meant for use with substeps." },
+  { "name": "Group", "type": "group", "default": "", "description": "Only points in this group are moved; the rest still push but stay put. Empty moves every point." },
+  { "name": "Tangled Group", "type": "group", "default": "", "description": "If set, a point group of this name is written with the points of edges and triangles still crossing each other after the solve. Empty skips this extra search." }
  ]
 }
diff --git a/nodes/develop.json b/nodes/develop.json
index f3588f7..4b09c13 100644
--- a/nodes/develop.json
+++ b/nodes/develop.json
@@ -4,11 +4,11 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "growth" },
-  { "name": "Scale", "type": "slider", "default": "0.10", "min": -2.0, "max": 2.0, "step": 0.01 },
-  { "name": "Direction", "type": "choice:Normal,Attribute", "default": "Normal" },
-  { "name": "Source", "type": "attribute", "default": "" },
-  { "name": "Group", "type": "group", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose surface is displaced by a growth attribute." },
+  { "name": "Attribute", "type": "attribute", "default": "growth", "description": "Name of the point attribute giving how far each point moves; its value times Scale is the distance. Empty leaves the geometry unchanged." },
+  { "name": "Scale", "type": "slider", "default": "0.10", "min": -2.0, "max": 2.0, "step": 0.01, "description": "Multiplier on the attribute's value, giving the displacement in world units per unit of attribute. Negative moves points the opposite way." },
+  { "name": "Direction", "type": "choice:Normal,Attribute", "default": "Normal", "description": "Normal moves each point along its surface normal. Attribute moves it along a vector attribute named by Source instead." },
+  { "name": "Source", "type": "attribute", "default": "", "description": "Name of the vector point attribute used as the direction when Direction is Attribute. Its length also scales the move." },
+  { "name": "Group", "type": "group", "default": "", "description": "Point group to displace; other points stay put. Empty displaces every point." }
  ]
 }
diff --git a/nodes/distance.json b/nodes/distance.json
index 42d4b39..5ee7d81 100644
--- a/nodes/distance.json
+++ b/nodes/distance.json
@@ -4,11 +4,11 @@
  "inputs": 2,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "To", "type": "node", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "dist" },
-  { "name": "Direction", "type": "attribute", "default": "" },
-  { "name": "Signed", "type": "choice:false,true", "default": "false" },
-  { "name": "Maximum", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.05 }
+  { "name": "Input", "type": "node", "default": "", "description": "The geometry whose points are measured; each point gets its distance to the nearest point on the To surface." },
+  { "name": "To", "type": "node", "default": "", "description": "The geometry to measure to, read as a surface. If it cannot be found, the input passes through and an error is reported." },
+  { "name": "Attribute", "type": "attribute", "default": "dist", "description": "Name of the float point attribute written with each point's distance. Empty skips writing it." },
+  { "name": "Direction", "type": "attribute", "default": "", "description": "If set, a vector point attribute of this name is written with the unit direction from each point toward its nearest surface point. Empty skips it." },
+  { "name": "Signed", "type": "choice:false,true", "default": "false", "description": "When true, points behind the nearest face (inside the surface) get negative distances. Judged by that face's normal, so it can misread in concave creases." },
+  { "name": "Maximum", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.05, "description": "Clamps the distance to at most this many world units, keeping a falloff bounded. 0 means no clamp." }
  ]
 }
diff --git a/nodes/embryo.json b/nodes/embryo.json
index 047985c..7578d66 100644
--- a/nodes/embryo.json
+++ b/nodes/embryo.json
@@ -7,17 +7,20 @@
   {
    "name": "Input",
    "type": "node",
-   "default": ""
+   "default": "",
+   "description": "Node whose geometry is used as the seed shape when Source is set to Input."
   },
   {
    "name": "Source",
    "type": "choice:Internal,Input",
-   "default": "Internal"
+   "default": "Internal",
+   "description": "Starting shape: Internal builds a UV sphere from Radius and Base Resolution; Input uses the geometry wired into Input."
   },
   {
    "name": "Method",
    "type": "choice:Basic,Scatter",
-   "default": "Basic"
+   "default": "Basic",
+   "description": "Basic passes the source shape on as it is; Scatter scatters points over its surface and wraps them in a convex hull, giving an evenly spaced mesh."
   },
   {
    "name": "Base Resolution",
@@ -26,7 +29,8 @@
    "min": 3,
    "max": 100,
    "step": 1,
-   "show_when": "Source == Internal"
+   "show_when": "Source == Internal",
+   "description": "Rows and columns of the internal UV sphere. Higher values give a smoother, denser seed."
   },
   {
    "name": "Radius",
@@ -35,7 +39,8 @@
    "min": 0.01,
    "max": 10.0,
    "step": 0.01,
-   "show_when": "Source == Internal"
+   "show_when": "Source == Internal",
+   "description": "Radius of the internal sphere in world units, centered at the origin."
   },
   {
    "name": "Scatter Count",
@@ -44,7 +49,8 @@
    "min": 10,
    "max": 10000,
    "step": 10,
-   "show_when": "Method == Scatter"
+   "show_when": "Method == Scatter",
+   "description": "Number of points scattered over the source surface before the hull is built. More points give a finer hull mesh."
   },
   {
    "name": "Scatter Seed",
@@ -53,13 +59,15 @@
    "min": 0.0,
    "max": 10.0,
    "step": 0.1,
-   "show_when": "Method == Scatter"
+   "show_when": "Method == Scatter",
+   "description": "Random seed for the scatter. Change it for a different but equally spaced point layout."
   },
   {
    "name": "Relax Points",
    "type": "toggle",
    "default": "true",
-   "show_when": "Method == Scatter"
+   "show_when": "Method == Scatter",
+   "description": "When on, pushes the scattered points apart across the surface so they are evenly spaced before the hull is built."
   },
   {
    "name": "Scatter Relax Iterations",
@@ -68,7 +76,8 @@
    "min": 0,
    "max": 100,
    "step": 1,
-   "show_when": "Method == Scatter && Relax Points == true"
+   "show_when": "Method == Scatter && Relax Points == true",
+   "description": "How many push-apart passes the scattered points get. More passes give a more even spacing; 0 does none."
   },
   {
    "name": "Scale Radii By",
@@ -77,13 +86,15 @@
    "min": 0.0,
    "max": 2.0,
    "step": 0.001,
-   "show_when": "Method == Scatter && Relax Points == true"
+   "show_when": "Method == Scatter && Relax Points == true",
+   "description": "Multiplier on each scattered point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully."
   },
   {
    "name": "Use Max Relax Radius",
    "type": "toggle",
    "default": "true",
-   "show_when": "Method == Scatter && Relax Points == true"
+   "show_when": "Method == Scatter && Relax Points == true",
+   "description": "When on, the scattered points' push radius is capped at Scatter Relax Radius."
   },
   {
    "name": "Scatter Relax Radius",
@@ -92,7 +103,8 @@
    "min": 0.0,
    "max": 100.0,
    "step": 0.1,
-   "show_when": "Method == Scatter && Relax Points == true && Use Max Relax Radius == true"
+   "show_when": "Method == Scatter && Relax Points == true && Use Max Relax Radius == true",
+   "description": "Upper limit on the scattered points' push radius in world units, used when Use Max Relax Radius is on."
   },
   {
    "name": "Relax Iterations",
@@ -100,7 +112,8 @@
    "default": "0",
    "min": 0,
    "max": 50,
-   "step": 1
+   "step": 1,
+   "description": "Passes of the final repel relax that pushes the mesh's points apart. 0 turns the relax off."
   },
   {
    "name": "Relax Radius",
@@ -108,12 +121,14 @@
    "default": "1",
    "min": 0.001,
    "max": 1.0,
-   "step": 0.001
+   "step": 0.001,
+   "description": "Radius in world units of the sphere each point pushes with in the final relax; points closer than twice this move apart."
   },
   {
    "name": "Relax in 3D Space",
    "type": "toggle",
-   "default": "false"
+   "default": "false",
+   "description": "When off, the final relax slides points along the surface to keep its shape; when on, points may move freely in 3D."
   },
   {
    "name": "Subdivision Depth",
@@ -121,7 +136,8 @@
    "default": "0",
    "min": 0,
    "max": 3,
-   "step": 1
+   "step": 1,
+   "description": "How many times each triangle is split into four at the end, without smoothing. 0 leaves the mesh as is."
   }
  ],
  "children": [
diff --git a/nodes/export.json b/nodes/export.json
index 89040d4..20e7573 100644
--- a/nodes/export.json
+++ b/nodes/export.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "File", "type": "text", "default": "" },
-  { "name": "Format", "type": "choice:STL,STL (ASCII),OBJ", "default": "STL" },
-  { "name": "Scale", "type": "slider", "default": "1.00", "min": 0.001, "max": 100.0, "step": 0.01 },
-  { "name": "Export", "type": "button", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose output is written. A page chain is written as a PNG; geometry is written in the mesh format chosen by Format. Passed through unchanged." },
+  { "name": "File", "type": "text", "default": "", "description": "Path of the file to write; ~/ means your home folder and missing folders are created. An existing file is overwritten. Required before exporting." },
+  { "name": "Format", "type": "choice:STL,STL (ASCII),OBJ", "default": "STL", "description": "Mesh format for geometry: binary STL, ASCII STL (readable text, larger), or OBJ, which keeps shared points and quads. Ignored for pages, always PNG." },
+  { "name": "Scale", "type": "slider", "default": "1.00", "min": 0.001, "max": 100.0, "step": 0.01, "description": "Multiplies every coordinate as the mesh is written, for example to convert units for a slicer. Does not affect the scene, or a page's PNG." },
+  { "name": "Export", "type": "button", "default": "", "description": "Writes the input to File now, at the current frame, and reports the result on the status line. Nothing is written when the node is merely evaluated." }
  ]
 }
diff --git a/nodes/extrude.json b/nodes/extrude.json
index 9bf43b3..53df1b9 100644
--- a/nodes/extrude.json
+++ b/nodes/extrude.json
@@ -7,7 +7,8 @@
     {
       "name": "Input",
       "default": "",
-      "type": "node"
+      "type": "node",
+      "description": "The node whose surface is extruded as a whole: its points move along their normals and walls rise from its open edges."
     },
     {
       "name": "Distance",
@@ -15,12 +16,14 @@
       "type": "slider",
       "min": -1.0,
       "max": 1.0,
-      "step": 0.01
+      "step": 0.01,
+      "description": "How far, in world units, each point moves along its point normal to form the top. Negative extrudes inward, against the normals."
     },
     {
       "name": "Keep Base",
       "default": "true",
-      "type": "toggle"
+      "type": "toggle",
+      "description": "Keep the original faces, reversed, as the bottom so a sheet becomes a closed slab. Off leaves only the top and the side walls."
     }
   ]
 }
diff --git a/nodes/grid.json b/nodes/grid.json
index d646aa4..55b2ae0 100644
--- a/nodes/grid.json
+++ b/nodes/grid.json
@@ -4,10 +4,10 @@
  "inputs": 0,
  "outputs": 1,
  "params": [
-  { "name": "Rows", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0 },
-  { "name": "Columns", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0 },
-  { "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
-  { "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05 },
-  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" }
+  { "name": "Rows", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0, "description": "Number of quad divisions along the length (Z). More rows give a finer grid." },
+  { "name": "Columns", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0, "description": "Number of quad divisions across the width (X). More columns give a finer grid." },
+  { "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along X in world units." },
+  { "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along Z in world units." },
+  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the grid's center in world units; the grid lies flat in the XZ plane through this point." }
  ]
 }
diff --git a/nodes/group.json b/nodes/group.json
index 5dae6e1..c4307b0 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -7,23 +7,27 @@
     {
       "name": "Input",
       "type": "node",
-      "default": ""
+      "default": "",
+      "description": "The node whose geometry this reads; the selected elements are added to the named group and the geometry passed on."
     },
     {
       "name": "Group Name",
       "type": "group",
-      "default": "group1"
+      "default": "group1",
+      "description": "The name of the group the selection is written to, which downstream nodes can pick by name."
     },
     {
       "name": "Element Type",
       "type": "choice:Points,Primitives,Edges",
       "default": "Points",
-      "show_when": "Mode != Expand|Attribute"
+      "show_when": "Mode != Expand|Attribute",
+      "description": "What is selected: points, primitives (tested at their centre, with their points), or edges (both ends must match; their points are grouped)."
     },
     {
       "name": "Mode",
       "type": "choice:Box,Random,Attribute,Expand",
-      "default": "Box"
+      "default": "Box",
+      "description": "How elements are chosen: inside a box, a random count, by comparing an attribute to a threshold, or by growing or shrinking an existing group."
     },
     {
       "name": "Count",
@@ -32,7 +36,8 @@
       "min": 1.0,
       "max": 500.0,
       "step": 1.0,
-      "show_when": "Mode == Random"
+      "show_when": "Mode == Random",
+      "description": "How many elements are picked at random. More than there are selects them all."
     },
     {
       "name": "Seed",
@@ -41,7 +46,8 @@
       "min": 0.0,
       "max": 999.0,
       "step": 1.0,
-      "show_when": "Mode == Random"
+      "show_when": "Mode == Random",
+      "description": "Changes which elements are picked at random; the same seed always picks the same ones."
     },
     {
       "name": "Center",
@@ -49,7 +55,8 @@
       "default": "0.00:0.00:0.00",
       "min": -10.0,
       "max": 10.0,
-      "show_when": "Mode == Box"
+      "show_when": "Mode == Box",
+      "description": "The centre of the selection box, in world units."
     },
     {
       "name": "Size",
@@ -57,41 +64,48 @@
       "default": "1.00:1.00:1.00",
       "min": 0.0,
       "max": 20.0,
-      "show_when": "Mode == Box"
+      "show_when": "Mode == Box",
+      "description": "The full width, height and depth of the selection box, in world units. Elements inside or on its faces are selected."
     },
     {
       "name": "Invert",
       "type": "toggle",
-      "default": "false"
+      "default": "false",
+      "description": "Selects everything the mode would not select, and nothing it would."
     },
     {
       "name": "Highlight",
       "type": "toggle",
-      "default": "true"
+      "default": "true",
+      "description": "Tints the selected points amber so the group can be seen in the viewport. Changes their colour in the output."
     },
     {
       "name": "Attribute",
       "type": "attribute",
       "default": "",
-      "show_when": "Mode == Attribute"
+      "show_when": "Mode == Attribute",
+      "description": "The point attribute compared against Threshold; points without it are not selected."
     },
     {
       "name": "Comparison",
       "type": "choice:Below,Above",
       "default": "Above",
-      "show_when": "Mode == Attribute"
+      "show_when": "Mode == Attribute",
+      "description": "Whether points whose attribute is below or above Threshold are selected. A value equal to Threshold is never selected."
     },
     {
       "name": "Threshold",
       "type": "float",
       "default": "0.50",
-      "show_when": "Mode == Attribute"
+      "show_when": "Mode == Attribute",
+      "description": "The value the attribute is compared against."
     },
     {
       "name": "Source Group",
       "type": "group",
       "default": "",
-      "show_when": "Mode == Expand"
+      "show_when": "Mode == Expand",
+      "description": "The existing point group that is grown or shrunk across the mesh."
     },
     {
       "name": "Rings",
@@ -100,7 +114,8 @@
       "min": -8.0,
       "max": 8.0,
       "step": 1.0,
-      "show_when": "Mode == Expand"
+      "show_when": "Mode == Expand",
+      "description": "How many rings of neighbouring points the source group grows by. Negative shrinks it by peeling its boundary; 0 copies it unchanged."
     }
   ]
 }
diff --git a/nodes/hull.json b/nodes/hull.json
index ed817f6..17ee5fb 100644
--- a/nodes/hull.json
+++ b/nodes/hull.json
@@ -4,6 +4,6 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "Node whose points are wrapped in a convex hull. Input points that are flat or in a line cannot enclose a volume and pass through unchanged." }
  ]
 }
diff --git a/nodes/line.json b/nodes/line.json
index c512f8d..1d2b12b 100644
--- a/nodes/line.json
+++ b/nodes/line.json
@@ -4,7 +4,7 @@
   "inputs": 0,
   "outputs": 1,
   "params": [
-    { "name": "Length", "default": "1.0", "type": "slider" },
-    { "name": "Thickness", "default": "0.02", "type": "slider" }
+    { "name": "Length", "default": "1.0", "type": "slider", "description": "Height of the vertical bar in world units, measured up the Y axis from its start. The start is placed automatically by the node's order in the network." },
+    { "name": "Thickness", "default": "0.02", "type": "slider", "description": "Width of the bar's square cross-section in world units." }
   ]
 }
diff --git a/nodes/mold_shell.json b/nodes/mold_shell.json
index d7fa0a1..833267f 100644
--- a/nodes/mold_shell.json
+++ b/nodes/mold_shell.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Maximum Thickness", "type": "slider", "default": "0.75", "min": 0.001, "max": 5.0, "step": 0.01 },
-  { "name": "Minimum Thickness", "type": "slider", "default": "0.60", "min": 0.001, "max": 5.0, "step": 0.01 },
-  { "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01 },
-  { "name": "Ramp", "type": "choice:Linear,Smooth,Constant", "default": "Linear" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose surface is the cast; a shell is built inward from it with a thickness that follows its curvature." },
+  { "name": "Maximum Thickness", "type": "slider", "default": "0.75", "min": 0.001, "max": 5.0, "step": 0.01, "description": "Shell thickness in world units where the surface is most concave, cupping inward, where a mold needs the most material." },
+  { "name": "Minimum Thickness", "type": "slider", "default": "0.60", "min": 0.001, "max": 5.0, "step": 0.01, "description": "Shell thickness in world units where the surface is most convex, bulging outward." },
+  { "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01, "description": "Edge length in world units the surface is remeshed to before thickening, which sets how finely the thickness can vary." },
+  { "name": "Ramp", "type": "choice:Linear,Smooth,Constant", "default": "Linear", "description": "How curvature maps to thickness between the minimum and maximum. Linear is proportional, Smooth eases at both ends, Constant uses the maximum everywhere." }
  ]
 }
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
index 3bed3dd..4fd3cb8 100644
--- a/nodes/neighbour.json
+++ b/nodes/neighbour.json
@@ -7,23 +7,27 @@
     {
       "name": "Input",
       "type": "node",
-      "default": ""
+      "default": "",
+      "description": "The geometry whose point attribute is changed by comparing each point with the points around it."
     },
     {
       "name": "Attribute",
       "type": "attribute",
-      "default": "mass"
+      "default": "mass",
+      "description": "The point attribute to operate on; must already exist. Works on floats, integers and vectors; integers stay whole."
     },
     {
       "name": "Mode",
       "type": "choice:Diffuse,Concentrate,Migrate,Bleed,Align,Lead,Charge",
-      "default": "Diffuse"
+      "default": "Diffuse",
+      "description": "Diffuse smooths toward neighbours, Concentrate sharpens away from them, Migrate moves value along Direction, Bleed decays to zero, Align and Lead turn vectors, Charge accumulates and discharges."
     },
     {
       "name": "Neighbourhood",
       "type": "choice:Connectivity,Radius,Global",
       "default": "Connectivity",
-      "show_when": "Mode != Bleed"
+      "show_when": "Mode != Bleed",
+      "description": "Which points count as neighbours: those within Rings edges on the mesh, those within Radius in space (across folds too), or every point."
     },
     {
       "name": "Rings",
@@ -32,7 +36,8 @@
       "min": 1.0,
       "max": 8.0,
       "step": 1.0,
-      "show_when": "Neighbourhood == Connectivity && Mode != Bleed"
+      "show_when": "Neighbourhood == Connectivity && Mode != Bleed",
+      "description": "How many edge rings out from a point count as its neighbours. More rings spread the effect wider per evaluation."
     },
     {
       "name": "Radius",
@@ -41,7 +46,8 @@
       "min": 0.0,
       "max": 2.0,
       "step": 0.01,
-      "show_when": "Neighbourhood == Radius && Mode != Bleed"
+      "show_when": "Neighbourhood == Radius && Mode != Bleed",
+      "description": "Distance in world units within which points count as neighbours, regardless of mesh connectivity."
     },
     {
       "name": "Amount",
@@ -49,36 +55,42 @@
       "default": "0.50",
       "min": 0.0,
       "max": 1.0,
-      "step": 0.01
+      "step": 0.01,
+      "description": "Strength of each evaluation, 0 to 1: the fraction of the way toward the target, the fraction moved or decayed, or the charge added per step. Inside a simnet it acts as a rate."
     },
     {
       "name": "Direction",
       "type": "attribute",
       "default": "",
-      "show_when": "Mode == Migrate"
+      "show_when": "Mode == Migrate",
+      "description": "A vector point attribute giving the direction value flows in Migrate mode; neighbours in front of each point receive what it gives up."
     },
     {
       "name": "Group",
       "type": "group",
-      "default": ""
+      "default": "",
+      "description": "Only points in this group are changed; neighbours outside it are still read. Empty changes every point."
     },
     {
       "name": "Target",
       "type": "choice:Local Average,Global Average,Constant,Attribute,Surface Tangent",
       "default": "Local Average",
-      "show_when": "Mode == Align"
+      "show_when": "Mode == Align",
+      "description": "What vectors turn toward: the neighbours' average, the whole geometry's average, the Constant vector, the Source attribute, or the vector's own projection onto the surface."
     },
     {
       "name": "Constant",
       "type": "float3",
       "default": "0.00:1.00:0.00",
-      "show_when": "Mode == Align && Target == Constant"
+      "show_when": "Mode == Align && Target == Constant",
+      "description": "The fixed direction vectors turn toward when Target is Constant."
     },
     {
       "name": "Source",
       "type": "attribute",
       "default": "",
-      "show_when": "Mode == Align|Lead"
+      "show_when": "Mode == Align|Lead",
+      "description": "In Align with Target set to Attribute, the vector attribute to turn toward. In Lead, a number weighting each neighbour's influence; empty weighs all equally."
     },
     {
       "name": "Release",
@@ -87,7 +99,8 @@
       "min": 0.0,
       "max": 10.0,
       "step": 0.05,
-      "show_when": "Mode == Charge"
+      "show_when": "Mode == Charge",
+      "description": "Threshold at which a point's charge discharges, split evenly among its neighbours. Higher stores more before firing; 0 never discharges."
     }
   ]
 }
diff --git a/nodes/normal.json b/nodes/normal.json
index b4b8f73..70f55dd 100644
--- a/nodes/normal.json
+++ b/nodes/normal.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Class", "type": "choice:Points,Vertices", "default": "Points" },
-  { "name": "Attribute", "type": "attribute", "default": "N" },
-  { "name": "Cusp Angle", "type": "slider", "default": "60", "min": 0.0, "max": 180.0, "step": 1.0, "show_when": "Class == Vertices" },
-  { "name": "Flip", "type": "choice:false,true", "default": "false" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose normals are computed from its faces." },
+  { "name": "Class", "type": "choice:Points,Vertices", "default": "Points", "description": "Points writes one averaged normal per point. Vertices writes one per face corner, so edges sharper than the Cusp Angle stay hard." },
+  { "name": "Attribute", "type": "attribute", "default": "N", "description": "Name of the attribute the normals are written to. Empty leaves the geometry unchanged." },
+  { "name": "Cusp Angle", "type": "slider", "default": "60", "min": 0.0, "max": 180.0, "step": 1.0, "show_when": "Class == Vertices", "description": "In degrees: faces meeting at less than this angle are averaged and read smooth, sharper ones keep their own normal. 180 is fully smooth." },
+  { "name": "Flip", "type": "choice:false,true", "default": "false", "description": "Reverse the computed normals so they point the other way." }
  ]
 }
diff --git a/nodes/output.json b/nodes/output.json
index 762d95f..4f4425e 100644
--- a/nodes/output.json
+++ b/nodes/output.json
@@ -4,6 +4,6 @@
   "inputs": 1,
   "outputs": 0,
   "params": [
-    { "name": "Input", "default": "", "type": "node" }
+    { "name": "Input", "default": "", "type": "node", "description": "Node whose geometry becomes the result of the enclosing subnet, what nodes outside it read." }
   ]
 }
diff --git a/nodes/page.json b/nodes/page.json
index d6f8da0..dba4a62 100644
--- a/nodes/page.json
+++ b/nodes/page.json
@@ -4,14 +4,14 @@
  "inputs": 0,
  "outputs": 1,
  "params": [
-  { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square,Custom", "default": "Letter" },
-  { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches" },
-  { "name": "Width", "type": "float", "default": "8.5", "show_when": "Preset == Custom" },
-  { "name": "Height", "type": "float", "default": "11.0", "show_when": "Preset == Custom" },
-  { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "show_when": "Preset == Letter|A4|Legal|Tabloid" },
-  { "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6 },
-  { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0 },
-  { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01 },
-  { "name": "Position", "type": "float3", "default": "0.00:0.00:0.00", "min": -1000.0, "max": 1000.0 }
+  { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square,Custom", "default": "Letter", "description": "Size of the page. Letter, A4, Legal and Tabloid are paper sizes; HD, 4K and Square are fixed pixel sizes; Custom uses Width and Height." },
+  { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches", "description": "Unit that Width and Height, and every length on the nodes drawing onto this page, are written in. Pixels are converted to inches by Resolution." },
+  { "name": "Width", "type": "float", "default": "8.5", "show_when": "Preset == Custom", "description": "Page width for the Custom preset, in Units. In Pixels it is the image's pixel width; its physical size then depends on Resolution." },
+  { "name": "Height", "type": "float", "default": "11.0", "show_when": "Preset == Custom", "description": "Page height for the Custom preset, in Units. In Pixels it is the image's pixel height; its physical size then depends on Resolution." },
+  { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "show_when": "Preset == Letter|A4|Legal|Tabloid", "description": "Portrait keeps the paper size upright; Landscape swaps its width and height. Only paper presets can be turned." },
+  { "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6, "description": "Pixels per inch (DPI). More pixels for a paper size; for pixel presets it sets the physical size. Stored in exported PNGs so prints come out at size." },
+  { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "description": "Background colour the whole page is filled with before anything is drawn on it." },
+  { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "description": "Opacity of the background fill, 0 to 1. At 0 the page is clear and only what is drawn on it shows; clear areas are see-through in the viewport and PNG." },
+  { "name": "Position", "type": "float3", "default": "0.00:0.00:0.00", "min": -1000.0, "max": 1000.0, "description": "Where the centre of the page stands in the 3D scene, in world units. The page lies in the XY plane facing +Z at its physical size." }
  ]
 }
diff --git a/nodes/page_border.json b/nodes/page_border.json
index 0da56f4..49bcf5f 100644
--- a/nodes/page_border.json
+++ b/nodes/page_border.json
@@ -4,9 +4,9 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Width", "type": "float", "default": "0.06" },
-  { "name": "Inset", "type": "float", "default": "0.40" },
-  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 }
+  { "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image the border is drawn onto. Without one the node produces nothing." },
+  { "name": "Width", "type": "float", "default": "0.06", "description": "Thickness of the border band, in the page's Units, measured inward from its outer edge. Limited to half the page's shorter side." },
+  { "name": "Inset", "type": "float", "default": "0.40", "description": "Distance from the page edge to the outer edge of the border, in the page's Units. 0 puts the border flush with the edge." },
+  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the border band, drawn fully opaque over the page." }
  ]
 }
diff --git a/nodes/page_grid.json b/nodes/page_grid.json
index 000f5d5..6640cbd 100644
--- a/nodes/page_grid.json
+++ b/nodes/page_grid.json
@@ -4,11 +4,11 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Cell Size", "type": "float", "default": "0.25" },
-  { "name": "Line Width", "type": "float", "default": "0.01" },
-  { "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
-  { "name": "Fill Cells", "type": "toggle", "default": "false" },
-  { "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true" }
+  { "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image this grid is drawn onto. Without one the node produces nothing." },
+  { "name": "Cell Size", "type": "float", "default": "0.25", "description": "Spacing between grid lines, in the page's Units. Lines start at the top-left corner; smaller values give a denser grid, never finer than one pixel." },
+  { "name": "Line Width", "type": "float", "default": "0.01", "description": "Thickness of each grid line, in the page's Units, centred on its coordinate. Very small values are drawn as a hairline at least half a pixel wide." },
+  { "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the grid lines, drawn fully opaque over the page." },
+  { "name": "Fill Cells", "type": "toggle", "default": "false", "description": "When on, fills the whole page with Cell Color before ruling the lines, covering what the input had drawn. When off, lines are drawn over the input as is." },
+  { "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true", "description": "Colour the page is filled with under the grid lines when Fill Cells is on." }
  ]
 }
diff --git a/nodes/page_shape.json b/nodes/page_shape.json
index b1ddf49..23cb954 100644
--- a/nodes/page_shape.json
+++ b/nodes/page_shape.json
@@ -4,21 +4,21 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Shape", "type": "choice:Rectangle,Ellipse,Line,Polygon", "default": "Rectangle" },
-  { "name": "X", "type": "float", "default": "4.25" },
-  { "name": "Y", "type": "float", "default": "5.50" },
-  { "name": "Width", "type": "float", "default": "3.00" },
-  { "name": "Height", "type": "float", "default": "2.00", "show_when": "Shape != Line" },
-  { "name": "Rotation", "type": "slider", "default": "0", "min": -180.0, "max": 180.0, "step": 1.0 },
-  { "name": "Corner Radius", "type": "float", "default": "0.00", "show_when": "Shape == Rectangle" },
-  { "name": "Sides", "type": "spinbox", "default": "3", "min": 3, "max": 64, "step": 1, "show_when": "Shape == Polygon" },
-  { "name": "Fill", "type": "toggle", "default": "true", "show_when": "Shape != Line" },
-  { "name": "Fill Color", "type": "float3", "default": "0.20:0.45:0.85", "min": 0.0, "max": 1.0, "show_when": "Shape != Line && Fill == true" },
-  { "name": "Fill Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Shape != Line && Fill == true" },
-  { "name": "Stroke", "type": "toggle", "default": "true" },
-  { "name": "Stroke Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "show_when": "Stroke == true" },
-  { "name": "Stroke Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Stroke == true" },
-  { "name": "Stroke Width", "type": "float", "default": "0.02", "show_when": "Stroke == true" }
+  { "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image the shape is drawn onto. Without one the node produces nothing." },
+  { "name": "Shape", "type": "choice:Rectangle,Ellipse,Line,Polygon", "default": "Rectangle", "description": "Outline to draw: a rectangle, an ellipse, a straight line, or a regular polygon fitted inside the Width by Height box." },
+  { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the shape's centre, in the page's Units from the page's left edge." },
+  { "name": "Y", "type": "float", "default": "5.50", "description": "Vertical position of the shape's centre, in the page's Units from the page's top edge (y runs down)." },
+  { "name": "Width", "type": "float", "default": "3.00", "description": "Width of the shape's box before rotation, in the page's Units. For a line, its length." },
+  { "name": "Height", "type": "float", "default": "2.00", "show_when": "Shape != Line", "description": "Height of the shape's box before rotation, in the page's Units. A line's thickness comes from Stroke Width instead." },
+  { "name": "Rotation", "type": "slider", "default": "0", "min": -180.0, "max": 180.0, "step": 1.0, "description": "Turns the shape about its centre, in degrees; positive is clockwise as the page is viewed." },
+  { "name": "Corner Radius", "type": "float", "default": "0.00", "show_when": "Shape == Rectangle", "description": "Rounds the rectangle's corners, in the page's Units. 0 gives sharp corners; limited to half the shorter side." },
+  { "name": "Sides", "type": "spinbox", "default": "3", "min": 3, "max": 64, "step": 1, "show_when": "Shape == Polygon", "description": "Number of corners of the polygon, 3 to 64, with the first corner at the top. More sides approach an ellipse." },
+  { "name": "Fill", "type": "toggle", "default": "true", "show_when": "Shape != Line", "description": "When on, the inside of the shape is filled with Fill Color. Turn off for an outline only." },
+  { "name": "Fill Color", "type": "float3", "default": "0.20:0.45:0.85", "min": 0.0, "max": 1.0, "show_when": "Shape != Line && Fill == true", "description": "Colour of the shape's fill; how solid it is comes from Fill Opacity." },
+  { "name": "Fill Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Shape != Line && Fill == true", "description": "Opacity of the fill, 0 (invisible) to 1 (solid), blended over what is already on the page." },
+  { "name": "Stroke", "type": "toggle", "default": "true", "description": "When on, the outline is drawn in Stroke Color at Stroke Width. A line shape is drawn only while this is on." },
+  { "name": "Stroke Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "show_when": "Stroke == true", "description": "Colour of the outline, and of the whole line for the Line shape." },
+  { "name": "Stroke Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Stroke == true", "description": "Opacity of the outline, 0 (invisible) to 1 (solid), blended over the fill and the page." },
+  { "name": "Stroke Width", "type": "float", "default": "0.02", "show_when": "Stroke == true", "description": "Thickness of the outline in the page's Units, centred on the shape's edge (half inside, half out). For a line, its thickness." }
  ]
 }
diff --git a/nodes/page_text.json b/nodes/page_text.json
index 3629811..11df1e9 100644
--- a/nodes/page_text.json
+++ b/nodes/page_text.json
@@ -4,15 +4,15 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Text", "type": "text", "default": "Title" },
-  { "name": "Font", "type": "text", "default": "" },
-  { "name": "Size", "type": "float", "default": "0.25" },
-  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
-  { "name": "X", "type": "float", "default": "4.25" },
-  { "name": "Y", "type": "float", "default": "0.80" },
-  { "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center" },
-  { "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top" },
-  { "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05 }
+  { "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image the text is drawn onto. Without one the node produces nothing." },
+  { "name": "Text", "type": "text", "default": "Title", "description": "The text to draw. It is not wrapped: a long line runs past the page edge rather than breaking." },
+  { "name": "Font", "type": "text", "default": "", "description": "Font family name, such as a system font installed on this machine. Empty uses the default font." },
+  { "name": "Size", "type": "float", "default": "0.25", "description": "Font size in the page's Units. The same size prints the same at any Resolution; only the pixel count changes." },
+  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the text, drawn fully opaque over the page." },
+  { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the text's anchor, in the page's Units from the left edge. Horizontal decides which part of the text sits here." },
+  { "name": "Y", "type": "float", "default": "0.80", "description": "Vertical position of the text's anchor, in the page's Units from the top edge (y runs down). Vertical decides which part of the text sits here." },
+  { "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center", "description": "Which part of the text is placed at X: its left edge, its centre, or its right edge." },
+  { "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top", "description": "Which part of the text block is placed at Y: its top, its middle, or its bottom." },
+  { "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05, "description": "Line spacing as a multiple of Size. 1 sets lines tightly; larger values space multi-line text further apart." }
  ]
 }
diff --git a/nodes/plane.json b/nodes/plane.json
index 7145267..676c8b3 100644
--- a/nodes/plane.json
+++ b/nodes/plane.json
@@ -7,12 +7,14 @@
     {
       "name": "Width",
       "default": "1.0",
-      "type": "slider:0:4"
+      "type": "slider:0:4",
+      "description": "Size of the sheet along X in world units."
     },
     {
       "name": "Length",
       "default": "1.0",
-      "type": "slider:0:4"
+      "type": "slider:0:4",
+      "description": "Size of the sheet along Z in world units."
     },
     {
       "name": "Columns",
@@ -20,7 +22,8 @@
       "type": "spinbox",
       "min": 1,
       "max": 128,
-      "step": 1
+      "step": 1,
+      "description": "Number of quad divisions across the width (X). More columns give a finer sheet."
     },
     {
       "name": "Rows",
@@ -28,27 +31,32 @@
       "type": "spinbox",
       "min": 1,
       "max": 128,
-      "step": 1
+      "step": 1,
+      "description": "Number of quad divisions along the length (Z). More rows give a finer sheet."
     },
     {
       "name": "Center X",
       "default": "0.0",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "X position of the sheet's center in world units."
     },
     {
       "name": "Center Y",
       "default": "0.0",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "Height of the flat sheet in world units; the plane lies level in XZ at this Y."
     },
     {
       "name": "Center Z",
       "default": "0.0",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "Z position of the sheet's center in world units."
     },
     {
       "name": "Color",
       "default": "true",
-      "type": "toggle"
+      "type": "toggle",
+      "description": "When on, colors the points with a gradient across the sheet; when off, the plane uses the plain default color."
     }
   ]
 }
diff --git a/nodes/points.json b/nodes/points.json
index 78142ca..6ae9e7c 100644
--- a/nodes/points.json
+++ b/nodes/points.json
@@ -10,17 +10,20 @@
       "default": "100",
       "min": 10.0,
       "max": 5000.0,
-      "step": 10.0
+      "step": 10.0,
+      "description": "Number of points to generate along the chosen Shape. With Markers on, each one also gets a small marker sphere."
     },
     {
       "name": "Shape",
       "type": "choice:None,Spiral,Line,Circle,Grid",
-      "default": "None"
+      "default": "None",
+      "description": "Pattern the points are laid out in: Spiral, Line, Circle or a square Grid. None puts every point at the same spot. Placed automatically by the node's order in the network."
     },
     {
       "name": "Markers",
       "type": "choice:true,false",
-      "default": "true"
+      "default": "true",
+      "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read."
     }
   ]
 }
diff --git a/nodes/polygon.json b/nodes/polygon.json
index 4236a7e..2949b83 100644
--- a/nodes/polygon.json
+++ b/nodes/polygon.json
@@ -4,10 +4,10 @@
  "inputs": 0,
  "outputs": 1,
  "params": [
-  { "name": "Sides", "type": "spinbox", "default": "4", "min": 3.0, "max": 256.0, "step": 1.0 },
-  { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.01, "max": 10.0, "step": 0.01 },
-  { "name": "Inner Radius", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01 },
-  { "name": "Fill", "type": "choice:true,false", "default": "true" },
-  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" }
+  { "name": "Sides", "type": "spinbox", "default": "4", "min": 3.0, "max": 256.0, "step": 1.0, "description": "Number of corners: 3 is a triangle, 4 a square, high counts approach a circle. With an Inner Radius it is the number of star points." },
+  { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.01, "max": 10.0, "step": 0.01, "description": "Distance from the center to each outer corner, in world units." },
+  { "name": "Inner Radius", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01, "description": "Distance of the inner corners for a star, in world units. 0 makes a regular polygon; above 0 every other corner is pulled to this radius." },
+  { "name": "Fill", "type": "choice:true,false", "default": "true", "description": "true builds a filled shape of triangles fanned from the center; false builds only the outline as edges." },
+  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the shape's center in world units; the polygon lies flat in the XZ plane through this point." }
  ]
 }
diff --git a/nodes/relax.json b/nodes/relax.json
index cef9b7c..3209b35 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -7,24 +7,28 @@
     {
       "name": "Input",
       "default": "",
-      "type": "node"
+      "type": "node",
+      "description": "The geometry to relax. Its points are moved and everything else passes through."
     },
     {
       "name": "Mode",
       "type": "choice:Springs,Repel",
-      "default": "Springs"
+      "default": "Springs",
+      "description": "Springs pulls edge lengths back toward those of the Rest geometry. Repel pushes points apart until spheres of Radius around them stop overlapping."
     },
     {
       "name": "Rest",
       "default": "",
       "type": "node",
-      "show_when": "Mode == Springs"
+      "show_when": "Mode == Springs",
+      "description": "The geometry whose edge lengths the springs restore; inside a simnet usually its input node. Must have the same point count as Input, or nothing moves."
     },
     {
       "name": "Pin Group",
       "default": "",
       "type": "group",
-      "show_when": "Mode == Springs"
+      "show_when": "Mode == Springs",
+      "description": "Points in this group keep their input position and push the others instead, so a displacement applied to them spreads through the surface. Empty pins nothing."
     },
     {
       "name": "Stiffness",
@@ -33,7 +37,8 @@
       "min": 0.0,
       "max": 1.0,
       "step": 0.05,
-      "show_when": "Mode == Springs"
+      "show_when": "Mode == Springs",
+      "description": "Fraction of each edge's length error corrected per pass. Higher values relax faster and stiffer; 0 leaves the points where they are."
     },
     {
       "name": "Iterations",
@@ -41,7 +46,8 @@
       "default": "8",
       "min": 0.0,
       "max": 50.0,
-      "step": 1.0
+      "step": 1.0,
+      "description": "Number of relaxation passes. More passes settle further. In Repel mode 0 turns the node off; Springs always runs at least one."
     },
     {
       "name": "Radius",
@@ -50,13 +56,15 @@
       "min": 0.001,
       "max": 1.0,
       "step": 0.001,
-      "show_when": "Mode == Repel"
+      "show_when": "Mode == Repel",
+      "description": "Radius of the sphere around each point, in world units; points are pushed apart until these spheres stop overlapping. Larger spaces points further apart."
     },
     {
       "name": "In 3D Space",
       "type": "toggle",
       "default": "false",
-      "show_when": "Mode == Repel"
+      "show_when": "Mode == Repel",
+      "description": "When off, points slide only along the surface (in their tangent plane) as they repel. When on, they may move freely in 3D and leave the surface."
     }
   ]
 }
diff --git a/nodes/remesh.json b/nodes/remesh.json
index cd4e72a..5e0c0b3 100644
--- a/nodes/remesh.json
+++ b/nodes/remesh.json
@@ -7,7 +7,8 @@
   {
    "name": "Input",
    "type": "node",
-   "default": ""
+   "default": "",
+   "description": "The node whose surface is remeshed. Its triangles are rebuilt toward an even edge length; point attributes ride through splits and collapses."
   },
   {
    "name": "Target Length",
@@ -15,7 +16,8 @@
    "default": "0.10",
    "min": 0.001,
    "max": 1.0,
-   "step": 0.005
+   "step": 0.005,
+   "description": "Edge length, in world units, the mesh is steered toward. Edges over 4/3 of it are split and edges under 4/5 collapsed; smaller means denser."
   },
   {
    "name": "Iterations",
@@ -23,7 +25,8 @@
    "default": "3",
    "min": 1.0,
    "max": 20.0,
-   "step": 1.0
+   "step": 1.0,
+   "description": "How many times the split, collapse, flip and relax passes repeat. More rounds settle the mesh closer to the target; it stops early once nothing changes."
   },
   {
    "name": "Relax",
@@ -31,56 +34,66 @@
    "default": "0.50",
    "min": 0.0,
    "max": 1.0,
-   "step": 0.01
+   "step": 0.01,
+   "description": "Strength of the tangential smoothing that slides points to even out triangles without moving the surface. 0 turns it off; 1 is full strength."
   },
   {
    "name": "Split",
    "type": "choice:true,false",
-   "default": "true"
+   "default": "true",
+   "description": "Whether edges longer than 4/3 of the Target Length are split at their midpoint, adding points where the mesh is too coarse."
   },
   {
    "name": "Collapse",
    "type": "choice:true,false",
-   "default": "true"
+   "default": "true",
+   "description": "Whether edges shorter than 4/5 of the Target Length are collapsed, removing points where the mesh is too dense. The surviving point keeps its values."
   },
   {
    "name": "Flip",
    "type": "choice:true,false",
-   "default": "true"
+   "default": "true",
+   "description": "Whether edges are flipped to bring points closer to six neighbours each, which evens out the shape of the triangles."
   },
   {
    "name": "Project",
    "type": "choice:true,false",
-   "default": "true"
+   "default": "true",
+   "description": "Whether relaxed points are pulled back onto the input surface after each round, so repeated remeshing does not shrink or drift the shape."
   },
   {
    "name": "Transfer",
    "type": "toggle",
-   "default": "false"
+   "default": "false",
+   "description": "After remeshing, copy point attributes (and optionally groups) onto the new points from the nearest point of a source mesh, so they survive the rebuild."
   },
   {
    "name": "From",
    "type": "node",
    "default": "",
-   "show_when": "Transfer == true"
+   "show_when": "Transfer == true",
+   "description": "The node whose point attributes and groups are copied onto the remeshed points. Empty uses this node's own input as it was before the remesh."
   },
   {
    "name": "Attributes",
    "type": "text",
    "default": "",
-   "show_when": "Transfer == true"
+   "show_when": "Transfer == true",
+   "description": "Comma-separated names of the point attributes to copy from the source. Empty copies every point attribute the source has."
   },
   {
    "name": "Transfer Groups",
    "type": "toggle",
    "default": "false",
-   "show_when": "Transfer == true"
+   "show_when": "Transfer == true",
+   "description": "Also copy point group membership from the nearest source point, joining and leaving groups to match the source."
   },
   {
    "name": "Groups",
    "type": "text",
    "default": "",
-   "show_when": "Transfer == true && Transfer Groups == true"
+   "show_when": "Transfer == true && Transfer Groups == true",
+   "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has."
   },
   {
    "name": "Maximum Distance",
@@ -89,7 +102,8 @@
    "min": 0.0,
    "max": 10.0,
    "step": 0.01,
-   "show_when": "Transfer == true"
+   "show_when": "Transfer == true",
+   "description": "Only points within this many world units of a source point receive values; farther points keep their own. 0 means no limit."
   }
  ]
 }
diff --git a/nodes/scatter.json b/nodes/scatter.json
index 4c0909d..cf7a6ad 100644
--- a/nodes/scatter.json
+++ b/nodes/scatter.json
@@ -4,16 +4,16 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "node" },
-    { "name": "Mode", "type": "choice:Volume,Surface", "default": "Volume" },
-    { "name": "Points", "type": "spinbox", "default": "100", "min": 10.0, "max": 10000.0, "step": 10.0 },
-    { "name": "Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Mode == Surface" },
-    { "name": "Relax Points", "type": "toggle", "default": "false", "show_when": "Mode == Surface" },
-    { "name": "Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Mode == Surface && Relax Points == true" },
-    { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Mode == Surface && Relax Points == true" },
-    { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Mode == Surface && Relax Points == true" },
-    { "name": "Max Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Mode == Surface && Relax Points == true && Use Max Relax Radius == true" },
-    { "name": "Radius", "type": "slider", "default": "0.02", "min": 0.005, "max": 0.1, "step": 0.005, "show_when": "Markers == true" },
-    { "name": "Markers", "type": "choice:true,false", "default": "true" }
+    { "name": "Input", "default": "", "type": "node", "description": "Node whose geometry the points are scattered inside (Volume) or on the surface of (Surface)." },
+    { "name": "Mode", "type": "choice:Volume,Surface", "default": "Volume", "description": "Volume places points inside the closed input shape; Surface places them on its surface, spread by area, with optional relaxing." },
+    { "name": "Points", "type": "spinbox", "default": "100", "min": 10.0, "max": 10000.0, "step": 10.0, "description": "Number of points to scatter. In Volume mode fewer may be produced if the shape is thin or not closed." },
+    { "name": "Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Mode == Surface", "description": "Random seed for Surface mode. Change it for a different point layout; the same seed always gives the same points." },
+    { "name": "Relax Points", "type": "toggle", "default": "false", "show_when": "Mode == Surface", "description": "When on, pushes the scattered points apart across the surface so they end up evenly spaced." },
+    { "name": "Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Mode == Surface && Relax Points == true", "description": "How many push-apart passes the points get. More passes give a more even spacing; 0 does none." },
+    { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Mode == Surface && Relax Points == true", "description": "Multiplier on each point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully." },
+    { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Mode == Surface && Relax Points == true", "description": "When on, the push radius is capped at Max Relax Radius." },
+    { "name": "Max Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Mode == Surface && Relax Points == true && Use Max Relax Radius == true", "description": "Upper limit on each point's push radius in world units, used when Use Max Relax Radius is on." },
+    { "name": "Radius", "type": "slider", "default": "0.02", "min": 0.005, "max": 0.1, "step": 0.005, "show_when": "Markers == true", "description": "Size of the marker sphere drawn at each point, in world units." },
+    { "name": "Markers", "type": "choice:true,false", "default": "true", "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read." }
   ]
 }
diff --git a/nodes/simnet.json b/nodes/simnet.json
index 733b9eb..81582b1 100644
--- a/nodes/simnet.json
+++ b/nodes/simnet.json
@@ -7,7 +7,8 @@
     {
       "name": "Input",
       "default": "",
-      "type": "node"
+      "type": "node",
+      "description": "The seed geometry the simulation starts from. It is what the simnet shows at its start frame and what the first step reads."
     },
     {
       "name": "Substeps",
@@ -15,17 +16,20 @@
       "default": "1",
       "min": 1.0,
       "max": 64.0,
-      "step": 1.0
+      "step": 1.0,
+      "description": "How many times the chain inside runs per frame. A dt detail attribute of 1/substeps is set each run, so rates scaled by it stay steady rather than speeding up."
     },
     {
       "name": "Start Frame",
       "type": "text",
-      "default": ""
+      "default": "",
+      "description": "The frame at which the simulation shows its seed; earlier frames also show the seed. Empty follows the timeline's start frame."
     },
     {
       "name": "Cache",
       "type": "choice:false,true",
-      "default": "false"
+      "default": "false",
+      "description": "When true, the latest solved frame is saved to disk so a later session can resume from it instead of solving from the seed."
     }
   ],
   "children": [
diff --git a/nodes/soft_transform.json b/nodes/soft_transform.json
index ea2db18..0f6179e 100644
--- a/nodes/soft_transform.json
+++ b/nodes/soft_transform.json
@@ -4,12 +4,12 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Translation", "type": "float3", "default": "0.00:0.20:0.00" },
-  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00" },
-  { "name": "Group", "type": "group", "default": "" },
-  { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01 },
-  { "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth" },
-  { "name": "Attribute", "type": "attribute", "default": "" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is moved, with the move fading out smoothly around the affected region." },
+  { "name": "Translation", "type": "float3", "default": "0.00:0.20:0.00", "description": "Offset in world units applied at full strength to the affected points, and faded by the falloff for points around them." },
+  { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Point in world space the falloff is measured from when no Group is given. Points within Radius of it are moved." },
+  { "name": "Group", "type": "group", "default": "", "description": "Point group to move at full strength; the falloff is measured from its nearest member rather than from Center. Empty uses Center." },
+  { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01, "description": "Distance in world units over which the move fades to nothing. Points farther than this from the center or group are not moved." },
+  { "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth", "description": "Shape of the fade with distance. Smooth eases in with no crease, Linear falls off evenly, and Constant moves everything inside the radius fully." },
+  { "name": "Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute to write each point's falloff weight to, from 1 at the center to 0 at the radius. Empty writes nothing." }
  ]
 }
diff --git a/nodes/sphere.json b/nodes/sphere.json
index 2df581d..0b8e142 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -7,12 +7,14 @@
     {
       "name": "Method",
       "default": "UV",
-      "type": "choice:UV,Icosphere,Cube"
+      "type": "choice:UV,Icosphere,Cube",
+      "description": "How the sphere is tessellated: UV uses latitude rows and longitude columns, Icosphere subdivides an icosahedron into even triangles, Cube projects a gridded cube into quads."
     },
     {
       "name": "Radius",
       "default": "0.5",
-      "type": "slider"
+      "type": "slider",
+      "description": "Radius of the sphere in world units, the distance from Center to every point on its surface."
     },
     {
       "name": "Rows",
@@ -21,7 +23,8 @@
       "min": 2,
       "max": 128,
       "step": 1,
-      "show_when": "Method == UV"
+      "show_when": "Method == UV",
+      "description": "Number of latitude bands from pole to pole for the UV method. More rows give a smoother profile and more points."
     },
     {
       "name": "Columns",
@@ -30,7 +33,8 @@
       "min": 3,
       "max": 128,
       "step": 1,
-      "show_when": "Method == UV"
+      "show_when": "Method == UV",
+      "description": "Number of longitude segments around the UV sphere. More columns give a rounder silhouette and more points."
     },
     {
       "name": "Frequency",
@@ -39,7 +43,8 @@
       "min": 1,
       "max": 16,
       "step": 1,
-      "show_when": "Method == Icosphere"
+      "show_when": "Method == Icosphere",
+      "description": "How many times each icosahedron edge is divided for the Icosphere method; each of the 20 faces becomes Frequency squared triangles."
     },
     {
       "name": "Resolution",
@@ -48,27 +53,32 @@
       "min": 1,
       "max": 64,
       "step": 1,
-      "show_when": "Method == Cube"
+      "show_when": "Method == Cube",
+      "description": "Grid divisions along each edge of every cube face for the Cube method; each face gets Resolution squared quads."
     },
     {
       "name": "Center X",
       "default": "0.0",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "X position of the sphere's center in world units."
     },
     {
       "name": "Center Y",
       "default": "0.55",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "Y (height) position of the sphere's center in world units."
     },
     {
       "name": "Center Z",
       "default": "0.0",
-      "type": "slider:-2:2"
+      "type": "slider:-2:2",
+      "description": "Z position of the sphere's center in world units."
     },
     {
       "name": "Color",
       "default": "true",
-      "type": "toggle"
+      "type": "toggle",
+      "description": "When on, colors each point by its surface direction (normal mapped to RGB); when off, the sphere uses the plain default color."
     }
   ]
 }
diff --git a/nodes/subdivide.json b/nodes/subdivide.json
index f1f0dcd..458b75c 100644
--- a/nodes/subdivide.json
+++ b/nodes/subdivide.json
@@ -4,7 +4,7 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Depth", "type": "spinbox", "default": "1", "min": 0.0, "max": 6.0, "step": 1.0 }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose triangles are subdivided." },
+  { "name": "Depth", "type": "spinbox", "default": "1", "min": 0.0, "max": 6.0, "step": 1.0, "description": "How many times each triangle is split into four at its edge midpoints. Each level quadruples the count; points do not move. 0 passes the input through." }
  ]
 }
diff --git a/nodes/subnet.json b/nodes/subnet.json
index f370d32..b0bf005 100644
--- a/nodes/subnet.json
+++ b/nodes/subnet.json
@@ -4,6 +4,6 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "node" }
+    { "name": "Input", "default": "", "type": "node", "description": "Node whose geometry is handed into this subnet; the input node inside reads it." }
   ]
 }
diff --git a/nodes/suture.json b/nodes/suture.json
index 7b04db9..346e994 100644
--- a/nodes/suture.json
+++ b/nodes/suture.json
@@ -4,10 +4,10 @@
  "inputs": 2,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Against", "type": "node", "default": "" },
-  { "name": "Distance Threshold", "type": "slider", "default": "0.05", "min": 0.0, "max": 1.0, "step": 0.005 },
-  { "name": "Fusion Threshold", "type": "spinbox", "default": "3", "min": 1.0, "max": 100.0, "step": 1.0 },
-  { "name": "Counter", "type": "attribute", "default": "contact" }
+  { "name": "Input", "type": "node", "default": "", "description": "The surface to resolve. Points too close to the Against geometry are pushed out, and points in sustained contact are fused." },
+  { "name": "Against", "type": "node", "default": "", "description": "The geometry the input is kept apart from, read as a surface. With nothing connected no contact is found and nothing is pushed." },
+  { "name": "Distance Threshold", "type": "slider", "default": "0.05", "min": 0.0, "max": 1.0, "step": 0.005, "description": "Contact distance in world units: nearer points are pushed back out to it, and fusing points within it merge. 0 turns the node off." },
+  { "name": "Fusion Threshold", "type": "spinbox", "default": "3", "min": 1.0, "max": 100.0, "step": 1.0, "description": "How many evaluations in a row a point must stay in contact, usually simulation steps, before it can fuse with nearby fusing points. Higher fuses later." },
+  { "name": "Counter", "type": "attribute", "default": "contact", "description": "Integer point attribute counting consecutive steps in contact; it resets to 0 when contact breaks. Empty turns the node off." }
  ]
 }
diff --git a/nodes/switch.json b/nodes/switch.json
index 82d94ac..4d92fcc 100644
--- a/nodes/switch.json
+++ b/nodes/switch.json
@@ -4,10 +4,10 @@
  "inputs": 4,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Input 2", "type": "node", "default": "" },
-  { "name": "Input 3", "type": "node", "default": "" },
-  { "name": "Input 4", "type": "node", "default": "" },
-  { "name": "Index", "type": "spinbox", "default": "0", "min": 0, "max": 3, "step": 1 }
+  { "name": "Input", "type": "node", "default": "", "description": "Node whose geometry is passed through unchanged when Index is 0." },
+  { "name": "Input 2", "type": "node", "default": "", "description": "Node whose geometry is passed through unchanged when Index is 1." },
+  { "name": "Input 3", "type": "node", "default": "", "description": "Node whose geometry is passed through unchanged when Index is 2." },
+  { "name": "Input 4", "type": "node", "default": "", "description": "Node whose geometry is passed through unchanged when Index is 3." },
+  { "name": "Index", "type": "spinbox", "default": "0", "min": 0, "max": 3, "step": 1, "description": "Which input to pass on: 0 is Input, 1 is Input 2, and so on. Values past the range are clamped; an empty slot passes nothing." }
  ]
 }
diff --git a/nodes/time.json b/nodes/time.json
index e03fbb1..d6c9a3a 100644
--- a/nodes/time.json
+++ b/nodes/time.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "t" },
-  { "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0 },
-  { "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0 },
-  { "name": "Clamp", "type": "choice:true,false", "default": "true" }
+  { "name": "Input", "type": "node", "default": "", "description": "The geometry that passes through with a time detail attribute added." },
+  { "name": "Attribute", "type": "attribute", "default": "t", "description": "Name of the detail attribute written with the 0 to 1 progress through the frame range. Empty writes nothing." },
+  { "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 0." },
+  { "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 1. If equal to Start Frame, it jumps from 0 to 1 at that frame." },
+  { "name": "Clamp", "type": "choice:true,false", "default": "true", "description": "When true the value stays between 0 and 1 outside the range. When false it keeps running, below 0 before the start and above 1 after the end." }
  ]
 }
diff --git a/nodes/transfer.json b/nodes/transfer.json
index 63b7d47..68ab5dc 100644
--- a/nodes/transfer.json
+++ b/nodes/transfer.json
@@ -7,28 +7,33 @@
   {
    "name": "Input",
    "type": "node",
-   "default": ""
+   "default": "",
+   "description": "The geometry that receives values: each of its points takes them from the nearest point of the From geometry."
   },
   {
    "name": "From",
    "type": "node",
-   "default": ""
+   "default": "",
+   "description": "The geometry to copy point attributes and groups from, by nearest point. Nothing is copied if it is unconnected or has no points."
   },
   {
    "name": "Attributes",
    "type": "text",
-   "default": ""
+   "default": "",
+   "description": "Comma-separated names of the point attributes to copy. Empty copies every point attribute the source has."
   },
   {
    "name": "Transfer Groups",
    "type": "toggle",
-   "default": "false"
+   "default": "false",
+   "description": "Also copies point group membership from the nearest source point, so points join or leave each group to match the source."
   },
   {
    "name": "Groups",
    "type": "text",
    "default": "",
-   "show_when": "Transfer Groups == true"
+   "show_when": "Transfer Groups == true",
+   "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has."
   },
   {
    "name": "Maximum Distance",
@@ -36,12 +41,14 @@
    "default": "0.00",
    "min": 0.0,
    "max": 10.0,
-   "step": 0.01
+   "step": 0.01,
+   "description": "Points further than this from any source point, in world units, receive nothing. 0 means no limit."
   },
   {
    "name": "Group",
    "type": "group",
-   "default": ""
+   "default": "",
+   "description": "Only points of the input in this group receive values. Empty lets every point receive."
   }
  ]
 }
diff --git a/nodes/transform.json b/nodes/transform.json
index 41d6799..346bef5 100644
--- a/nodes/transform.json
+++ b/nodes/transform.json
@@ -4,7 +4,7 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "node" },
-    { "name": "Translation", "default": "0.00:0.00:0.00", "type": "float3", "min": -10.0, "max": 10.0 }
+    { "name": "Input", "default": "", "type": "node", "description": "The node whose geometry is moved by the Translation." },
+    { "name": "Translation", "default": "0.00:0.00:0.00", "type": "float3", "min": -10.0, "max": 10.0, "description": "Offset in world units added to every point's position." }
   ]
 }
diff --git a/nodes/valence.json b/nodes/valence.json
index f6f6e8d..43f0301 100644
--- a/nodes/valence.json
+++ b/nodes/valence.json
@@ -4,8 +4,8 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Attribute", "type": "attribute", "default": "valence" },
-  { "name": "Measure", "type": "choice:Neighbours,Primitives", "default": "Neighbours" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is measured; each point's valence is written as a point attribute and the geometry passed on." },
+  { "name": "Attribute", "type": "attribute", "default": "valence", "description": "The name of the integer point attribute the count is written to. Empty writes nothing." },
+  { "name": "Measure", "type": "choice:Neighbours,Primitives", "default": "Neighbours", "description": "What is counted per point: its neighbouring points along edges, or the primitives that use it." }
  ]
 }
diff --git a/nodes/visualize.json b/nodes/visualize.json
index 2090268..f8d1db8 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -7,47 +7,55 @@
     {
       "name": "Input",
       "type": "node",
-      "default": ""
+      "default": "",
+      "description": "The node whose geometry this reads; its point colours are set, or vector lines added, and the geometry passed on."
     },
     {
       "name": "Attribute",
       "type": "attribute",
-      "default": "mass"
+      "default": "mass",
+      "description": "The point attribute shown. Ramp colours points by its value (first component); Vector draws it as a line from each point."
     },
     {
       "name": "Mode",
       "type": "choice:Ramp,Vector",
-      "default": "Ramp"
+      "default": "Ramp",
+      "description": "Ramp colours each point by the attribute's value; Vector draws a line from each point along the attribute as a direction."
     },
     {
       "name": "Ramp",
       "type": "choice:Grayscale,Heat,Spectrum,Viridis",
       "default": "Viridis",
-      "show_when": "Mode == Ramp"
+      "show_when": "Mode == Ramp",
+      "description": "The colour scale values are mapped through, from the low end to the high end."
     },
     {
       "name": "Range",
       "type": "choice:Auto,Manual",
       "default": "Auto",
-      "show_when": "Mode == Ramp"
+      "show_when": "Mode == Ramp",
+      "description": "Auto spreads the ramp from the attribute's lowest to highest value over all points; Manual uses From and To."
     },
     {
       "name": "From",
       "type": "float",
       "default": "0.00",
-      "show_when": "Mode == Ramp && Range == Manual"
+      "show_when": "Mode == Ramp && Range == Manual",
+      "description": "The value shown at the low end of the ramp. Values below it are clamped to that end."
     },
     {
       "name": "To",
       "type": "float",
       "default": "1.00",
-      "show_when": "Mode == Ramp && Range == Manual"
+      "show_when": "Mode == Ramp && Range == Manual",
+      "description": "The value shown at the high end of the ramp. Values above it are clamped to that end."
     },
     {
       "name": "Blend",
       "type": "choice:Set,Mix,Multiply,Add",
       "default": "Set",
-      "show_when": "Mode == Ramp"
+      "show_when": "Mode == Ramp",
+      "description": "How the ramp colour meets the existing colour: Set and Mix replace it, Multiply tints it, Add brightens it. Opacity then fades toward the result."
     },
     {
       "name": "Opacity",
@@ -56,7 +64,8 @@
       "min": 0.0,
       "max": 1.0,
       "step": 0.01,
-      "show_when": "Mode == Ramp"
+      "show_when": "Mode == Ramp",
+      "description": "How far each point's colour moves from its old colour toward the blended ramp colour: 1 is fully, 0 not at all."
     },
     {
       "name": "Scale",
@@ -65,12 +74,14 @@
       "min": 0.0,
       "max": 2.0,
       "step": 0.01,
-      "show_when": "Mode == Vector"
+      "show_when": "Mode == Vector",
+      "description": "Multiplies the attribute's vector to get the drawn line's length, in world units per unit of the attribute."
     },
     {
       "name": "Group",
       "type": "group",
-      "default": ""
+      "default": "",
+      "description": "A point group limiting which points are coloured or get a line. Empty means every point; Auto range still measures all points."
     }
   ]
 }
diff --git a/nodes/volume.json b/nodes/volume.json
index 252c92f..68cd091 100644
--- a/nodes/volume.json
+++ b/nodes/volume.json
@@ -4,10 +4,10 @@
  "inputs": 1,
  "outputs": 1,
  "params": [
-  { "name": "Input", "type": "node", "default": "" },
-  { "name": "Mode", "type": "choice:Offset,Shell", "default": "Offset" },
-  { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005 },
-  { "name": "Offset", "type": "slider", "default": "0.00", "min": -1.0, "max": 1.0, "step": 0.005 },
-  { "name": "Thickness", "type": "slider", "default": "0.05", "min": 0.001, "max": 1.0, "step": 0.005, "show_when": "Mode == Shell" }
+  { "name": "Input", "type": "node", "default": "", "description": "The node whose closed surface is turned into a distance field and rebuilt as an offset surface or a shell." },
+  { "name": "Mode", "type": "choice:Offset,Shell", "default": "Offset", "description": "Offset moves the whole surface out or in. Shell keeps only a wall between the offset surface and a copy moved inward by Thickness." },
+  { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005, "description": "Grid spacing of the distance field in world units. Smaller keeps finer detail but costs more; the grid is capped at 256 samples per axis." },
+  { "name": "Offset", "type": "slider", "default": "0.00", "min": -1.0, "max": 1.0, "step": 0.005, "description": "How far, in world units, the surface is moved: positive grows it outward, negative shrinks it inward. 0 rebuilds it in place." },
+  { "name": "Thickness", "type": "slider", "default": "0.05", "min": 0.001, "max": 1.0, "step": 0.005, "show_when": "Mode == Shell", "description": "Wall thickness of the shell in world units, measured inward from the offset surface." }
  ]
 }
diff --git a/nodes/wrangle.json b/nodes/wrangle.json
index f9d3712..6fc458c 100644
--- a/nodes/wrangle.json
+++ b/nodes/wrangle.json
@@ -4,13 +4,14 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "node" },
-    { "name": "Class", "type": "choice:Points,Primitives,Detail", "default": "Points" },
-    { "name": "Group", "default": "", "type": "group" },
+    { "name": "Input", "default": "", "type": "node", "description": "The node whose geometry the script runs over. With nothing connected the script runs on empty geometry and can build its own." },
+    { "name": "Class", "type": "choice:Points,Primitives,Detail", "default": "Points", "description": "What the script runs once for: each point, each primitive, or once for the whole geometry (Detail). Sets what @P and @name refer to." },
+    { "name": "Group", "default": "", "type": "group", "description": "A group of the chosen class limiting which elements the script runs on. Empty means all; ignored in Detail class." },
     {
       "name": "Code",
       "type": "code",
-      "default": "// Runs once per point. @P is the position, @name any attribute;\n// ch(\"Name\") reads a parameter, neighbours(@ptnum) the topology.\n@P.y += sin(@P.x * 4.0) * 0.15;\n"
+      "default": "// Runs once per point. @P is the position, @name any attribute;\n// ch(\"Name\") reads a parameter, neighbours(@ptnum) the topology.\n@P.y += sin(@P.x * 4.0) * 0.15;\n",
+      "description": "The Rhai script run per element. @name reads or writes an attribute, ch(\"path\") reads a parameter. Applies on ctrl+enter or when leaving the editor; an error leaves the input unchanged."
     }
   ]
 }
diff --git a/src/app.rs b/src/app.rs
index b638717..503e6b0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -666,6 +666,31 @@ pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static
     (control, ty)
 }
 
+/// How wide a line of a parameter's description is in its row menu, in
+/// characters. The menu is as wide as its widest row, so an unwrapped
+/// sentence would stretch it across the window.
+pub const PARAM_DESCRIPTION_WIDTH: usize = 44;
+
+/// `text` broken into lines of at most `width` characters, at spaces; a
+/// word longer than a line has one to itself.
+pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
+    let mut lines: Vec<String> = Vec::new();
+    let mut line = String::new();
+    for word in text.split_whitespace() {
+        if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width {
+            lines.push(std::mem::take(&mut line));
+        }
+        if !line.is_empty() {
+            line.push(' ');
+        }
+        line.push_str(word);
+    }
+    if !line.is_empty() {
+        lines.push(line);
+    }
+    lines
+}
+
 /// The range a presented row carries in its display type (`slider:lo:hi`,
 /// `float3:lo:hi`), for a row whose parameter declares none of its own.
 fn shown_row_range(shown: &str) -> Option<(f32, f32, Option<f32>)> {
@@ -5186,7 +5211,11 @@ impl State {
     /// parameter's name, which is what a `ch()` path and a wire spell —
     /// with `Label:` after it only when the template gives one (the pane
     /// shows the name otherwise, and a Label row repeating it would say
-    /// there is one), and `Shown when:` closes it with the row's
+    /// there is one) and the parameter's DESCRIPTION under them, ahead of
+    /// `Control:` — what it does, from the template
+    /// (`ParamDef::description`), wrapped to `PARAM_DESCRIPTION_WIDTH` over
+    /// as many rows as it takes and unprefixed, since it reads as prose and
+    /// not as a field — and `Shown when:` closes the list with the row's
     /// `show_when` condition when it has one.
     pub fn param_menu_rows(&self, slot: usize, pname: &str) -> (Vec<String>, Vec<ParamMenuAction>, usize) {
         let dir = self.param_editor_dir();
@@ -5201,6 +5230,9 @@ impl State {
         if let Some(label) = param.map(|p| p.label.as_str()).filter(|l| !l.is_empty()) {
             options.push(format!("Label: {label}"));
         }
+        if let Some(p) = param {
+            options.extend(wrap_words(&p.description, PARAM_DESCRIPTION_WIDTH));
+        }
         options.push(format!("Control: {control}"));
         options.push(format!("Type: {ty}"));
         options.push(format!("Expression: {is_expr}"));
diff --git a/src/main.rs b/src/main.rs
index 6c4b88a..4c2823f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4578,6 +4578,8 @@ mod tests {
                 o.entry(k).or_insert(Value::Null);
             }
             o.entry("show_when").or_insert("".into());
+            // A template's description is read and never written.
+            o.remove("description");
             if o.get("expr") == Some(&Value::Bool(false)) {
                 o.remove("expr");
             }
@@ -8956,6 +8958,88 @@ mod tests {
         assert!(!rename_node_in_tree(&mut root, "zzz", "x"), "and so is one of a node that is not there");
     }
 
+    /// Every parameter a template ships says what it does, and the row
+    /// menu shows it: under the name, wrapped so the menu stays narrow,
+    /// ahead of the readouts. The description is the TEMPLATE's: an
+    /// instance loaded from a save carries none and is handed it by the
+    /// merge, a child of a subnet template takes its base template's, and
+    /// a save never writes one.
+    #[test]
+    fn the_row_menu_says_what_a_parameter_does() {
+        use crate::app::{wrap_words, McpAction, PARAM_DESCRIPTION_WIDTH};
+        // Every shipped template's own parameters, read off the RAW files
+        // so a description the loader dropped would not pass for one.
+        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("nodes");
+        let mut missing = Vec::new();
+        for entry in fs::read_dir(&dir).unwrap().flatten() {
+            let path = entry.path();
+            if path.extension().and_then(|e| e.to_str()) != Some("json") {
+                continue;
+            }
+            let v: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
+            for p in v["params"].as_array().into_iter().flatten() {
+                let d = p["description"].as_str().unwrap_or("").trim();
+                if d.is_empty() {
+                    missing.push(format!("{}: {}", path.file_name().unwrap().to_string_lossy(), p["name"]));
+                }
+            }
+        }
+        assert!(missing.is_empty(), "parameters with no description: {missing:#?}");
+
+        // A wrap keeps every word, in order, and no line is wider than
+        // the width unless one word is.
+        let text = "Radius of the sphere in world units; larger values make a bigger ball.";
+        let lines = wrap_words(text, 20);
+        assert_eq!(lines.join(" "), text);
+        assert!(lines.iter().all(|l| l.chars().count() <= 20), "{lines:?}");
+        assert_eq!(wrap_words("antidisestablishmentarianism is long", 10), vec!["antidisestablishmentarianism", "is long"]);
+        assert!(wrap_words("", 10).is_empty());
+
+        let mut state = State::new(false);
+        state.param_editor = crate::slots::CONTENT_IDX;
+        let mut redraw = false;
+        let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        // sphere1 comes from the bundled project, whose file has no
+        // descriptions: the merge hands it the template's.
+        let sphere = slot_of(&state, "sphere1");
+        let templates = state.node_templates.clone();
+        let template = |name: &str, pname: &str| -> String {
+            templates.iter().find(|t| t.node.name == name).unwrap().node.params.iter()
+                .find(|p| p.name == pname).unwrap().description.clone()
+        };
+        let radius = template("Sphere", "Radius");
+        let want = wrap_words(&radius, PARAM_DESCRIPTION_WIDTH);
+        assert!(want.len() > 1, "a sentence spans rows: {want:?}");
+        let (rows, _, headers) = state.param_menu_rows(sphere, "Radius");
+        assert_eq!(rows[0], "Name: Radius");
+        assert_eq!(&rows[1..1 + want.len()], &want[..], "the description sits under the name");
+        assert_eq!(rows[1 + want.len()], "Control: slider");
+        assert!(1 + want.len() < headers, "the description rows are headers, and run nothing");
+
+        // A child inside a subnet template takes its base template's.
+        state.apply_action(McpAction::AddNode { template_name: "Embryo".into(), name: Some("embryo1".into()), x: 3.0, y: 8.0 }, &mut redraw).unwrap();
+        let embryo = slot_of(&state, "embryo1");
+        let (rows, _, _) = state.param_menu_rows(embryo, "Radius");
+        let own = wrap_words(&template("Embryo", "Radius"), PARAM_DESCRIPTION_WIDTH);
+        assert_ne!(own, want, "the Embryo's Radius is described as the Embryo's");
+        assert_eq!(&rows[1..1 + own.len()], &own[..]);
+        state.apply_action(McpAction::Enter { slot: embryo }, &mut redraw).unwrap();
+        let inner = slot_of(&state, "sphere1");
+        let (rows, _, _) = state.param_menu_rows(inner, "Radius");
+        assert_eq!(&rows[1..1 + want.len()], &want[..], "the Embryo's sphere1 says what a Sphere's Radius does");
+        state.apply_action(McpAction::Up, &mut redraw).unwrap();
+
+        // A parameter no template names says nothing, and the menu goes
+        // straight from the name to the readouts.
+        state.apply_action(McpAction::AddParam { slot: sphere, name: "Extra".into(), param_type: "float".into(), default: "3".into() }, &mut redraw).unwrap();
+        let (rows, _, _) = state.param_menu_rows(sphere, "Extra");
+        assert_eq!(rows[1], "Control: text box");
+
+        // A save never carries one, so the file is what it was.
+        let saved = serde_json::to_string(&state.current_dir().children[sphere]).unwrap();
+        assert!(!saved.contains("description"), "{saved}");
+    }
+
     /// The parameter row menu, end to end: a right press on a row in the
     /// params pane opens it (and nothing else claims the press), Copy
     /// Parameter then Paste Relative Reference on another node's row writes
@@ -8996,6 +9080,19 @@ mod tests {
             let (x, y, w, h) = rects[i];
             (x + w * 0.5, y + h * 0.5)
         };
+        // A parameter's description, as the menu wraps it, and the menu's
+        // rows with it taken out: the readouts the rest of this test is
+        // about. `the_row_menu_says_what_a_parameter_does` covers the
+        // description itself.
+        let described = |state: &State, slot: usize, pname: &str| -> Vec<String> {
+            let p = state.current_dir().children[slot].params.iter().find(|p| p.name == pname).expect(pname);
+            crate::app::wrap_words(&p.description, crate::app::PARAM_DESCRIPTION_WIDTH)
+        };
+        let fields = |state: &State, slot: usize, pname: &str| -> (Vec<String>, usize) {
+            let (rows, _, h) = state.param_menu_rows(slot, pname);
+            let d = described(state, slot, pname);
+            (rows.into_iter().filter(|r| !d.contains(r)).collect(), h - d.len())
+        };
         show(&mut state, sphere);
         let (x, y) = row_center(&state, "Radius");
         assert_eq!(state.param_row_at(x, y), Some((sphere, "Radius".to_string())));
@@ -9005,9 +9102,10 @@ mod tests {
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
         assert!(state.param_menu_open(), "a right press on a row opens its menu");
         assert!(!state.viewport_menu_open());
+        let radius_desc = described(&state, sphere, "Radius");
         assert_eq!(
             state.param_menu_actions,
-            vec![ParamMenuAction::Info; 9].into_iter().chain([ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression]).collect::<Vec<_>>(),
+            vec![ParamMenuAction::Info; 9 + radius_desc.len()].into_iter().chain([ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression]).collect::<Vec<_>>(),
             "nothing copied yet, and the row holds a value"
         );
         // The header rows read the parameter out: its name, the control
@@ -9017,7 +9115,8 @@ mod tests {
         // default 0..2 since the template declares no range; it has no
         // label and no condition, so neither row appears. There is no
         // Value row: the Type row is the value's type.
-        let shown = cce_ui::widget::context_menu::options();
+        let shown: Vec<String> =
+            cce_ui::widget::context_menu::options().into_iter().filter(|r| !radius_desc.contains(r)).collect();
         assert_eq!(
             &shown[..10],
             &[
@@ -9027,12 +9126,12 @@ mod tests {
             ]
         );
         assert!(shown.iter().all(|r| !r.starts_with("Value:")), "{shown:?}");
-        let (_, _, headers) = state.param_menu_rows(sphere, "Radius");
+        let (_, headers) = fields(&state, sphere, "Radius");
         assert_eq!(headers, 9);
         // The headers are the rows before the separator; each one is
         // looked up by its readout, not its position.
         let headers_of = |state: &State, pname: &str| -> Vec<String> {
-            let (rows, _, h) = state.param_menu_rows(sphere, pname);
+            let (rows, h) = fields(state, sphere, pname);
             assert_eq!(rows[h], "-");
             rows[..h].to_vec()
         };
@@ -9064,7 +9163,7 @@ mod tests {
         let embryo = slot_of(&state, "embryo1");
         state.apply_action(McpAction::Enter { slot: embryo }, &mut redraw).unwrap();
         let inner = slot_of(&state, "sphere1");
-        let (rows, _, h) = state.param_menu_rows(inner, "Radius");
+        let (rows, h) = fields(&state, inner, "Radius");
         assert!(rows[..h].contains(&"Default: chf(\"../Radius\")".to_string()), "{rows:?}");
         state.apply_action(McpAction::Up, &mut redraw).unwrap();
         // A click on a header runs nothing.
@@ -9110,11 +9209,11 @@ mod tests {
         // text box, and still sets the float its slider would. Method is a
         // dropdown setting an enum, Rows a spinbox setting an integer.
         show(&mut state, ball);
-        let (rows, _, _) = state.param_menu_rows(ball, "Radius");
+        let (rows, _) = fields(&state, ball, "Radius");
         assert_eq!(&rows[1..4], &["Control: text box".to_string(), "Type: float".to_string(), "Expression: true".to_string()]);
-        let (rows, _, _) = state.param_menu_rows(ball, "Method");
+        let (rows, _) = fields(&state, ball, "Method");
         assert_eq!((&rows[1], &rows[2]), (&"Control: dropdown".to_string(), &"Type: enum".to_string()));
-        let (rows, _, _) = state.param_menu_rows(ball, "Rows");
+        let (rows, _) = fields(&state, ball, "Rows");
         assert_eq!((&rows[1], &rows[2]), (&"Control: spinbox".to_string(), &"Type: integer".to_string()));
 
         // The pull node: a text parameter the pane presents as sliders
@@ -9127,14 +9226,14 @@ mod tests {
             state.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
         }
         show(&mut state, pull);
-        let (rows, _, h) = state.param_menu_rows(pull, "Value");
+        let (rows, h) = fields(&state, pull, "Value");
         let want: Vec<String> = ["Name: Value", "Control: trackball and sliders", "Type: float3", "Expression: false"].iter().map(|s| s.to_string()).collect();
         assert_eq!(&rows[..4], &want[..], "{rows:?}");
         assert!(rows[..h].contains(&"Range: -1000..1000".to_string()), "{rows:?}");
         assert!(rows[..h].iter().all(|r| !r.starts_with("Value:")), "{rows:?}");
         state.apply_action(McpAction::SetParam { slot: pull, name: "Value".into(), value: "0.06".into() }, &mut redraw).unwrap();
         show(&mut state, pull);
-        let (rows, _, _) = state.param_menu_rows(pull, "Value");
+        let (rows, _) = fields(&state, pull, "Value");
         assert_eq!((&rows[1], &rows[2]), (&"Control: text box".to_string(), &"Type: string".to_string()), "a broadcast number stays a text box");
 
         // And a reference typed straight into a row (or scripted) becomes one.
diff --git a/src/param.rs b/src/param.rs
index 9d75589..4ebee13 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -196,6 +196,13 @@ pub struct ParamDef {
     /// Serialized only when set, so a file that never used it is
     /// byte-identical to what it was.
     pub view: String,
+    /// What the parameter does, in a sentence or two, shown in its row's
+    /// right-click menu. The TEMPLATE's: `adopt_ui_from` hands it to every
+    /// instance with the rest of the UI metadata, and it is read from a
+    /// template file and never written — a save carries values, and a
+    /// description kept there would go stale the day the template's
+    /// wording changed (and move `sim_solve_key` the day it did).
+    pub description: String,
 }
 
 /// The file shape of a [`ParamDef`] — the struct as it was before the value
@@ -231,6 +238,9 @@ struct ParamDefRepr {
     /// [`ParamDef::view`]; absent unless one was chosen.
     #[serde(default, skip_serializing_if = "String::is_empty")]
     view: String,
+    /// [`ParamDef::description`]; read, never written.
+    #[serde(default, skip_serializing)]
+    description: String,
 }
 
 fn default_param_type() -> String {
@@ -252,6 +262,7 @@ impl<'de> Deserialize<'de> for ParamDef {
             step: r.step,
             show_when: r.show_when,
             view: r.view,
+            description: r.description,
         };
         if !r.expr {
             p.reparse();
@@ -274,6 +285,7 @@ impl Serialize for ParamDef {
             show_when: self.show_when.clone(),
             expr: self.is_expr(),
             view: self.view.clone(),
+            description: String::new(),
         }
         .serialize(s)
     }
@@ -342,6 +354,7 @@ impl ParamDef {
             step: None,
             show_when: String::new(),
             view: String::new(),
+            description: String::new(),
         };
         p.reparse();
         p
@@ -560,6 +573,7 @@ impl ParamDef {
         self.max = template.max;
         self.step = template.step;
         self.show_when = template.show_when.clone();
+        self.description = template.description.clone();
         // The view is the instance's to choose; the template's is what it
         // starts from.
         if self.view.is_empty() {