diff --git a/app-proto/Cargo.toml b/app-proto/Cargo.toml
index c11fef4..8000327 100644
--- a/app-proto/Cargo.toml
+++ b/app-proto/Cargo.toml
@@ -13,6 +13,7 @@ itertools = "0.13.0"
 js-sys = "0.3.70"
 lazy_static = "1.5.0"
 nalgebra = "0.33.0"
+readonly = "0.2.12"
 rustc-hash = "2.0.0"
 slab = "0.4.9"
 sycamore = "0.9.0-beta.3"
diff --git a/app-proto/main.css b/app-proto/main.css
index b9fc0a1..4726a27 100644
--- a/app-proto/main.css
+++ b/app-proto/main.css
@@ -3,7 +3,8 @@
   --text-bright: white;
   --text-invalid: #f58fc2; /* bright pink */
   --border: #555; /* light gray */
-  --border-focus: #aaa; /* bright gray */
+  --border-focus-dark: #aaa; /* bright gray */
+  --border-focus-light: white;
   --border-invalid: #70495c; /* dusky pink */
   --selection-highlight: #444; /* medium gray */
   --page-background: #222; /* dark gray */
@@ -23,7 +24,7 @@ body {
   display: flex;
   flex-direction: column;
   float: left;
-  width: 450px;
+  width: 500px;
   height: 100vh;
   margin: 0px;
   padding: 0px;
@@ -77,12 +78,12 @@ summary.selected {
   background-color: var(--selection-highlight);
 }
 
-summary > div, .constraint {
+summary > div, .regulator {
   padding-top: 4px;
   padding-bottom: 4px;
 }
 
-.element, .constraint {
+.element, .regulator {
   display: flex;
   flex-grow: 1;
   padding-left: 8px;
@@ -107,7 +108,7 @@ details[open]:has(li) .element-switch::after {
   flex-grow: 1;
 }
 
-.constraint-label {
+.regulator-label {
   flex-grow: 1;
 }
 
@@ -123,26 +124,34 @@ details[open]:has(li) .element-switch::after {
   width: 56px;
 }
 
-.constraint {
+.regulator {
   font-style: italic;
 }
 
-.constraint.invalid {
-  color: var(--text-invalid);
+.regulator-type {
+  padding: 2px 8px 0px 8px;
+  font-size: 10pt;
 }
 
-.constraint > input[type=checkbox] {
-  margin: 0px 8px 0px 0px;
-}
-
-.constraint > input[type=text] {
+.regulator-input {
   color: inherit;
   background-color: inherit;
   border: 1px solid var(--border);
   border-radius: 2px;
 }
 
-.constraint.invalid > input[type=text] {
+.regulator-input::placeholder {
+  color: inherit;
+  opacity: 54%;
+  font-style: italic;
+}
+
+.regulator-input.constraint {
+  background-color: var(--display-background);
+}
+
+.regulator-input.invalid {
+  color: var(--text-invalid);
   border-color: var(--border-invalid);
 }
 
@@ -154,7 +163,7 @@ details[open]:has(li) .element-switch::after {
   font-style: normal;
 }
 
-.invalid > .status::after, details:has(.invalid):not([open]) .status::after {
+.regulator-input.invalid + .status::after, details:has(.invalid):not([open]) .status::after {
   content: '⚠';
   color: var(--text-invalid);
 }
@@ -171,5 +180,11 @@ canvas {
 }
 
 canvas:focus {
-  border-color: var(--border-focus);
+  border-color: var(--border-focus-dark);
+  outline: none;
+}
+
+input:focus {
+  border-color: var(--border-focus-light);
+  outline: none;
 }
\ No newline at end of file
diff --git a/app-proto/src/add_remove.rs b/app-proto/src/add_remove.rs
index ba02e65..5fed411 100644
--- a/app-proto/src/add_remove.rs
+++ b/app-proto/src/add_remove.rs
@@ -1,7 +1,11 @@
 use sycamore::prelude::*;
 use web_sys::{console, wasm_bindgen::JsValue};
 
-use crate::{engine, AppState, assembly::{Assembly, Constraint, Element}};
+use crate::{
+    engine,
+    AppState,
+    assembly::{Assembly, Element}
+};
 
 /* DEBUG */
 // load an example assembly for testing. this code will be removed once we've
@@ -190,44 +194,8 @@ pub fn AddRemove() -> View {
                             (subject_vec[0].clone(), subject_vec[1].clone())
                         }
                     );
-                    let lorentz_prod = create_signal(0.0);
-                    let lorentz_prod_valid = create_signal(false);
-                    let active = create_signal(true);
-                    state.assembly.insert_constraint(Constraint {
-                        subjects: subjects,
-                        lorentz_prod: lorentz_prod,
-                        lorentz_prod_text: create_signal(String::new()),
-                        lorentz_prod_valid: lorentz_prod_valid,
-                        active: active,
-                    });
+                    state.assembly.insert_new_regulator(subjects);
                     state.selection.update(|sel| sel.clear());
-                    
-                    /* DEBUG */
-                    // print updated constraint list
-                    console::log_1(&JsValue::from("Constraints:"));
-                    state.assembly.constraints.with(|csts| {
-                        for (_, cst) in csts.into_iter() {
-                            console::log_5(
-                                &JsValue::from(" "),
-                                &JsValue::from(cst.subjects.0),
-                                &JsValue::from(cst.subjects.1),
-                                &JsValue::from(":"),
-                                &JsValue::from(cst.lorentz_prod.get_untracked())
-                            );
-                        }
-                    });
-                    
-                    // update the realization when the constraint becomes active
-                    // and valid, or is edited while active and valid
-                    create_effect(move || {
-                        console::log_1(&JsValue::from(
-                            format!("Constraint ({}, {}) updated", subjects.0, subjects.1)
-                        ));
-                        lorentz_prod.track();
-                        if active.get() && lorentz_prod_valid.get() {
-                            state.assembly.realize();
-                        }
-                    });
                 }
             ) { "🔗" }
             select(bind:value=assembly_name) { /* DEBUG */ // example assembly chooser
diff --git a/app-proto/src/assembly.rs b/app-proto/src/assembly.rs
index 7073c9e..18176df 100644
--- a/app-proto/src/assembly.rs
+++ b/app-proto/src/assembly.rs
@@ -5,11 +5,14 @@ use std::{collections::BTreeSet, sync::atomic::{AtomicU64, Ordering}};
 use sycamore::prelude::*;
 use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
 
-use crate::engine::{realize_gram, local_unif_to_std, ConfigSubspace, PartialMatrix};
+use crate::{
+    engine::{Q, local_unif_to_std, realize_gram, ConfigSubspace, PartialMatrix},
+    specified::SpecifiedValue
+};
 
-// the types of the keys we use to access an assembly's elements and constraints
+// the types of the keys we use to access an assembly's elements and regulators
 pub type ElementKey = usize;
-pub type ConstraintKey = usize;
+pub type RegulatorKey = usize;
 
 pub type ElementColor = [f32; 3];
 
@@ -26,8 +29,11 @@ pub struct Element {
     pub label: String,
     pub color: ElementColor,
     pub representation: Signal<DVector<f64>>,
-    pub constraints: Signal<BTreeSet<ConstraintKey>>,
-
+    
+    // All regulators with this element as a subject. The assembly owning
+    // this element is responsible for keeping this set up to date.
+    pub regulators: Signal<BTreeSet<RegulatorKey>>,
+    
     // a serial number, assigned by `Element::new`, that uniquely identifies
     // each element
     pub serial: u64,
@@ -61,7 +67,7 @@ impl Element {
             label: label,
             color: color,
             representation: create_signal(representation),
-            constraints: create_signal(BTreeSet::default()),
+            regulators: create_signal(BTreeSet::default()),
             serial: serial,
             column_index: None
         }
@@ -111,13 +117,11 @@ impl Element {
     }
 }
 
-#[derive(Clone)]
-pub struct Constraint {
+#[derive(Clone, Copy)]
+pub struct Regulator {
     pub subjects: (ElementKey, ElementKey),
-    pub lorentz_prod: Signal<f64>,
-    pub lorentz_prod_text: Signal<String>,
-    pub lorentz_prod_valid: Signal<bool>,
-    pub active: Signal<bool>
+    pub measurement: ReadSignal<f64>,
+    pub set_point: Signal<SpecifiedValue>
 }
 
 // the velocity is expressed in uniform coordinates
@@ -131,9 +135,9 @@ type AssemblyMotion<'a> = Vec<ElementMotion<'a>>;
 // a complete, view-independent description of an assembly
 #[derive(Clone)]
 pub struct Assembly {
-    // elements and constraints
+    // elements and regulators
     pub elements: Signal<Slab<Element>>,
-    pub constraints: Signal<Slab<Constraint>>,
+    pub regulators: Signal<Slab<Regulator>>,
     
     // solution variety tangent space. the basis vectors are stored in
     // configuration matrix format, ordered according to the elements' column
@@ -155,13 +159,13 @@ impl Assembly {
     pub fn new() -> Assembly {
         Assembly {
             elements: create_signal(Slab::new()),
-            constraints: create_signal(Slab::new()),
+            regulators: create_signal(Slab::new()),
             tangent: create_signal(ConfigSubspace::zero(0)),
             elements_by_id: create_signal(FxHashMap::default())
         }
     }
     
-    // --- inserting elements and constraints ---
+    // --- inserting elements and regulators ---
     
     // insert an element into the assembly without checking whether we already
     // have an element with the same identifier. any element that does have the
@@ -204,14 +208,61 @@ impl Assembly {
         );
     }
     
-    pub fn insert_constraint(&self, constraint: Constraint) {
-        let subjects = constraint.subjects;
-        let key = self.constraints.update(|csts| csts.insert(constraint));
-        let subject_constraints = self.elements.with(
-            |elts| (elts[subjects.0].constraints, elts[subjects.1].constraints)
+    fn insert_regulator(&self, regulator: Regulator) {
+        let subjects = regulator.subjects;
+        let key = self.regulators.update(|regs| regs.insert(regulator));
+        let subject_regulators = self.elements.with(
+            |elts| (elts[subjects.0].regulators, elts[subjects.1].regulators)
         );
-        subject_constraints.0.update(|csts| csts.insert(key));
-        subject_constraints.1.update(|csts| csts.insert(key));
+        subject_regulators.0.update(|regs| regs.insert(key));
+        subject_regulators.1.update(|regs| regs.insert(key));
+    }
+    
+    pub fn insert_new_regulator(self, subjects: (ElementKey, ElementKey)) {
+        // create and insert a new regulator
+        let measurement = self.elements.map(
+            move |elts| {
+                let reps = (
+                    elts[subjects.0].representation.get_clone(),
+                    elts[subjects.1].representation.get_clone()
+                );
+                reps.0.dot(&(&*Q * reps.1))
+            }
+        );
+        let set_point = create_signal(SpecifiedValue::from_empty_spec());
+        self.insert_regulator(Regulator {
+            subjects: subjects,
+            measurement: measurement,
+            set_point: set_point
+        });
+        
+        /* DEBUG */
+        // print an updated list of regulators
+        console::log_1(&JsValue::from("Regulators:"));
+        self.regulators.with(|regs| {
+            for (_, reg) in regs.into_iter() {
+                console::log_5(
+                    &JsValue::from(" "),
+                    &JsValue::from(reg.subjects.0),
+                    &JsValue::from(reg.subjects.1),
+                    &JsValue::from(":"),
+                    &reg.set_point.with_untracked(
+                        |set_pt| JsValue::from(set_pt.spec.as_str())
+                    )
+                );
+            }
+        });
+        
+        // update the realization when the regulator becomes a constraint, or is
+        // edited while acting as a constraint
+        create_effect(move || {
+            console::log_1(&JsValue::from(
+                format!("Updated constraint with subjects ({}, {})", subjects.0, subjects.1)
+            ));
+            if set_point.with(|set_pt| set_pt.is_present()) {
+                self.realize();
+            }
+        });
     }
     
     // --- realization ---
@@ -228,14 +279,16 @@ impl Assembly {
         let (gram, guess) = self.elements.with_untracked(|elts| {
             // set up the off-diagonal part of the Gram matrix
             let mut gram_to_be = PartialMatrix::new();
-            self.constraints.with_untracked(|csts| {
-                for (_, cst) in csts {
-                    if cst.active.get_untracked() && cst.lorentz_prod_valid.get_untracked() {
-                        let subjects = cst.subjects;
-                        let row = elts[subjects.0].column_index.unwrap();
-                        let col = elts[subjects.1].column_index.unwrap();
-                        gram_to_be.push_sym(row, col, cst.lorentz_prod.get_untracked());
-                    }
+            self.regulators.with_untracked(|regs| {
+                for (_, reg) in regs {
+                    reg.set_point.with_untracked(|set_pt| {
+                        if let Some(val) = set_pt.value {
+                            let subjects = reg.subjects;
+                            let row = elts[subjects.0].column_index.unwrap();
+                            let col = elts[subjects.1].column_index.unwrap();
+                            gram_to_be.push_sym(row, col, val);
+                        }
+                    });
                 }
             });
             
diff --git a/app-proto/src/main.rs b/app-proto/src/main.rs
index f961504..6ab3e49 100644
--- a/app-proto/src/main.rs
+++ b/app-proto/src/main.rs
@@ -3,6 +3,7 @@ mod assembly;
 mod display;
 mod engine;
 mod outline;
+mod specified;
 
 use rustc_hash::FxHashSet;
 use sycamore::prelude::*;
diff --git a/app-proto/src/outline.rs b/app-proto/src/outline.rs
index a6e968d..002baea 100644
--- a/app-proto/src/outline.rs
+++ b/app-proto/src/outline.rs
@@ -1,56 +1,97 @@
 use itertools::Itertools;
 use sycamore::prelude::*;
 use web_sys::{
-    Event,
-    HtmlInputElement,
     KeyboardEvent,
     MouseEvent,
     wasm_bindgen::JsCast
 };
 
-use crate::{AppState, assembly, assembly::{Constraint, ConstraintKey, ElementKey}};
+use crate::{
+    AppState,
+    assembly,
+    assembly::{ElementKey, Regulator, RegulatorKey},
+    specified::SpecifiedValue
+};
 
-// an editable view of the Lorentz product representing a constraint
+// an editable view of a regulator
 #[component(inline_props)]
-fn LorentzProductInput(constraint: Constraint) -> View {
+fn RegulatorInput(regulator: Regulator) -> View {
+    let valid = create_signal(true);
+    let value = create_signal(
+        regulator.set_point.with_untracked(|set_pt| set_pt.spec.clone())
+    );
+    
+    // this closure resets the input value to the regulator's set point
+    // specification
+    let reset_value = move || {
+        batch(|| {
+            valid.set(true);
+            value.set(regulator.set_point.with(|set_pt| set_pt.spec.clone()));
+        })
+    };
+    
+    // reset the input value whenever the regulator's set point specification
+    // is updated
+    create_effect(reset_value);
+    
     view! {
         input(
             r#type="text",
-            bind:value=constraint.lorentz_prod_text,
-            on:change=move |event: Event| {
-                let target: HtmlInputElement = event.target().unwrap().unchecked_into();
-                match target.value().parse::<f64>() {
-                    Ok(lorentz_prod) => batch(|| {
-                        constraint.lorentz_prod.set(lorentz_prod);
-                        constraint.lorentz_prod_valid.set(true);
-                    }),
-                    Err(_) => constraint.lorentz_prod_valid.set(false)
-                };
+            class=move || {
+                if valid.get() {
+                    regulator.set_point.with(|set_pt| {
+                        if set_pt.is_present() {
+                            "regulator-input constraint"
+                        } else {
+                            "regulator-input"
+                        }
+                    })
+                } else {
+                    "regulator-input invalid"
+                }
+            },
+            placeholder=regulator.measurement.with(|result| result.to_string()),
+            bind:value=value,
+            on:change=move |_| {
+                valid.set(
+                    match SpecifiedValue::try_from(value.get_clone_untracked()) {
+                        Ok(set_pt) => {
+                            regulator.set_point.set(set_pt);
+                            true
+                        }
+                        Err(_) => false
+                    }
+                )
+            },
+            on:keydown={
+                move |event: KeyboardEvent| {
+                    match event.key().as_str() {
+                        "Escape" => reset_value(),
+                        _ => ()
+                    }
+                }
             }
         )
     }
 }
 
-// a list item that shows a constraint in an outline view of an element
+// a list item that shows a regulator in an outline view of an element
 #[component(inline_props)]
-fn ConstraintOutlineItem(constraint_key: ConstraintKey, element_key: ElementKey) -> View {
+fn RegulatorOutlineItem(regulator_key: RegulatorKey, element_key: ElementKey) -> View {
     let state = use_context::<AppState>();
     let assembly = &state.assembly;
-    let constraint = assembly.constraints.with(|csts| csts[constraint_key].clone());
-    let other_subject = if constraint.subjects.0 == element_key {
-        constraint.subjects.1
+    let regulator = assembly.regulators.with(|regs| regs[regulator_key]);
+    let other_subject = if regulator.subjects.0 == element_key {
+        regulator.subjects.1
     } else {
-        constraint.subjects.0
+        regulator.subjects.0
     };
     let other_subject_label = assembly.elements.with(|elts| elts[other_subject].label.clone());
-    let class = constraint.lorentz_prod_valid.map(
-        |&lorentz_prod_valid| if lorentz_prod_valid { "constraint" } else { "constraint invalid" }
-    );
     view! {
-        li(class=class.get()) {
-            input(r#type="checkbox", bind:checked=constraint.active)
-            div(class="constraint-label") { (other_subject_label) }
-            LorentzProductInput(constraint=constraint)
+        li(class="regulator") {
+            div(class="regulator-label") { (other_subject_label) }
+            div(class="regulator-type") { "Inversive distance" }
+            RegulatorInput(regulator=regulator)
             div(class="status")
         }
     }
@@ -74,9 +115,9 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
             ).collect::<Vec<_>>()
         )
     };
-    let constrained = element.constraints.map(|csts| csts.len() > 0);
-    let constraint_list = element.constraints.map(
-        |csts| csts.clone().into_iter().collect()
+    let regulated = element.regulators.map(|regs| regs.len() > 0);
+    let regulator_list = element.regulators.map(
+        |regs| regs.clone().into_iter().collect()
     );
     let details_node = create_node_ref();
     view! {
@@ -91,7 +132,7 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
                                     state.select(key, event.shift_key());
                                     event.prevent_default();
                                 },
-                                "ArrowRight" if constrained.get() => {
+                                "ArrowRight" if regulated.get() => {
                                     let _ = details_node
                                         .get()
                                         .unchecked_into::<web_sys::Element>()
@@ -138,16 +179,16 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
                         div(class="status")
                     }
                 }
-                ul(class="constraints") {
+                ul(class="regulators") {
                     Keyed(
-                        list=constraint_list,
-                        view=move |cst_key| view! {
-                            ConstraintOutlineItem(
-                                constraint_key=cst_key,
+                        list=regulator_list,
+                        view=move |reg_key| view! {
+                            RegulatorOutlineItem(
+                                regulator_key=reg_key,
                                 element_key=key
                             )
                         },
-                        key=|cst_key| cst_key.clone()
+                        key=|reg_key| reg_key.clone()
                     )
                 }
             }
@@ -155,9 +196,9 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
     }
 }
 
-// a component that lists the elements of the current assembly, showing the
-// constraints on each element as a collapsible sub-list. its implementation
-// is based on Kate Morley's HTML + CSS tree views:
+// a component that lists the elements of the current assembly, showing each
+// element's regulators in a collapsible sub-list. its implementation is based
+// on Kate Morley's HTML + CSS tree views:
 //
 //   https://iamkate.com/code/tree-views/
 //
diff --git a/app-proto/src/specified.rs b/app-proto/src/specified.rs
new file mode 100644
index 0000000..cfe7fc3
--- /dev/null
+++ b/app-proto/src/specified.rs
@@ -0,0 +1,44 @@
+use std::num::ParseFloatError;
+
+// a real number described by a specification string. since the structure is
+// read-only, we can guarantee that `spec` always specifies `value` in the
+// following format
+//   ┌──────────────────────────────────────────────────────┬───────────┐
+//   │ `spec`                                               │ `value`   │
+//   ┝━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━┥
+//   │ a string that parses to the floating-point value `x` │ `Some(x)` │
+//   ├──────────────────────────────────────────────────────┼───────────┤
+//   │ the empty string                                     │ `None`    │
+//   └──────────────────────────────────────────────────────┴───────────┘
+#[readonly::make]
+pub struct SpecifiedValue {
+    pub spec: String,
+    pub value: Option<f64>
+}
+
+impl SpecifiedValue {
+    pub fn from_empty_spec() -> SpecifiedValue {
+        SpecifiedValue { spec: String::new(), value: None }
+    }
+    
+    pub fn is_present(&self) -> bool {
+        matches!(self.value, Some(_))
+    }
+}
+
+// a `SpecifiedValue` can be constructed from a specification string, formatted
+// as described in the comment on the structure definition. the result is `Ok`
+// if the specification is properly formatted, and `Error` if not
+impl TryFrom<String> for SpecifiedValue {
+    type Error = ParseFloatError;
+    
+    fn try_from(spec: String) -> Result<Self, Self::Error> {
+        if spec.is_empty() {
+            Ok(SpecifiedValue::from_empty_spec())
+        } else {
+            spec.parse::<f64>().map(
+                |value| SpecifiedValue { spec: spec, value: Some(value) }
+            )
+        }
+    }
+}
\ No newline at end of file