git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit342a57490bf9812424848e6a3cfadd63ac0663e0
parentb2238b5cdd
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 13:53
refactor: cce-relief's knobs leave the style block for its own state.kdl

The Shoulder / Base / Bias triples behind each profile spec were
`(bevel)`-typed keys in style.surface.relief (`wall.knobs` /
`edge.knobs`, before that `profile_knobs` / `edge_knobs`), written so
the editor could reopen where it was left: editor state beside the
values that draw, and the one relief key nothing but the editor read.

They live in ~/.config/cce/cce-relief/state.kdl now, one `knobs` node
per Save target — `shared`, a retargeted file's path, or `<path>#<key>`
for a `(relief)` value — because a per-app override and the shared
material are two materials whose sliders must not seed each other
(`knob_state`, with tests). Seeds read the state file first, then a
`(relief)` value's own `k=` ride-along, then a knob key a config still
carries; Save writes the state file and takes the knob keys off the
config under either spelling. The shared config is parsed for seeds too
(only a retargeted one was), since a legacy knob key has no registry
slot left to fall back to: the `bevel_profile_knobs` /
`roll_profile_knobs` registry keys and their flatten arms are gone,
along with the knobs entries of `prefer_relief_wall_edge`.

The `(bevel)` type and `parse_bevel_knobs` stay: a `(relief)` value
still carries `k=`, which is what the data editor's preview draws.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                         |  36 ++++--
 src/bin/cce-relief.rs             | 233 ++++++++++++++++++++++++++++++--------
 src/config.rs                     |   4 +-
 src/layout.rs                     |  43 ++++---
 src/widget/input/bevel_preview.rs |   5 +-
 5 files changed, 236 insertions(+), 85 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 659f07b..46214c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -680,8 +680,8 @@ multiply on the plate's own fill plus a specular crest. Each is a node under
 
 ```kdl
 relief depth=0.15 width=9.3 {
-    wall height=(mm)0.3 profile="smooth;…" knobs=(bevel)"0.5,0.5,0.5"
-    edge height=4.0    profile="smooth;…" knobs=(bevel)"0.5,0.5,0.5"
+    wall height=(mm)0.3 profile="smooth;…"
+    edge height=4.0    profile="smooth;…"
 }
 ```
 
@@ -694,21 +694,35 @@ carve drops `relief_shade::RECESS_DEPTH` (0.6) of its wall (saturating at the
 DE roll width) and the roll is a quarter-round of radius width, the look every
 config had. `profile` is the curve as a ramp spec (absent or the identity
 sentinel = the analytic curve: smoothstep for a wall, the superellipse
-quadrant for an edge), and `knobs` is cce-relief's slider triple behind it,
-which only the editor reads. The registry keys never moved — `bevel_height`
-/ `roll_height`, `bevel_profile_spec` / `roll_profile_spec`,
-`bevel_profile_knobs` / `roll_profile_knobs` — so nothing downstream of the
-registry knows. Until 2026-09-28 the six were flat on the node with the wall
-UNNAMED (`height`, `profile`, `profile_knobs`) and the edge prefixed
-(`edge_height`, `edge_profile`, `edge_knobs`), which read as one shape with an
-"edge" variant rather than two shapes; those spellings survive as aliases in
+quadrant for an edge). The registry keys never moved — `bevel_height`
+/ `roll_height`, `bevel_profile_spec` / `roll_profile_spec` — so nothing
+downstream of the registry knows. Until 2026-09-28 the keys were flat on the
+node with the wall UNNAMED (`height`, `profile`) and the edge prefixed
+(`edge_height`, `edge_profile`), which read as one shape with an "edge"
+variant rather than two shapes; those spellings survive as aliases in
 `layout.rs`'s flatten table, and `prefer_relief_wall_edge` drops a flat one
 whenever its node spelling is present, so a file carrying both is decided by
 the node and not by line order (`the_node_spelling_of_a_relief_key_wins_over_the_flat_one`).
 `cce-relief`'s Save writes the node spellings and REMOVES the flat ones
 (`config::remove_config_value`), so a file migrates the first time it is
 saved; `wall` and `edge` are `PROP_NODES` members so their keys land as
