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

commita1a8cc2d1d271055be71e96927415041e63e119d
parent4dcb970d84
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 22:33
refactor: the GUI-free modules live in cce-core, re-exported here

config, input, motion, units, relief_spec and ipc moved to the new
sibling crate cce-core (github.com/lsgalante/cce-core), with the pure
halves of three more: color's hex/sRGB helpers, the ramp spec functions
(widget::{format,parse}_ramp_spec, layout::sample_ramp_keys) and
scene::paint::DropletSpec. Each is re-exported at its old path, so
`cce_ui::config::…` and `crate::config::…` resolve as before and no app
changes.

What did change:
- DropletSpec::finish() is the extension trait scene::paint::DropletFinish
  (a Finish is a renderer type cce-core cannot name).
- The cfg(test) gates in the moved modules are
  cfg(any(test, feature = "test-isolation")) there; cce-ui's
  dev-dependency enables it, so this suite still reads no machine config.
  Two style-layer tests from config.rs's suite are config_style_tests.rs.

The compositor, which used cce-ui only for config, input bindings,
motion::enabled and the relief/droplet parsers, now depends on cce-core
alone and no longer links the toolkit's Wayland/Vulkan/text stack.
613 tests here and 38 in cce-core, the 651 there were.

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

 CLAUDE.md                 |   42 +-
 Cargo.toml                |    9 +
 src/color.rs              |   57 +-
 src/config.rs             | 1662 ---------------------------------------------
 src/config_style_tests.rs |   92 +++
 src/input.rs              |  632 -----------------
 src/ipc.rs                |  299 --------
 src/ipc/instance.rs       |  200 ------
 src/layout.rs             |   72 +-
 src/lib.rs                |   15 +-
 src/motion.rs             |  128 ----
 src/relief_spec.rs        |  143 ----
 src/scene/paint.rs        |  218 +-----
 src/units.rs              |  424 ------------
 src/widget/input/ramp.rs  |   30 +-
 tests/plate_golden.rs     |    2 +-
 16 files changed, 169 insertions(+), 3856 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4ace4ad..f1d9491 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,13 +52,15 @@ cargo run   -p cce-ui                            # run the demo/reference app (n
 ```
 
 Tests are headless unit tests colocated in `#[cfg(test)]` modules — concentrated in `src/scene/*`
-(the arena/layout/paint/anim engine) and `src/color.rs`, `src/config.rs`, `src/layout.rs`, plus a
+(the arena/layout/paint/anim engine) and `src/color.rs`, `src/layout.rs`, plus a
 scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When touching the scene
 engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
 anything else.
 
 **The tests never read the machine's config** (since 2026-10-07). Under `cfg(test)`,
-`config::config_home()` is a per-process directory nobody creates, so `config.kdl`, the per-app
+`config::config_home()` is a per-process directory nobody creates (`config`, `input` and
+`motion` live in `cce-core` now, where that gate is its `test-isolation` feature, which
+cce-ui's dev-dependency turns on — see "The GUI-free half is cce-core" below), so `config.kdl`, the per-app
 overrides and `input.kdl` are all absent and every getter answers its default. A test that needs a
 setting loads it from a string (`color::reload_colors`) or pins it on its thread. Before this, six
 heightfield and material tests failed on the desktop, whose `relief edge height` and frost keys
@@ -1625,7 +1627,10 @@ cce-system-interface) to confirm behavior, not just the test suite.
 - `src/layout.rs` (largest file, ~6.9k lines) — fonts + sizing; many `*_font_parsed()` getters and
   the `read_preferred_fonts` / font-family resolution used by the cosmic-text path.
 - `color.rs` — color model and named colors (`colors` re-export module in `lib.rs`).
-- `config.rs` — KDL loading and `kdl_to_json` conversion (see workspace `CLAUDE.md` for paths).
+- **`config`, `input`, `motion`, `units`, `relief_spec`, `ipc`** — re-exported from
+  `cce-core` (see "The GUI-free half is cce-core"), as are `color`'s hex/sRGB helpers,
+  `scene::paint::DropletSpec` and the ramp spec functions (`widget::{format,parse}_ramp_spec`,
+  `layout::sample_ramp_keys`).
 - `context.rs` — `UiContext`: the retained widget tree, event routing, spatial grid, dirty
   tracking, hit-testing.
 - `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
@@ -1699,16 +1704,39 @@ cce-system-interface) to confirm behavior, not just the test suite.
   for (with the limits a caller names raised to the adapter's).
 - `mac/` — macOS only: the AppKit shell, `run` (see "And on a Mac, type-checked only").
 - `protocol.rs` — inline-generated Wayland protocol bindings.
-- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
-  the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
-  claim-or-forward for apps that run once per session.
+- `ipc` (in `cce-core`) — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`,
+  `send_command`, the bounded `read_request_line`, `focus_window`), and `ipc::instance`:
+  single-instance claim-or-forward for apps that run once per session.
 - `icon.rs` — XDG icon-theme lookup: a `.desktop` `Icon=` key (or an SNI tray icon
   name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
   **Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
   own name for in-widget use; this one resolves names any installed app may ship.
 - `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection, and
   `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
-- `units.rs` — lengths with units and the display metric; see the Units section below.
+- `units` (in `cce-core`) — lengths with units and the display metric; see the Units section below.
+
+### The GUI-free half is cce-core (since 2026-10-07)
+
+`config`, `input`, `motion`, `units`, `relief_spec` and `ipc` — and the parsers for the specs the
+DE writes (hex colours, ramps, droplets) — moved to the sibling crate `cce-core`
+(github.com/lsgalante/cce-core). cce-ui depends on it and re-exports each at its old path
+(`pub use cce_core::config;` in `lib.rs`, `pub use cce_core::droplet::DropletSpec` in
+`scene::paint`, …), so `cce_ui::config::…` and `crate::config::…` resolve as they always did and
+no app changed. Two things did change:
+
+- `DropletSpec::finish()` became the extension trait `scene::paint::DropletFinish` (a `Finish` is
+  a renderer type `cce-core` cannot name): a call site writes
+  `use cce_ui::scene::paint::DropletFinish;`.
+- The `cfg(test)` gates in those modules are `cfg(any(test, feature = "test-isolation"))` there,
+  and cce-ui's `[dev-dependencies]` names `cce-core` with that feature, so this suite still never
+  reads the machine. Two tests of the style layer that sat in `config.rs`'s suite are
+  `src/config_style_tests.rs`.
+
+The compositor depends on `cce-core` alone (it used cce-ui only for config, input bindings,
+`motion::enabled` and the relief/droplet parsers, and linked the whole toolkit for it), as do
+`cce-browser-open` (the instance client, no copy any more) and cce-window-manager (the ramp, with
+`default-features = false`: no config half, so no KDL or JSON). A change to these modules is a
+change to `cce-core`; push it, then `bump-revs.sh` repins cce-ui and the rest.
 
 ## Markdown: `MarkdownView` and `DocEditor` (features, 2026-10-01)
 
diff --git a/Cargo.toml b/Cargo.toml
index f31c838..cbf5333 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,9 @@ markdown = ["dep:cce-vault"]
 doc_editor = []
 
 [dependencies]
+# The GUI-free half: config, input, motion, units, ipc and the spec parsers,
+# re-exported at their old paths (`cce_ui::config`, …). See lib.rs.
+cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "39e989d6ebc7850a1e5aa96bf228fe8714e538e0" }
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "41ca52bd51ea3655a791c58005be9fce939e4333", optional = true }
 glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
@@ -225,6 +228,12 @@ crate-type = ["cdylib"]
 name = "demo_web"
 crate-type = ["cdylib"]
 
+[dev-dependencies]
+# The same crate with its `cfg(test)` behaviour switched on, so this suite reads
+# no machine config now that config/input/motion live there (cce-core's
+# `test-isolation`; resolver 2 keeps it out of normal builds).
+cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "39e989d6ebc7850a1e5aa96bf228fe8714e538e0", features = ["test-isolation"] }
+
 [build-dependencies]
 # build.rs compiles the fixed WGSL shaders to SPIR-V once, instead of in every
 # process that builds a renderer. Same version as the runtime dependency above,
