git.lucas.co / cce-core
GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git

commit39e989d6ebc7850a1e5aa96bf228fe8714e538e0
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 22:21
feat: cce-core, the GUI-free half of the cce toolkit

Moved out of cce-ui, which re-exports every module at its old path:
config (the KDL config and its paths), input (input.kdl bindings and
settings), motion (the animations switch), units (lengths and the display
metric), ipc (the socket convention and ipc::instance), and the parsers
for the specs the DE writes — color (hex and sRGB), ramp (the ramp spec
and its monotone cubic), relief_spec, droplet (DropletSpec).

A process that does not draw — the compositor, a sync daemon, a CLI
helper — can depend on this crate alone and link none of the toolkit's
Wayland, Vulkan or text stack; the compositor linked all of it for its
config, bindings and two spec parsers.

The cfg(test) gates that keep a suite off the machine's config are
cfg(any(test, feature = "test-isolation")), and cce-ui turns the feature
on through its dev-dependency, so its suite stays isolated.

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

 .github/workflows/ci.yml |   19 +
 .gitignore               |    2 +
 CLAUDE.md                |   47 ++
 Cargo.lock               |  334 ++++++++++
 Cargo.toml               |   29 +
 src/color.rs             |   59 ++
 src/config.rs            | 1573 ++++++++++++++++++++++++++++++++++++++++++++++
 src/droplet.rs           |  193 ++++++
 src/input.rs             |  632 +++++++++++++++++++
 src/ipc.rs               |  299 +++++++++
 src/ipc/instance.rs      |  200 ++++++
 src/lib.rs               |   23 +
 src/motion.rs            |  128 ++++
 src/ramp.rs              |  105 ++++
 src/relief_spec.rs       |  143 +++++
 src/units.rs             |  424 +++++++++++++
 16 files changed, 4210 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..02dc891
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,19 @@
+name: CI
+
+on:
+  push:
+  pull_request:
+
+env:
+  CARGO_TERM_COLOR: always
+  RUSTFLAGS: -D warnings
+
+jobs:
+  test:
+    runs-on: ubuntu-24.04
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - run: cargo build --all-targets
+      - run: cargo test
+      - run: cargo test --features test-isolation
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c17da7f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..58417b1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,47 @@
+# CLAUDE.md
+
+This is the `cce-core` crate, the GUI-free half of the cce toolkit. Read the
+workspace guide `../cce-compositor/WORKSPACE.md` first: the multi-repo layout,
+the standalone-build rule, `ccebuild`, and the concurrent-sessions rules. This
+crate is SHARED like `cce-ui`. Run `git status` before editing it, and treat
+foreign dirt as another session's.
+
+## What lives here
+
+| Module | What it is |
+|---|---|
+| `config` | the KDL config: paths (`~/.config/cce`, XDG), reading, writing with rolling backups, the JSON view |
+| `input` | `input.kdl`: domain-scoped bindings and pointer settings |
+| `motion` | the DE-wide animations switch (`/run/cce/animations`) |
+| `units` | lengths with units (`(mm)2.0`) and the display metric |
+| `ipc` | the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` convention, `ipc::instance` (not wasm) |
+| `color`, `ramp`, `relief_spec`, `droplet` | the parsers for the specs the DE writes: hex colours, ramp curves, relief, droplets |
+
+`cce-ui` re-exports every module at its old path (`cce_ui::config`,
+`cce_ui::motion`, `cce_ui::scene::paint::DropletSpec`, `cce_ui::layout::sample_ramp_keys`,
+…), so apps never name this crate. A process that does not draw (the compositor,
+a sync daemon, a CLI helper) depends on it directly and links none of the
+toolkit's Wayland, Vulkan or text stack. Nothing here may depend on cce-ui; that
+is the point of the split.
+
+## The test-isolation feature
+
+Several functions answer differently under `cfg(test)`, so a suite never reads
+the machine. `config::config_home()` is a per-process directory nobody creates,
+`input::natural_scroll()` reads false, and `motion::enabled()` is on unless
+`motion::force_for_test` says otherwise. Since those modules live here,
+`cfg(test)` would only cover this crate's own suite. Every such gate is
+`cfg(any(test, feature = "test-isolation"))` instead, and cce-ui turns the
+feature on through its dev-dependency. Resolver 2 keeps it out of normal builds,
+and a dependent's test build that does not build cce-ui's dev-dependencies does
+not get it either, which is what those builds had before the split.
+
+## Build, test
+
+```sh
+cargo test -p cce-core
+cargo test -p cce-core --features test-isolation
+```
+
+The crate builds for `wasm32-unknown-unknown` too (minus `ipc`), as cce-ui does.
+After pushing, run `../bump-revs.sh cce-core` to repin cce-ui and the compositor.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..a63ebe2
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,334 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cce-core"
+version = "0.1.0"
+dependencies = [
+ "kdl",
+ "libc",
+ "log",
+ "serde_json",
+ "web-time",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kdl"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117"
+dependencies = [
+ "miette",
+ "nom",
+ "thiserror",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror",
+ "unicode-width",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..43b5972
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "cce-core"
+version = "0.1.0"
+edition = "2021"
+description = "The GUI-free half of the cce toolkit: config, input bindings, the animations switch, units, IPC and the DE's spec parsers"
+
+[lib]
+name = "cce_core"
+path = "src/lib.rs"
+
+[features]
+# What `cfg(test)` means inside this crate, for a DEPENDENT's test build: the
+# config home is a per-process directory nobody creates, natural scrolling
+# reads false and the animations switch is on unless a test forces it, so a
+# suite never reads the machine's ~/.config/cce or /run/cce. cce-ui turns it on
+# through its dev-dependency only (resolver 2 keeps dev-dependency features out
+# of normal builds), which is what keeps its suite isolated now that these
+# modules live here; a shipped binary never has it.
+test-isolation = []
+
+[dependencies]
+kdl = "4.6"
+serde_json = "1.0"
+log = "0.4"
+# `Instant` that works in the browser: std's panics on wasm32-unknown-unknown.
+web-time = "1"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+libc = "0.2"
diff --git a/src/color.rs b/src/color.rs
new file mode 100644
index 0000000..82d58b6
--- /dev/null
+++ b/src/color.rs
@@ -0,0 +1,59 @@
+//! sRGB/linear conversion and the DE's hex colour strings — the pure half of
+//! `cce_ui::color`, which re-exports every item here.
+
+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
+    }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..5743b59
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,1573 @@
+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(any(test, feature = "test-isolation"))]
+    {
+        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");
+    }
+
+    /// 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/droplet.rs b/src/droplet.rs
new file mode 100644
index 0000000..c01fd5f
--- /dev/null
+++ b/src/droplet.rs
@@ -0,0 +1,193 @@
+//! The droplet spec: the shape and material knobs of the DE's droplet — the status bar's
+//! module drops and the compositor's scenefx droplet node read the same string, so the two
+//! sides can never disagree on a field. Moved here from `cce_ui::scene::paint`, which
+//! re-exports it, so the compositor can parse a spec without linking the toolkit.
+
+/// Shape and material knobs for `cce_ui::scene::paint::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 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,
+        }
+    }
+}
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..fc2f7bc
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,632 @@
+// ~/.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(any(test, feature = "test-isolation"))]
+    {
+        false
+    }
+    #[cfg(not(any(test, feature = "test-isolation")))]
+    {
+        *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(any(test, feature = "test-isolation")))]
+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
new file mode 100644
index 0000000..c26a6ef
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,299 @@
+//! 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 cce-ui `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
new file mode 100644
index 0000000..34029ee
--- /dev/null
+++ b/src/ipc/instance.rs
@@ -0,0 +1,200 @@
+//! 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/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..76ca4bb
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,23 @@
+//! The GUI-free half of the cce toolkit.
+//!
+//! What a cce process needs whether or not it draws anything: the KDL config
+//! (`config`) and input bindings (`input`), the DE-wide animations switch
+//! (`motion`), lengths with units and the display metric (`units`), the
+//! Unix-socket IPC convention (`ipc`), and the parsers for the specs the DE
+//! writes in its config — colours (`color`), ramps (`ramp`), relief
+//! (`relief_spec`) and droplets (`droplet`).
+//!
+//! `cce-ui` re-exports every module here at its old path (`cce_ui::config`,
+//! `cce_ui::motion`, …), so an app never names this crate. A process that does
+//! not draw — the compositor, a sync daemon, a CLI helper — depends on it
+//! directly and links none of the toolkit's Wayland, Vulkan or text stack.
+pub mod color;
+pub mod config;
+pub mod droplet;
+pub mod input;
+#[cfg(not(target_arch = "wasm32"))]
+pub mod ipc;
+pub mod motion;
+pub mod ramp;
+pub mod relief_spec;
+pub mod units;
diff --git a/src/motion.rs b/src/motion.rs
new file mode 100644
index 0000000..78fcc0b
--- /dev/null
+++ b/src/motion.rs
@@ -0,0 +1,128 @@
+//! 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(any(test, feature = "test-isolation"))]
+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(any(test, feature = "test-isolation"))]
+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(any(test, feature = "test-isolation"))]
+    {
+        return FORCED.with(|f| f.get());
+    }
+    #[cfg(not(any(test, feature = "test-isolation")))]
+    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(any(test, feature = "test-isolation")))]
+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/ramp.rs b/src/ramp.rs
new file mode 100644
index 0000000..66ecb23
--- /dev/null
+++ b/src/ramp.rs
@@ -0,0 +1,105 @@
+//! The DE's ramp spec — `"smooth;0.000:0.500,0.200:1.000,…"` — and the curve it draws:
+//! what cce-ui's Ramp widget writes, its relief profiles read, and the window manager's
+//! camera transitions evaluate. `cce_ui::widget` and `cce_ui::layout` re-export these.
+
+/// 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))
+}
+
+/// 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)
+}
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
new file mode 100644
index 0000000..28c299c
--- /dev/null
+++ b/src/relief_spec.rs
@@ -0,0 +1,143 @@
+//! 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/units.rs b/src/units.rs
new file mode 100644
index 0000000..6e04114
--- /dev/null
+++ b/src/units.rs
@@ -0,0 +1,424 @@
+//! 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");
+    }
+}