-properties. `layout::carve_depth_px` states the drop rule
+properties.
+
+**The editor's knobs are not a style key.** cce-relief's Shoulder / Base /
+Bias triples — the slider positions behind each profile spec — rode in the
+style block as `(bevel)`-typed keys (`relief.wall.knobs` / `edge.knobs`,
+before that `profile_knobs` / `edge_knobs`) so the editor could reopen where
+it was left: editor state beside the values that draw, and the one relief
+key nothing but the editor read. They live in that app's own
+`~/.config/cce/cce-relief/state.kdl` now, one `knobs` node per Save target
+(`shared`, a retargeted file's path, or `<path>#<key>` for a `(relief)`
+value — two materials must not seed each other's sliders; `knob_state` in
+`cce-relief.rs`). The registry keys `bevel_profile_knobs` /
+`roll_profile_knobs` and their flatten arms are gone; a config that still
+carries a knob key seeds the editor once, off the raw file, and the next
+Save takes the key off under either spelling. The `(bevel)` type and
+`parse_bevel_knobs` stay, because a `(relief)` value still carries its own
+`k=` ride-along and the data editor's preview of such a value draws it. `layout::carve_depth_px` states the drop rule
 once for the tessellator's CSG features and, through `WindowInfo.relief_meta`,
 the shader's free carves; `carve_depth_ratio` / `roll_height_ratio` feed the
 shading twin (`Finish.carve_depth` / `roll_height`). A `(relief)` value
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 16872a9..7701932 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -32,7 +32,16 @@
 //! sampled spec to stdout; Save persists to `~/.config/cce/config.kdl`
 //! (`style.surface.relief`) so every cce app starts with the material —
 //! or, with `--config <path>`, to that file instead (a per-app override
-//! like cce-designer's), which also seeds the knobs/depth/width on open.
+//! like cce-designer's), which also seeds the depth/width on open.
+//!
+//! The Shoulder / Base / Bias knob triples are NOT material: they are this
+//! editor's slider positions behind the profile specs, which nothing else
+//! reads. Since 2026-09-28 they live in this app's own state file
+//! (`~/.config/cce/cce-relief/state.kdl`, one `knobs` node per Save
+//! target — see `knob_state`), not in the style block beside the values
+//! that draw. A config that still carries them (`relief.wall.knobs` /
+//! `edge.knobs`, or the older `profile_knobs` / `edge_knobs`) seeds the
+//! sliders once, and the next Save takes the keys off the file.
 //!
 //! `--key <dotted.key>` edits a single `(relief)` VALUE in place instead
 //! (`cce_ui::relief_spec::ReliefSpec` — width/depth/wall knobs/wall