diff --git a/src/color.rs b/src/color.rs
index c4af422..c98090f 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1115,62 +1115,7 @@ pub fn well_frame_color(hovered: bool, active: bool) -> [f32; 4] {
 pub const TEXT_HEADER: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
 pub const TEXT_ACCENT: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
 
-pub fn srgb_to_linear(c: f32) -> f32 {
-    if c <= 0.04045 {
-        c / 12.92
-    } else {
-        ((c + 0.055) / 1.055).powf(2.4)
-    }
-}
-
-/// Parse a hex color string into raw RGBA bytes.
-///
-/// Accepts `#RRGGBB` or `#RRGGBBAA`, tolerating surrounding quotes/whitespace and
-/// an optional leading `#`. 6-digit input yields alpha `255`. Returns `None` for
-/// any shorter/invalid input. This is the primitive the `[f32;_]` parsers build on.
-pub fn parse_hex_bytes(s: &str) -> Option<[u8; 4]> {
-    let hex = s
-        .trim_matches(|c| c == '"' || c == '\'' || c == ' ')
-        .trim_start_matches('#');
-    let b = |i: usize| u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok();
-    if hex.len() >= 8 {
-        Some([b(0)?, b(1)?, b(2)?, b(3)?])
-    } else if hex.len() >= 6 {
-        Some([b(0)?, b(1)?, b(2)?, 255])
-    } else {
-        None
-    }
-}
-
-/// Parse a hex color string into raw sRGB RGBA in `[0,1]` (no gamma conversion).
-/// See [`parse_hex_bytes`] for the accepted formats. Apply [`srgb_to_linear`] to
-/// the RGB channels yourself if your render target expects linear color.
-pub fn parse_hex_rgba(s: &str) -> Option<[f32; 4]> {
-    parse_hex_bytes(s).map(|[r, g, b, a]| {
-        [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0]
-    })
-}
-
-/// Like [`parse_hex_rgba`] but drops alpha, returning raw sRGB RGB in `[0,1]`.
-pub fn parse_hex_rgb(s: &str) -> Option<[f32; 3]> {
-    parse_hex_rgba(s).map(|[r, g, b, _]| [r, g, b])
-}
-
-/// Like [`parse_hex_rgba`] but converts RGB from sRGB to linear (alpha kept as-is).
-/// Use when your render target samples colors in linear space.
-pub fn parse_hex_rgba_linear(s: &str) -> Option<[f32; 4]> {
-    parse_hex_rgba(s).map(|[r, g, b, a]| {
-        [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), a]
-    })
-}
-
-pub fn linear_to_srgb(c: f32) -> f32 {
-    if c <= 0.0031308 {
-        c * 12.92
-    } else {
-        1.055 * c.powf(1.0 / 2.4) - 0.055
-    }
-}
+pub use cce_core::color::{linear_to_srgb, parse_hex_bytes, parse_hex_rgb, parse_hex_rgba, parse_hex_rgba_linear, srgb_to_linear};
 
 pub fn to_linear(color: [f32; 4]) -> [f32; 4] {
     [
diff --git a/src/config.rs b/src/config.rs
deleted file mode 100644
index af8788c..0000000
--- a/src/config.rs
+++ /dev/null
@@ -1,1662 +0,0 @@
-use std::fs;
-use serde_json::Value;
-
-/// A unit-annotated number (`width=(mm)2.0`, `(px)9.3`, `(in)0.5`,
-/// `(pt)6`) becomes the JSON string `"2mm"` — the form
-/// `crate::units::Len::parse` reads — so the unit survives the JSON hop
-/// into the style registry, where it resolves against the live metric at
-/// every read. A bare number stays a number: a logical px, as always.
-fn unit_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> Option<serde_json::Value> {
-    let ty = entry.ty()?.value();
-    let len = crate::units::Len::from_annotated(value as f32, ty)?;
-    Some(serde_json::Value::String(len.serialize()))
-}
-
-fn int_entry_to_json(value: i64, entry: &kdl::KdlEntry) -> serde_json::Value {
-    unit_entry_to_json(value as f64, entry)
-        .unwrap_or_else(|| serde_json::Value::Number(serde_json::Number::from(value)))
-}
-
-fn float_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> serde_json::Value {
-    if let Some(v) = unit_entry_to_json(value, entry) {
-        return v;
-    }
-    let mut val_f = value;
-    if let Some(ty) = entry.ty() {
-        let ty_str = ty.value();
-        if let Some(range_str) = ty_str.strip_prefix("f64:") {
-            if let Some(dash_idx) = range_str.find('-') {
-                let min_str = range_str[..dash_idx].trim();
-                let max_str = range_str[dash_idx + 1..].trim();
-                if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
-                    val_f = val_f.clamp(min_f, max_f);
-                }
-            }
-        }
-    }
-    match serde_json::Number::from_f64(val_f) {
-        Some(num) => serde_json::Value::Number(num),
-        None => serde_json::Value::Null,
-    }
-}
-
-fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
-    let mut map = serde_json::Map::new();
-    for node in doc.nodes() {
-        let name = node.name().value().to_string();
-        
-        let mut node_map = serde_json::Map::new();
-        let mut has_props = false;
-        for entry in node.entries() {
-            if let Some(prop_name) = entry.name() {
-                has_props = true;
-                let j_val = match entry.value() {
-                    kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
-                    kdl::KdlValue::Base2(i) |
-                    kdl::KdlValue::Base8(i) |
-                    kdl::KdlValue::Base10(i) |
-                    kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
-                    kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
-                    kdl::KdlValue::String(s) |
-                    kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
-                    kdl::KdlValue::Null => serde_json::Value::Null,
-                };
-                node_map.insert(prop_name.value().to_string(), j_val);
-            }
-        }
-
-        let val = if name == "key_bindings" {
-            if let Some(children) = node.children() {
-                let mut binds = Vec::new();
-                for child in children.nodes() {
-                    let mut child_map = serde_json::Map::new();
-                    for entry in child.entries() {
-                        if let Some(prop_name) = entry.name() {
-                            let j_val = match entry.value() {
-                                kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
-                                kdl::KdlValue::Base2(i) |
-                                kdl::KdlValue::Base8(i) |
-                                kdl::KdlValue::Base10(i) |
-                                kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
-                                kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
-                                kdl::KdlValue::String(s) |
-                                kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
-                                kdl::KdlValue::Null => serde_json::Value::Null,
-                            };
-                            child_map.insert(prop_name.value().to_string(), j_val);
-                        }
-                    }
-                    binds.push(serde_json::Value::Object(child_map));
-                }
-                serde_json::Value::Array(binds)
-            } else if has_props {
-                serde_json::Value::Object(node_map)
-            } else {
-                serde_json::Value::Null
-            }
-        } else {
-            let mut node_val = serde_json::Value::Null;
-            if has_props {
-                node_val = serde_json::Value::Object(node_map);
-            } else if node.entries().len() > 1
-                && node.entries().iter().all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
-            {
-                // A string LIST (`rounded_apps "a" "b"`) is an array, so the
-                // writer can put the args back. Joined into one string, as the
-                // numeric multi-arg case below still is (`(vec2i)100 200`),
-                // it came back as ONE quoted arg — `rounded_apps "a b"` — and
-                // a compositor allowlist silently matched nothing after
-                // every cce-data-editor save.
-                node_val = serde_json::Value::Array(
-                    node.entries()
-                        .iter()
-                        .filter_map(|e| e.value().as_string().map(|s| serde_json::Value::String(s.to_string())))
-                        .collect(),
-                );
-            } else if node.entries().len() > 1 {
-                let parts: Vec<String> = node.entries().iter().map(|entry| {
-                    match entry.value() {
-                        kdl::KdlValue::Bool(b) => b.to_string(),
-                        kdl::KdlValue::Base2(i) |
-                        kdl::KdlValue::Base8(i) |
-                        kdl::KdlValue::Base10(i) |
-                        kdl::KdlValue::Base16(i) => i.to_string(),
-                        kdl::KdlValue::Base10Float(f) => f.to_string(),
-                        kdl::KdlValue::String(s) |
-                        kdl::KdlValue::RawString(s) => s.clone(),
-                        kdl::KdlValue::Null => "null".to_string(),
-                    }
-                }).collect();
-                node_val = serde_json::Value::String(parts.join(" "));
-            } else if let Some(entry) = node.entries().first() {
-                node_val = match entry.value() {
-                    kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
-                    kdl::KdlValue::Base2(i) |
-                    kdl::KdlValue::Base8(i) |
-                    kdl::KdlValue::Base10(i) |
-                    kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
-                    kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
-                    kdl::KdlValue::String(s) |
-                    kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
-                    kdl::KdlValue::Null => serde_json::Value::Null,
-                };
-            }
-
-            if let Some(children) = node.children() {
-                let children_val = kdl_to_json(children);
-                if let serde_json::Value::Object(children_map) = children_val {
-                    if let serde_json::Value::Object(mut nm) = node_val {
-                        for (k, v) in children_map {
-                            nm.insert(k, v);
-                        }
-                        serde_json::Value::Object(nm)
-                    } else {
-                        serde_json::Value::Object(children_map)
-                    }
-                } else {
-                    node_val
-                }
-            } else {
-                node_val
-            }
-        };
-
-        if let Some(existing) = map.remove(&name) {
-            match existing {
-                serde_json::Value::Array(mut arr) => {
-                    match val {
-                        serde_json::Value::Array(new_arr) => {
-                            arr.extend(new_arr);
-                        }
-                        _ => {
-                            arr.push(val);
-                        }
-                    }
-                    map.insert(name, serde_json::Value::Array(arr));
-                }
-                other => {
-                    match val {
-                        serde_json::Value::Array(new_arr) => {
-                            let mut combined = vec![other];
-                            combined.extend(new_arr);
-                            map.insert(name, serde_json::Value::Array(combined));
-                        }
-                        _ => {
-                            map.insert(name, serde_json::Value::Array(vec![other, val]));
-                        }
-                    }
-                }
-            }
-        } else {
-            let list_names = ["key_bindings", "pointer_bind", "gesture_bind", "mode_rule", "tag_layout", "startup", "device"];
-            if list_names.contains(&name.as_str()) {
-                match val {
-                    serde_json::Value::Array(_) => {
-                        map.insert(name, val);
-                    }
-                    _ => {
-                        map.insert(name, serde_json::Value::Array(vec![val]));
-                    }
-                }
-            } else {
-                map.insert(name, val);
-            }
-        }
-    }
-    serde_json::Value::Object(map)
-}
-
-/// The calling app's name — the basename of its executable — used to locate
-/// its per-app config (`~/.config/cce/<name>/config.kdl`) and its `input.kdl`
-/// domain.
-///
-/// Derived from `/proc/self/exe` once per process, which the kernel renders as
-/// `<path> (deleted)` once the binary on disk has been replaced (`ccebuild
-/// install` unlinks before writing). Without the strip, a still-running client
-/// would resolve its override to `~/.config/cce/<name> (deleted)/config.kdl`
-/// on its next config reload and silently lose the whole file — the status
-/// bar's droplet bubbles reverted to square boxes this way on 2026-09-03.
-///
-/// Cached because every config getter reaches it (through
-/// [`config_files_modified`]), many of them per frame; the stripped basename
-/// cannot change for the life of the process.
-pub fn get_app_name() -> Option<String> {
-    app_name().map(str::to_string)
-}
-
-/// [`get_app_name`] without the allocation.
-fn app_name() -> Option<&'static str> {
-    static NAME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
-    NAME.get_or_init(|| {
-        std::env::current_exe()
-            .ok()
-            .and_then(|p| p.file_name().and_then(|s| s.to_str().map(app_name_from_exe_basename)))
-    })
-    .as_deref()
-}
-
-/// [`get_app_name`]'s normalization: the kernel's ` (deleted)` marker on an
-/// unlinked executable is not part of the name.
-fn app_name_from_exe_basename(basename: &str) -> String {
-    basename.strip_suffix(" (deleted)").unwrap_or(basename).to_string()
-}
-
-pub fn get_app_config_path(app_name: &str) -> std::path::PathBuf {
-    get_config_path().parent().unwrap().join(app_name).join("config.kdl")
-}
-
-fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
-    match (a, b) {
-        (serde_json::Value::Object(a_map), serde_json::Value::Object(b_map)) => {
-            for (k, v) in b_map {
-                if !v.is_null() {
-                    merge_json(a_map.entry(k.clone()).or_insert(serde_json::Value::Null), v);
-                }
-            }
-        }
-        (a_val, b_val) => {
-            *a_val = b_val.clone();
-        }
-    }
-}
-
-pub fn parse_kdl_to_json(content: &str) -> serde_json::Value {
-    let mut main_val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
-        kdl_to_json(&doc)
-    } else {
-        serde_json::json!({})
-    };
-
-    if let Some(app_name) = app_name() {
-        let app_path = get_app_config_path(app_name);
-        if let Ok(override_content) = std::fs::read_to_string(&app_path) {
-            if let Ok(override_doc) = override_content.parse::<kdl::KdlDocument>() {
-                let override_val = kdl_to_json(&override_doc);
-                merge_json(&mut main_val, &override_val);
-            }
-        }
-    }
-
-    main_val
-}
-
-pub fn update_json_in_memory(val_obj: &mut Value, key: &str, value: &str, default_section: &str) -> bool {
-    let j_val = if let Ok(parsed_val) = serde_json::from_str::<Value>(value) {
-        parsed_val
-    } else {
-        serde_json::json!(value)
-    };
-
-    let mut updated = false;
-    if let Some(obj) = val_obj.as_object_mut() {
-        for (_sec_name, sec_val) in obj.iter_mut() {
-            if let Some(sec_obj) = sec_val.as_object_mut() {
-                if sec_obj.contains_key(key) {
-                    sec_obj.insert(key.to_string(), j_val.clone());
-                    updated = true;
-                    break;
-                }
-            }
-        }
-        if !updated {
-            if let Some(sec_obj) = obj.get_mut(default_section).and_then(|s| s.as_object_mut()) {
-                sec_obj.insert(key.to_string(), j_val);
-                updated = true;
-            } else {
-                let mut map = serde_json::Map::new();
-                map.insert(key.to_string(), j_val);
-                obj.insert(default_section.to_string(), Value::Object(map));
-                updated = true;
-            }
-        }
-    }
-    updated
-}
-
-pub fn parse_config_path(key: &str, default_section: &str) -> (String, String, Option<String>) {
-    let parts: Vec<&str> = key.split('.').collect();
-    if parts.len() == 3 {
-        (parts[0].to_string(), parts[1].to_string(), Some(parts[2].to_string()))
-    } else if parts.len() == 2 {
-        (parts[0].to_string(), parts[1].to_string(), None)
-    } else {
-        (default_section.to_string(), key.to_string(), None)
-    }
-}
-
-const PROP_NODES: &[&str] = &[
-    "gestures", "key_bindings", "pointer_bind", "gesture_bind",
-    "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
-    "status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
-    "menubar", "statusbar", "node", "relief", "frost", "finish",
-    // The relief's two shapes (`relief { wall height=… profile=… knobs=… ; edge … }`).
-    "wall", "edge"
-];
-
-fn get_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
-    if path.is_empty() {
-        return None;
-    }
-    let idx = doc.nodes().iter().position(|n| n.name().value() == path[0])?;
-    let node = &mut doc.nodes_mut()[idx];
-    if path.len() == 1 {
-        Some(node)
-    } else {
-        get_node_mut(node.children_mut().as_mut()?, &path[1..])
-    }
-}
-
-/// Remove `key` from the document — a property off its `PROP_NODES` node, or
-/// the whole node otherwise — creating nothing on the way. `true` when
-/// something was removed; a key that is not there is `false`, so a writer
-/// can tell "migrated" from "was already clean".
-pub fn remove_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str) -> bool {
-    let parts: Vec<&str> = key.split('.').collect();
-    let Some(leaf) = parts.last().copied() else {
-        return false;
-    };
-    // A PROP_NODES parent holds its keys as properties — but it may also
-    // hold child NODES (`relief { wall … }`), so a leaf that is not among
-    // the properties is looked for among the children before giving up.
-    let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
-    if is_property {
-        let Some(node) = get_node_mut(doc, &parts[..parts.len() - 1]) else {
-            return false;
-        };
-        let before = node.entries().len();
-        node.entries_mut().retain(|e| e.name().map(|n| n.value()) != Some(leaf));
-        if node.entries().len() != before {
-            return true;
-        }
-    }
-    let parent: &mut kdl::KdlDocument = if parts.len() == 1 {
-        doc
-    } else {
-        match get_node_mut(doc, &parts[..parts.len() - 1]).and_then(|n| n.children_mut().as_mut()) {
-            Some(children) => children,
-            None => return false,
-        }
-    };
-    let before = parent.nodes().len();
-    parent.nodes_mut().retain(|n| n.name().value() != leaf);
-    parent.nodes().len() != before
-}
-
-fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
-    if path.is_empty() {
-        return None;
-    }
-    let segment = path[0];
-    let idx = if let Some(i) = doc.nodes().iter().position(|n| n.name().value() == segment) {
-        i
-    } else {
-        let new_node = format!("{}\n", segment).parse::<kdl::KdlNode>().ok()?;
-        doc.nodes_mut().push(new_node);
-        doc.nodes().len() - 1
-    };
-    if path.len() == 1 {
-        Some(&mut doc.nodes_mut()[idx])
-    } else {
-        let children = doc.nodes_mut()[idx].ensure_children();
-        get_or_create_node_mut(children, &path[1..])
-    }
-}
-
-fn get_node_ref<'a>(doc: &'a kdl::KdlDocument, path: &[&str]) -> Option<&'a kdl::KdlNode> {
-    if path.is_empty() {
-        return None;
-    }
-    let segment = path[0];
-    let node = doc.nodes().iter().find(|n| n.name().value() == segment)?;
-    if path.len() == 1 {
-        Some(node)
-    } else {
-        let children = node.children()?;
-        get_node_ref(children, &path[1..])
-    }
-}
-
-pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str, default_section: &str) -> bool {
-    update_kdl_in_memory_typed(doc, key, value, default_section, None)
-}
-
-/// [`update_kdl_in_memory`] with an explicit type annotation for the written
-/// entry. `forced_ty` overrides both the value-shape inference and the
-/// preserved existing annotation — how a writer ESTABLISHES a custom type
-/// (e.g. cce-relief writing `(bevel)` knob keys into a config that never had
-/// them; preservation alone can't create the annotation).
-pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value: &str, _default_section: &str, forced_ty: Option<&str>) -> bool {
-    let parts: Vec<&str> = key.split('.').collect();
-    if parts.is_empty() {
-        return false;
-    }
-
-    let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
-
-    let (node_path, target_prop) = if is_property {
-        (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
-    } else {
-        (&parts[0..parts.len()], None)
-    };
-
-    let child_node = if let Some(node) = get_or_create_node_mut(doc, node_path) {
-        node
-    } else {
-        return false;
-    };
-
-    let existing_ty = if let Some(ref prop_name) = target_prop {
-        child_node.entries().iter()
-            .find(|e| e.name().map(|n| n.value()) == Some(prop_name))
-            .and_then(|e| e.ty().map(|t| t.value().to_string()))
-    } else {
-        child_node.entries().first()
-            .and_then(|e| e.ty().map(|t| t.value().to_string()))
-    };
-
-    let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
-        (kdl::KdlValue::Bool(b), Some("bool".to_string()))
-    } else if let Some(len) = crate::units::Len::parse(value) {
-        // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
-        // stays a number the editor's spinbox can step.
-        (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
-    } else if value.starts_with('#') {
-        let s_clean = value.trim_start_matches('#');
-        let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
-        (kdl::KdlValue::String(value.to_string()), Some(ty.to_string()))
-    } else if value.contains('.') {
-        if let Ok(f) = value.parse::<f64>() {
-            (kdl::KdlValue::Base10Float(f), Some("f64".to_string()))
-        } else {
-            (kdl::KdlValue::String(value.to_string()), None)
-        }
-    } else if let Ok(i) = value.parse::<i64>() {
-        (kdl::KdlValue::Base10(i), Some("i64".to_string()))
-    } else {
-        let s = value.trim_matches('"').to_string();
-        (kdl::KdlValue::String(s), None)
-    };
-
-    if let Some(ref ext_ty) = existing_ty {
-        if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" || ext_ty == "bevel" || ext_ty == "keybind" {
-            kdl_ty = Some(ext_ty.clone());
-        }
-        // A bare number written over a unit-annotated slot keeps the unit:
-        // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
-        // to logical px.
-        if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().map_or(true, |t| t == "f64" || t == "i64") {
-            kdl_ty = Some(ext_ty.clone());
-        }
-    }
-    if let Some(f) = forced_ty {
-        kdl_ty = Some(f.to_string());
-    }
-
-    if let Some(prop_name) = target_prop {
-        let mut found = false;
-        for entry in child_node.entries_mut() {
-            if let Some(id) = entry.name() {
-                if id.value() == prop_name {
-                    *entry = kdl::KdlEntry::new_prop(prop_name.clone(), kdl_val.clone());
-                    if let Some(ref ty) = kdl_ty {
-                        entry.set_ty(ty.as_str());
-                    }
-                    found = true;
-                    break;
-                }
-            }
-        }
-        if !found {
-            let mut entry = kdl::KdlEntry::new_prop(prop_name, kdl_val);
-            if let Some(ref ty) = kdl_ty {
-                entry.set_ty(ty.as_str());
-            }
-            child_node.entries_mut().push(entry);
-        }
-    } else {
-        child_node.entries_mut().clear();
-        if kdl_ty.as_deref() == Some("vec2i") {
-            let parts: Vec<&str> = value.split_whitespace().collect();
-            for (idx, part) in parts.iter().enumerate() {
-                if let Ok(i) = part.parse::<i64>() {
-                    let mut entry = kdl::KdlEntry::new(kdl::KdlValue::Base10(i));
-                    if idx == 0 {
-                        entry.set_ty("vec2i");
-                    }
-                    child_node.entries_mut().push(entry);
-                }
-            }
-        } else {
-            let mut entry = kdl::KdlEntry::new(kdl_val);
-            if let Some(ref ty) = kdl_ty {
-                entry.set_ty(ty.as_str());
-            }
-            child_node.entries_mut().push(entry);
-        }
-    }
-
-    true
-}
-
-/// XDG config base directory: `$XDG_CONFIG_HOME`, else `~/.config`.
-///
-/// Under this crate's own tests it is a per-process directory nobody
-/// creates, so every config read (`config.kdl`, a per-app override,
-/// `input.kdl`) finds no file and the toolkit's defaults are what a test
-/// sees. Until 2026-10-07 the tests read the machine's `~/.config/cce`:
-/// six of them failed on the user's desktop (a `relief edge height` pinned
-/// the plate rise the heightfield tests set; the frost and blur keys moved
-/// the material ones) and passed on a machine without that config. A test
-/// that wants a configuration loads it from a string (`reload_colors`).
-/// Dependents' tests link the non-test build and are unaffected.
-pub fn config_home() -> std::path::PathBuf {
-    #[cfg(test)]
-    {
-        return std::env::temp_dir().join(format!("cce-ui-test-config-{}", std::process::id()));
-    }
-    #[allow(unreachable_code)]
-    match std::env::var("XDG_CONFIG_HOME") {
-        Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
-        _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"),
-    }
-}
-
-/// XDG data base directory: `$XDG_DATA_HOME`, else `~/.local/share`.
-pub fn data_home() -> std::path::PathBuf {
-    match std::env::var("XDG_DATA_HOME") {
-        Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
-        _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
-            .join(".local")
-            .join("share"),
-    }
-}
-
-/// XDG state base directory: `$XDG_STATE_HOME`, else `~/.local/state`.
-///
-/// State is what should survive a restart but is not worth backing up or
-/// syncing like data, and is not the user's to edit like config: history,
-/// open tabs, logs, a "never ask again" list. A relative `$XDG_STATE_HOME`
-/// counts as unset, as the XDG spec says it must (an empty one too).
-pub fn state_home() -> std::path::PathBuf {
-    match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
-        Some(x) if x.is_absolute() => x,
-        _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
-            .join(".local")
-            .join("state"),
-    }
-}
-
-/// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
-///
-/// Unlike the other bases there is no `~/...` fallback to construct: the
-/// runtime dir is `/run/user/UID`, created by pam_systemd at login and mode
-/// 0700. An unset variable means we are outside a login session, where the
-/// shared temp dir is the honest answer rather than a path we would have to
-/// invent (and could not create with the right ownership anyway).
-pub fn runtime_dir() -> std::path::PathBuf {
-    match std::env::var("XDG_RUNTIME_DIR") {
-        Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
-        _ => std::env::temp_dir(),
-    }
-}
-
-/// The cce config directory (`<config_home>/cce`).
-pub fn cce_config_dir() -> std::path::PathBuf {
-    config_home().join("cce")
-}
-
-/// The cce state directory (`<state_home>/cce`); each app keeps its own
-/// subdirectory under it (`cce/browser`, `cce/mail`, …). Not created here:
-/// a caller creates the subdirectory it writes, with the mode it needs.
-pub fn cce_state_dir() -> std::path::PathBuf {
-    state_home().join("cce")
-}
-
-/// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
-///
-/// Session-scoped files — logs, sockets, pid files — belong here rather than
-/// in `/tmp`, which is one namespace shared by every user on the machine: a
-/// fixed `/tmp/cce-*.log` is a path the first user to log in owns, and the
-/// sticky bit then denies everyone else. Creating on demand keeps call sites
-/// to one line; a failure surfaces when the caller opens its file, which it
-/// already has to handle.
-pub fn cce_runtime_dir() -> std::path::PathBuf {
-    let dir = runtime_dir().join("cce");
-    let _ = std::fs::create_dir_all(&dir);
-    dir
-}
-
-pub fn get_config_path() -> std::path::PathBuf {
-    cce_config_dir().join("config.kdl")
-}
-
-struct ConfigCache {
-    last_modified: Option<std::time::SystemTime>,
-    /// Shared, not cloned per read: the getters below run many times a frame,
-    /// and a deep copy of the whole tree to read one key was most of their cost.
-    parsed: Option<std::sync::Arc<serde_json::Value>>,
-    raw_content: String,
-}
-
-static CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
-    last_modified: None,
-    parsed: None,
-    raw_content: String::new(),
-});
-
-/// The cce config parsed to JSON, cached on the config file's mtime (per process).
-/// Re-reads and re-parses only when `get_config_path()`'s modification time changes.
-/// The newest mtime across the shared config and the calling app's override
-/// file — the cache key for [`cached_config`], and what apps should poll for
-/// live-reload triggers. Compared by EQUALITY, so an app-file deletion (max
-/// drops back to the shared mtime) also invalidates.
-pub fn config_files_modified() -> Option<std::time::SystemTime> {
-    let shared = std::fs::metadata(get_config_path()).ok().and_then(|m| m.modified().ok());
-    let app = app_name()
-        .and_then(|n| std::fs::metadata(get_app_config_path(n)).ok())
-        .and_then(|m| m.modified().ok());
-    match (shared, app) {
-        (Some(a), Some(b)) => Some(a.max(b)),
-        (a, b) => a.or(b),
-    }
-}
-
-pub fn cached_config() -> serde_json::Value {
-    (*cached_config_arc()).clone()
-}
-
-/// [`cached_config`] without the copy — what every getter here reads.
-pub fn cached_config_arc() -> std::sync::Arc<serde_json::Value> {
-    let path = get_config_path();
-    // Both files participate in the parse (parse_kdl_to_json merges the
-    // per-app override), so both participate in the cache key.
-    let current_modified = config_files_modified();
-
-    if let Ok(cache) = CONFIG_CACHE.read() {
-        if cache.last_modified.is_some() && cache.last_modified == current_modified {
-            if let Some(ref val) = cache.parsed {
-                return val.clone();
-            }
-        }
-    }
-
-    let content = std::fs::read_to_string(&path).unwrap_or_default();
-    let val = std::sync::Arc::new(parse_kdl_to_json(&content));
-    if let Ok(mut cache) = CONFIG_CACHE.write() {
-        cache.last_modified = current_modified;
-        cache.parsed = Some(val.clone());
-        cache.raw_content = content;
-    }
-    val
-}
-
-/// The raw text of the cce config, cached alongside [`cached_config`].
-pub fn cached_config_content() -> String {
-    let _ = cached_config_arc();
-    CONFIG_CACHE.read().map(|c| c.raw_content.clone()).unwrap_or_default()
-}
-
-static SHARED_CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
-    last_modified: None,
-    parsed: None,
-    raw_content: String::new(),
-});
-
-/// The SHARED config alone — the per-app override is deliberately NOT merged.
-/// For values that describe something outside the app (the compositor's
-/// window silhouette radius), where an app-local override restyles the app
-/// but must not desynchronize it from the DE. Mtime-cached like
-/// [`cached_config`].
-pub fn cached_shared_config() -> serde_json::Value {
-    (*cached_shared_config_arc()).clone()
-}
-
-/// [`cached_shared_config`] without the copy.
-pub fn cached_shared_config_arc() -> std::sync::Arc<serde_json::Value> {
-    let path = get_config_path();
-    let current_modified = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
-
-    if let Ok(cache) = SHARED_CONFIG_CACHE.read() {
-        if cache.last_modified.is_some() && cache.last_modified == current_modified {
-            if let Some(ref val) = cache.parsed {
-                return val.clone();
-            }
-        }
-    }
-
-    let content = std::fs::read_to_string(&path).unwrap_or_default();
-    let val = std::sync::Arc::new(if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
-        kdl_to_json(&doc)
-    } else {
-        serde_json::json!({})
-    });
-    if let Ok(mut cache) = SHARED_CONFIG_CACHE.write() {
-        cache.last_modified = current_modified;
-        cache.parsed = Some(val.clone());
-        cache.raw_content = content;
-    }
-    val
-}
-
-/// Read an i64 at `pointer` from the SHARED config only (no per-app merge),
-/// or `default` — see [`cached_shared_config`].
-pub fn get_i64_shared(pointer: &str, default: i64) -> i64 {
-    cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
-}
-
-/// [`get_i64_shared`] without a default — for canonical-first alias chains
-/// (RFC Phase 7a) where absence must fall through to the next spelling.
-pub fn get_i64_shared_opt(pointer: &str) -> Option<i64> {
-    cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64())
-}
-
-// ── Typed accessors over the cached config ──────────────────────────────────
-// Each reads the mtime-cached config and extracts a value at a JSON pointer
-// (e.g. "/notifications/enable"), returning the default when absent or mistyped.
-
-/// Read a boolean at `pointer` from the cached config, or `default`.
-pub fn get_bool(pointer: &str, default: bool) -> bool {
-    cached_config_arc().pointer(pointer).and_then(|v| v.as_bool()).unwrap_or(default)
-}
-
-/// Read an f32 at `pointer` from the cached config, or `default`.
-pub fn get_f32(pointer: &str, default: f32) -> f32 {
-    cached_config_arc()
-        .pointer(pointer)
-        .and_then(|v| v.as_f64())
-        .map(|f| f as f32)
-        .unwrap_or(default)
-}
-
-/// Read an i64 at `pointer` from the cached config, or `default`.
-pub fn get_i64(pointer: &str, default: i64) -> i64 {
-    cached_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
-}
-
-/// Read a string at `pointer` from the cached config.
-pub fn get_string(pointer: &str) -> Option<String> {
-    cached_config_arc().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
-}
-
-/// Read a hex color string at `pointer` and parse it to raw sRGB RGBA (`[0,1]`).
-/// Apply [`crate::color::srgb_to_linear`] if your render target expects linear.
-pub fn get_color(pointer: &str) -> Option<[f32; 4]> {
-    get_string(pointer).as_deref().and_then(crate::color::parse_hex_rgba)
-}
-
-/// Recursively search a JSON value for the first entry whose object key equals
-/// `key`, returning a reference to its value. Depth-first over objects and arrays.
-pub fn find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
-    match val {
-        serde_json::Value::Object(map) => {
-            if let Some(found) = map.get(key) {
-                return Some(found);
-            }
-            for v in map.values() {
-                if let Some(found) = find_key(v, key) {
-                    return Some(found);
-                }
-            }
-            None
-        }
-        serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_key(v, key)),
-        _ => None,
-    }
-}
-
-
-
-fn perform_rolling_backup(path: &str) {
-    let config_path = get_config_path();
-    if std::path::Path::new(path) != config_path {
-        return;
-    }
-    if !std::path::Path::new(path).exists() {
-        return;
-    }
-    let backup_dir = config_path.parent().unwrap().join("backups");
-    if let Err(_) = fs::create_dir_all(&backup_dir) {
-        return;
-    }
-    for i in (1..=4).rev() {
-        let src = backup_dir.join(format!("config.kdl.{}.bak", i));
-        let dst = backup_dir.join(format!("config.kdl.{}.bak", i + 1));
-        if src.exists() {
-            let _ = fs::rename(src, dst);
-        }
-    }
-    let dst = backup_dir.join("config.kdl.1.bak");
-    let _ = fs::copy(path, dst);
-}
-
-pub(crate) fn safe_write(path: &str, content: &str) -> bool {
-    perform_rolling_backup(path);
-    if let Some(parent) = std::path::Path::new(path).parent() {
-        let _ = fs::create_dir_all(parent);
-    }
-    let temp_path = format!("{}.tmp", path);
-    if fs::write(&temp_path, content).is_ok() {
-        if fs::rename(&temp_path, path).is_ok() {
-            return true;
-        }
-        let _ = fs::remove_file(&temp_path);
-    }
-    false
-}
-
-pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &str) -> bool {
-    write_config_value_typed(path, key, value, default_section, None)
-}
-
-/// Remove `key` from the config file at `path` ([`remove_kdl_in_memory`]),
-/// writing only when something was removed. `true` when the key is gone —
-/// absent to begin with, or removed and written — so a writer migrating a
-/// legacy spelling can fold it into its own success.
-pub fn remove_config_value(path: &str, key: &str) -> bool {
-    let content = fs::read_to_string(path).unwrap_or_default();
-    let mut doc = match content.parse::<kdl::KdlDocument>() {
-        Ok(d) => d,
-        Err(_) => return true,
-    };
-    if remove_kdl_in_memory(&mut doc, key) {
-        return safe_write(path, &doc.to_string());
-    }
-    true
-}
-
-/// [`write_config_value`] with an explicit type annotation — see
-/// [`update_kdl_in_memory_typed`].
-pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
-    let content = fs::read_to_string(path).unwrap_or_default();
-    let mut doc = match content.parse::<kdl::KdlDocument>() {
-        Ok(d) => d,
-        Err(_) => kdl::KdlDocument::new(),
-    };
-
-    if update_kdl_in_memory_typed(&mut doc, key, value, default_section, forced_ty) {
-        let updated_str = doc.to_string();
-        return safe_write(path, &updated_str);
-    }
-    false
-}
-
-pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
-    let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
-    let parts: Vec<&str> = key_path.split('.').collect();
-    if parts.is_empty() {
-        return None;
-    }
-
-    let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
-
-    let (node_path, target_prop) = if is_property {
-        (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
-    } else {
-        (&parts[0..parts.len()], None)
-    };
-
-    let child_node = get_node_ref(&doc, node_path)?;
-
-    if let Some(prop_name) = target_prop {
-        let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
-        entry.ty().map(|t| t.value().to_string())
-    } else {
-        let entry = child_node.entries().first()?;
-        entry.ty().map(|t| t.value().to_string())
-    }
-}
-
-pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
-    let doc = match kdl_content.parse::<kdl::KdlDocument>() {
-        Ok(d) => Some(d),
-        Err(_) => None,
-    };
-    key_paths.iter().map(|key_path| {
-        let doc = doc.as_ref()?;
-        let parts: Vec<&str> = key_path.split('.').collect();
-        if parts.is_empty() {
-            return None;
-        }
-
-        let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
-
-        let (node_path, target_prop) = if is_property {
-            (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
-        } else {
-            (&parts[0..parts.len()], None)
-        };
-
-        let child_node = get_node_ref(doc, node_path)?;
-
-        if let Some(prop_name) = target_prop {
-            let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
-            entry.ty().map(|t| t.value().to_string())
-        } else {
-            let entry = child_node.entries().first()?;
-            entry.ty().map(|t| t.value().to_string())
-        }
-    }).collect()
-}
-
-
-#[cfg(test)]
-mod tests {
-    /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
-    /// unset, empty or relative. Nothing else in the crate reads the
-    /// variable, so setting it here cannot race another test.
-    #[test]
-    fn state_home_follows_xdg_and_falls_back() {
-        let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
-        let fallback = home.join(".local").join("state");
-        std::env::set_var("XDG_STATE_HOME", "/srv/state");
-        assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
-        assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
-        for invalid in ["", "relative/state"] {
-            std::env::set_var("XDG_STATE_HOME", invalid);
-            assert_eq!(super::state_home(), fallback, "{invalid:?}");
-        }
-        std::env::remove_var("XDG_STATE_HOME");
-        assert_eq!(super::state_home(), fallback);
-        assert_eq!(super::cce_state_dir(), fallback.join("cce"));
-    }
-
-    /// A string the writer emits comes back as it went in, quotes,
-    /// backslashes and control characters included — as a section value,
-    /// a top-level value, a keybind (written with a type annotation), a
-    /// list item and a key that is not an identifier. Until 2026-10-01
-    /// none of it was escaped, a quote inside a value made a line the
-    /// parser refused, and an app whose settings file fails to parse loads
-    /// its defaults.
-    #[test]
-    fn strings_round_trip_through_the_kdl_writer() {
-        let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
-        let val = serde_json::json!({
-            "section": {
-                "plain": odd,
-                "shortcut": "ctrl+\"",
-                "items": [odd, "x"],
-                "not an ident": odd,
-            },
-            "top": odd,
-        });
-        let text = super::json_to_kdl_string(&val);
-        let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
-        let back = super::kdl_to_json(&doc);
-        assert_eq!(back["section"]["plain"], odd, "{text}");
-        assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
-        assert_eq!(back["section"]["items"][0], odd, "{text}");
-        assert_eq!(back["section"]["not an ident"], odd, "{text}");
-        assert_eq!(back["top"], odd, "{text}");
-        assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
-    }
-
-    /// A material node's frost and finish are written as PROPERTIES of a
-    /// `frost` / `finish` child (RFC material § 5), created on demand under
-    /// `style.surface.material.<name>`, and read back through the same
-    /// pointer the loader uses.
-    #[test]
-    fn material_keys_write_as_frost_and_finish_props() {
-        use super::{parse_kdl_to_json, update_kdl_in_memory};
-        let mut doc = kdl::KdlDocument::new();
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
-        let text = doc.to_string();
-        let val = parse_kdl_to_json(&text);
-        assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
-        assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
-        assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
-        assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
-        assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
-        // One `frost` node with two props, not two `frost` nodes.
-        assert_eq!(text.matches("frost").count(), 1, "{text}");
-        assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
-    }
-
-    #[test]
-    fn unit_annotations_become_len_strings() {
-        let v = parse_kdl_to_json("style {\n    relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n    ruler (in)0.5\n}\n");
-        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
-        assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
-        assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
-        assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
-    }
-
-    #[test]
-    fn unit_strings_write_back_annotated() {
-        let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
-        let out = json_to_kdl_string(&v);
-        assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
-        assert!(out.contains("depth=(f64)0.15"), "{out}");
-        let back = parse_kdl_to_json(&out);
-        assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
-    }
-
-    #[test]
-    fn typed_write_keeps_and_sets_units() {
-        let mut doc: kdl::KdlDocument = "style {\n    relief width=(mm)2.0\n}\n".parse().unwrap();
-        // A bare number over a (mm) slot stays mm.
-        assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
-        let v = parse_kdl_to_json(&doc.to_string());
-        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
-        // A suffixed value sets the unit.
-        assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
-        let v = parse_kdl_to_json(&doc.to_string());
-        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
-    }
-
-    #[test]
-    fn app_name_strips_the_kernels_deleted_marker() {
-        use super::app_name_from_exe_basename as name;
-        assert_eq!(name("cce-status-interface"), "cce-status-interface");
-        assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
-        // Only the exact trailing marker: a name that merely contains the
-        // word, or an unspaced variant, is left alone.
-        assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
-        assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
-    }
-
-    use super::*;
-
-    #[test]
-    fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
-        // No env mutation: reading the real base keeps this correct both in a
-        // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
-        // avoids racing every other test in the process.
-        let base = runtime_dir();
-        assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
-        let dir = cce_runtime_dir();
-        assert_eq!(dir, base.join("cce"));
-        // The create-on-demand contract callers depend on: they open a file
-        // inside this directory without creating it themselves.
-        assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
-    }
-
-    #[test]
-    fn relief_annotated_string_passes_through() {
-        // The (relief) custom value type: an annotated string prop must
-        // survive kdl_to_json as a plain JSON string at its pointer.
-        let content = "style {\n    surface {\n        desktop gap_width=(i64)16 line_relief=(relief)\"w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000\"\n    }\n}\n";
-        let val = parse_kdl_to_json(content);
-        assert_eq!(
-            val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
-            Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
-        );
-    }
-
-    #[test]
-    fn test_nested_parsing() {
-        let content = "style {\n    status box_opacity=(f64)0.75\n}\n";
-        let val = parse_kdl_to_json(content);
-        println!("val = {:?}", val);
-        let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
-        assert_eq!(sec, "style");
-        assert_eq!(node, "status");
-        assert_eq!(prop, Some("box_opacity".to_string()));
-        
-        let sec_val = val.get(&sec).unwrap();
-        let node_val = sec_val.get(&node).unwrap();
-        let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
-        assert_eq!(prop_val.as_f64().unwrap(), 0.75);
-    }
-
-    /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
-    /// child nodes of the existing `relief` node — the node keeps its own
-    /// `depth=` / `width=` properties and grows one child block — and the
-    /// reload path reads them back at the nested paths the registry maps.
-    /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
-    /// which is how cce-relief's Save migrates a file in place.
-    #[test]
-    fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
-        let content = "style {\n    surface {\n        relief light=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\n    }\n}\n";
-        let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
-        let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
-        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.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"));
-        assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
-        assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
-        assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
-        let out = doc.to_string();
-        assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
-        assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
-        assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
-        assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
-
-        let val = parse_kdl_to_json(&out);
-        let relief = val.pointer("/style/surface/relief").unwrap();
-        assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
-        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!(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"));
-        let val = parse_kdl_to_json(&doc.to_string());
-        assert!(val.pointer("/style/surface/relief/edge").is_none());
-        assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
-    }
-
-    #[test]
-    fn relief_keys_write_as_properties_and_round_trip() {
-        // `relief` is a PROP_NODES member: style.surface.relief.* must land as
-        // properties on the existing relief node (the config.kdl shape), not
-        // as duplicate child nodes shadowing the light=/width= properties.
-        let content = "style {\n    surface {\n        relief light=(f64)0.15 width=(f64)9.3\n    }\n}\n";
-        let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
-        let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
-        assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
-        let out = doc.to_string();
-        // Still one relief node, no child block grown under it.
-        assert_eq!(out.matches("relief").count(), 1, "out: {out}");
-        assert!(!out.contains("relief {"), "out: {out}");
-
-        // The reload path reads through parse_kdl_to_json: the new property
-        // must surface at the same dotted path the style registry maps.
-        let val = parse_kdl_to_json(&out);
-        let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
-        assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
-        assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
-        assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
-    }
-
-    #[test]
-    fn test_get_kdl_type_annotation() {
-        let content = "input {\n    accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n    touchpad {\n        gestures pinch=(bool)true\n    }\n}\n";
-        let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
-        assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
-        
-        let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
-        assert_eq!(ty2, Some("bool".to_string()));
-
-        let keys = vec![
-            "input.accel_profile".to_string(),
-            "input.touchpad.gestures.pinch".to_string(),
-            "input.invalid_key".to_string(),
-        ];
-        let tys = get_kdl_type_annotations(content, &keys);
-        assert_eq!(tys.len(), 3);
-        assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
-        assert_eq!(tys[1], Some("bool".to_string()));
-        assert_eq!(tys[2], None);
-    }
-
-    #[test]
-    fn test_json_to_kdl_with_special_annotations() {
-        let mut annotations = std::collections::HashMap::new();
-        annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
-        
-        let mut desktop_map = serde_json::Map::new();
-        desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
-        
-        let mut surface_map = serde_json::Map::new();
-        surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
-        
-        let mut style_map = serde_json::Map::new();
-        style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
-        
-        let mut root_map = serde_json::Map::new();
-        root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
-        
-        let root = serde_json::Value::Object(root_map);
-        let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
-        println!("Generated KDL:\n{}", kdl_str);
-        
-        let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
-        assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
-    }
-
-    #[test]
-    fn test_brightness_annotations() {
-        let mut edp_map = serde_json::Map::new();
-        edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
-        edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
-        edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
-        edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
-
-        let mut output_map = serde_json::Map::new();
-        output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
-
-        let mut root_map = serde_json::Map::new();
-        root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
-
-        let root = serde_json::Value::Object(root_map);
-        let kdl_str = json_to_kdl_string(&root);
-        println!("Generated KDL for brightness:\n{}", kdl_str);
-
-        let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
-        
-        let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
-        let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
-        
-        let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
-        assert_eq!(up_entry.ty().unwrap().value(), "keybind");
-
-        let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
-        assert_eq!(down_entry.ty().unwrap().value(), "keybind");
-
-        let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
-        assert_eq!(interval_entry.ty().unwrap().value(), "i64");
-    }
-
-    #[test]
-    fn test_root_plate_menubar_statusbar_styling() {
-        // Global color state: serialize against the other reload_colors tests,
-        // and fire both once-per-process live-config loads before our reload
-        // so neither can rewrite the state mid-assert.
-        let _guard = crate::color::test_color_state_lock();
-        let _ = crate::color::root_plate_statusbar_blur();
-        crate::layout::lazy_init_style_registry();
-
-        let content = r##"
-            style {
-                surface {
-                    plate {
-                        root blur=(f64)0.1 color=(rgba)"#5e657acf" corner_radius=(i64)12 {
-                            menubar blur=(bool)true color=(rgba)"#1a1d26d0" text_color=(rgba)"#e2e4f0ff"
-                        }
-                    }
-                    statusbar blur=(bool)false color=(rgba)"#12141cd0" text_color=(rgba)"#b5b9c8ff"
-                }
-                control {
-                    dropdown color=(rgba)"#08080cff"
-                }
-                data {
-                    textbox placeholder_text_color=(rgba)"#60606aff"
-                }
-            }
-        "##;
-        
-        // Parse into json and set colors
-        crate::color::reload_colors(content);
-
-        // Verify values are parsed correctly through the root_plate_* getters.
-        assert_eq!(crate::color::root_plate_menubar_blur(), true);
-        
-        let dd_color = crate::color::dropdown_background_color();
-        assert!((dd_color[0] - crate::color::srgb_to_linear(8.0 / 255.0)).abs() < 0.0001);
-        
-        let placeholder_color = crate::color::textbox_placeholder_text_color();
-        assert_eq!(placeholder_color, [0x60, 0x60, 0x6a]);
-        assert_eq!(crate::color::root_plate_statusbar_blur(), false);
-
-        // Colors are in sRGB converted to linear, let's verify text colors
-        let menubar_txt = crate::color::root_plate_menubar_text_color();
-        assert!(menubar_txt[0] > 0.0);
-        let statusbar_txt = crate::color::root_plate_statusbar_text_color();
-        assert!(statusbar_txt[0] > 0.0);
-    }
-
-    /// `style.surface.plate.root.*` is the only root-plate spelling: the
-    /// legacy `root plate.*` block is ignored, whether it stands beside the
-    /// canonical block or alone (its read-alias was removed 2026-09-06).
-    #[test]
-    fn test_plate_root_canonical_spelling() {
-        // Global color state: serialize against the other reload_colors tests,
-        // and fire both once-per-process live-config loads before our reload
-        // so neither can rewrite the state mid-assert.
-        let _guard = crate::color::test_color_state_lock();
-        let _ = crate::color::root_plate_corner_radius();
-        crate::layout::lazy_init_style_registry();
-
-        let content = r##"
-            style {
-                surface {
-                    plate {
-                        root corner_radius=(i64)17 {
-                            menubar blur=(bool)true color=(rgba)"#1a1d26d0"
-                        }
-                    }
-                    backplate corner_radius=(i64)9
-                }
-            }
-        "##;
-        crate::color::reload_colors(content);
-        assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "canonical read; legacy ignored");
-        assert_eq!(crate::color::root_plate_menubar_blur(), true);
-
-        // A legacy-only spelling no longer feeds the getter: the value from
-        // the canonical load above stands.
-        let legacy = r##"
-            style {
-                surface {
-                    backplate corner_radius=(i64)9
-                }
-            }
-        "##;
-        crate::color::reload_colors(legacy);
-        assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
-    }
-
-    /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
-    /// trip cce-data-editor saves through — it used to come back as ONE arg,
-    /// `rounded_apps "a b"`, and the compositor's allowlist then matched
-    /// nothing (Claude Desktop lost its corners after every save).
-    #[test]
-    fn test_string_list_roundtrip() {
-        let content = "window_manager {\n    rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n    corner_shape (f64)4.5\n}\n";
-        let val = parse_kdl_to_json(content);
-        let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
-        assert_eq!(
-            list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
-            vec!["claude-desktop", "com.anthropic.Claude"]
-        );
-        let kdl_str = json_to_kdl_string(&val);
-        assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
-        // And it re-parses to the same two args, not one.
-        let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
-        let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
-        let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
-        assert_eq!(ra.entries().len(), 2);
-        // A single-arg string node stays a plain string.
-        let single = parse_kdl_to_json("window_manager {\n    rounded_apps \"claude-desktop\"\n}\n");
-        assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
-    }
-
-    #[test]
-    fn test_vec2i_lossless_roundtrip() {
-        let content = "style {\n    surface {\n        cloud {\n            position_default (vec2i)100 200\n        }\n    }\n}\n";
-        let val = parse_kdl_to_json(content);
-        println!("Parsed KDL to JSON: {:?}", val);
-        
-        let position_default_val = val.get("style").unwrap()
-            .get("surface").unwrap()
-            .get("cloud").unwrap()
-            .get("position_default").unwrap();
-        assert_eq!(position_default_val.as_str().unwrap(), "100 200");
-
-        let mut annotations = std::collections::HashMap::new();
-        annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
-        
-        let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
-        println!("Generated KDL:\n{}", kdl_str);
-        
-        // Assert that (vec2i)100 200 is preserved without quotes
-        assert!(kdl_str.contains("position_default (vec2i)100 200"));
-        
-        // Test update_kdl_in_memory preserves and updates the KDL Document correctly
-        let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
-        let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
-        assert!(updated);
-        let updated_kdl = doc.to_string();
-        println!("Updated KDL:\n{}", updated_kdl);
-        assert!(updated_kdl.contains("position_default (vec2i)150 250"));
-    }
-}
-
-/// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
-/// reads back with) escapes: a quote, a backslash and the control
-/// characters. Every string the writer emits goes through here — until
-/// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
-/// quote inside a value made a line no parser reads, and an app whose
-/// settings file fails to parse loads its DEFAULTS.
-pub fn kdl_quote(s: &str) -> String {
-    let mut out = String::with_capacity(s.len() + 2);
-    out.push('"');
-    for c in s.chars() {
-        match c {
-            '"' => out.push_str("\\\""),
-            '\\' => out.push_str("\\\\"),
-            '\n' => out.push_str("\\n"),
-            '\r' => out.push_str("\\r"),
-            '\t' => out.push_str("\\t"),
-            '\u{08}' => out.push_str("\\b"),
-            '\u{0C}' => out.push_str("\\f"),
-            c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
-            c => out.push(c),
-        }
-    }
-    out.push('"');
-    out
-}
-
-fn format_kdl_type(ty: &str) -> String {
-    let is_ident = !ty.is_empty()
-        && !ty.chars().next().unwrap().is_ascii_digit()
-        && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
-    if is_ident {
-        ty.to_string()
-    } else {
-        kdl_quote(ty)
-    }
-}
-
-fn format_kdl_identifier(name: &str) -> String {
-    let is_ident = !name.is_empty()
-        && !name.chars().next().unwrap().is_ascii_digit()
-        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
-    if is_ident {
-        name.to_string()
-    } else {
-        kdl_quote(name)
-    }
-}
-
-pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
-    value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
-}
-
-pub fn value_to_kdl_with_annotations(
-    key: &str,
-    val: &serde_json::Value,
-    indent: usize,
-    parent_path: &str,
-    annotations: &std::collections::HashMap<String, String>,
-) -> String {
-    let indent_str = "    ".repeat(indent);
-    let current_path = if parent_path.is_empty() {
-        key.to_string()
-    } else {
-        format!("{}.{}", parent_path, key)
-    };
-
-    match val {
-        serde_json::Value::Object(map) => {
-            let has_objects = map.values().any(|v| v.is_object());
-            if has_objects {
-                let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
-                for (k, v) in map {
-                    out.push_str(&value_to_kdl_with_annotations(k, v, indent + 1, &current_path, annotations));
-                }
-                out.push_str(&format!("{}}}\n", indent_str));
-                out
-            } else {
-                let mut prop_parts = Vec::new();
-                let mut child_parts = Vec::new();
-                for (prop_name, prop_val) in map {
-                    let prop_path = format!("{}.{}", current_path, prop_name);
-                    let is_vec2i = annotations.get(&prop_path).map_or(false, |a| a == "vec2i");
-                    if is_vec2i {
-                        if let serde_json::Value::String(ref s) = prop_val {
-                            child_parts.push(format!("{}{} (vec2i){}\n", "    ".repeat(indent + 1), format_kdl_identifier(prop_name), s));
-                        }
-                    } else {
-                        let (val_str, val_ty) = match prop_val {
-                            serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
-                            serde_json::Value::Number(num) => {
-                                if prop_name == "light_source_position" {
-                                    (num.to_string(), Some("radian".to_string()))
-                                } else if num.is_f64() {
-                                    (num.to_string(), Some("f64".to_string()))
-                                } else {
-                                    (num.to_string(), Some("i64".to_string()))
-                                }
-                            }
-                            serde_json::Value::String(s) => {
-                                if let Some(len) = crate::units::Len::parse(s) {
-                                    (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
-                                } else if let Some(anno) = annotations.get(&prop_path) {
-                                    if anno == "vec2i" {
-                                        (s.clone(), Some(anno.clone()))
-                                    } else {
-                                        (kdl_quote(s), Some(anno.clone()))
-                                    }
-                                } else if s.starts_with('#') {
-                                    let s_clean = s.trim_start_matches('#');
-                                    let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
-                                    (kdl_quote(s), Some(ty.to_string()))
-                                } else if prop_name == "key" || prop_name == "keybind" || prop_name == "shortcut" || prop_name == "open_search" || prop_name == "close_search" || prop_name == "delete" || prop_name.ends_with("_key") || prop_name.ends_with(".key") || prop_name.ends_with(".keybind") || prop_name.ends_with(".open_search") || prop_name.ends_with(".close_search") || prop_name == "brightness_up" || prop_name == "brightness_down" || prop_name.ends_with(".brightness_up") || prop_name.ends_with(".brightness_down") {
-                                    (kdl_quote(s), Some("keybind".to_string()))
-                                } else {
-                                    (kdl_quote(s), None)
-                                }
-                            }
-                            _ => (prop_val.to_string(), None),
-                        };
-                        if let Some(ty) = val_ty {
-                            prop_parts.push(format!("{}=({}){}", format_kdl_identifier(prop_name), format_kdl_type(&ty), val_str));
-                        } else {
-                            prop_parts.push(format!("{}={}", format_kdl_identifier(prop_name), val_str));
-                        }
-                    }
-                }
-                if !child_parts.is_empty() {
-                    let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
-                    if !prop_parts.is_empty() {
-                        out.push_str(&format!("{}{}\n", "    ".repeat(indent + 1), prop_parts.join(" ")));
-                    }
-                    for child in child_parts {
-                        out.push_str(&child);
-                    }
-                    out.push_str(&format!("{}}}\n", indent_str));
-                    out
-                } else {
-                    format!("{}{} {}\n", indent_str, format_kdl_identifier(key), prop_parts.join(" "))
-                }
-            }
-        }
-        serde_json::Value::Array(arr) => {
-            // An array of strings is one node with several positional args
-            // (the shape `kdl_to_json` reads `rounded_apps "a" "b"` into);
-            // any other array is one node per item (key_bindings' objects).
-            if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
-                let args: Vec<String> = arr
-                    .iter()
-                    .filter_map(|v| v.as_str().map(|s| kdl_quote(s)))
-                    .collect();
-                return format!("{}{} {}\n", indent_str, format_kdl_identifier(key), args.join(" "));
-            }
-            let mut out = String::new();
-            for item in arr {
-                out.push_str(&value_to_kdl_with_annotations(key, item, indent, parent_path, annotations));
-            }
-            out
-        }
-        _ => {
-            let (val_str, val_ty) = match val {
-                serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
-                serde_json::Value::Number(num) => {
-                    if key == "light_source_position" {
-                        (num.to_string(), Some("radian".to_string()))
-                    } else if num.is_f64() {
-                        (num.to_string(), Some("f64".to_string()))
-                    } else {
-                        (num.to_string(), Some("i64".to_string()))
-                    }
-                }
-                serde_json::Value::String(s) => {
-                    if let Some(len) = crate::units::Len::parse(s) {
-                        (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
-                    } else if let Some(anno) = annotations.get(&current_path) {
-                        if anno == "vec2i" {
-                            (s.clone(), Some(anno.clone()))
-                        } else {
-                            (kdl_quote(s), Some(anno.clone()))
-                        }
-                    } else if s.starts_with('#') {
-                        let s_clean = s.trim_start_matches('#');
-                        let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
-                        (kdl_quote(s), Some(ty.to_string()))
-                    } else if key == "key" || key == "keybind" || key == "shortcut" || key == "open_search" || key == "close_search" || key == "delete" || key.ends_with("_key") || key.ends_with(".key") || key.ends_with(".keybind") || key.ends_with(".open_search") || key.ends_with(".close_search") || key == "brightness_up" || key == "brightness_down" || key.ends_with(".brightness_up") || key.ends_with(".brightness_down") {
-                        (kdl_quote(s), Some("keybind".to_string()))
-                    } else {
-                        (kdl_quote(s), None)
-                    }
-                }
-                _ => (val.to_string(), None),
-            };
-            if let Some(ty) = val_ty {
-                format!("{}{} ({}){}\n", indent_str, format_kdl_identifier(key), format_kdl_type(&ty), val_str)
-            } else {
-                format!("{}{} {}\n", indent_str, format_kdl_identifier(key), val_str)
-            }
-        }
-    }
-}
-
-pub fn json_to_kdl_string(val: &serde_json::Value) -> String {
-    json_to_kdl_string_with_annotations(val, &std::collections::HashMap::new())
-}
-
-pub fn json_to_kdl_string_with_annotations(
-    val: &serde_json::Value,
-    annotations: &std::collections::HashMap<String, String>,
-) -> String {
-    let mut out = String::new();
-    if let serde_json::Value::Object(map) = val {
-        for (sec_name, sec_val) in map {
-            if let serde_json::Value::Object(sec_map) = sec_val {
-                out.push_str(&format!("{} {{\n", format_kdl_identifier(sec_name)));
-                for (k, v) in sec_map {
-                    out.push_str(&value_to_kdl_with_annotations(k, v, 1, sec_name, annotations));
-                }
-                out.push_str("}\n");
-            } else {
-                out.push_str(&value_to_kdl_with_annotations(sec_name, sec_val, 0, "", annotations));
-            }
-        }
-    }
-    out
-}
-
-pub fn get_app_recent_files_path() -> std::path::PathBuf {
-    let app_name = get_app_name().unwrap_or_else(|| "cce-app".to_string());
-    get_config_path().parent().unwrap().join(app_name).join("recent-files.kdl")
-}
-
-pub fn load_recent_files() -> Vec<String> {
-    let path = get_app_recent_files_path();
-    if path.exists() {
-        if let Ok(content) = std::fs::read_to_string(&path) {
-            if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
-                if let Some(recent_node) = doc.get("recent") {
-                    if let Some(children) = recent_node.children() {
-                        let mut files = Vec::new();
-                        for node in children.nodes() {
-                            if node.name().value() == "file" {
-                                if let Some(entry) = node.entries().first() {
-                                    if let kdl::KdlValue::String(s) = entry.value() {
-                                        files.push(s.clone());
-                                    }
-                                }
-                            }
-                        }
-                        return files;
-                    }
-                }
-            }
-        }
-    }
-    Vec::new()
-}
-
-pub fn save_recent_files(files: &[String]) {
-    let path = get_app_recent_files_path();
-    if let Some(parent) = path.parent() {
-        let _ = std::fs::create_dir_all(parent);
-    }
-    let mut kdl_str = "recent {\n".to_string();
-    for file in files {
-        kdl_str.push_str(&format!("    file {}\n", kdl_quote(file)));
-    }
-    kdl_str.push_str("}\n");
-    let _ = std::fs::write(path, kdl_str);
-}
diff --git a/src/config_style_tests.rs b/src/config_style_tests.rs
new file mode 100644
index 0000000..e2349ff
--- /dev/null
+++ b/src/config_style_tests.rs
@@ -0,0 +1,92 @@
+//! Tests of how the style layer reads the root plate's config keys. They lived in
+//! `config.rs`'s suite until that module moved to `cce_core::config`; they test
+//! `color` and `layout`, which stay here.
+
+/// `style.surface.plate.root.*` is the only root-plate spelling: the
+/// legacy `root plate.*` block is ignored, whether it stands beside the
+/// canonical block or alone (its read-alias was removed 2026-09-06).
+#[test]
+fn test_plate_root_canonical_spelling() {
+    // Global color state: serialize against the other reload_colors tests,
+    // and fire both once-per-process live-config loads before our reload
+    // so neither can rewrite the state mid-assert.
+    let _guard = crate::color::test_color_state_lock();
+    let _ = crate::color::root_plate_corner_radius();
+    crate::layout::lazy_init_style_registry();
+
+    let content = r##"
+        style {
+            surface {
+                plate {
+                    root corner_radius=(i64)17 {
+                        menubar blur=(bool)true color=(rgba)"#1a1d26d0"
+                    }
+                }
+                backplate corner_radius=(i64)9
+            }
+        }
+    "##;
+    crate::color::reload_colors(content);
+    assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "canonical read; legacy ignored");
+    assert_eq!(crate::color::root_plate_menubar_blur(), true);
+
+    // A legacy-only spelling no longer feeds the getter: the value from
+    // the canonical load above stands.
+    let legacy = r##"
+        style {
+            surface {
+                backplate corner_radius=(i64)9
+            }
+        }
+    "##;
+    crate::color::reload_colors(legacy);
+    assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
+}
+
+#[test]
+fn test_root_plate_menubar_statusbar_styling() {
+    // Global color state: serialize against the other reload_colors tests,
+    // and fire both once-per-process live-config loads before our reload
+    // so neither can rewrite the state mid-assert.
+    let _guard = crate::color::test_color_state_lock();
+    let _ = crate::color::root_plate_statusbar_blur();
+    crate::layout::lazy_init_style_registry();
+
+    let content = r##"
+        style {
+            surface {
+                plate {
+                    root blur=(f64)0.1 color=(rgba)"#5e657acf" corner_radius=(i64)12 {
+                        menubar blur=(bool)true color=(rgba)"#1a1d26d0" text_color=(rgba)"#e2e4f0ff"
+                    }
+                }
+                statusbar blur=(bool)false color=(rgba)"#12141cd0" text_color=(rgba)"#b5b9c8ff"
+            }
+            control {
+                dropdown color=(rgba)"#08080cff"
+            }
+            data {
+                textbox placeholder_text_color=(rgba)"#60606aff"
+            }
+        }
+    "##;
+    
+    // Parse into json and set colors
+    crate::color::reload_colors(content);
+
+    // Verify values are parsed correctly through the root_plate_* getters.
+    assert_eq!(crate::color::root_plate_menubar_blur(), true);
+    
+    let dd_color = crate::color::dropdown_background_color();
+    assert!((dd_color[0] - crate::color::srgb_to_linear(8.0 / 255.0)).abs() < 0.0001);
+    
+    let placeholder_color = crate::color::textbox_placeholder_text_color();
+    assert_eq!(placeholder_color, [0x60, 0x60, 0x6a]);
+    assert_eq!(crate::color::root_plate_statusbar_blur(), false);
+
+    // Colors are in sRGB converted to linear, let's verify text colors
+    let menubar_txt = crate::color::root_plate_menubar_text_color();
+    assert!(menubar_txt[0] > 0.0);
+    let statusbar_txt = crate::color::root_plate_statusbar_text_color();
+    assert!(statusbar_txt[0] > 0.0);
+}
diff --git a/src/input.rs b/src/input.rs
deleted file mode 100644
index 3195719..0000000
--- a/src/input.rs
+++ /dev/null
@@ -1,632 +0,0 @@
-// ~/.config/cce/input.kdl — domain-scoped keybindings and pointer input
-// settings for the whole desktop.
-//
-// Top-level nodes are DOMAINS; their children are bindings. One top-level
-// node is special: `input { }` holds the global hardware pointer defaults
-// (accel, scroll factors per device class), consumed by the compositor.
-// Inside a domain, an `input { }` child holds that app's behavior
-// overrides, consumed client-side by this module:
-//
-//     input {                          // global hardware defaults (compositor)
-//         accel_profile "flat"
-//         accel_speed 1.0
-//         mouse { scroll_factor 1.0 }
-//         trackpad { tap_to_click true; natural_scroll true; scroll_factor 1.5 }
-//         trackpoint { accel_speed 0.5 }
-//     }
-//     cce-window-manager {
-//         close_window "super+q"
-//         spawn "super+d" command="cce-cloud --apps"
-//         focus_left "swipe3_left"     // a touchpad gesture chord: swipe|pinch,
-//                                      // optional finger count, direction
-//     }
-//     cce-ui {
-//         open_search "ctrl+f"         // toolkit-wide widget defaults
-//         input {
-//             scroll_factor 1.0        // toolkit-wide scroll default
-//             smooth_scroll true       // wheel notches glide (widget::scroll_motion)
-//             scroll_ease 12.0         // glide rate, 1/s
-//             kinetic_scroll true      // trackpad flicks coast after the lift
-//             scroll_friction 6.0      // coast decay, 1/s
-//         }
-//     }
-//     cce-files {
-//         open_search "/"              // per-app override of the cce-ui default
-//         input {
-//             scroll_factor 0.8        // both device kinds
-//             trackpad { scroll_factor 0.6 }
-//         }
-//     }
-//
-// The chord is the first string argument (a `(keybind)` or `(gesture)` type
-// annotation is accepted and ignored); a `key="..."` property works too.
-// Extra properties (e.g. `command=` for spawn) ride along on the entry.
-//
-// Resolution order for an app is `<app>.<name>` → `cce-ui.<name>`; widgets
-// match the resolved chord string with `widget::match_key_shortcut`. The
-// `cce-window-manager` domain is consumed by the compositor, which maps
-// names to policy `Action`s — chords never get interpreted here.
-//
-// Per-app scroll factors compose with the compositor's device scaling: the
-// compositor applies the global `input` block at the event source; a client
-// then scales its own wheel deltas by the resolved app factor (pixel deltas
-// count as `trackpad`, discrete wheel clicks as `mouse`).
-
-use std::collections::BTreeMap;
-
-/// Domain holding toolkit-wide default widget bindings.
-pub const UI_DOMAIN: &str = "cce-ui";
-/// Domain holding compositor / window-management bindings.
-pub const WINDOW_MANAGER_DOMAIN: &str = "cce-window-manager";
-
-/// `~/.config/cce/input.kdl` (honoring `XDG_CONFIG_HOME`).
-pub fn get_input_path() -> std::path::PathBuf {
-    crate::config::cce_config_dir().join("input.kdl")
-}
-
-/// One binding line inside a domain block.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct BindingEntry {
-    /// Node name, e.g. `open_search`. Names may repeat (several `spawn`s).
-    pub name: String,
-    /// The chord string, e.g. `"super+shift+h"`.
-    pub chord: String,
-    /// `command="..."` property, for entries that launch something.
-    pub command: Option<String>,
-}
-
-/// A typed value inside an `input { }` settings block.
-#[derive(Debug, Clone, PartialEq)]
-pub enum SettingValue {
-    Float(f64),
-    Bool(bool),
-    Str(String),
-}
-
-impl SettingValue {
-    pub fn as_f64(&self) -> Option<f64> {
-        match self {
-            SettingValue::Float(f) => Some(*f),
-            _ => None,
-        }
-    }
-
-    pub fn as_bool(&self) -> Option<bool> {
-        match self {
-            SettingValue::Bool(b) => Some(*b),
-            _ => None,
-        }
-    }
-
-    pub fn as_str(&self) -> Option<&str> {
-        match self {
-            SettingValue::Str(s) => Some(s),
-            _ => None,
-        }
-    }
-}
-
-/// Device classes an `input { }` block may scope settings to.
-pub const DEVICE_CLASSES: [&str; 3] = ["mouse", "trackpad", "trackpoint"];
-
-/// One `input { }` block: generic `name value` settings plus per-device-class
-/// sub-blocks (`mouse` / `trackpad` / `trackpoint`).
-#[derive(Debug, Clone, Default, PartialEq)]
-pub struct InputSettings {
-    values: BTreeMap<String, SettingValue>,
-    classes: BTreeMap<String, BTreeMap<String, SettingValue>>,
-}
-
-impl InputSettings {
-    fn parse(node: &kdl::KdlNode) -> InputSettings {
-        let mut settings = InputSettings::default();
-        let Some(children) = node.children() else { return settings };
-        for child in children.nodes() {
-            let name = child.name().value();
-            if DEVICE_CLASSES.contains(&name) {
-                let class = settings.classes.entry(name.to_string()).or_default();
-                if let Some(class_children) = child.children() {
-                    for leaf in class_children.nodes() {
-                        if let Some(v) = setting_value(leaf) {
-                            class.insert(leaf.name().value().to_string(), v);
-                        }
-                    }
-                }
-            } else if let Some(v) = setting_value(child) {
-                settings.values.insert(name.to_string(), v);
-            }
-        }
-        settings
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.values.is_empty() && self.classes.values().all(|c| c.is_empty())
-    }
-
-    /// A generic (class-independent) setting.
-    pub fn get(&self, key: &str) -> Option<&SettingValue> {
-        self.values.get(key)
-    }
-
-    /// A setting for one device class, falling back to the generic value.
-    pub fn get_class(&self, class: &str, key: &str) -> Option<&SettingValue> {
-        self.classes.get(class).and_then(|c| c.get(key)).or_else(|| self.get(key))
-    }
-}
-
-/// First-argument value of a settings leaf node, if it is a scalar.
-fn setting_value(node: &kdl::KdlNode) -> Option<SettingValue> {
-    let entry = node.entries().iter().find(|e| e.name().is_none())?;
-    match entry.value() {
-        kdl::KdlValue::Base10Float(f) => Some(SettingValue::Float(*f)),
-        kdl::KdlValue::Base2(i) | kdl::KdlValue::Base8(i) | kdl::KdlValue::Base10(i) | kdl::KdlValue::Base16(i) => {
-            Some(SettingValue::Float(*i as f64))
-        }
-        kdl::KdlValue::Bool(b) => Some(SettingValue::Bool(*b)),
-        kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => Some(SettingValue::Str(s.clone())),
-        kdl::KdlValue::Null => None,
-    }
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct InputConfig {
-    domains: BTreeMap<String, Vec<BindingEntry>>,
-    /// Per-domain `input { }` behavior overrides.
-    settings: BTreeMap<String, InputSettings>,
-    /// The top-level `input { }` block: global hardware defaults, consumed
-    /// by the compositor.
-    global: InputSettings,
-}
-
-impl InputConfig {
-    /// Parse the file content. Domain blocks with no children are ignored;
-    /// a child with no chord (no string argument and no `key=`) is skipped.
-    pub fn parse(content: &str) -> Result<InputConfig, String> {
-        let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("{}", e))?;
-        let mut domains: BTreeMap<String, Vec<BindingEntry>> = BTreeMap::new();
-        let mut settings: BTreeMap<String, InputSettings> = BTreeMap::new();
-        let mut global = InputSettings::default();
-        for domain_node in doc.nodes() {
-            // The top-level `input { }` block is global hardware defaults,
-            // not a domain.
-            if domain_node.name().value() == "input" {
-                global = InputSettings::parse(domain_node);
-                continue;
-            }
-            let Some(children) = domain_node.children() else { continue };
-            let domain = domain_node.name().value().to_string();
-            let entries = domains.entry(domain.clone()).or_default();
-            for node in children.nodes() {
-                // A domain's `input { }` child is its settings block.
-                if node.name().value() == "input" {
-                    settings.insert(domain.clone(), InputSettings::parse(node));
-                    continue;
-                }
-                let mut chord: Option<String> = None;
-                let mut command: Option<String> = None;
-                for entry in node.entries() {
-                    let value = match entry.value() {
-                        kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => s.clone(),
-                        _ => continue,
-                    };
-                    match entry.name().map(|n| n.value()) {
-                        None | Some("key") => {
-                            if chord.is_none() {
-                                chord = Some(value);
-                            }
-                        }
-                        Some("command") => command = Some(value),
-                        Some(_) => {}
-                    }
-                }
-                if let Some(chord) = chord {
-                    entries.push(BindingEntry {
-                        name: node.name().value().to_string(),
-                        chord,
-                        command,
-                    });
-                }
-            }
-        }
-        Ok(InputConfig { domains, settings, global })
-    }
-
-    /// Load `input.kdl`. Missing file → empty config; a parse error is
-    /// logged and also yields an empty config, so callers fall back to
-    /// their defaults instead of losing all input.
-    pub fn load() -> InputConfig {
-        let path = get_input_path();
-        let Ok(content) = std::fs::read_to_string(&path) else {
-            return InputConfig::default();
-        };
-        match InputConfig::parse(&content) {
-            Ok(config) => config,
-            Err(e) => {
-                eprintln!("[cce-ui] failed to parse {}: {}", path.display(), e);
-                InputConfig::default()
-            }
-        }
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.domains.values().all(|v| v.is_empty())
-    }
-
-    /// All entries of one domain, in file order.
-    pub fn domain(&self, domain: &str) -> &[BindingEntry] {
-        self.domains.get(domain).map(Vec::as_slice).unwrap_or(&[])
-    }
-
-    /// First entry named `name` in `domain`, no fallback.
-    pub fn get(&self, domain: &str, name: &str) -> Option<&BindingEntry> {
-        self.domain(domain).iter().find(|e| e.name == name)
-    }
-
-    /// Domain resolution for apps: `<app>.<name>`, falling back to
-    /// `cce-ui.<name>`.
-    pub fn resolve(&self, app: &str, name: &str) -> Option<&BindingEntry> {
-        self.get(app, name).or_else(|| self.get(UI_DOMAIN, name))
-    }
-
-    /// The top-level `input { }` block (global hardware defaults).
-    pub fn global_input(&self) -> &InputSettings {
-        &self.global
-    }
-
-    /// One domain's `input { }` behavior overrides.
-    pub fn domain_input(&self, domain: &str) -> Option<&InputSettings> {
-        self.settings.get(domain)
-    }
-
-    /// Setting resolution for apps, most specific first: the app domain's
-    /// class value → its generic value → the cce-ui domain's class value →
-    /// its generic value. The global `input` block is deliberately NOT in
-    /// the chain — the compositor already applies it at the event source.
-    pub fn resolve_setting(&self, app: &str, class: &str, key: &str) -> Option<&SettingValue> {
-        self.domain_input(app)
-            .and_then(|s| s.get_class(class, key))
-            .or_else(|| self.domain_input(UI_DOMAIN).and_then(|s| s.get_class(class, key)))
-    }
-
-    /// Resolved chord string for widgets, with a compiled-in default as the
-    /// last resort.
-    pub fn resolve_chord(&self, app: &str, name: &str, default: &str) -> String {
-        self.resolve(app, name).map(|e| e.chord.clone()).unwrap_or_else(|| default.to_string())
-    }
-}
-
-/// Replace (or append) one domain block in `input.kdl` content, leaving all
-/// other domains and their formatting untouched. `entries` becomes the whole
-/// new block, in order; an empty slice removes the domain. Pure — the I/O
-/// wrapper is `write_domain`.
-pub fn upsert_domain(content: &str, domain: &str, entries: &[BindingEntry]) -> Result<String, String> {
-    let mut doc: kdl::KdlDocument = if content.trim().is_empty() {
-        kdl::KdlDocument::new()
-    } else {
-        content.parse().map_err(|e| format!("{}", e))?
-    };
-
-    let mut block = format!("{} {{\n", kdl_ident(domain));
-    for entry in entries {
-        block.push_str(&format!("    {} (keybind){:?}", kdl_ident(&entry.name), entry.chord));
-        if let Some(ref command) = entry.command {
-            block.push_str(&format!(" command={:?}", command));
-        }
-        block.push('\n');
-    }
-    block.push_str("}\n");
-
-    doc.nodes_mut().retain(|n| n.name().value() != domain);
-    if !entries.is_empty() {
-        let node: kdl::KdlNode = block.parse().map_err(|e| format!("{}", e))?;
-        doc.nodes_mut().push(node);
-    }
-    let mut out = doc.to_string();
-    if !out.ends_with('\n') {
-        out.push('\n');
-    }
-    Ok(out)
-}
-
-/// Quote a node name if it isn't a bare KDL identifier.
-fn kdl_ident(name: &str) -> String {
-    let bare = !name.is_empty()
-        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
-        && !name.starts_with(|c: char| c.is_ascii_digit());
-    if bare { name.to_string() } else { format!("{:?}", name) }
-}
-
-/// Rewrite one domain of the file at `path` (created if missing). This is
-/// the editor API for settings UIs and migration tools.
-pub fn write_domain(path: &std::path::Path, domain: &str, entries: &[BindingEntry]) -> Result<(), String> {
-    let content = std::fs::read_to_string(path).unwrap_or_default();
-    let updated = upsert_domain(&content, domain, entries)?;
-    let path_str = path.to_string_lossy();
-    if crate::config::safe_write(&path_str, &updated) {
-        Ok(())
-    } else {
-        Err(format!("failed to write {}", path_str))
-    }
-}
-
-static CACHED: std::sync::OnceLock<InputConfig> = std::sync::OnceLock::new();
-
-/// Process-wide cached `input.kdl`, loaded on first use. Widget-default
-/// getters go through this so the file is read once per app.
-pub fn cached() -> &'static InputConfig {
-    CACHED.get_or_init(InputConfig::load)
-}
-
-/// Chord for one of this app's bindings: `<app>.<name>` → `cce-ui.<name>` →
-/// the compiled-in default. The app domain is the binary name. This is the
-/// standard way for a client to resolve its shortcuts at startup:
-///
-/// ```text
-/// let open = cce_ui::input::app_chord("open_file", "enter");
-/// ... cce_ui::widget::match_key_shortcut(event, &open) ...
-/// ```
-pub fn app_chord(name: &str, default: &str) -> String {
-    let app = crate::config::get_app_name().unwrap_or_default();
-    cached().resolve_chord(&app, name, default)
-}
-
-/// Whether the trackpad scrolls NATURALLY — the content following the
-/// fingers — per input.kdl's `trackpad { natural_scroll }`, as the
-/// compositor applies it. Read once per process; a thread's override
-/// (`force_natural_scroll`, for a test) wins over it, and under `cfg(test)`
-/// with no override the answer is `false`, so the toolkit's own suite does
-/// not read the machine.
-///
-/// What it is for: a VALUE control — a slider, a spinbox, a menu's slider
-/// row — takes the wheel as "up is more", and a finger under natural
-/// scrolling as the same thing, which is the opposite sign of the pixel
-/// delta the runner hands it (the delta is what a LIST scrolls by, and a
-/// list under natural scrolling moves its content the way the fingers
-/// went). See `MouseScrollDelta::value_notches_y`.
-pub fn natural_scroll() -> bool {
-    if let Some(forced) = NATURAL_OVERRIDE.with(|f| f.get()) {
-        return forced;
-    }
-    #[cfg(test)]
-    {
-        false
-    }
-    #[cfg(not(test))]
-    {
-        *NATURAL_SCROLL.get_or_init(|| {
-            let input = cached();
-            let app = crate::config::get_app_name().unwrap_or_default();
-            input.resolve_setting(&app, "trackpad", "natural_scroll").and_then(SettingValue::as_bool).unwrap_or(false)
-        })
-    }
-}
-
-#[cfg(not(test))]
-static NATURAL_SCROLL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
-
-thread_local! {
-    static NATURAL_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
-}
-
-/// Force what [`natural_scroll`] answers on this thread, for a test that
-/// drives a value control with a finger; `None` lifts it. Thread-local,
-/// because a suite runs its tests in parallel and a process-wide override
-/// set by one would race every other test's read. The macOS shell sets it
-/// too, on the main thread its events arrive on, from each trackpad
-/// event's `isDirectionInvertedFromDevice`: there the system's setting is
-/// the one in force, not input.kdl's.
-pub fn force_natural_scroll(natural: Option<bool>) {
-    NATURAL_OVERRIDE.with(|f| f.set(natural));
-}
-
-/// Run `f` with [`natural_scroll`] answering `natural`, then put back
-/// whatever override was in force. For an input whose direction is known
-/// regardless of the trackpad setting: a touchscreen finger is always
-/// natural (the runner's touch scroll, `backend/touch.rs`).
-pub fn with_natural_scroll<R>(natural: bool, f: impl FnOnce() -> R) -> R {
-    let prev = NATURAL_OVERRIDE.with(|o| o.replace(Some(natural)));
-    let out = f();
-    NATURAL_OVERRIDE.with(|o| o.set(prev));
-    out
-}
-
-/// This app's effective wheel-delta multipliers, resolved once per process.
-/// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct ScrollFactors {
-    pub mouse: f64,
-    pub trackpad: f64,
-}
-
-static SCROLL_FACTORS: std::sync::OnceLock<ScrollFactors> = std::sync::OnceLock::new();
-
-pub fn scroll_factors() -> ScrollFactors {
-    *SCROLL_FACTORS.get_or_init(|| {
-        let input = cached();
-        let app = crate::config::get_app_name().unwrap_or_default();
-        let factor = |class: &str| {
-            input
-                .resolve_setting(&app, class, "scroll_factor")
-                .and_then(SettingValue::as_f64)
-                .filter(|f| f.is_finite() && *f > 0.0)
-                .unwrap_or(1.0)
-        };
-        ScrollFactors { mouse: factor("mouse"), trackpad: factor("trackpad") }
-    })
-}
-
-/// Chord for a widget binding, resolved by specificity: the app's own
-/// `input.kdl` domain, then the caller-supplied legacy value (per-widget
-/// `config.kdl` props), then the toolkit-wide `cce-ui` domain, then the
-/// compiled-in default.
-pub fn widget_chord(name: &str, legacy: &str, default: &str) -> String {
-    let input = cached();
-    if let Some(app) = crate::config::get_app_name() {
-        if let Some(e) = input.get(&app, name) {
-            return e.chord.clone();
-        }
-    }
-    if !legacy.is_empty() {
-        return legacy.to_string();
-    }
-    if let Some(e) = input.get(UI_DOMAIN, name) {
-        return e.chord.clone();
-    }
-    default.to_string()
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    const SAMPLE: &str = r#"
-cce-window-manager {
-    close_window "super+q"
-    toggle_fullscreen (keybind)"super+f"
-    spawn "super+d" command="cce-cloud --apps"
-    spawn "super+t" command="foot"
-}
-cce-ui {
-    open_search "ctrl+f"
-    close_search "escape"
-}
-cce-files {
-    open_file key="enter"
-    open_search "/"
-}
-"#;
-
-    #[test]
-    fn parses_domains_and_entries() {
-        let c = InputConfig::parse(SAMPLE).unwrap();
-        assert_eq!(c.domain(WINDOW_MANAGER_DOMAIN).len(), 4);
-        assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "close_window").unwrap().chord, "super+q");
-        // Type annotations are transparent.
-        assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "toggle_fullscreen").unwrap().chord, "super+f");
-        // Repeated names keep every entry, in order, with their commands.
-        let spawns: Vec<_> =
-            c.domain(WINDOW_MANAGER_DOMAIN).iter().filter(|e| e.name == "spawn").collect();
-        assert_eq!(spawns.len(), 2);
-        assert_eq!(spawns[0].command.as_deref(), Some("cce-cloud --apps"));
-        assert_eq!(spawns[1].chord, "super+t");
-        // key= property form.
-        assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
-    }
-
-    #[test]
-    fn resolution_prefers_app_over_ui_domain() {
-        let c = InputConfig::parse(SAMPLE).unwrap();
-        // Overridden in cce-files.
-        assert_eq!(c.resolve("cce-files", "open_search").unwrap().chord, "/");
-        // Not overridden: falls back to cce-ui.
-        assert_eq!(c.resolve("cce-files", "close_search").unwrap().chord, "escape");
-        // Unknown app: pure cce-ui fallback.
-        assert_eq!(c.resolve("cce-mail", "open_search").unwrap().chord, "ctrl+f");
-        // Nowhere: compiled-in default.
-        assert_eq!(c.resolve_chord("cce-mail", "save", "ctrl+s"), "ctrl+s");
-    }
-
-    const SETTINGS_SAMPLE: &str = r#"
-input {
-    accel_profile "flat"
-    accel_speed 1.0
-    mouse {
-        accel_speed 0.5
-        scroll_factor 2.0
-    }
-    trackpad {
-        tap_to_click true
-        scroll_factor 1.5
-    }
-}
-cce-ui {
-    open_search "ctrl+f"
-    input {
-        scroll_factor 1.25
-    }
-}
-cce-files {
-    open_file "enter"
-    input {
-        scroll_factor 0.8
-        trackpad {
-            scroll_factor 0.6
-        }
-    }
-}
-"#;
-
-    #[test]
-    fn parses_settings_blocks() {
-        let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
-        // The top-level input block is global, not a domain.
-        assert!(c.domain("input").is_empty());
-        let g = c.global_input();
-        assert_eq!(g.get("accel_profile").and_then(SettingValue::as_str), Some("flat"));
-        assert_eq!(g.get("accel_speed").and_then(SettingValue::as_f64), Some(1.0));
-        // Class value wins over generic; missing class value falls back.
-        assert_eq!(g.get_class("mouse", "accel_speed").and_then(SettingValue::as_f64), Some(0.5));
-        assert_eq!(g.get_class("trackpad", "accel_speed").and_then(SettingValue::as_f64), Some(1.0));
-        assert_eq!(g.get_class("trackpad", "tap_to_click").and_then(SettingValue::as_bool), Some(true));
-        // Settings blocks don't pollute the binding lists.
-        assert_eq!(c.domain("cce-files").len(), 1);
-        assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
-    }
-
-    #[test]
-    fn setting_resolution_prefers_app_then_ui_domain() {
-        let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
-        // App class value → app generic → cce-ui.
-        let f = |app: &str, class: &str| {
-            c.resolve_setting(app, class, "scroll_factor").and_then(SettingValue::as_f64)
-        };
-        assert_eq!(f("cce-files", "trackpad"), Some(0.6));
-        assert_eq!(f("cce-files", "mouse"), Some(0.8)); // generic app value
-        assert_eq!(f("cce-mail", "trackpad"), Some(1.25)); // cce-ui fallback
-        // The global input block is not in the client chain.
-        assert_eq!(c.resolve_setting("cce-mail", "mouse", "accel_speed"), None);
-    }
-
-    #[test]
-    fn upsert_domain_round_trips() {
-        let entries = vec![
-            BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
-            BindingEntry {
-                name: "spawn".into(),
-                chord: "super+d".into(),
-                command: Some("cce-cloud --apps".into()),
-            },
-        ];
-        // Insert into empty content, then read back.
-        let out = upsert_domain("", WINDOW_MANAGER_DOMAIN, &entries).unwrap();
-        let parsed = InputConfig::parse(&out).unwrap();
-        assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).to_vec(), entries);
-
-        // Replace the domain without touching other domains.
-        let combined = format!("{}\n{}", SAMPLE, ""); // SAMPLE already has the domain
-        let replaced = upsert_domain(
-            &combined,
-            WINDOW_MANAGER_DOMAIN,
-            &entries[..1],
-        )
-        .unwrap();
-        let parsed = InputConfig::parse(&replaced).unwrap();
-        assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).len(), 1);
-        assert_eq!(parsed.get("cce-files", "open_search").unwrap().chord, "/");
-
-        // Empty entries removes the block entirely.
-        let removed = upsert_domain(&replaced, WINDOW_MANAGER_DOMAIN, &[]).unwrap();
-        let parsed = InputConfig::parse(&removed).unwrap();
-        assert!(parsed.domain(WINDOW_MANAGER_DOMAIN).is_empty());
-        assert_eq!(parsed.resolve("cce-files", "close_search").unwrap().chord, "escape");
-    }
-
-    #[test]
-    fn empty_and_invalid_input() {
-        assert!(InputConfig::parse("").unwrap().is_empty());
-        // Chord-less entries are skipped, childless nodes ignored.
-        let c = InputConfig::parse("cce-ui {\n    broken\n}\nstray-node\n").unwrap();
-        assert!(c.is_empty());
-        assert!(InputConfig::parse("cce-ui {").is_err());
-    }
-}
diff --git a/src/ipc.rs b/src/ipc.rs
deleted file mode 100644
index bc71e92..0000000
--- a/src/ipc.rs
+++ /dev/null
@@ -1,299 +0,0 @@
-//! Helpers for the CCE Unix-socket IPC convention: `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock`.
-
-use std::io::{Read, Write};
-use std::os::unix::net::UnixStream;
-
-pub mod instance;
-
-/// Path of a CCE IPC socket for `prefix`, keyed by `$WAYLAND_DISPLAY`.
-///
-/// `socket_path("cce")` → `/tmp/cce-<display>.sock` (the compositor control socket);
-/// `socket_path("cce-status-interface")` → the status socket. Falls back to
-/// `/tmp/<prefix>.sock` when `$WAYLAND_DISPLAY` is unset.
-pub fn socket_path(prefix: &str) -> String {
-    match std::env::var("WAYLAND_DISPLAY") {
-        Ok(d) if !d.is_empty() => format!("/tmp/{}-{}.sock", prefix, d),
-        _ => format!("/tmp/{}.sock", prefix),
-    }
-}
-
-/// Connect to the `prefix` socket, send `command` (newline-terminated), and
-/// return the reply text. Errors if the socket can't be reached.
-pub fn send_command(prefix: &str, command: &str) -> std::io::Result<String> {
-    let mut stream = UnixStream::connect(socket_path(prefix))?;
-    stream.write_all(command.as_bytes())?;
-    if !command.ends_with('\n') {
-        stream.write_all(b"\n")?;
-    }
-    let mut reply = String::new();
-    stream.read_to_string(&mut reply)?;
-    Ok(reply)
-}
-
-/// Ask the compositor to dissolve this client's surfaces out, and return how
-/// long it says that will take.
-///
-/// The fade is the compositor's, not the app's: it ramps the opacity of the
-/// scene subtree, which carries the backdrop blur, the drop shadow and the
-/// bevel down with the window. A client fading its own pixels instead leaves
-/// its surface fully present, so the blur behind it hangs at full strength
-/// over a dissolving window — and any part of its drawing that is not plain
-/// vertex alpha (shader-lit plate rims, specular) does not fade at all.
-///
-/// The contract is that the caller keeps its surfaces mapped and its process
-/// alive for the returned duration and only then exits. `window_runner` does
-/// that for every [`Application`](crate::backend::window_runner::Application);
-/// an app driving its own event loop calls this itself. Zero — no compositor,
-/// nothing of ours on screen, or fading configured off — means exit now.
-pub fn request_close_fade() -> std::time::Duration {
-    // This runs on the exit path of every cce-ui app, so it does its own
-    // socket call rather than `send_command`: that one reads to EOF with no
-    // deadline, and a compositor wedged mid-frame would hang the quit
-    // forever. A second is far longer than an IPC round trip and short
-    // enough that a user who hit Close still sees the window go.
-    const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
-    let Ok(mut stream) = UnixStream::connect(socket_path("cce")) else {
-        return std::time::Duration::ZERO;
-    };
-    let _ = stream.set_write_timeout(Some(REPLY_TIMEOUT));
-    let _ = stream.set_read_timeout(Some(REPLY_TIMEOUT));
-    if stream.write_all(b"fade-out\n").is_err() {
-        return std::time::Duration::ZERO;
-    }
-    // The duration is the compositor's to decide (`surface { fade out_ms }`),
-    // so it is read back rather than assumed: the two sides would otherwise
-    // drift apart the moment the config changed, and the visible failure —
-    // the window vanishing partway through its own dissolve — reads as a
-    // rendering bug rather than a disagreement about a number.
-    let mut reply = String::new();
-    if stream.read_to_string(&mut reply).is_err() {
-        return std::time::Duration::ZERO;
-    }
-    let ms: u64 = reply.trim().parse().unwrap_or(0);
-    // The compositor clamps this already; clamped again here because a
-    // client must never be made to hang on a number from the other side.
-    std::time::Duration::from_millis(ms.min(2000))
-}
-
-/// Ask the compositor to bring a window to the user: un-minimize it, focus
-/// it, raise it, and pan the camera to it — `ccectl focus-window <query>`.
-///
-/// `query` is what the compositor resolves: a numeric window id, else an
-/// app_id (an exact match beats a substring one). An app bringing *itself*
-/// forward passes its own app_id — which is what a single-instance app does
-/// when a later launch forwards to it, or the work lands in a window parked
-/// off-camera and the launch looks like it did nothing. (xdg-activation is
-/// not the route to this: the compositor deliberately answers it with an
-/// attention notification, not focus.)
-///
-/// Blocks for one round trip, bounded at a second for the same reason as
-/// [`request_close_fade`] — `send_command` reads with no deadline, and a
-/// compositor wedged mid-frame must not hang the caller. `Err` when there is
-/// no compositor to ask, it does not answer in time, it answers `error: …`
-/// (no such window, no seat), or `query` is empty or spans lines.
-pub fn focus_window(query: &str) -> std::io::Result<()> {
-    focus_window_at(&socket_path("cce"), query)
-}
-
-fn focus_window_at(path: &str, query: &str) -> std::io::Result<()> {
-    use std::io::{Error, ErrorKind};
-    const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
-    let query = query.trim();
-    // A newline would end this command and start another on the control
-    // socket; refuse rather than pass an injection along.
-    if query.is_empty() || query.contains(['\n', '\r']) {
-        return Err(Error::new(ErrorKind::InvalidInput, format!("not a window query: {query:?}")));
-    }
-    let mut stream = UnixStream::connect(path)?;
-    stream.set_write_timeout(Some(REPLY_TIMEOUT))?;
-    stream.set_read_timeout(Some(REPLY_TIMEOUT))?;
-    stream.write_all(format!("focus-window {query}\n").as_bytes())?;
-    let mut reply = String::new();
-    stream.read_to_string(&mut reply)?;
-    match reply.trim() {
-        "ok" => Ok(()),
-        other => Err(Error::new(ErrorKind::Other, format!("focus-window {query}: {other}"))),
-    }
-}
-
-/// One request line from a socket client, bounded in size and in TOTAL time.
-///
-/// For a listener thread that serves one client after another: a plain
-/// `BufReader::read_line` with no timeout lets a client that connects and
-/// says nothing (or trickles a byte at a time) hold the thread for good, and
-/// every later client waits behind it. A per-read timeout alone is not
-/// enough, since a trickle resets it with every byte; the deadline here is
-/// for the whole line.
-///
-/// Returns the line with its `\n` (or what arrived before EOF). `None` on
-/// EOF before any byte, the deadline, more than `limit` bytes without a
-/// newline, a read error, or invalid UTF-8. The stream's read timeout is
-/// cleared again on success.
-///
-/// Nothing past the newline is consumed: each chunk is PEEKED first and only
-/// the bytes up to the newline are read, so whatever the client sent after
-/// the line is still on the socket for the caller to read. Until 2026-10-06
-/// the whole chunk was read and the tail truncated away — cce-cloud's
-/// switcher client sends its request line and then the window list down the
-/// same connection, and when both had arrived by the time the daemon read,
-/// the list went with the tail and the switcher opened empty.
-pub fn read_request_line(conn: &UnixStream, limit: usize, deadline: std::time::Duration) -> Option<String> {
-    let until = std::time::Instant::now() + deadline;
-    let mut buf: Vec<u8> = Vec::new();
-    let mut chunk = [0u8; 4096];
-    let mut reader = conn;
-    loop {
-        let left = until.checked_duration_since(std::time::Instant::now()).filter(|d| !d.is_zero())?;
-        conn.set_read_timeout(Some(left)).ok()?;
-        let n = match peek(conn, &mut chunk) {
-            Ok(n) => n,
-            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
-            Err(_) => return None,
-        };
-        if n == 0 {
-            if buf.is_empty() {
-                return None;
-            }
-            break;
-        }
-        // Consume through the newline if the peek holds one, else all of it.
-        let take = chunk[..n].iter().position(|&b| b == b'\n').map_or(n, |end| end + 1);
-        // Already queued, so this returns at once with exactly `take` bytes.
-        reader.read_exact(&mut chunk[..take]).ok()?;
-        buf.extend_from_slice(&chunk[..take]);
-        if buf.last() == Some(&b'\n') {
-            break;
-        }
-        if buf.len() > limit {
-            return None;
-        }
-    }
-    let _ = conn.set_read_timeout(None);
-    String::from_utf8(buf).ok()
-}
-
-/// `recv(MSG_PEEK)`: what is queued on the socket, left there. Blocks (up to
-/// the read timeout) like `read` when nothing is. `UnixStream::peek` is
-/// still unstable.
-fn peek(conn: &UnixStream, buf: &mut [u8]) -> std::io::Result<usize> {
-    use std::os::fd::AsRawFd;
-    // SAFETY: `buf` is a live, writable slice of `buf.len()` bytes.
-    let n = unsafe { libc::recv(conn.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len(), libc::MSG_PEEK) };
-    if n < 0 {
-        Err(std::io::Error::last_os_error())
-    } else {
-        Ok(n as usize)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::read_request_line;
-    use std::io::{Read, Write};
-    use std::os::unix::net::UnixStream;
-    use std::time::{Duration, Instant};
-
-    #[test]
-    fn a_request_line_is_bounded_in_time_and_size() {
-        let (mut client, server) = UnixStream::pair().unwrap();
-        client.write_all(b"open note\nmore").unwrap();
-        assert_eq!(read_request_line(&server, 1024, Duration::from_secs(1)).as_deref(), Some("open note\n"));
-
-        // Only the line is consumed: what the client sent after it in the
-        // same burst (cce-cloud's switcher list) is still there to read.
-        let (mut client, mut server) = UnixStream::pair().unwrap();
-        client.write_all(b"{\"args\":[]}\nWindow A (a)\nWindow B (b)\n").unwrap();
-        drop(client);
-        assert_eq!(read_request_line(&server, 1024, Duration::from_secs(1)).as_deref(), Some("{\"args\":[]}\n"));
-        let mut rest = String::new();
-        server.read_to_string(&mut rest).unwrap();
-        assert_eq!(rest, "Window A (a)\nWindow B (b)\n");
-
-        // A line longer than one peek, then a tail: read across chunks,
-        // and the tail is still left.
-        let (mut client, mut server) = UnixStream::pair().unwrap();
-        let long = format!("{}\ntail", "y".repeat(10_000));
-        let writer = std::thread::spawn(move || client.write_all(long.as_bytes()).unwrap());
-        let line = read_request_line(&server, 64 * 1024, Duration::from_secs(1)).unwrap();
-        writer.join().unwrap();
-        assert_eq!(line.len(), 10_001);
-        let mut rest = String::new();
-        server.read_to_string(&mut rest).unwrap();
-        assert_eq!(rest, "tail");
-
-        // Silent: given up at the deadline, not held forever.
-        let (_quiet, server) = UnixStream::pair().unwrap();
-        let t = Instant::now();
-        assert_eq!(read_request_line(&server, 1024, Duration::from_millis(100)), None);
-        assert!(t.elapsed() < Duration::from_millis(500));
-
-        // Trickling a byte at a time: the TOTAL deadline still ends it.
-        let (mut client, server) = UnixStream::pair().unwrap();
-        let trickle = std::thread::spawn(move || {
-            for _ in 0..40 {
-                if client.write_all(b"x").is_err() {
-                    break;
-                }
-                std::thread::sleep(Duration::from_millis(25));
-            }
-        });
-        let t = Instant::now();
-        assert_eq!(read_request_line(&server, 1024, Duration::from_millis(150)), None);
-        assert!(t.elapsed() < Duration::from_millis(500), "took {:?}", t.elapsed());
-        drop(server);
-        trickle.join().unwrap();
-
-        // Over the size cap.
-        let (mut client, server) = UnixStream::pair().unwrap();
-        client.write_all(&[b'z'; 2000]).unwrap();
-        assert_eq!(read_request_line(&server, 1024, Duration::from_millis(200)), None);
-    }
-
-    /// Against a stand-in compositor on a private path (never the session's
-    /// control socket): the command it sends, `ok` as success, `error: …`
-    /// as an error, a silent compositor bounded, a bad query never sent.
-    #[test]
-    fn focus_window_sends_one_command_and_reads_the_verdict() {
-        use super::focus_window_at;
-        use std::io::Read;
-        use std::os::unix::net::UnixListener;
-
-        let path = format!("/tmp/cce-ui-focus-test-{}.sock", std::process::id());
-        let _ = std::fs::remove_file(&path);
-        let listener = UnixListener::bind(&path).unwrap();
-        let replies: [&[u8]; 3] = [b"ok\n", b"error: window not found\n", b""];
-        let server = std::thread::spawn(move || {
-            let mut got = Vec::new();
-            for reply in replies {
-                let (mut conn, _) = listener.accept().unwrap();
-                let line = read_request_line(&conn, 1024, Duration::from_secs(1)).unwrap();
-                got.push(line);
-                if reply.is_empty() {
-                    // Say nothing and keep the connection open: a wedged compositor.
-                    let mut rest = Vec::new();
-                    let _ = conn.read_to_end(&mut rest);
-                } else {
-                    conn.write_all(reply).unwrap();
-                }
-            }
-            got
-        });
-
-        assert!(focus_window_at(&path, "cce-browser").is_ok());
-        let err = focus_window_at(&path, "  nothing-here ").unwrap_err();
-        assert!(err.to_string().contains("window not found"), "{err}");
-        let t = Instant::now();
-        assert!(focus_window_at(&path, "12").is_err(), "a silent compositor is an error");
-        assert!(t.elapsed() < Duration::from_secs(3), "and a bounded one: {:?}", t.elapsed());
-        for bad in ["", "   ", "a\nquit", "a\rb"] {
-            assert_eq!(
-                focus_window_at(&path, bad).unwrap_err().kind(),
-                std::io::ErrorKind::InvalidInput,
-                "{bad:?}"
-            );
-        }
-        let got = server.join().unwrap();
-        assert_eq!(got, ["focus-window cce-browser\n", "focus-window nothing-here\n", "focus-window 12\n"]);
-        let _ = std::fs::remove_file(&path);
-    }
-}
diff --git a/src/ipc/instance.rs b/src/ipc/instance.rs
deleted file mode 100644
index 34029ee..0000000
--- a/src/ipc/instance.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-//! Single-instance apps over the CCE socket convention: claim or forward.
-//!
-//! An app that should run once per session (a link opened from elsewhere
-//! becomes a tab, `cce-notes open X` shows X in the running window) listens
-//! on `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` — keyed by display, so shadow
-//! sessions stay apart for free — and every later launch hands it one line
-//! and exits before any Wayland or engine work.
-//!
-//! ```ignore
-//! fn main() {
-//!     if cce_ui::ipc::instance::forward_or_claim("cce-foo", &launch_line()) {
-//!         return; // the running instance took it
-//!     }
-//!     cce_ui::engine::run::<Foo>();
-//!     cce_ui::ipc::instance::cleanup();
-//! }
-//!
-//! // in Application::new, once the loop's sender exists:
-//! cce_ui::ipc::instance::serve(move |line| {
-//!     let msg = parse(line)?;            // None: hang up without a reply
-//!     sender.send(msg).ok()?;
-//!     Some("ok".into())
-//! });
-//! ```
-//!
-//! The order in [`forward_or_claim`] is what closes the startup race: try to
-//! connect, and only bind after a connect has failed. A refused connection
-//! means the socket file outlived a crashed instance and is removed before
-//! binding; losing the bind to a simultaneous launch falls back to one more
-//! connect. If that also fails the launch proceeds un-listened rather than
-//! not at all.
-//!
-//! The claimed listener has to survive from `main()` (before the engine
-//! starts) to `Application::new` (where the app's loop sender first exists),
-//! and `engine::run` takes no arguments, so it parks in this module until
-//! [`serve`] adopts it. One claim per process.
-//!
-//! The wire protocol is the app's: one request line in, one reply line out.
-//! Anything a forwarded argument needs to mean the same thing in the
-//! instance — a relative path made absolute against the *sender's* cwd —
-//! is the caller's to do before building the line.
-
-use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::sync::Mutex;
-use std::time::Duration;
-
-/// The listener claimed by [`forward_or_claim`], waiting for [`serve`].
-static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
-/// The socket path this process bound (and must unlink on exit), if any.
-static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
-
-/// How long a launch waits for the running instance to answer. Bounded so an
-/// instance whose listener is stuck cannot hang every later launch forever;
-/// unanswered, the launch is not forwarded.
-const FORWARD_TIMEOUT: Duration = Duration::from_secs(5);
-
-/// How long, and how much, the listener reads from one client before giving
-/// up on it and serving the next (see [`super::read_request_line`]).
-const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
-const REQUEST_LIMIT: usize = 64 * 1024;
-
-/// Send `line` to the instance listening on `prefix`'s socket and return its
-/// reply line, trimmed.
-///
-/// `None` when nothing is listening, the write fails, or no reply arrives
-/// within the timeout. `Some("")` when the instance read the line and hung
-/// up without answering — it still received it. The client half alone, for
-/// an app that talks to another app's instance.
-pub fn forward(prefix: &str, line: &str) -> Option<String> {
-    forward_to(&super::socket_path(prefix), line)
-}
-
-fn forward_to(path: &str, line: &str) -> Option<String> {
-    let mut stream = UnixStream::connect(path).ok()?;
-    let mut msg = line.trim_end_matches('\n').to_string();
-    msg.push('\n');
-    stream.write_all(msg.as_bytes()).ok()?;
-    // Wait for the answer: returning (and exiting) on the write alone races
-    // the instance actually reading the line.
-    stream.set_read_timeout(Some(FORWARD_TIMEOUT)).ok()?;
-    let mut reply = String::new();
-    BufReader::new(stream).read_line(&mut reply).ok()?;
-    Some(reply.trim().to_string())
-}
-
-/// Hand `line` to a running instance, or claim the instance socket.
-///
-/// `true` when a running instance took the launch: the caller should exit
-/// without starting. `false` when this process is now the instance — the
-/// listener parked for [`serve`] — or when single-instance handling failed
-/// entirely and the launch should proceed standalone.
-pub fn forward_or_claim(prefix: &str, line: &str) -> bool {
-    let path = super::socket_path(prefix);
-    if forward_to(&path, line).is_some() {
-        return true;
-    }
-    // Nothing answered. A socket file that still exists is a leftover from a
-    // crashed instance; binding needs it gone.
-    if std::path::Path::new(&path).exists() {
-        let _ = std::fs::remove_file(&path);
-    }
-    match UnixListener::bind(&path) {
-        Ok(listener) => {
-            *CLAIMED.lock().unwrap_or_else(|e| e.into_inner()) = Some(listener);
-            *OWNED_PATH.lock().unwrap_or_else(|e| e.into_inner()) = Some(path);
-            false
-        }
-        // Lost the bind race to a simultaneous launch: it is the instance.
-        Err(_) => forward_to(&path, line).is_some(),
-    }
-}
-
-/// Serve the listener [`forward_or_claim`] claimed, on a thread of its own.
-///
-/// `handle` gets each request line, trimmed, and returns the reply line to
-/// write back (a trailing newline is added), or `None` to hang up without
-/// one. It runs on the listener thread, so it should hand work to the app's
-/// loop (a calloop channel) rather than do it; answering straight from
-/// shared state is fine for a query the loop need not see.
-///
-/// Each client is read under a total deadline and size cap, so one that
-/// connects and says nothing cannot wedge the listener for every launch
-/// after it. `false` when this process claimed nothing (it runs standalone,
-/// or `serve` already took the listener).
-pub fn serve<F>(mut handle: F) -> bool
-where
-    F: FnMut(&str) -> Option<String> + Send + 'static,
-{
-    let Some(listener) = CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).take() else {
-        return false;
-    };
-    std::thread::spawn(move || {
-        for conn in listener.incoming() {
-            let Ok(conn) = conn else { continue };
-            let Some(line) = super::read_request_line(&conn, REQUEST_LIMIT, REQUEST_TIMEOUT) else {
-                continue;
-            };
-            if let Some(mut reply) = handle(line.trim()) {
-                reply.push('\n');
-                let _ = (&conn).write_all(reply.as_bytes());
-            }
-        }
-    });
-    true
-}
-
-/// Unlink the socket if this process bound it. Call it after the engine loop
-/// returns; a crash skips it, which is what the stale-socket removal in
-/// [`forward_or_claim`] exists for.
-pub fn cleanup() {
-    if let Some(path) = OWNED_PATH.lock().unwrap_or_else(|e| e.into_inner()).take() {
-        let _ = std::fs::remove_file(path);
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// The whole round trip in one process: the first call claims, `serve`
-    /// answers, a second launch is forwarded and sees the reply, and
-    /// `cleanup` removes the socket. One test, because the claim is
-    /// process-wide.
-    #[test]
-    fn claim_serve_forward_cleanup() {
-        let prefix = format!("cce-ui-instance-test-{}", std::process::id());
-        let path = crate::ipc::socket_path(&prefix);
-        // A stale file from a "crashed" instance is replaced, not fatal.
-        std::fs::write(&path, b"").unwrap();
-
-        assert!(!forward_or_claim(&prefix, "first"), "nothing was running: this launch is the instance");
-        let (tx, rx) = std::sync::mpsc::channel();
-        assert!(serve(move |line| {
-            tx.send(line.to_string()).ok()?;
-            match line {
-                "silent" => None,
-                l => Some(format!("ok {l}")),
-            }
-        }));
-        assert!(!serve(|_| None), "the listener is served once");
-
-        assert_eq!(forward(&prefix, "open x\n").as_deref(), Some("ok open x"));
-        assert!(forward_or_claim(&prefix, "second"), "a running instance takes the launch");
-        // Read and hung up on: still received.
-        assert_eq!(forward(&prefix, "silent").as_deref(), Some(""));
-        let got: Vec<String> = rx.try_iter().collect();
-        assert_eq!(got, ["open x", "second", "silent"]);
-
-        // A client that connects and says nothing does not wedge the listener.
-        let _quiet = UnixStream::connect(&path).unwrap();
-        let t = std::time::Instant::now();
-        assert_eq!(forward(&prefix, "after").as_deref(), Some("ok after"));
-        assert!(t.elapsed() < FORWARD_TIMEOUT, "took {:?}", t.elapsed());
-
-        cleanup();
-        assert!(!std::path::Path::new(&path).exists());
-        assert_eq!(forward(&prefix, "gone"), None);
-    }
-}
diff --git a/src/layout.rs b/src/layout.rs
index a404298..dac56e1 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1968,77 +1968,7 @@ static ROLL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
     std::sync::RwLock::new(None);
 static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
 
-/// Evaluate a ramp key list at `t` — THE ramp interpolation of the DE.
-/// `cce_ui::widget::Ramp` draws it, `RampPreview` previews it, the relief
-/// profile LUTs sample it, and cce-window-manager's camera speed ramp mirrors
-/// it verbatim (that crate stays dependency-minimal), so a curve sculpted in
-/// the widget is exactly the curve every consumer evaluates. Keys are
-/// `(pos, value)` sorted by pos; outside the key range the end values hold.
-///
-/// `smooth` is the widget's curved line type: a **monotone cubic** through
-/// the keys (Fritsch–Butland tangents, cubic Hermite segments) — C1, passes
-/// through every key, never overshoots a key, and flattens only at the ends
-/// and at genuine local extrema. It used to be a smoothstep blend PER
-/// SEGMENT, which forces zero slope at every key: a curve with more than two
-/// keys came out as a chain of little bumps, and a wall profile built from
-/// it read as jagged and uneven where a smooth slope was drawn. A two-key
-/// ramp is unchanged — zero tangents at both ends make the single Hermite
-/// segment exactly the old smoothstep — so the identity sentinel and every
-/// simple ease keep their look. `false` is straight segments.
-pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
-    let Some(first) = keys.first() else { return 0.0 };
-    let last = keys.last().unwrap();
-    if t <= first.0 {
-        return first.1;
-    }
-    if t >= last.0 {
-        return last.1;
-    }
-    for i in 0..keys.len() - 1 {
-        let ((x0, y0), (x1, y1)) = (keys[i], keys[i + 1]);
-        if t < x0 || t > x1 {
-            continue;
-        }
-        let h = x1 - x0;
-        if h.abs() < 0.0001 {
-            return y0;
-        }
-        let s = (t - x0) / h;
-        if !smooth {
-            return y0 + (y1 - y0) * s;
-        }
-        let (m0, m1) = (ramp_key_tangent(keys, i), ramp_key_tangent(keys, i + 1));
-        let (s2, s3) = (s * s, s * s * s);
-        let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
-        let h10 = s3 - 2.0 * s2 + s;
-        let h01 = -2.0 * s3 + 3.0 * s2;
-        let h11 = s3 - s2;
-        return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
-    }
-    first.1
-}
-
-/// The monotone cubic's tangent (dy/dpos) at key `i`: zero at either end and
-/// at any local extremum (so the curve never overshoots a key), otherwise the
-/// Fritsch–Butland weighted harmonic mean of the two neighbouring secants —
-/// the shape-preserving choice, which keeps every segment monotone whenever
-/// its keys are.
-fn ramp_key_tangent(keys: &[(f32, f32)], i: usize) -> f32 {
-    if i == 0 || i + 1 >= keys.len() {
-        return 0.0;
-    }
-    let ((xp, yp), (x, y), (xn, yn)) = (keys[i - 1], keys[i], keys[i + 1]);
-    let (h0, h1) = (x - xp, xn - x);
-    if h0 <= 0.0001 || h1 <= 0.0001 {
-        return 0.0;
-    }
-    let (d0, d1) = ((y - yp) / h0, (yn - y) / h1);
-    if d0 * d1 <= 0.0 {
-        return 0.0;
-    }
-    let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
-    (w0 + w1) / (w0 / d0 + w1 / d1)
-}
+pub use cce_core::ramp::sample_ramp_keys;
 
 #[cfg(test)]
 mod ramp_sampling_tests {
diff --git a/src/lib.rs b/src/lib.rs
index c98e1dc..7d54195 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,19 +7,19 @@
 pub mod color;
 pub mod compute;
 pub mod widget;
-pub mod config;
-pub mod input;
+pub use cce_core::config;
+pub use cce_core::input;
 pub mod history;
 pub mod ime;
 pub mod layout;
-pub mod relief_spec;
+pub use cce_core::relief_spec;
 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 pub mod wayland;
 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 pub mod protocol;
 pub mod engine;
 pub mod scale;
-pub mod units;
+pub use cce_core::units;
 pub mod backend;
 pub mod context;
 pub mod draw;
@@ -28,10 +28,10 @@ pub mod scene;
 pub mod file_dialog;
 pub mod icon;
 #[cfg(not(target_arch = "wasm32"))]
-pub mod ipc;
+pub use cce_core::ipc;
 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 pub mod mcp;
-pub mod motion;
+pub use cce_core::motion;
 pub mod text_input;
 #[cfg(not(target_arch = "wasm32"))]
 pub mod vk;
@@ -40,6 +40,9 @@ pub mod web;
 #[cfg(target_os = "macos")]
 pub mod mac;
 
+#[cfg(test)]
+mod config_style_tests;
+
 pub mod colors {
     pub use crate::color::*;
 }
diff --git a/src/motion.rs b/src/motion.rs
deleted file mode 100644
index cbf6e35..0000000
--- a/src/motion.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-//! The DE-wide animations switch.
-//!
-//! One flag, followed by every cce-ui widget that eases and by the
-//! compositor: when it is off, anything that would glide, fade or slide
-//! lands on its target in the same frame instead. Disabled means
-//! "snap", never "freeze" — a dropdown still opens, a scroll still moves.
-//! A trackpad flick's coast is NOT under it (`widget::scroll_motion`): that
-//! is the hand's gesture carried on, not an animation the toolkit adds.
-//!
-//! The switch is a file, [`STATE_PATH`], holding `on` or `off`. The System
-//! Interface's Power page sets it per power mode and `cce-power-apply`
-//! writes it as root whenever the mode changes (plug, unplug, boot), which
-//! is why it lives under /run rather than in `~/.config/cce`: the writer has
-//! no session and no `$HOME`. No file means on.
-//!
-//! [`enabled`] is cheap enough for per-frame use: it re-reads the file at
-//! most every [`RECHECK`], so a running client follows a mode change within
-//! half a second and nothing needs restarting or reloading. `CCE_ANIMATIONS`
-//! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
-
-use std::time::Duration;
-
-/// Where the switch lives. Shared with `cce-power-apply`, the writer.
-pub const STATE_PATH: &str = "/run/cce/animations";
-
-/// How stale [`enabled`] may be. A mode change is a plug or an unplug, so
-/// half a second is instant to a person, and the stat stays off the frame.
-pub const RECHECK: Duration = Duration::from_millis(500);
-
-/// `on` / `off` (surrounding whitespace ignored); anything else says nothing.
-pub fn parse(text: &str) -> Option<bool> {
-    match text.trim() {
-        "on" | "1" | "true" => Some(true),
-        "off" | "0" | "false" => Some(false),
-        _ => None,
-    }
-}
-
-/// What the state file says right now, uncached. `None` when there is no
-/// file or it holds nothing recognizable — both of which mean "animate".
-pub fn read_state() -> Option<bool> {
-    parse(&std::fs::read_to_string(STATE_PATH).ok()?)
-}
-
-// Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
-// unless a test forces it with [`force_for_test`]. Until 2026-09-28
-// `enabled` read `/run/cce/animations` in the test binary too, so three
-// glide and fade tests passed or failed with the laptop's power mode —
-// off on battery, on when plugged in — and read as a broken glide rather
-// than a borrowed switch. Thread-local rather than the shared cache,
-// because libtest runs tests in parallel and a process-wide override set
-// by one test would race every other test's read; a test that forces it
-// does so for its own thread only, and the value resets with the thread.
-#[cfg(test)]
-thread_local! {
-    static FORCED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
-}
-
-/// Set what [`enabled`] answers on this thread, for a test that exercises
-/// the snap-instead-of-ease path. Tests never set `CCE_ANIMATIONS`, since
-/// an environment variable is process-wide.
-#[cfg(test)]
-pub fn force_for_test(value: bool) {
-    FORCED.with(|f| f.set(value));
-}
-
-/// Whether to animate. Every easing in the toolkit asks this before it
-/// steps, and snaps to its target when the answer is no.
-pub fn enabled() -> bool {
-    #[cfg(test)]
-    {
-        return FORCED.with(|f| f.get());
-    }
-    #[cfg(not(test))]
-    enabled_on_this_machine()
-}
-
-/// [`enabled`] as the shipped binary answers it: the `CCE_ANIMATIONS`
-/// override for this process, else the state file, re-read at most every
-/// [`RECHECK`].
-#[cfg(not(test))]
-fn enabled_on_this_machine() -> bool {
-    static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
-    if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
-        return forced;
-    }
-    use std::sync::Mutex;
-    use web_time::Instant;
-    static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
-    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
-    let now = Instant::now();
-    match *cache {
-        Some((at, value)) if now.duration_since(at) < RECHECK => value,
-        _ => {
-            let value = read_state().unwrap_or(true);
-            *cache = Some((now, value));
-            value
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn parse_reads_the_helpers_spelling_and_nothing_else() {
-        assert_eq!(parse("off\n"), Some(false));
-        assert_eq!(parse("on"), Some(true));
-        assert_eq!(parse(" 0 "), Some(false));
-        assert_eq!(parse(""), None);
-        assert_eq!(parse("disabled"), None);
-    }
-
-    /// The suite's switch is on whatever the machine's file says, a test
-    /// can force it off for its own thread only, and another thread still
-    /// sees it on.
-    #[test]
-    fn the_suite_animates_whatever_the_machine_says() {
-        assert!(enabled(), "on by default, not read off {STATE_PATH}");
-        force_for_test(false);
-        assert!(!enabled(), "a test can force the snap path");
-        let elsewhere = std::thread::spawn(enabled).join().unwrap();
-        assert!(elsewhere, "the force is this thread's alone");
-        force_for_test(true);
-        assert!(enabled());
-    }
-}
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
deleted file mode 100644
index 28c299c..0000000
--- a/src/relief_spec.rs
+++ /dev/null
@@ -1,143 +0,0 @@
-//! The `(relief)` config value type: one relief material folded into a
-//! single string, so any config key can carry its own material and
-//! `cce-relief --key <dotted.key>` can edit it in place.
-//!
-//! Format: whitespace-separated `name=value` pairs, e.g.
-//!
-//! ```text
-//! w=6.0 d=0.350 k=0.500,0.500,0.500 p=0.000:0.000,0.032:0.001,...,1.000:1.000
-//! ```
-//!
-//! - `w` — wall/roll width in logical px (required)
-//! - `h` — the wall's geometric drop, a length: `h=0.5mm`, `h=4px`, or a
-//!   bare number of logical px (optional; absent = follow the width at the
-//!   analytic ratio, `relief_shade::RECESS_DEPTH` × width)
-//! - `d` — light strength across the wall (`bevel_depth`; optional). Kept
-//!   as `d` on the wire for every reader already out there; `l` is read as
-//!   an alias. It is NOT a length — `h` is.
-//! - `k` — the editor's Shoulder/Base/Bias knob triple, a ride-along seed
-//!   so `cce-relief` reopens where it was left (optional)
-//! - `p` — the wall's height curve as the same ramp spec
-//!   `style.surface.relief.wall.profile` carries (optional; absent or the
-//!   identity sentinel = the analytic profile)
-//!
-//! No value contains whitespace (ramp specs are `;`/`:`/`,`-delimited), so
-//! parsing is a plain split. Unknown pairs are skipped, not errors —
-//! forward compatibility for future fields.
-
-/// One parsed `(relief)` value. See the module doc for the string format.
-#[derive(Debug, Clone, PartialEq)]
-pub struct ReliefSpec {
-    /// Wall/roll width in logical px.
-    pub width: f32,
-    /// The wall's drop, a length; `None` = follow the width at the analytic
-    /// ratio.
-    pub height: Option<crate::units::Len>,
-    /// Light strength (`bevel_depth`); `None` = keep the process's material.
-    pub light: Option<f32>,
-    /// Shoulder/Base/Bias editor knobs behind `profile` — seed only, the
-    /// renderer never reads them.
-    pub knobs: Option<(f32, f32, f32)>,
-    /// Wall profile ramp spec; `None` = the analytic profile. May carry the
-    /// identity sentinel verbatim — installers filter it like the config
-    /// loader does.
-    pub profile: Option<String>,
-}
-
-impl ReliefSpec {
-    /// Parse a `(relief)` value. `None` when `w=` is absent or malformed —
-    /// a spec without a width says nothing drawable.
-    pub fn parse(s: &str) -> Option<Self> {
-        let mut width = None;
-        let mut height = None;
-        let mut light = None;
-        let mut knobs = None;
-        let mut profile = None;
-        for tok in s.split_whitespace() {
-            let Some((k, v)) = tok.split_once('=') else { continue };
-            match k {
-                "w" => width = v.parse::<f32>().ok().filter(|w| w.is_finite() && *w >= 0.0),
-                "d" | "l" => light = v.parse::<f32>().ok().filter(|d| d.is_finite()),
-                "h" => {
-                    height = crate::units::Len::parse(v)
-                        .or_else(|| v.parse::<f32>().ok().map(crate::units::Len::px))
-                        .filter(|l| l.value.is_finite() && l.value > 0.0);
-                }
-                "k" => {
-                    let mut it = v.splitn(3, ',').map(|p| p.parse::<f32>().ok());
-                    if let (Some(Some(a)), Some(Some(b)), Some(Some(c))) =
-                        (it.next(), it.next(), it.next())
-                    {
-                        knobs = Some((a, b, c));
-                    }
-                }
-                "p" => profile = Some(v.to_string()),
-                _ => {}
-            }
-        }
-        Some(Self { width: width?, height, light, knobs, profile })
-    }
-
-    /// The string `parse` reads back — what `cce-relief --key` saves.
-    pub fn serialize(&self) -> String {
-        let mut out = format!("w={:.2}", self.width);
-        if let Some(h) = self.height {
-            out.push_str(&format!(" h={}", h.serialize()));
-        }
-        if let Some(d) = self.light {
-            out.push_str(&format!(" d={d:.3}"));
-        }
-        if let Some((a, b, c)) = self.knobs {
-            out.push_str(&format!(" k={a:.3},{b:.3},{c:.3}"));
-        }
-        if let Some(p) = &self.profile {
-            out.push_str(&format!(" p={p}"));
-        }
-        out
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn roundtrip_full() {
-        let spec = ReliefSpec {
-            width: 6.0,
-            height: Some(crate::units::Len::mm(0.5)),
-            light: Some(0.35),
-            knobs: Some((0.8, 0.2, 0.5)),
-            profile: Some("0.000:0.000,0.500:0.700,1.000:1.000".to_string()),
-        };
-        assert_eq!(ReliefSpec::parse(&spec.serialize()), Some(spec));
-    }
-
-    #[test]
-    fn width_only_and_unknown_pairs_skip() {
-        let spec = ReliefSpec::parse("w=4 future=stuff junk").unwrap();
-        assert_eq!(spec.width, 4.0);
-        assert_eq!(spec.height, None);
-        assert_eq!(spec.light, None);
-        assert_eq!(spec.knobs, None);
-        assert_eq!(spec.profile, None);
-    }
-
-    #[test]
-    fn height_and_light_aliases() {
-        let s = ReliefSpec::parse("w=6 h=3 l=0.2").unwrap();
-        assert_eq!(s.height, Some(crate::units::Len::px(3.0)), "bare h is logical px");
-        assert_eq!(s.light, Some(0.2), "l is read as d");
-        let s = ReliefSpec::parse("w=6 h=0.5mm d=0.1").unwrap();
-        assert_eq!(s.height, Some(crate::units::Len::mm(0.5)));
-        assert_eq!(s.serialize(), "w=6.00 h=0.5mm d=0.100");
-        assert_eq!(ReliefSpec::parse("w=6 h=-1").unwrap().height, None);
-    }
-
-    #[test]
-    fn missing_or_bad_width_rejects() {
-        assert_eq!(ReliefSpec::parse("d=0.3"), None);
-        assert_eq!(ReliefSpec::parse("w=-1"), None);
-        assert_eq!(ReliefSpec::parse(""), None);
-    }
-}
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index e64aafb..3693a94 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -402,212 +402,34 @@ impl ControlPlate {
     }
 }
 
-/// Call the family **relief primitives**, not "bevel primitives": `Bevel` is one
-/// specific member — a filled rounded rect plus a lit roll on its lip — and a
-/// groove, a fillet or a sphere is not a bevel in any sense. "Relief" is also
-/// what the rest of the stack already says: `layout::control_relief` gates the
-/// whole family, and the config node is `relief`. The name **bevel** is reserved
-/// for two things: the `Bevel` prim, and the shared *edge treatment* every
-/// relief primitive is shaded with (`bevel_width`, `bevel_depth`,
-/// `bevel_shader`, `bevel_profile` — the lit roll, not the shape).
-/// Shape and material knobs for [`Prim::Droplet`]. Fractions are of the
-/// droplet rect's height unless said otherwise, so a spec is resolution- and
-/// module-size-independent; the tessellator resolves and clamps them against
-/// the concrete rect.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct DropletSpec {
-    /// How far the sheet's bottom lifts above the rect bottom (the waist the
-    /// sides pull up into), fraction of height. 0 = no waist (a capsule).
-    pub sag: f32,
-    /// Belly capsule radius, fraction of height. **≤ 0 disables the belly**:
-    /// the drop is the sheet alone — with `attach` and `sheet_r` rounding its
-    /// top and bottom this is the oval dewdrop, and the default.
-    pub belly: f32,
-    /// Belly half-width, fraction of the half-width left after the belly
-    /// radius (1 = the belly spans the whole bottom).
-    pub belly_w: f32,
-    /// Smooth-union blend distance, fraction of height — bigger = softer neck
-    /// between sheet and belly.
-    pub blend: f32,
-    /// Sheet bottom-corner radius, fraction of height.
-    pub sheet_r: f32,
-    /// Sheet TOP-corner radius (the meniscus taper at the attach line),
-    /// fraction of height. 0 = the sides meet the attach edge square (the
-    /// clinging-pool look); larger values narrow the contact span so the
-    /// silhouette curves into the edge like a dewdrop. When `attach + sheet_r`
-    /// exceeds the sheet height the pair scales down proportionally, so 0.5 +
-    /// 0.5 is the fully continuous egg curve with no straight side segment.
-    pub attach: f32,
-    /// Tint opacity at the deep interior relative to the color's own alpha;
-    /// the rim falls toward `clarity` × that (thin water is clearer). 1 = flat.
-    pub clarity: f32,
-    /// Dome slope amplitude: scales the surface tilt the shading sees.
-    pub dome: f32,
-    /// Shaded band width (the dome's curved skirt), fraction of height.
-    pub band: f32,
-    /// Specular (gleam) strength — replaces the DE material's slot.
-    pub gleam: f32,
-    /// Wet-surface shininess exponent.
-    pub shine: f32,
-    /// Fresnel rim crest amplitude (the glass-edge brightening).
-    pub rim: f32,
-    /// Bottom bow: the drop's bottom boundary becomes ONE continuous circular
-    /// arc — lowest at center, rising by `bow` (fraction of height) at the
-    /// drop's side extents. The arc's radius is derived per drop from that
-    /// fixed edge rise, so a wide drop gets a huge radius and the curvature
-    /// stays subtle at the middle while a narrow drop curves visibly. 0
-    /// disables it (flat bottom run between the corner arcs).
-    pub bow: f32,
-    /// Corner-curve exponent for the silhouette (and the dome profile riding
-    /// it): 2 = circular arcs, above 2 = superellipse quadrants whose
-    /// curvature ramps to ZERO at both ends of each arc — every junction
-    /// (attach↔side, side↔bottom, curve↔flat top) becomes curvature-
-    /// continuous, so unequal attach/sheet_r radii read as ONE flowing curve
-    /// instead of two arcs meeting, and the contact eases out of the flat
-    /// top like a meniscus. Clamped to [2, 6].
-    pub curve: f32,
-    /// Extra tint density at the drop's deep interior: the body opacity ramps
-    /// from `clarity` at the rim up to `1 + core` (× the color's own alpha,
-    /// clamped to opaque) inside — the water reads thickest in the middle,
-    /// which is also where a module's text sits, so glyphs get a calmer
-    /// field without giving up the watery rim. 0 = the original flat
-    /// interior falloff.
-    pub core: f32,
-    /// Refraction strength in logical px — how far the COMPOSITOR's droplet
-    /// backdrop pass bends the image behind the drop at the rim. Client-side
-    /// rendering ignores it (a Wayland client cannot see behind its own
-    /// window); the compositor reads the same spec and drives its scenefx
-    /// droplet node with it. 0 disables the backdrop pass.
-    pub refr: f32,
-    /// Strength (0-1) of the compositor pass's inverted lens ghost — the
-    /// faint upside-down image of the scene a real hanging drop shows in its
-    /// belly. Client-side ignored, like `refr`.
-    pub ghost: f32,
-    /// Contact-shadow strength (0-1): a soft dark falloff cast below the
-    /// drop's lower arc, outside the silhouette — the volume cue of a bead
-    /// sitting proud of the surface. The host must leave room beneath the
-    /// drop box for it (the status bar insets the box by
-    /// [`DropletSpec::shadow_gap`]). 0 disables it.
-    pub shadow: f32,
-}
-
-impl DropletSpec {
-    /// Parse the DE's droplet spec string — whitespace-separated `k=v` pairs
-    /// onto the defaults (an empty string is all defaults). Unknown keys and
-    /// non-numeric values `log::warn!` and are skipped, so a typo surfaces in
-    /// the log instead of silently reverting one knob. Shared by the status
-    /// bar (which draws the drop) and the compositor (whose scenefx droplet
-    /// node refracts the backdrop behind it) so the two sides can never
-    /// disagree about a spec's meaning.
-    pub fn parse(raw: &str) -> Self {
-        let mut spec = Self::default();
-        for tok in raw.split_whitespace() {
-            let Some((key, val)) = tok.split_once('=') else {
-                log::warn!("droplet spec: token '{}' is not k=v — skipped", tok);
-                continue;
-            };
-            let Ok(v) = val.parse::<f32>() else {
-                log::warn!("droplet spec: '{}' has a non-numeric value — skipped", tok);
-                continue;
-            };
-            match key {
-                "sag" => spec.sag = v,
-                "belly" => spec.belly = v,
-                "belly_w" => spec.belly_w = v,
-                "blend" => spec.blend = v,
-                "sheet_r" => spec.sheet_r = v,
-                "attach" => spec.attach = v,
-                "clarity" => spec.clarity = v,
-                "dome" => spec.dome = v,
-                "band" => spec.band = v,
-                "gleam" => spec.gleam = v,
-                "shine" => spec.shine = v,
-                "rim" => spec.rim = v,
-                "bow" => spec.bow = v,
-                "curve" => spec.curve = v,
-                "core" => spec.core = v,
-                "refr" => spec.refr = v,
-                "ghost" => spec.ghost = v,
-                "shadow" => spec.shadow = v,
-                _ => log::warn!("droplet spec: unknown key '{}' — skipped", key),
-            }
-        }
-        spec
-    }
-
-    /// Resolve the silhouette's height-fraction knobs against a concrete rect
-    /// (logical px) with the SAME clamps the tessellator applies: returns
-    /// `(sheet_r, attach_r, bow_rise)` in logical px, the attach/sheet pair
-    /// proportionally scaled down when it overfills the height. The
-    /// compositor's droplet backdrop node uses this so its refracting
-    /// silhouette and the client-drawn drop are the same shape.
-    pub fn resolve_silhouette(&self, w: f32, h: f32) -> (f32, f32, f32) {
-        let hx = w * 0.5;
-        let hy = h * 0.5;
-        let mut sr = (self.sheet_r.clamp(0.0, 1.0) * h).min(hx);
-        let mut ar = (self.attach.clamp(0.0, 1.0) * h).min(hx);
-        let sheet_h = 2.0 * hy;
-        if sr + ar > sheet_h && sr + ar > 0.0 {
-            let f = sheet_h / (sr + ar);
-            sr *= f;
-            ar *= f;
-        }
-        let bow = (self.bow.clamp(0.0, 0.5) * h).min(hy * 0.9);
-        (sr, ar, bow)
-    }
-
-    /// Vertical room (logical px) a host should leave BELOW the drop box for
-    /// the contact shadow, given the full slot height. One place, so the
-    /// bar's reserved gap and the shader's falloff reach stay proportioned.
-    pub fn shadow_gap(&self, slot_h: f32) -> f32 {
-        if self.shadow > 0.0 {
-            (0.16 * slot_h).ceil()
-        } else {
-            0.0
-        }
-    }
-}
-
-impl DropletSpec {
+// Call the family **relief primitives** — see `Prim`. (This note sat above `DropletSpec`,
+// which moved to `cce_core::droplet`.)
+// Call the family **relief primitives**, not "bevel primitives": `Bevel` is one
+// specific member — a filled rounded rect plus a lit roll on its lip — and a
+// groove, a fillet or a sphere is not a bevel in any sense. "Relief" is also
+// what the rest of the stack already says: `layout::control_relief` gates the
+// whole family, and the config node is `relief`. The name **bevel** is reserved
+// for two things: the `Bevel` prim, and the shared *edge treatment* every
+// relief primitive is shaded with (`bevel_width`, `bevel_depth`,
+// `bevel_shader`, `bevel_profile` — the lit roll, not the shape).
+pub use cce_core::droplet::DropletSpec;
+
+/// What a droplet's material is shaded with — an extension, since [`DropletSpec`] lives in
+/// `cce_core`, which knows no [`Finish`]. `use cce_ui::scene::paint::DropletFinish;` at a
+/// call site that writes `spec.finish()`.
+pub trait DropletFinish {
     /// The drop's finish: its own gleam, shine and rim in the specular,
     /// shininess and curvature slots of a [`Finish`] (a drop is wetter than
     /// the DE's plates), the shading strength the DE's. The material a
     /// droplet is emitted with carries this — `Material::from_fill(c)
     /// .with_finish(spec.finish())` — and the tessellator reads it from
     /// there like any plate's, instead of packing the slots by hand.
-    pub fn finish(&self) -> Finish {
-        Finish { spec: self.gleam, shininess: self.shine, curvature: self.rim, ..Finish::from_style() }
-    }
+    fn finish(&self) -> Finish;
 }
 
-impl Default for DropletSpec {
-    fn default() -> Self {
-        // The oval dewdrop: no belly, no sag — one continuous curve from a
-        // tapered attach line to a fully round bottom. attach + sheet_r fill
-        // the whole height (no straight side segment), biased bottom-heavy,
-        // and the superellipse curve exponent keeps the unequal pair
-        // curvature-continuous. The pendant-pool look is reachable by
-        // setting `belly` > 0 (and usually some `sag`).
-        Self {
-            sag: 0.0,
-            belly: 0.0,
-            belly_w: 0.5,
-            blend: 0.35,
-            sheet_r: 0.58,
-            attach: 0.42,
-            clarity: 0.5,
-            dome: 0.9,
-            band: 0.9,
-            gleam: 1.4,
-            shine: 32.0,
-            rim: 0.5,
-            bow: 0.12,
-            curve: 2.6,
-            core: 0.35,
-            refr: 0.0,
-            ghost: 0.0,
-            shadow: 0.35,
-        }
+impl DropletFinish for DropletSpec {
+    fn finish(&self) -> Finish {
+        Finish { spec: self.gleam, shininess: self.shine, curvature: self.rim, ..Finish::from_style() }
     }
 }
 
diff --git a/src/units.rs b/src/units.rs
deleted file mode 100644
index 6e04114..0000000
--- a/src/units.rs
+++ /dev/null
@@ -1,424 +0,0 @@
-//! Lengths with units, and the one bridge between them and the screen.
-//!
-//! The toolkit's working unit is and stays the **logical pixel**: every
-//! layout node, style slot and widget measure is an `f32` of logical px, as
-//! it always was. This module adds the two things that were missing:
-//!
-//! - [`Len`] — a length that remembers its unit (`px`, `mm`, `cm`, `in`,
-//!   `pt`), parsed from config (`width=(mm)2.0`, or the string `"2mm"`)
-//!   and resolved to logical px through a [`Metric`].
-//! - [`Metric`] — how many logical px one millimetre covers on the display
-//!   this process is on, and where that number came from. Measured from the
-//!   output's EDID size when the compositor reports one, configured by the
-//!   user when EDID lies, forced by `CCE_FORCE_PPI` for headless shadows,
-//!   or *assumed* at the CSS convention of 96 logical px per inch when
-//!   nothing better is known. The source is carried, not hidden: an
-//!   assumed metric is a guess, and anything fabricating from it should
-//!   say so.
-//!
-//! Why the toolkit is not converted to millimetres internally: UI sizes are
-//! perceptual and angular, not physical. A hit target should not become 8 mm
-//! on a projector three metres away. Documents and fabrication content are
-//! the things that live in real units, and they convert at view time. Two
-//! domains, one bridge — this one.
-//!
-//! The process-wide metric lives here ([`metric`] / [`set_metric`]), fed by
-//! the window runner from the Wayland output the surface is on, exactly as
-//! `scale::scale_factor` is. Style slots carrying a unit resolve through it
-//! at every read, so a metric arriving after config load, or changing when
-//! the window moves to another display, is honoured without a reload.
-
-use std::fmt;
-use std::sync::{OnceLock, RwLock};
-
-/// Millimetres per inch.
-pub const MM_PER_INCH: f32 = 25.4;
-/// Points per inch (PostScript/CSS points).
-pub const PT_PER_INCH: f32 = 72.0;
-/// The CSS reference pixel: what a logical px is taken to measure when the
-/// display's real size is unknown. Same convention as the `Xft.dpi 96×scale`
-/// the compositor writes for Xwayland, so a bare pixel keeps its meaning
-/// under the fallback.
-pub const ASSUMED_PPI: f32 = 96.0;
-
-/// A length unit. `Px` is the logical pixel; the rest are real-world.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum Unit {
-    Px,
-    Mm,
-    Cm,
-    M,
-    In,
-    Pt,
-}
-
-impl Unit {
-    /// Every unit, in the order a unit toggle should cycle them.
-    pub const ALL: [Unit; 6] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::M, Unit::In, Unit::Pt];
-
-    /// The config suffix / KDL type annotation: `px`, `mm`, `cm`, `in`, `pt`.
-    pub fn suffix(self) -> &'static str {
-        match self {
-            Unit::Px => "px",
-            Unit::Mm => "mm",
-            Unit::Cm => "cm",
-            Unit::M => "m",
-            Unit::In => "in",
-            Unit::Pt => "pt",
-        }
-    }
-
-    /// Parse a suffix or KDL type annotation. `None` for anything else, so a
-    /// caller can tell "not a unit" from a unit — `(f64)` is not a length.
-    pub fn parse(s: &str) -> Option<Unit> {
-        match s.trim().to_ascii_lowercase().as_str() {
-            "px" => Some(Unit::Px),
-            "mm" => Some(Unit::Mm),
-            "cm" => Some(Unit::Cm),
-            "m" | "metre" | "meter" | "metres" | "meters" => Some(Unit::M),
-            "in" | "inch" | "inches" => Some(Unit::In),
-            "pt" => Some(Unit::Pt),
-            _ => None,
-        }
-    }
-
-    /// Whether this unit is a real-world length (everything but `Px`).
-    pub fn is_physical(self) -> bool {
-        !matches!(self, Unit::Px)
-    }
-
-    /// Millimetres per one of this unit. `None` for `Px`, whose size depends
-    /// on the metric.
-    fn mm_per_unit(self) -> Option<f32> {
-        match self {
-            Unit::Px => None,
-            Unit::Mm => Some(1.0),
-            Unit::Cm => Some(10.0),
-            Unit::M => Some(1000.0),
-            Unit::In => Some(MM_PER_INCH),
-            Unit::Pt => Some(MM_PER_INCH / PT_PER_INCH),
-        }
-    }
-}
-
-impl fmt::Display for Unit {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.write_str(self.suffix())
-    }
-}
-
-/// Where a [`Metric`]'s px-per-mm came from — carried so a consumer can tell
-/// a measurement from a guess.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum MetricSource {
-    /// Computed from the output's reported physical size (EDID via
-    /// `wl_output` geometry) and its logical size.
-    Measured,
-    /// The user's per-output `size_mm` override, forwarded by the compositor
-    /// in place of the EDID value.
-    Configured,
-    /// `CCE_FORCE_PPI` in the environment.
-    Forced,
-    /// Nothing known: the CSS 96 px/in convention. A guess.
-    Assumed,
-}
-
-impl MetricSource {
-    pub fn as_str(self) -> &'static str {
-        match self {
-            MetricSource::Measured => "measured",
-            MetricSource::Configured => "configured",
-            MetricSource::Forced => "forced",
-            MetricSource::Assumed => "assumed",
-        }
-    }
-}
-
-/// The bridge between logical pixels and real lengths for one display.
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct Metric {
-    /// The output scale (logical → physical px), as `scale::scale_factor`.
-    pub scale: f32,
-    /// Logical px per millimetre.
-    pub px_per_mm: f32,
-    pub source: MetricSource,
-}
-
-impl Metric {
-    /// The fallback metric: 96 logical px per inch, flagged as assumed.
-    pub fn assumed(scale: f32) -> Self {
-        Metric { scale, px_per_mm: ASSUMED_PPI / MM_PER_INCH, source: MetricSource::Assumed }
-    }
-
-    /// A metric from a display's logical size and physical size in mm.
-    /// `None` when either is unusable (zero, negative, or an implausible
-    /// density outside 25–1000 logical px per inch — an EDID that reports
-    /// the 16×9 cm a TV likes to claim is a lie, not a measurement).
-    pub fn from_sizes(scale: f32, logical_px: (f32, f32), mm: (f32, f32), source: MetricSource) -> Option<Self> {
-        if logical_px.0 <= 0.0 || logical_px.1 <= 0.0 || mm.0 <= 0.0 || mm.1 <= 0.0 {
-            return None;
-        }
-        // Average the two axes: EDID rounds each to the millimetre, and a
-        // panel's pixels are square, so the mean is closer than either.
-        let px_per_mm = 0.5 * (logical_px.0 / mm.0 + logical_px.1 / mm.1);
-        Self::from_px_per_mm(scale, px_per_mm, source)
-    }
-
-    /// A metric from a density directly, with the same plausibility gate.
-    pub fn from_px_per_mm(scale: f32, px_per_mm: f32, source: MetricSource) -> Option<Self> {
-        let ppi = px_per_mm * MM_PER_INCH;
-        if !ppi.is_finite() || !(25.0..=1000.0).contains(&ppi) {
-            return None;
-        }
-        Some(Metric { scale, px_per_mm, source })
-    }
-
-    /// Logical px per inch.
-    pub fn ppi(&self) -> f32 {
-        self.px_per_mm * MM_PER_INCH
-    }
-
-    /// Physical (buffer) px per millimetre.
-    pub fn physical_px_per_mm(&self) -> f32 {
-        self.px_per_mm * self.scale
-    }
-
-    /// Millimetres per logical px.
-    pub fn mm_per_px(&self) -> f32 {
-        1.0 / self.px_per_mm
-    }
-
-    /// Whether this metric was measured or configured, i.e. safe to
-    /// dimension real objects from.
-    pub fn is_real(&self) -> bool {
-        matches!(self.source, MetricSource::Measured | MetricSource::Configured)
-    }
-
-    /// Logical px for `value` of `unit`.
-    pub fn to_px(&self, value: f32, unit: Unit) -> f32 {
-        match unit.mm_per_unit() {
-            None => value,
-            Some(mm) => value * mm * self.px_per_mm,
-        }
-    }
-
-    /// `unit` for a length of `px` logical px.
-    pub fn from_px(&self, px: f32, unit: Unit) -> f32 {
-        match unit.mm_per_unit() {
-            None => px,
-            Some(mm) => px / (mm * self.px_per_mm),
-        }
-    }
-}
-
-/// A length that remembers its unit. Resolve it with [`Len::resolve`] (or
-/// [`Len::px`] against the process metric) exactly once, at the boundary
-/// where a config or document value becomes a layout number.
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct Len {
-    pub value: f32,
-    pub unit: Unit,
-}
-
-impl Len {
-    pub const fn new(value: f32, unit: Unit) -> Self {
-        Len { value, unit }
-    }
-    pub const fn px(value: f32) -> Self {
-        Len::new(value, Unit::Px)
-    }
-    pub const fn mm(value: f32) -> Self {
-        Len::new(value, Unit::Mm)
-    }
-    pub const fn cm(value: f32) -> Self {
-        Len::new(value, Unit::Cm)
-    }
-    pub const fn m(value: f32) -> Self {
-        Len::new(value, Unit::M)
-    }
-    pub const fn inches(value: f32) -> Self {
-        Len::new(value, Unit::In)
-    }
-    pub const fn pt(value: f32) -> Self {
-        Len::new(value, Unit::Pt)
-    }
-
-    /// Parse `"2mm"`, `"0.5 in"`, `"12px"`, `"6pt"`. A bare number is
-    /// `None`: the caller decides what an unsuffixed number means (in
-    /// config it is a logical px and takes the fast path), and this parser
-    /// only ever claims a value that *said* its unit.
-    pub fn parse(s: &str) -> Option<Len> {
-        let s = s.trim();
-        let split = s.find(|c: char| c.is_ascii_alphabetic())?;
-        let (num, suffix) = s.split_at(split);
-        let value = num.trim().parse::<f32>().ok().filter(|v| v.is_finite())?;
-        let unit = Unit::parse(suffix)?;
-        Some(Len { value, unit })
-    }
-
-    /// Parse a number plus a KDL type annotation (`(mm)2.0`): `None` when
-    /// the annotation is not a unit.
-    pub fn from_annotated(value: f32, annotation: &str) -> Option<Len> {
-        Unit::parse(annotation).map(|unit| Len { value, unit })
-    }
-
-    /// Logical px under `metric`.
-    pub fn resolve(&self, metric: &Metric) -> f32 {
-        metric.to_px(self.value, self.unit)
-    }
-
-    /// Logical px under the process metric.
-    pub fn to_px(&self) -> f32 {
-        self.resolve(&metric())
-    }
-
-    /// The same length expressed in `unit` under `metric`.
-    pub fn convert(&self, unit: Unit, metric: &Metric) -> Len {
-        Len { value: metric.from_px(self.resolve(metric), unit), unit }
-    }
-
-    /// The compact form `parse` reads back: `2mm`, `9.3px`. Trailing zeros
-    /// trimmed so a config line stays as the user typed it.
-    pub fn serialize(&self) -> String {
-        format!("{}{}", fmt_num(self.value), self.unit.suffix())
-    }
-}
-
-impl fmt::Display for Len {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.write_str(&self.serialize())
-    }
-}
-
-/// A number with up to four decimals, trailing zeros dropped.
-pub fn fmt_num(v: f32) -> String {
-    let s = format!("{:.4}", v);
-    let s = s.trim_end_matches('0').trim_end_matches('.');
-    if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
-}
-
-static METRIC: RwLock<Metric> = RwLock::new(Metric {
-    scale: 1.0,
-    px_per_mm: ASSUMED_PPI / MM_PER_INCH,
-    source: MetricSource::Assumed,
-});
-static FORCED_PPI: OnceLock<Option<f32>> = OnceLock::new();
-
-/// `CCE_FORCE_PPI=<logical px per inch>`: pin the metric regardless of what
-/// the outputs report — a headless shadow has no EDID and would otherwise
-/// run assumed, so a test that measures a millimetre sets this to the live
-/// panel's figure (141.8 on the 3840×2400 / 344 mm laptop at scale 2).
-pub fn forced_ppi() -> Option<f32> {
-    *FORCED_PPI.get_or_init(|| {
-        std::env::var("CCE_FORCE_PPI")
-            .ok()
-            .and_then(|v| v.parse::<f32>().ok())
-            .filter(|p| p.is_finite() && *p > 0.0)
-    })
-}
-
-/// The process-wide metric.
-pub fn metric() -> Metric {
-    *METRIC.read().unwrap()
-}
-
-/// Install the process-wide metric. A forced PPI overrides everything but
-/// keeps the caller's scale. Called by the window runner as outputs come and
-/// go; apps only read.
-pub fn set_metric(m: Metric) {
-    let m = match forced_ppi() {
-        Some(ppi) => Metric { scale: m.scale, px_per_mm: ppi / MM_PER_INCH, source: MetricSource::Forced },
-        None => m,
-    };
-    if let Ok(mut lock) = METRIC.write() {
-        if *lock != m {
-            log::info!(
-                "[units] metric: {:.3} logical px/mm ({:.1} ppi, scale {}) — {}",
-                m.px_per_mm,
-                m.ppi(),
-                m.scale,
-                m.source.as_str()
-            );
-        }
-        *lock = m;
-    }
-}
-
-/// Logical px per millimetre under the process metric.
-pub fn px_per_mm() -> f32 {
-    metric().px_per_mm
-}
-
-/// Logical px for `v` millimetres under the process metric.
-pub fn mm(v: f32) -> f32 {
-    metric().to_px(v, Unit::Mm)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// The live laptop panel: 3840×2400 over 344×215 mm at scale 2.
-    fn panel() -> Metric {
-        Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap()
-    }
-
-    #[test]
-    fn panel_metric_is_about_5_6_px_per_mm() {
-        let m = panel();
-        assert!((m.px_per_mm - 5.58).abs() < 0.02, "{}", m.px_per_mm);
-        assert!((m.ppi() - 141.8).abs() < 0.5);
-        assert!((m.physical_px_per_mm() - 11.16).abs() < 0.05);
-        assert!(m.is_real());
-    }
-
-    #[test]
-    fn assumed_is_css_px() {
-        let m = Metric::assumed(1.0);
-        assert_eq!(Len::inches(1.0).resolve(&m), 96.0);
-        assert!((Len::pt(72.0).resolve(&m) - 96.0).abs() < 1e-4);
-        assert!(!m.is_real());
-    }
-
-    #[test]
-    fn implausible_sizes_reject() {
-        // An EDID claiming 16×9 mm at 4K: 240 px/mm, nonsense. (The gate is
-        // deliberately wide — a 4K panel over 16×9 *cm* is 610 ppi, which a
-        // phone-class panel can be — so only the absurd is refused; the
-        // `size_mm` override exists for the merely wrong.)
-        assert!(Metric::from_sizes(1.0, (3840.0, 2160.0), (16.0, 9.0), MetricSource::Measured).is_none());
-        assert!(Metric::from_sizes(1.0, (1920.0, 1080.0), (0.0, 0.0), MetricSource::Measured).is_none());
-    }
-
-    #[test]
-    fn parse_and_serialize_roundtrip() {
-        for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm", "0.3m"] {
-            let l = Len::parse(s).unwrap();
-            assert_eq!(l.serialize(), s, "{s}");
-        }
-        assert_eq!(Len::parse("2 mm"), Some(Len::mm(2.0)));
-        assert_eq!(Len::parse("2"), None, "bare numbers are the caller's");
-        assert_eq!(Len::parse("2em"), None);
-        assert_eq!(Len::parse("mm"), None);
-        assert_eq!(Len::from_annotated(2.0, "mm"), Some(Len::mm(2.0)));
-        assert_eq!(Len::from_annotated(2.0, "f64"), None);
-    }
-
-    #[test]
-    fn resolve_and_convert() {
-        let m = panel();
-        let roll = Len::px(9.3);
-        let in_mm = roll.convert(Unit::Mm, &m);
-        assert!((in_mm.value - 1.67).abs() < 0.01, "{in_mm}");
-        assert!((Len::mm(1.0).resolve(&m) - 5.58).abs() < 0.02);
-        assert_eq!(Len::px(4.0).resolve(&m), 4.0);
-    }
-
-    #[test]
-    fn fmt_num_trims() {
-        assert_eq!(fmt_num(2.0), "2");
-        assert_eq!(fmt_num(9.3), "9.3");
-        assert_eq!(fmt_num(0.0), "0");
-        assert_eq!(fmt_num(1.23456), "1.2346");
-    }
-}
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 67c9430..5da4064 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -279,35 +279,7 @@ pub const RAMP_PRESETS: &[(&str, &[(f32, f32)])] = &[
     ("Valley", &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)]),
 ];
 