@@ -392,8 +401,8 @@ struct ProfileKnobs {
 impl ProfileKnobs {
     /// `installed` is whether the live material actually carries a custom
     /// LUT for this profile (`layout::*_profile_slopes().is_some()` — the
-    /// shader's own condition). It is NOT the same as "config carried saved
-    /// knobs": Save writes the knob triples as a ride-along even for an
+    /// shader's own condition). It is NOT the same as "the state file
+    /// carried saved knobs": Save remembers the knob triples even for an
     /// untouched section (so the editor reopens where it was left), while
     /// writing the identity SPEC — which installs nothing. Seeding `custom`
     /// from the knobs' presence made the prediction follow the knob curve
@@ -509,6 +518,9 @@ struct BevelPopup {
     /// seeds come from it. The edge section still previews but is not part
     /// of a `(relief)` value (a feature material has one wall curve).
     target_key: Option<String>,
+    /// The knob state's key for this Save target (`knob_state::target_id`):
+    /// which `knobs` node of `state.kdl` remembers this window's sliders.
+    state_target: String,
     /// Short label for a retargeted config ("cce-designer"), shown in the
     /// title and status so it's obvious which material is being edited.
     target_label: Option<String>,
@@ -1249,9 +1261,12 @@ impl BevelPopup {
     /// Persist the current material to the shared config
     /// (`style.surface.relief` — the same keys every app reads at startup).
     /// Untouched sections write the identity sentinel (= analytic); the knob
-    /// triples ride along so this editor reopens where you left it.
+    /// triples go to this app's own state file (`knob_state`) so the editor
+    /// reopens where you left it without the config carrying editor state.
     fn save_to_config(&mut self) {
         let p = self.config_path.to_string_lossy().into_owned();
+        // The sliders' positions, whichever target the material goes to.
+        let knobs_ok = knob_state::save(&self.state_target, self.wall.values(), self.edge.values());
         // `--key` mode: the whole material folds into ONE `(relief)` value
         // at that key — width, depth, the wall curve, and the knob triple
         // behind it (so reopening with --key seeds these sliders). The edge
@@ -1266,13 +1281,14 @@ impl BevelPopup {
                 knobs: Some(self.wall.values()),
                 profile: self.wall.custom.then(|| self.wall.last_spec.clone()),
             };
-            let ok = cce_ui::config::write_config_value_typed(
-                &p,
-                &key,
-                &spec.serialize(),
-                "style",
-                Some("relief"),
-            );
+            let ok = knobs_ok
+                & cce_ui::config::write_config_value_typed(
+                    &p,
+                    &key,
+                    &spec.serialize(),
+                    "style",
+                    Some("relief"),
+                );
             self.status = if ok {
                 println!("saved {key} -> {p}");
                 format!("Saved — {key} holds this material.")
@@ -1283,18 +1299,9 @@ impl BevelPopup {
         }
         let depth = format!("{:.3}", self.depth_slider.inner().get_scaled_value());
         let width = format!("{:.2}", self.width_slider.inner().get_scaled_value());
-        let knob_str = |k: &ProfileKnobs| {
-            let (s, b, c) = k.values();
-            format!("{s:.3},{b:.3},{c:.3}")
-        };
         let w = &mut |key: &str, value: &str| {
             cce_ui::config::write_config_value(&p, key, value, "style")
         };
-        // The knob keys carry the (bevel) type explicitly, so a config that
-        // never had them gains the annotation (and its editors' previews).
-        let wb = |key: &str, value: &str| {
-            cce_ui::config::write_config_value_typed(&p, key, value, "style", Some("bevel"))
-        };
         // The pinned drop is a LENGTH: written in millimetres when the
         // display metric is real (fabrication reads it straight), in logical
         // px when it is only assumed; 0 = follow the width.
@@ -1316,19 +1323,23 @@ impl BevelPopup {
         // save MIGRATES: the flat spellings this editor wrote until
         // 2026-09-28 (`height`, `profile`, `profile_knobs` for the wall,
         // `edge_*` for the edge) come off the file, or the node spelling
-        // would shadow a stale line forever. `remove_config_value` is true
-        // for a key that is not there, so a clean file costs nothing.
+        // would shadow a stale line forever; and the knob triples come off
+        // under either spelling, since they live in the state file now.
+        // `remove_config_value` is true for a key that is not there, so a
+        // clean file costs nothing.
         let ok = height_ok
             & material_ok
+            & knobs_ok
             & w("style.surface.relief.depth", &depth)
             & w("style.surface.relief.width", &width)
             & w("style.surface.relief.wall.profile", &self.wall.last_spec)
-            & w("style.surface.relief.edge.profile", &self.edge.last_spec)
-            & wb("style.surface.relief.wall.knobs", &knob_str(&self.wall))
-            & wb("style.surface.relief.edge.knobs", &knob_str(&self.edge));
-        let migrated = ["height", "edge_height", "profile", "edge_profile", "profile_knobs", "edge_knobs"]
-            .iter()
-            .all(|k| cce_ui::config::remove_config_value(&p, &format!("style.surface.relief.{k}")));
+            & w("style.surface.relief.edge.profile", &self.edge.last_spec);
+        let migrated = [
+            "height", "edge_height", "profile", "edge_profile",
+            "profile_knobs", "edge_knobs", "wall.knobs", "edge.knobs",
+        ]
+        .iter()
+        .all(|k| cce_ui::config::remove_config_value(&p, &format!("style.surface.relief.{k}")));
         let ok = ok & migrated;
         self.status = if ok {
             println!("saved {p}");
@@ -1381,12 +1392,13 @@ impl Application for BevelPopup {
         });
 
         // Seeds prefer the target file's own relief keys, falling back to
-        // the DE-wide registry for anything it lacks.
-        let target_json = target_label
-            .is_some()
-            .then(|| std::fs::read_to_string(&config_path).ok())
-            .flatten()
+        // the DE-wide registry for anything it lacks. The shared config is
+        // read too (until 2026-09-28 only a retargeted one was), because a
+        // legacy knob key still in it has no registry slot to fall back to.
+        let target_json = std::fs::read_to_string(&config_path)
+            .ok()
             .map(|c| cce_ui::config::parse_kdl_to_json(&c));
+        let state_target = knob_state::target_id(&config_path, &shared_path, target_key.as_deref());
         let target_relief = target_json.as_ref().and_then(|v| v.pointer("/style/surface/relief").cloned());
         let rel_f32 = |k: &str| {
             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_f64()).map(|f| f as f32)
@@ -1437,20 +1449,16 @@ impl Application for BevelPopup {
             }
             cce_ui::layout::install_wall_profile_spec(ks.profile.as_deref());
         }
-        let (wall_seed, edge_seed) = {
-            let reg = cce_ui::layout::get_style_registry().read().unwrap();
-            (
-                key_spec
-                    .as_ref()
-                    .and_then(|s| s.knobs)
-                    .or_else(|| rel_shape_str("wall", "knobs", "profile_knobs").as_deref().and_then(parse_knobs))
-                    .or_else(|| reg.get_string("bevel_profile_knobs").as_deref().and_then(parse_knobs)),
-                rel_shape_str("edge", "knobs", "edge_knobs")
-                    .as_deref()
-                    .and_then(parse_knobs)
-                    .or_else(|| reg.get_string("roll_profile_knobs").as_deref().and_then(parse_knobs)),
-            )
-        };
+        // The sliders reopen where the last Save of THIS target left them:
+        // the state file first, then a `(relief)` value's own `k=` ride-along,
+        // then a knob key a config still carries from before the move.
+        let (saved_wall, saved_edge) = knob_state::load(&state_target);
+        let (wall_seed, edge_seed) = (
+            saved_wall
+                .or_else(|| key_spec.as_ref().and_then(|s| s.knobs))
+                .or_else(|| rel_shape_str("wall", "knobs", "profile_knobs").as_deref().and_then(parse_knobs)),
+            saved_edge.or_else(|| rel_shape_str("edge", "knobs", "edge_knobs").as_deref().and_then(parse_knobs)),
+        );
 
         let depth = key_spec
             .as_ref()
@@ -1622,6 +1630,7 @@ impl Application for BevelPopup {
             },
             config_path,
             target_key,
+            state_target,
             target_label,
             ui_context: cce_ui::context::UiContext::new(),
             width: 520,
@@ -2024,3 +2033,133 @@ impl Application for BevelPopup {
 fn main() {
     cce_ui::engine::run::<BevelPopup>();
 }
+
+/// The editor's slider positions, kept in this app's own state file —
+/// `~/.config/cce/cce-relief/state.kdl` (`$XDG_CONFIG_HOME` honoured through
+/// `cce_config_dir`) — one `knobs` node per Save target:
+///
+/// ```kdl
+/// knobs target="shared" wall=(bevel)"0.500,0.500,0.500" edge=(bevel)"0.500,0.500,0.500"
+/// knobs target="/home/me/.config/cce/cce-designer/config.kdl" wall=… edge=…
+/// knobs target="/home/me/.config/cce/config.kdl#style.surface.desktop.line_relief" wall=…
+/// ```
+///
+/// Per target, because a per-app override and the shared material are two
+/// materials with two curves each, and reopening one must not seed it with
+/// the other's sliders. The triples used to ride in the style block as
+/// `relief.wall.knobs` / `edge.knobs` (before that `profile_knobs` /
+/// `edge_knobs`), `(bevel)`-typed so the data editor could preview them —
+/// editor state beside the values that draw, and the one relief key nothing
+/// but this editor read. The `(bevel)` type and `parse_bevel_knobs` stay:
+/// a `(relief)` value still carries its own `k=` ride-along, which is what
+/// the data editor's preview of such a value draws.
+mod knob_state {
+    use cce_ui::widget::parse_bevel_knobs as parse_knobs;
+
+    pub fn path() -> std::path::PathBuf {
+        cce_ui::config::cce_config_dir().join("cce-relief").join("state.kdl")
+    }
+
+    /// Which `knobs` node a Save target owns: `shared` for the DE-wide
+    /// config.kdl, a retargeted file by its path, a `--key` value by
+    /// `<path>#<key>`.
+    pub fn target_id(config_path: &std::path::Path, shared_path: &std::path::Path, key: Option<&str>) -> String {
+        match key {
+            Some(k) => format!("{}#{k}", config_path.display()),
+            None if config_path == shared_path => "shared".to_string(),
+            None => config_path.display().to_string(),
+        }
+    }
+
+    /// The saved (wall, edge) triples for `target`, each absent when the
+    /// state file has none — or a triple that does not parse, which reads
+    /// as unsaved rather than as midpoints, so the seed chain can go on to
+    /// a config's legacy key.
+    pub fn load(target: &str) -> (Option<(f32, f32, f32)>, Option<(f32, f32, f32)>) {
+        std::fs::read_to_string(path()).ok().map_or((None, None), |c| lookup(&c, target))
+    }
+
+    /// Remember `wall` and `edge` for `target`, keeping every other target's
+    /// node. `true` when the file was written.
+    pub fn save(target: &str, wall: (f32, f32, f32), edge: (f32, f32, f32)) -> bool {
+        let p = path();
+        let current = std::fs::read_to_string(&p).unwrap_or_default();
+        let Some(next) = upsert(&current, target, wall, edge) else {
+            return false;
+        };
+        if let Some(dir) = p.parent() {
+            let _ = std::fs::create_dir_all(dir);
+        }
+        // Temp-and-rename, so a crash mid-write leaves the old file whole.
+        let tmp = p.with_extension("kdl.tmp");
+        std::fs::write(&tmp, next).is_ok() && std::fs::rename(&tmp, &p).is_ok()
+    }
+
+    /// The (wall, edge) triples of `target`'s node in `content`.
+    pub fn lookup(content: &str, target: &str) -> (Option<(f32, f32, f32)>, Option<(f32, f32, f32)>) {
+        let Ok(doc) = content.parse::<kdl::KdlDocument>() else {
+            return (None, None);
+        };
+        let Some(node) = doc.nodes().iter().find(|n| is_target(n, target)) else {
+            return (None, None);
+        };
+        let triple = |k: &str| node.get(k).and_then(|e| e.value().as_string()).and_then(parse_knobs);
+        (triple("wall"), triple("edge"))
+    }
+
+    /// `content` with `target`'s node replaced (or added), every other node
+    /// kept as it was. `None` when `content` is not KDL — the file is this
+    /// app's own, so a corrupt one is reported by the Save rather than
+    /// silently replaced.
+    pub fn upsert(content: &str, target: &str, wall: (f32, f32, f32), edge: (f32, f32, f32)) -> Option<String> {
+        let mut doc = content.parse::<kdl::KdlDocument>().ok()?;
+        doc.nodes_mut().retain(|n| !is_target(n, target));
+        let fmt = |(s, b, c): (f32, f32, f32)| format!("{s:.3},{b:.3},{c:.3}");
+        let line = format!(
+            "knobs target={} wall=(bevel){} edge=(bevel){}\n",
+            kdl::KdlValue::String(target.to_string()),
+            kdl::KdlValue::String(fmt(wall)),
+            kdl::KdlValue::String(fmt(edge)),
+        );
+        doc.nodes_mut().push(line.parse::<kdl::KdlNode>().ok()?);
+        Some(doc.to_string())
+    }
+
+    fn is_target(node: &kdl::KdlNode, target: &str) -> bool {
+        node.name().value() == "knobs" && node.get("target").and_then(|e| e.value().as_string()) == Some(target)
+    }
+
+    #[cfg(test)]
+    mod tests {
+        use super::*;
+
+        /// A Save adds or replaces exactly its own target's node and leaves
+        /// the others alone; a lookup reads back what was saved, and a
+        /// target with no node (or a corrupt triple) reads as unsaved.
+        #[test]
+        fn knob_state_keeps_one_node_per_target() {
+            let a = (0.2, 0.7, 0.9);
+            let b = (0.5, 0.5, 0.5);
+            let c = (0.1, 0.1, 0.1);
+            let s1 = upsert("", "shared", a, b).unwrap();
+            let s2 = upsert(&s1, "/x/config.kdl", c, c).unwrap();
+            let s3 = upsert(&s2, "shared", b, a).unwrap();
+            assert_eq!(s3.matches("knobs ").count(), 2, "{s3}");
+            assert_eq!(lookup(&s3, "shared"), (Some(b), Some(a)));
+            assert_eq!(lookup(&s3, "/x/config.kdl"), (Some(c), Some(c)));
+            assert_eq!(lookup(&s3, "/y/config.kdl#some.key"), (None, None));
+            assert!(s3.contains("(bevel)\"0.200,0.700,0.900\""), "the (bevel) value format: {s3}");
+            assert_eq!(lookup("knobs target=\"shared\" wall=\"junk\"", "shared"), (None, None));
+            assert!(upsert("not kdl {{{", "shared", a, b).is_none(), "a corrupt file is refused, not replaced");
+        }
+
+        #[test]
+        fn a_target_is_the_shared_file_a_path_or_a_key() {
+            let shared = std::path::Path::new("/home/me/.config/cce/config.kdl");
+            let app = std::path::Path::new("/home/me/.config/cce/cce-designer/config.kdl");
+            assert_eq!(target_id(shared, shared, None), "shared");
+            assert_eq!(target_id(app, shared, None), app.display().to_string());
+            assert_eq!(target_id(shared, shared, Some("style.surface.desktop.line_relief")), format!("{}#style.surface.desktop.line_relief", shared.display()));
+        }
+    }
+}
diff --git a/src/config.rs b/src/config.rs
index 5d86d2c..b808e8f 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1005,7 +1005,7 @@ mod tests {
         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
         assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
-        assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.knobs", "0.4,0.5,0.6", "style", Some("bevel")));
+        assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
         // Migrate: the flat spellings come off, and a spelling that is not
         // there reports nothing removed.
         assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
@@ -1024,7 +1024,7 @@ mod tests {
         assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
         assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
         assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
-        assert_eq!(relief.pointer("/edge/knobs").and_then(|v| v.as_str()), Some("0.4,0.5,0.6"));
+        assert!(relief.pointer("/edge/height").is_some(), "{relief}");
         assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
         // A whole node comes off too.
         assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
diff --git a/src/layout.rs b/src/layout.rs
index 5c102a2..e443c8b 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -252,16 +252,19 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 // ridge or trough cut into a surface — and `edge` is the
                 // plate's perimeter roll. Each carries `height` (a length:
                 // the carve's drop, the roll's rise; unset = follow the
-                // width), `profile` (the ramp spec of its curve, written by
-                // cce-relief, installed by reload_config) and `knobs`
-                // (cce-relief's "shoulder,base,bias" slider positions behind
-                // that spec — only the editor reads them). The registry keys
-                // keep their old names. The flat spellings on the second
-                // line of each pair are the pre-rename aliases
-                // (`height` / `profile` / `profile_knobs` were the wall's,
-                // `edge_*` the edge's); `prefer_relief_wall_edge` drops a
-                // flat one whenever its node spelling is present, so a config
+                // width) and `profile` (the ramp spec of its curve, written
+                // by cce-relief, installed by reload_config). The registry
+                // keys keep their old names. The flat spellings on the
+                // second line of each pair are the pre-rename aliases
+                // (`height` / `profile` were the wall's, `edge_*` the
+                // edge's); `prefer_relief_wall_edge` drops a flat one
+                // whenever its node spelling is present, so a config
                 // carrying both is decided by the node, not by line order.
+                // cce-relief's slider knobs are NOT a style key any more
+                // (`relief.wall.knobs` / `edge.knobs`, before that
+                // `profile_knobs` / `edge_knobs`): editor state, kept in
+                // that app's own state.kdl, and read off a config only by
+                // the editor itself, as a one-time seed.
                 "style.surface.relief.wall.height"
                 | "style.surface.relief.height" => "bevel_height",
                 "style.surface.relief.edge.height"
@@ -270,10 +273,6 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 | "style.surface.relief.profile" => "bevel_profile_spec",
                 "style.surface.relief.edge.profile"
                 | "style.surface.relief.edge_profile" => "roll_profile_spec",
-                "style.surface.relief.wall.knobs"
-                | "style.surface.relief.profile_knobs" => "bevel_profile_knobs",
-                "style.surface.relief.edge.knobs"
-                | "style.surface.relief.edge_knobs" => "roll_profile_knobs",
                 "style.container.section.depth" => "section_depth",
                 "style.surface.param.backdrop_compression" => "param_compression",
                 "style.surface.param.label_layout" => "param_label_layout",
@@ -407,10 +406,8 @@ pub(crate) fn prefer_relief_wall_edge(val: &mut serde_json::Value) {
     const PAIRS: &[(&str, &str, &str)] = &[
         ("height", "wall", "height"),
         ("profile", "wall", "profile"),
-        ("profile_knobs", "wall", "knobs"),
         ("edge_height", "edge", "height"),
         ("edge_profile", "edge", "profile"),
-        ("edge_knobs", "edge", "knobs"),
     ];
     let Some(relief) = val.pointer_mut("/style/surface/relief").and_then(|v| v.as_object_mut()) else {
         return;
@@ -6966,8 +6963,8 @@ mod tests {
         let val: serde_json::Value = serde_json::json!({
             "style": { "surface": { "relief": {
                 "width": 9.3,
-                "wall": { "height": "0.3mm", "profile": "smooth;0:0,1:1", "knobs": "0.5,0.5,0.5" },
-                "edge": { "height": 4.0, "profile": "smooth;0:0,1:0.9", "knobs": "0.4,0.5,0.6" }
+                "wall": { "height": "0.3mm", "profile": "smooth;0:0,1:1" },
+                "edge": { "height": 4.0, "profile": "smooth;0:0,1:0.9" }
             } } }
         });
         let mut flat = String::new();
@@ -6982,20 +6979,20 @@ mod tests {
         assert_eq!(value("roll_height").as_deref(), Some("4"), "{flat}");
         assert_eq!(value("bevel_profile_spec").as_deref(), Some("smooth;0:0,1:1"), "{flat}");
         assert_eq!(value("roll_profile_spec").as_deref(), Some("smooth;0:0,1:0.9"), "{flat}");
-        assert_eq!(value("bevel_profile_knobs").as_deref(), Some("0.5,0.5,0.5"), "{flat}");
-        assert_eq!(value("roll_profile_knobs").as_deref(), Some("0.4,0.5,0.6"), "{flat}");
         // And the flat legacy spellings still land on the same keys.
         let old: serde_json::Value = serde_json::json!({
             "style": { "surface": { "relief": {
                 "height": 2.0, "edge_height": 3.0, "profile": "a", "edge_profile": "b",
-                "profile_knobs": "1,1,1", "edge_knobs": "2,2,2"
+                "wall": { "knobs": "1,1,1" }, "edge_knobs": "2,2,2"
             } } }
         });
         let mut flat = String::new();
         flatten_json_to_flat_props(&old, "", &mut flat);
-        for k in ["bevel_height", "roll_height", "bevel_profile_spec", "roll_profile_spec", "bevel_profile_knobs", "roll_profile_knobs"] {
+        for k in ["bevel_height", "roll_height", "bevel_profile_spec", "roll_profile_spec"] {
             assert!(flat.lines().any(|l| l.starts_with(&format!("{k} = "))), "{k} missing: {flat}");
         }
+        // The knobs are editor state, not style: no registry key of their own.
+        assert!(!flat.contains("profile_knobs"), "{flat}");
     }
 
     /// A config carrying both spellings of one relief key is decided by the
@@ -7009,7 +7006,7 @@ mod tests {
                 "depth": 0.15,
                 "height": 2.0, "wall": { "height": 5.0 },
                 "edge_profile": "old", "edge": { "profile": "new" },
-                "profile_knobs": "0.1,0.1,0.1"
+                "profile": "flat-only"
             } } }
         });
         prefer_relief_wall_edge(&mut val);
@@ -7018,7 +7015,7 @@ mod tests {
         assert!(relief.get("edge_profile").is_none(), "the flat edge profile yields: {relief}");
         assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_f64()), Some(5.0));
         assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some("new"));
-        assert_eq!(relief.get("profile_knobs").and_then(|v| v.as_str()), Some("0.1,0.1,0.1"), "no node spelling: kept");
+        assert_eq!(relief.get("profile").and_then(|v| v.as_str()), Some("flat-only"), "no node spelling: kept");
         assert_eq!(relief.get("depth").and_then(|v| v.as_f64()), Some(0.15));
         let mut flat = String::new();
         flatten_json_to_flat_props(&val, "", &mut flat);
diff --git a/src/widget/input/bevel_preview.rs b/src/widget/input/bevel_preview.rs
index 8472e23..9010690 100644
--- a/src/widget/input/bevel_preview.rs
+++ b/src/widget/input/bevel_preview.rs
@@ -27,8 +27,9 @@ pub fn bevel_ease(shoulder: f32, base: f32, bias: f32, v: f32) -> f32 {
     (num / den).clamp(0.0, 1.0)
 }
 
-/// Parse a "shoulder,base,bias" knob triple (the `(bevel)` value format, and
-/// what cce-relief persists as `relief.wall.knobs` / `relief.edge.knobs`).
+/// Parse a "shoulder,base,bias" knob triple (the `(bevel)` value format: a
+/// `(relief)` value's `k=` ride-along, and what cce-relief keeps per target
+/// in its own state.kdl).
 pub fn parse_bevel_knobs(s: &str) -> Option<(f32, f32, f32)> {
     let mut it = s.split(',').map(|p| p.trim().parse::<f32>());
     match (it.next(), it.next(), it.next()) {