-/// Serialize ramp keys + line type as the DE's ramp spec string:
-/// `"smooth;0.000:0.500,0.200:1.000,…"` (`"linear;…"` for straight segments) —
-/// the format ramp-valued params travel in (`ParametersBg` "ramp" rows,
-/// project files, `cce_ui::layout::set_bevel_profile_keys` consumers).
-pub fn format_ramp_spec(keys: &[(f32, f32)], smooth: bool) -> String {
-    let body: Vec<String> =
-        keys.iter().map(|(p, v)| format!("{:.3}:{:.3}", p, v)).collect();
-    format!("{};{}", if smooth { "smooth" } else { "linear" }, body.join(","))
-}
-
-/// Parse a ramp spec string ([`format_ramp_spec`]) into `(keys, smooth)`.
-/// `None` for anything that doesn't yield at least two keys.
-pub fn parse_ramp_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
-    let (head, body) = spec.split_once(';')?;
-    let smooth = head.trim() == "smooth";
-    let mut keys = Vec::new();
-    for part in body.split(',') {
-        let (p, v) = part.split_once(':')?;
-        keys.push((
-            p.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
-            v.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
-        ));
-    }
-    if keys.len() < 2 {
-        return None;
-    }
-    keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
-    Some((keys, smooth))
-}
+pub use cce_core::ramp::{format_ramp_spec, parse_ramp_spec};
 
 
 
diff --git a/tests/plate_golden.rs b/tests/plate_golden.rs
index 92ad843..1927caf 100644
--- a/tests/plate_golden.rs
+++ b/tests/plate_golden.rs
@@ -24,7 +24,7 @@
 //! the scene tessellates).
 
 use cce_ui::scene::layout::Rect;
-use cce_ui::scene::paint::{ControlPlate, DropletSpec, PaintCtx, PlateSpec, PlateStance};
+use cce_ui::scene::paint::{ControlPlate, DropletFinish, DropletSpec, PaintCtx, PlateSpec, PlateStance};
 use cce_ui::scene::Material;
 use std::fmt::Write as _;