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

commit25f369d01e996fdd1b9f44f8f37ca154271303ba
parentd8f1970b75
authorLucas Galante <[email protected]>
date2026-09-20 15:23
Named materials in config, and cce-relief edits the finish and the frost (RFC material, step 4)

style.surface.material { <name> { color; frost …; finish … } } defines
materials; plate material=, plate { root material= } and
style.control.material bind them per rung. MaterialDef::resolve lays a
node over the rung's legacy material — unset fields fall back, no frost
child means opaque, a binding wins over the legacy keys, and a binding
to an undefined name warns and degrades to the legacy material.
Material::named hands an app any defined material. Nodes and bindings
are replaced wholesale on every load, so a reload forgets what config
no longer says. Frost::from_style follows the bound pane's recipe.

The DE finish's three fixed terms get config keys — relief.spec /
shininess / curvature — and the default frost's blur sigma is
plate.radius. The writer treats frost and finish as property nodes.

cce-relief grows two columns, Finish (Specular / Shininess / Curvature)
and Frost (Compression / Refraction / Blur radius), seeded from the pane
rung's effective material, applied live, and saved into the bound
material's node when the pane is bound, else into the DE keys — Save
never restructures a config that has no materials.

Tests: the designer's #05050840 / 0.6 / 0.3 spelled with the legacy
keys and as a bound  resolve to the same Material; binding
semantics; the writer's key shape. No live config or backup carries a
material node or binding, so every one resolves as before.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md             |  11 ++
 docs/rfc-material.md  |  53 +++++++---
 src/bin/cce-relief.rs | 184 +++++++++++++++++++++++++++++++-
 src/color.rs          | 121 +++++++++++++++++++++
 src/config.rs         |  27 ++++-
 src/scene/material.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++---
 src/scene/mod.rs      |   2 +-
 7 files changed, 645 insertions(+), 36 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c77063c..781173d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -381,6 +381,17 @@ What this buys, and where the code is heading:
   vertex from outside the display list falls to the no-recipe branch.
   `examples/frost_pair.rs` is the visual test: three recipes in one window, run in a
   shadow, measured in the RFC's step-3 note.
+- **Named materials in config** (RFC step 4): `style.surface.material { <name> { color;
+  frost …; finish … } }` and a binding per rung — `plate material="…"`, `plate { root
+  material="…" }`, `style.control.material` — resolved by `MaterialDef::resolve` over
+  the rung's legacy material (unset fields fall back; no `frost` child = opaque; a
+  binding wins over the legacy keys; an undefined name warns and degrades to legacy).
+  The DE finish's three fixed terms are `style.surface.relief.spec / shininess /
+  curvature`; the default frost's blur sigma is `style.surface.plate.radius`. cce-relief
+  edits them (Finish and Frost columns) and writes into the bound material's node or the
+  DE keys — never restructuring an unbound config. KDL trap when writing fixtures: two
+  nodes on one line need a `;`, and `a { b }` on one line is a parse error the loader
+  swallows into an empty document.
 - **`style.surface.plate.refraction` (0..1, default 0) is the rim, and it buys
   no legibility.** It is the answer to the other half of the question — not
   "can I read this" but "is this an object". The roll is a real surface with a
diff --git a/docs/rfc-material.md b/docs/rfc-material.md
index 32e2793..b9186d3 100644
--- a/docs/rfc-material.md
+++ b/docs/rfc-material.md
@@ -238,36 +238,44 @@ sites, all of the form `color: …, blur: …`. Each becomes `material: Material
 ```kdl
 style {
     surface {
-        material "glass" {
-            color (rgba)"#05050840"
-            frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
-            finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+        material {
+            glass {
+                color (rgba)"#05050840"
+                frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
+                finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+            }
+            plastic {
+                color (rgba)"#26263380"
+                finish light=(f64)0.15 spec=(f64)0.25 shininess=(f64)12.0
+            }
         }
-        material "plastic" {
-            color (rgba)"#26263380"
-            finish light=(f64)0.15 spec=(f64)0.25 shininess=(f64)12.0
-        }
-        plate {
+        plate material="glass" {        // the pane rung
             root material="glass"
-            material "glass"            // the pane rung
         }
-        control material="plastic"
     }
+    control material="plastic"
 }
 ```
 
+(As built: the materials are CHILDREN of one `material` node, named by node name, not
+`material "glass"` with a string argument — the config converter keys objects by node
+name and a node's argument would be lost; and `control` is `style.control`, the node the
+control rung's other keys already live under.)
+
 A `material` node with no `frost` child is `Opaque`. Missing `finish` keys take the rung
-default. A rung with no `material=` binding reads its material from the legacy keys below —
-which is how every existing config keeps its look.
+default; a missing `color` keeps the rung's tint. A rung with no `material=` binding reads
+its material from the legacy keys below — which is how every existing config keeps its
+look. `Material::named(name)` hands an app any defined material for its own surfaces.
 
 ### 5.2 Aliases: every existing key survives
 
 | Existing key | Resolves into |
 |---|---|
-| `style.surface.plate.color` / `.blur` / `.backdrop_compression` / `.refraction` | the pane rung's default material |
+| `style.surface.plate.color` / `.blur` / `.backdrop_compression` / `.refraction` / `.radius` | the pane rung's default material (`radius`, new: the default frost's blur sigma) |
 | `style.surface.param.color`, `style.surface.plate.opacity` | the pane rung's tint |
 | `style.surface.plate.root.color` / `.blur` | the root rung's default material |
 | `style.surface.relief.depth` / `.light` | every rung's `finish.strength` (and the free carves') |
+| `style.surface.relief.spec` / `.shininess` / `.curvature` | the DE finish's other three terms (new; were literals) — every rung's, and the carves' |
 | `style.surface.relief.width` / `.height` / `.edge_height` | unchanged: geometry, not material |
 | `style.surface.control.fill` (and the per-widget fills) | the control rung's tint |
 
@@ -466,7 +474,22 @@ the scale-2 panel and the other two plates differ only by their own recipes.
   batch count before/after on cce-designer's default view (expect +N for the flat frosted
   quads, N small).
 
-**Step 4 — config and editor.**
+**Step 4 — config and editor.** *DONE 2026-09-20.* The named-material nodes and the three
+rung bindings (§ 5.1, in the child-node shape), `MaterialDef::resolve` over the rung's
+legacy material, `Material::named`, the DE finish keys `relief.spec / shininess /
+curvature` and the default frost's `plate.radius`; live reload replaces nodes and bindings
+wholesale. cce-relief grew two columns — Finish (Specular / Shininess / Curvature) and
+Frost (Compression / Refraction / Blur radius) — seeded from the pane rung's effective
+material, applied live, and saved into the bound material's node when the pane is bound,
+else into the DE keys: **Save never restructures a config that has no materials**; the
+named form is opted into by writing the binding. In `--key` mode the material sliders
+are not part of a `(relief)` value and are not written. *Exit:* no live config or backup
+carries a `material` node or binding (grep), so all resolve as before by construction;
+`named_material_round_trips_the_legacy_spelling` pins the designer's `#05050840` / 0.6 /
+0.3 as a bound `glass` to the SAME `Material` as the legacy keys (same Material, same
+bytes — steps 2–3), and `material_keys_write_as_frost_and_finish_props` pins the
+writer's shape. Not exercised: a Save click in the shadow (the utility window is taller
+than the headless output).
 - `material "<name>"` nodes; per-rung `material=` bindings; the alias table (§ 5.2).
 - `cce-relief` grows a Finish section (spec / shininess / curvature) and a Frost section,
   and Save writes a named material.
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index d50b049..875fc35 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -73,6 +73,16 @@ const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
 const DEPTH_RANGE: (f32, f32) = (0.0, 0.6);
 const WIDTH_RANGE: (f32, f32) = (2.0, 24.0);
 const HEIGHT_RANGE: (f32, f32) = (0.0, 24.0);
+/// The finish's three fixed terms and the frost recipe — the material
+/// sections (RFC material step 4). Specular strength, shininess exponent,
+/// curvature/AO strength; luminance compression, rim refraction, blur sigma
+/// in logical px.
+const SPEC_RANGE: (f32, f32) = (0.0, 1.5);
+const SHINE_RANGE: (f32, f32) = (1.0, 64.0);
+const CURV_RANGE: (f32, f32) = (0.0, 1.0);
+const COMP_RANGE: (f32, f32) = (0.0, 1.0);
+const REFR_RANGE: (f32, f32) = (0.0, 1.0);
+const RADIUS_RANGE: (f32, f32) = (0.0, 20.0);
 
 /// Sample count for the spec written to config — enough that the 32-slot
 /// renderer LUT sees the curve, few enough that the config line stays sane.
@@ -114,7 +124,7 @@ fn content_height(width: f32) -> f32 {
     let button_h = 26.0;
     let status_h = HEADER_FONT_SIZE + 4.0;
     let fixed = knob_h + gap
-        + 6.0 * (knob_h + gap)
+        + 9.0 * (knob_h + gap)
         + button_h + 8.0 + status_h + 2.0 * gap;
     let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
         + 2.0 * CUT_MARGIN
@@ -455,6 +465,18 @@ struct BevelPopup {
     depth_slider: Adapted<Slider>,
     width_slider: Adapted<Slider>,
     height_slider: Adapted<Slider>,
+    /// The Finish column: what the surface does under light beyond its
+    /// strength (`Light` above) — `scene::material::Finish`'s spec /
+    /// shininess / curvature. Applied live to the DE finish, or to the pane
+    /// rung's bound material when config binds one.
+    spec_slider: Adapted<Slider>,
+    shine_slider: Adapted<Slider>,
+    curv_slider: Adapted<Slider>,
+    /// The Frost column: the pane material's recipe — compression,
+    /// refraction, blur radius (`scene::material::Frost`). Same live target.
+    comp_slider: Adapted<Slider>,
+    refr_slider: Adapted<Slider>,
+    radius_slider: Adapted<Slider>,
     save_button: Adapted<Button>,
     /// Cancel = discard-and-close: edits are live only in THIS process, so
     /// with nothing persisted, closing IS the discard (same as Escape).
@@ -997,7 +1019,7 @@ impl BevelPopup {
         self.active_shape().curve()
     }
 
-    fn root_ids(&self) -> [WidgetId; 13] {
+    fn root_ids(&self) -> [WidgetId; 19] {
         [
             self.profile_dropdown.id(),
             self.edge_dropdown.id(),
@@ -1010,12 +1032,18 @@ impl BevelPopup {
             self.depth_slider.id(),
             self.width_slider.id(),
             self.height_slider.id(),
+            self.spec_slider.id(),
+            self.shine_slider.id(),
+            self.curv_slider.id(),
+            self.comp_slider.id(),
+            self.refr_slider.id(),
+            self.radius_slider.id(),
             self.save_button.id(),
             self.cancel_button.id(),
         ]
     }
 
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 13] {
+    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 19] {
         [
             self.profile_dropdown.as_ptr_mut(),
             self.edge_dropdown.as_ptr_mut(),
@@ -1028,11 +1056,96 @@ impl BevelPopup {
             self.depth_slider.as_ptr_mut(),
             self.width_slider.as_ptr_mut(),
             self.height_slider.as_ptr_mut(),
+            self.spec_slider.as_ptr_mut(),
+            self.shine_slider.as_ptr_mut(),
+            self.curv_slider.as_ptr_mut(),
+            self.comp_slider.as_ptr_mut(),
+            self.refr_slider.as_ptr_mut(),
+            self.radius_slider.as_ptr_mut(),
             self.save_button.as_ptr_mut(),
             self.cancel_button.as_ptr_mut(),
         ]
     }
 
+    /// The pane rung's bound material name, when config binds one — the
+    /// target the material sliders edit and Save writes; `None` = the DE
+    /// keys (`style.surface.relief.*` for the finish, `style.surface.plate.*`
+    /// for the frost).
+    fn bound_material() -> Option<String> {
+        cce_ui::color::material_binding(cce_ui::scene::PlateRung::Pane)
+    }
+
+    /// Push the six material sliders into the live style: the bound
+    /// material's node when there is one (so the panes made of it follow),
+    /// else the DE keys every unbound rung reads.
+    fn apply_material_live(&self) {
+        use cce_ui::scene::{FrostDef, MaterialDef};
+        let spec = self.spec_slider.inner().get_scaled_value();
+        let shine = self.shine_slider.inner().get_scaled_value();
+        let curv = self.curv_slider.inner().get_scaled_value();
+        let comp = self.comp_slider.inner().get_scaled_value();
+        let refr = self.refr_slider.inner().get_scaled_value();
+        let radius = self.radius_slider.inner().get_scaled_value();
+        match Self::bound_material() {
+            Some(name) => {
+                let mut def: MaterialDef = cce_ui::color::named_material(&name).unwrap_or_default();
+                def.spec = Some(spec);
+                def.shininess = Some(shine);
+                def.curvature = Some(curv);
+                // Only a frosted material has a recipe to edit; an opaque
+                // node stays opaque (the sliders read as "when frosted").
+                if def.frost.is_some() {
+                    def.frost = Some(FrostDef { compression: Some(comp), refraction: Some(refr), radius: Some(radius) });
+                }
+                cce_ui::color::set_named_material(&name, Some(def));
+            }
+            None => {
+                cce_ui::color::set_finish_spec(spec);
+                cce_ui::color::set_finish_shininess(shine);
+                cce_ui::color::set_finish_curvature(curv);
+                cce_ui::color::set_plate_backdrop_compression(comp);
+                cce_ui::color::set_plate_refraction(refr);
+                cce_ui::color::set_plate_frost_radius(radius);
+            }
+        }
+    }
+
+    /// Persist the material sliders: into the bound material's node
+    /// (`style.surface.material.<name>.finish` / `.frost`) when the pane rung
+    /// is bound, else the DE keys. Never restructures a config that has no
+    /// materials — the named form is opted into by writing the binding.
+    fn save_material(&self, p: &str) -> bool {
+        let f = |v: f32| format!("{v:.3}");
+        let w = |key: &str, value: &str| cce_ui::config::write_config_value(p, key, value, "style");
+        let spec = f(self.spec_slider.inner().get_scaled_value());
+        let shine = f(self.shine_slider.inner().get_scaled_value());
+        let curv = f(self.curv_slider.inner().get_scaled_value());
+        let comp = f(self.comp_slider.inner().get_scaled_value());
+        let refr = f(self.refr_slider.inner().get_scaled_value());
+        let radius = f(self.radius_slider.inner().get_scaled_value());
+        match Self::bound_material() {
+            Some(name) => {
+                let m = format!("style.surface.material.{name}");
+                let frosted = cce_ui::color::named_material(&name).is_some_and(|d| d.frost.is_some());
+                w(&format!("{m}.finish.spec"), &spec)
+                    & w(&format!("{m}.finish.shininess"), &shine)
+                    & w(&format!("{m}.finish.curvature"), &curv)
+                    & (!frosted
+                        || (w(&format!("{m}.frost.backdrop_compression"), &comp)
+                            & w(&format!("{m}.frost.refraction"), &refr)
+                            & w(&format!("{m}.frost.radius"), &radius)))
+            }
+            None => {
+                w("style.surface.relief.spec", &spec)
+                    & w("style.surface.relief.shininess", &shine)
+                    & w("style.surface.relief.curvature", &curv)
+                    & w("style.surface.plate.backdrop_compression", &comp)
+                    & w("style.surface.plate.refraction", &refr)
+                    & w("style.surface.plate.radius", &radius)
+            }
+        }
+    }
+
     /// `take_*` plumbing after any routed dispatch — state-gated, so it does
     /// not matter which propagate call consumed the event.
     fn drain_widget_changes(&mut self) {
@@ -1088,6 +1201,25 @@ impl BevelPopup {
             println!("height {v:.2}px = {:.3}mm ({})", v * m.mm_per_px(), m.source.as_str());
             self.needs_rebuild = true;
         }
+        let material_moved = self.spec_slider.take_change()
+            | self.shine_slider.take_change()
+            | self.curv_slider.take_change()
+            | self.comp_slider.take_change()
+            | self.refr_slider.take_change()
+            | self.radius_slider.take_change();
+        if material_moved {
+            self.apply_material_live();
+            println!(
+                "material spec {:.3} shininess {:.1} curvature {:.3} | compression {:.3} refraction {:.3} radius {:.1}",
+                self.spec_slider.inner().get_scaled_value(),
+                self.shine_slider.inner().get_scaled_value(),
+                self.curv_slider.inner().get_scaled_value(),
+                self.comp_slider.inner().get_scaled_value(),
+                self.refr_slider.inner().get_scaled_value(),
+                self.radius_slider.inner().get_scaled_value(),
+            );
+            self.needs_rebuild = true;
+        }
         if self.save_button.take_click() {
             self.save_to_config();
             self.needs_rebuild = true;
@@ -1162,7 +1294,9 @@ impl BevelPopup {
         } else {
             w("style.surface.relief.height", "0")
         };
+        let material_ok = self.save_material(&p);
         let ok = height_ok
+            & material_ok
             & w("style.surface.relief.depth", &depth)
             & w("style.surface.relief.width", &width)
             & w("style.surface.relief.profile", &self.wall.last_spec)
@@ -1321,6 +1455,27 @@ impl Application for BevelPopup {
         let (dmin, dmax) = DEPTH_RANGE;
         let (wmin, wmax) = WIDTH_RANGE;
         let (hmin, hmax) = HEIGHT_RANGE;
+        // The material sliders seed from the pane rung's effective material
+        // — the bound node when config binds one, else the DE keys — so they
+        // open on what the panes actually wear.
+        let pane = cce_ui::scene::Material::pane();
+        let (comp0, refr0, radius0) = match pane.frost {
+            cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
+            cce_ui::scene::Frost::Opaque => match cce_ui::scene::Frost::from_style() {
+                cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
+                cce_ui::scene::Frost::Opaque => (0.0, 0.0, cce_ui::scene::Frost::DEFAULT_RADIUS),
+            },
+        };
+        let norm = |v: f32, (lo, hi): (f32, f32)| ((v - lo) / (hi - lo)).clamp(0.0, 1.0);
+        let material_slider = |label: &str, v: f32, range: (f32, f32), decimals: usize| {
+            Slider::new()
+                .with_label(label)
+                .with_range(range.0, range.1)
+                .with_value(norm(v, range))
+                .with_readout(true)
+                .with_decimals(decimals)
+                .with_scroll(true)
+        };
         // The persisted per-app plate opacity, falling back to the DE look.
         // New path first, then the pre-rename one, so an existing opacity
         // setting keeps working without a migration step.
@@ -1369,6 +1524,12 @@ impl Application for BevelPopup {
                 .with_readout(true)
                 .with_decimals(1)
                 .with_scroll(true),
+            spec_slider: material_slider("Specular", pane.finish.spec, SPEC_RANGE, 2),
+            shine_slider: material_slider("Shininess", pane.finish.shininess, SHINE_RANGE, 0),
+            curv_slider: material_slider("Curvature", pane.finish.curvature, CURV_RANGE, 2),
+            comp_slider: material_slider("Compression", comp0, COMP_RANGE, 2),
+            refr_slider: material_slider("Refraction", refr0, REFR_RANGE, 2),
+            radius_slider: material_slider("Blur radius", radius0, RADIUS_RANGE, 1),
             save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
             cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
             exit_requested: false,
@@ -1527,6 +1688,17 @@ impl Application for BevelPopup {
             y += knob_h + gap;
             self.height_slider.set_rect(x, y, w, knob_h);
             y += knob_h + gap;
+            // The material columns: Finish left, Frost right, three rows.
+            let half = ((w - gap) * 0.5).max(60.0);
+            for (l, r) in [
+                (&mut self.spec_slider, &mut self.comp_slider),
+                (&mut self.shine_slider, &mut self.refr_slider),
+                (&mut self.curv_slider, &mut self.radius_slider),
+            ] {
+                l.set_rect(x, y, half, knob_h);
+                r.set_rect(x + half + gap, y, half, knob_h);
+                y += knob_h + gap;
+            }
             self.save_button.set_rect(x, y, 96.0, button_h);
             self.cancel_button.set_rect(x + 96.0 + 12.0, y, 96.0, button_h);
             y += button_h + 8.0;
@@ -1590,6 +1762,12 @@ impl Application for BevelPopup {
             &self.depth_slider,
             &self.width_slider,
             &self.height_slider,
+            &self.spec_slider,
+            &self.shine_slider,
+            &self.curv_slider,
+            &self.comp_slider,
+            &self.refr_slider,
+            &self.radius_slider,
         ] {
             cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
         }
diff --git a/src/color.rs b/src/color.rs
index 805d9a7..5919133 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -659,6 +659,65 @@ fn parse_and_set_colors(content: &str) {
     } else if let Some(blur_val) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_f64()) {
         if let Ok(mut lock) = PLATE_BLUR.write() { *lock = blur_val > 0.001; }
     }
+    if let Some(r) = val.pointer("/style/surface/plate/radius").and_then(|v| v.as_f64()) {
+        if let Ok(mut lock) = PLATE_FROST_RADIUS.write() { *lock = (r as f32).max(0.0); }
+    }
+
+    // The DE finish beyond its strength (`relief.depth` / `light`, which
+    // lives in the style registry): the three terms that were literals.
+    let f = |k: &str| val.pointer(&format!("/style/surface/relief/{k}")).and_then(|v| v.as_f64()).map(|v| v as f32);
+    if let Some(v) = f("spec") {
+        if let Ok(mut lock) = FINISH_SPEC.write() { *lock = v.max(0.0); }
+    }
+    if let Some(v) = f("shininess") {
+        if let Ok(mut lock) = FINISH_SHININESS.write() { *lock = v.max(1.0); }
+    }
+    if let Some(v) = f("curvature") {
+        if let Ok(mut lock) = FINISH_CURVATURE.write() { *lock = v.max(0.0); }
+    }
+
+    // Named materials and the rung bindings (RFC material § 5), replaced
+    // wholesale so a reload forgets what config no longer says.
+    {
+        use crate::scene::material::{FrostDef, MaterialDef};
+        let num = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_f64()).map(|v| v as f32);
+        let mut map: Vec<(String, MaterialDef)> = Vec::new();
+        if let Some(obj) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
+            for (name, node) in obj {
+                let Some(node) = node.as_object() else { continue };
+                let frost = node.get("frost").map(|fr| match fr.as_object() {
+                    Some(fo) => FrostDef {
+                        compression: num(fo.get("backdrop_compression").or(fo.get("compression"))),
+                        refraction: num(fo.get("refraction")),
+                        radius: num(fo.get("radius")),
+                    },
+                    // A bare `frost` node (no knobs) is frosted at the defaults.
+                    None => FrostDef::default(),
+                });
+                let fin = node.get("finish").and_then(|v| v.as_object());
+                let fk = |k: &str| fin.and_then(|fo| num(fo.get(k)));
+                map.push((
+                    name.clone(),
+                    MaterialDef {
+                        tint: node.get("color").and_then(|v| v.as_str()).and_then(parse_hex),
+                        frost,
+                        light: fk("light").or(fk("depth")),
+                        spec: fk("spec"),
+                        shininess: fk("shininess"),
+                        curvature: fk("curvature"),
+                    },
+                ));
+            }
+        }
+        if let Ok(mut lock) = NAMED_MATERIALS.write() { *lock = map; }
+        let bind = |p: &str| val.pointer(p).and_then(|v| v.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty());
+        let bindings = [
+            bind("/style/surface/plate/root/material"),
+            bind("/style/surface/plate/material"),
+            bind("/style/control/material"),
+        ];
+        if let Ok(mut lock) = MATERIAL_BINDINGS.write() { *lock = bindings; }
+    }
 }
 
 fn load_colors_once() {
@@ -1753,6 +1812,68 @@ static FINISH_SPEC: RwLock<f32> = RwLock::new(0.4);
 static FINISH_SHININESS: RwLock<f32> = RwLock::new(24.0);
 static FINISH_CURVATURE: RwLock<f32> = RwLock::new(0.2);
 
+/// The default material's blur radius (`style.surface.plate.radius`, the
+/// kernel sigma in logical px) — [`crate::scene::Frost::DEFAULT_RADIUS`]
+/// unless config says otherwise.
+static PLATE_FROST_RADIUS: RwLock<f32> = RwLock::new(crate::scene::material::Frost::DEFAULT_RADIUS);
+
+pub fn plate_frost_radius() -> f32 {
+    load_colors_once();
+    style_read(&PLATE_FROST_RADIUS)
+}
+pub fn set_plate_frost_radius(r: f32) {
+    style_write(&PLATE_FROST_RADIUS, r.max(0.0));
+}
+
+/// The named materials config defines (`style.surface.material { <name> {…} }`)
+/// and the rung bindings (`plate material=`, `plate { root material= }`,
+/// `control material=`) — see `docs/rfc-material.md` § 5. Replaced wholesale
+/// on every config load, so a node or binding removed from config is gone
+/// after a reload.
+static NAMED_MATERIALS: RwLock<Vec<(String, crate::scene::material::MaterialDef)>> = RwLock::new(Vec::new());
+static MATERIAL_BINDINGS: RwLock<[Option<String>; 3]> = RwLock::new([None, None, None]);
+
+fn rung_index(rung: crate::scene::material::PlateRung) -> usize {
+    match rung {
+        crate::scene::material::PlateRung::Root => 0,
+        crate::scene::material::PlateRung::Pane => 1,
+        crate::scene::material::PlateRung::Control => 2,
+    }
+}
+
+pub fn named_material(name: &str) -> Option<crate::scene::material::MaterialDef> {
+    load_colors_once();
+    style_read(&NAMED_MATERIALS).iter().find(|(n, _)| n == name).map(|(_, d)| d.clone())
+}
+
+/// Every defined material's name, sorted.
+pub fn material_names() -> Vec<String> {
+    load_colors_once();
+    let mut v: Vec<String> = style_read(&NAMED_MATERIALS).into_iter().map(|(n, _)| n).collect();
+    v.sort();
+    v
+}
+
+pub fn set_named_material(name: &str, def: Option<crate::scene::material::MaterialDef>) {
+    let mut list = style_read(&NAMED_MATERIALS);
+    list.retain(|(n, _)| n != name);
+    if let Some(d) = def {
+        list.push((name.to_string(), d));
+    }
+    style_write(&NAMED_MATERIALS, list);
+}
+
+pub fn material_binding(rung: crate::scene::material::PlateRung) -> Option<String> {
+    load_colors_once();
+    style_read(&MATERIAL_BINDINGS)[rung_index(rung)].clone()
+}
+
+pub fn set_material_binding(rung: crate::scene::material::PlateRung, name: Option<String>) {
+    let mut b = style_read(&MATERIAL_BINDINGS);
+    b[rung_index(rung)] = name;
+    style_write(&MATERIAL_BINDINGS, b);
+}
+
 pub fn finish_spec() -> f32 {
     style_read(&FINISH_SPEC)
 }
diff --git a/src/config.rs b/src/config.rs
index 155344b..2dd97e1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -315,7 +315,7 @@ 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"
+    "menubar", "statusbar", "node", "relief", "frost", "finish"
 ];
 
 fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
@@ -812,6 +812,31 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
 
 #[cfg(test)]
 mod tests {
+    /// 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.backdrop_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/backdrop_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");
diff --git a/src/scene/material.rs b/src/scene/material.rs
index b3ca1b0..ffd0ce3 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -123,13 +123,18 @@ impl Frost {
         (hi / Self::PACK_MAX, (z - hi * Self::PACK_BASE) / Self::PACK_MAX)
     }
 
-    /// The DE's frost, from the plate-rung keys every frosted surface reads
-    /// today (the window-wide recipe, until step 3 makes it per plate).
+    /// The DE's frost recipe: the pane rung's, when its bound material is
+    /// frosted (`plate material="glass"`), else the default material's
+    /// plate-rung keys (`style.surface.plate.backdrop_compression` /
+    /// `refraction` / `radius`). What `from_fill` and `popover` frost with.
     pub fn from_style() -> Self {
+        if let Some(f @ Frost::Frosted { .. }) = Material::bound(PlateRung::Pane).map(|m| m.frost) {
+            return f;
+        }
         Frost::Frosted {
             compression: crate::color::plate_backdrop_compression(),
             refraction: crate::color::plate_refraction(),
-            radius: Self::DEFAULT_RADIUS,
+            radius: crate::color::plate_frost_radius(),
         }
     }
 
@@ -144,6 +149,81 @@ impl Frost {
     }
 }
 
+/// The three rungs of the plate ladder a material can be bound to in
+/// config (`docs/rfc-material.md` § 5): `plate { root material="…" }`,
+/// `plate material="…"` and `control material="…"`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum PlateRung {
+    Root,
+    Pane,
+    Control,
+}
+
+/// A named material as config spells it — every field optional, resolved
+/// against the rung's legacy material by [`MaterialDef::resolve`]:
+///
+/// ```kdl
+/// style { surface { material {
+///     glass {
+///         color (rgba)"#05050840"
+///         frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
+///         finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+///     }
+/// } } }
+/// ```
+///
+/// A node with no `frost` child is opaque — not "frosted at zero", none. A
+/// missing `color` keeps the rung's tint; a missing `finish` key keeps the
+/// DE's. `light` is the finish strength in the units `style.surface.relief.
+/// depth` uses (0.15 = the default strength of 1).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MaterialDef {
+    pub tint: Option<[f32; 4]>,
+    pub frost: Option<FrostDef>,
+    pub light: Option<f32>,
+    pub spec: Option<f32>,
+    pub shininess: Option<f32>,
+    pub curvature: Option<f32>,
+}
+
+/// The `frost` child of a material node: present means frosted, each knob
+/// defaulting (0, 0, [`Frost::DEFAULT_RADIUS`]).
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct FrostDef {
+    pub compression: Option<f32>,
+    pub refraction: Option<f32>,
+    pub radius: Option<f32>,
+}
+
+impl MaterialDef {
+    /// The material this definition names, over `base` — the rung's legacy
+    /// material, which supplies everything the node leaves unsaid.
+    pub fn resolve(&self, base: Material) -> Material {
+        let frost = match self.frost {
+            Some(f) => Frost::Frosted {
+                compression: f.compression.unwrap_or(0.0).clamp(0.0, 1.0),
+                refraction: f.refraction.unwrap_or(0.0).clamp(0.0, 1.0),
+                radius: f.radius.unwrap_or(Frost::DEFAULT_RADIUS).max(0.0),
+            },
+            None => Frost::Opaque,
+        };
+        let mut finish = base.finish;
+        if let Some(l) = self.light {
+            finish.strength = l / 0.15;
+        }
+        if let Some(v) = self.spec {
+            finish.spec = v.max(0.0);
+        }
+        if let Some(v) = self.shininess {
+            finish.shininess = v.max(1.0);
+        }
+        if let Some(v) = self.curvature {
+            finish.curvature = v.max(0.0);
+        }
+        Material { tint: self.tint.unwrap_or(base.tint), frost, finish }
+    }
+}
+
 /// Which frost regime a plate is under: a root plate's frost is the
 /// compositor's blur-behind (its fill stays positive-alpha whatever its
 /// material says), a nested plate's is the in-app pass (the negative-alpha
@@ -189,34 +269,91 @@ impl Material {
 
     // ---- the rung defaults -------------------------------------------------
 
-    /// The root rung: the window's background, `style.surface.plate.root.
-    /// color` (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
+    /// The root rung: `plate { root material="…" }` when bound, else the
+    /// window's background, `style.surface.plate.root.color`
+    /// (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
     /// `Frost::Opaque` on the client side by construction: a root plate's
     /// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
     /// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
     /// a `Frosted` here anyway.
     pub fn root() -> Self {
-        Self::opaque(crate::color::page_low_color())
+        Self::bound(PlateRung::Root).unwrap_or_else(Self::root_legacy)
     }
 
-    /// The pane rung: the params plate's tint (`style.surface.param.color`)
-    /// at the global plate opacity, frosted when `style.surface.plate.blur`
-    /// says so — exactly what `color::param_plate_fill` resolved before this
-    /// type existed (it now resolves through here).
+    /// The pane rung: `plate material="…"` when bound, else the params
+    /// plate's tint (`style.surface.param.color`) at the global plate
+    /// opacity, frosted when `style.surface.plate.blur` says so — exactly
+    /// what `color::param_plate_fill` resolved before this type existed (it
+    /// now resolves through here).
     pub fn pane() -> Self {
+        Self::bound(PlateRung::Pane).unwrap_or_else(Self::pane_legacy)
+    }
+
+    /// The control rung: `control material="…"` when bound, else the button
+    /// fill, never frosted. Control faces under the relief stances are laid
+    /// through a stroke the sentinel cannot reach (see `PlateStance::Flat`);
+    /// frost at this rung is `Flat` only and takes the pane's material
+    /// verbatim ([`Material::flat_control`]).
+    pub fn control() -> Self {
+        Self::bound(PlateRung::Control).unwrap_or_else(Self::control_legacy)
+    }
+
+    /// The rung's material from the legacy keys alone — what every config
+    /// without a `material=` binding resolves to, and the base a bound
+    /// material's unset fields fall back to.
+    pub fn legacy(rung: PlateRung) -> Self {
+        match rung {
+            PlateRung::Root => Self::root_legacy(),
+            PlateRung::Pane => Self::pane_legacy(),
+            PlateRung::Control => Self::control_legacy(),
+        }
+    }
+
+    fn root_legacy() -> Self {
+        Self::opaque(crate::color::page_low_color())
+    }
+
+    fn pane_legacy() -> Self {
         let mut tint = crate::color::param_bg_color();
         tint[3] *= crate::layout::plate_opacity();
-        Self { tint, frost: Frost::from_flag(crate::color::plate_blur()), finish: Finish::from_style() }
+        let frost = if crate::color::plate_blur() {
+            Frost::Frosted {
+                compression: crate::color::plate_backdrop_compression(),
+                refraction: crate::color::plate_refraction(),
+                radius: crate::color::plate_frost_radius(),
+            }
+        } else {
+            Frost::Opaque
+        };
+        Self { tint, frost, finish: Finish::from_style() }
     }
 
-    /// The control rung: the button fill, never frosted. Control faces under
-    /// the relief stances are laid through a stroke the sentinel cannot reach
-    /// (see `PlateStance::Flat`); frost at this rung is `Flat` only and takes
-    /// the pane's material verbatim ([`Material::flat_control`]).
-    pub fn control() -> Self {
+    fn control_legacy() -> Self {
         Self::opaque(crate::color::button_background_color())
     }
 
+    /// The material `rung` is bound to in config, resolved over the rung's
+    /// legacy material — `None` when the rung is unbound. A binding to a
+    /// name no `material` node defines is reported once and treated as
+    /// unbound, so a typo degrades to today's look rather than to nothing.
+    pub fn bound(rung: PlateRung) -> Option<Self> {
+        let name = crate::color::material_binding(rung)?;
+        match crate::color::named_material(&name) {
+            Some(def) => Some(def.resolve(Self::legacy(rung))),
+            None => {
+                log::warn!("{rung:?} plate rung is bound to material \"{name}\", which no material node defines");
+                None
+            }
+        }
+    }
+
+    /// A named material from config, resolved over the pane rung's legacy
+    /// material — for an app that wants a material by name for its own
+    /// surfaces. `None` when no node defines it.
+    pub fn named(name: &str) -> Option<Self> {
+        crate::color::named_material(name).map(|def| def.resolve(Self::pane_legacy()))
+    }
+
     /// The legacy bridge: a fill as the renderer consumed it before this
     /// type existed. A negative alpha is the frost sentinel — `Frosted` at
     /// the DE recipe, tint alpha `|a|`; otherwise `Opaque` with the colour
@@ -348,6 +485,7 @@ mod tests {
     /// tint at plate opacity, negated under plate blur.
     #[test]
     fn pane_resolves_like_param_plate_fill_did() {
+        let _lock = crate::color::test_color_state_lock();
         let old = |blur: bool| {
             let mut c = crate::color::param_bg_color();
             c[3] *= crate::layout::plate_opacity();
@@ -383,6 +521,7 @@ mod tests {
     /// kernel; the flag form is today's `blur: bool`.
     #[test]
     fn frost_from_style_and_flag() {
+        let _lock = crate::color::test_color_state_lock();
         crate::color::set_plate_backdrop_compression(0.6);
         crate::color::set_plate_refraction(0.3);
         assert_eq!(Frost::from_style(), frosted());
@@ -459,10 +598,122 @@ mod tests {
         assert_eq!(lit("LEGACY_STRIDE"), Frost::DEFAULT_RADIUS * 2.0 / 2.0);
     }
 
+    const DESIGNER_LEGACY: &str = r##"
+        style {
+            surface {
+                param color=(rgba)"#05050840"
+                plate backdrop_compression=(f64)0.6 refraction=(f64)0.3 bevel_width=(f64)12.0 blur=(bool)true {
+                    root corner_radius=(i64)24
+                }
+                relief depth=(f64)0.08
+            }
+        }
+    "##;
+
+    const DESIGNER_NAMED: &str = r##"
+        style {
+            surface {
+                material {
+                    glass {
+                        color (rgba)"#05050840"
+                        frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
+                    }
+                }
+                plate material="glass" bevel_width=(f64)12.0 {
+                    root corner_radius=(i64)24
+                }
+                relief depth=(f64)0.08
+            }
+        }
+    "##;
+
+    /// The designer's frosted pane spelled with the legacy keys and as a
+    /// named material bound to the pane rung resolve to the SAME material —
+    /// the step-4 exit test: same Material, same bytes (steps 2–3).
+    #[test]
+    fn named_material_round_trips_the_legacy_spelling() {
+        let _lock = crate::color::test_color_state_lock();
+        crate::layout::lazy_init_style_registry();
+        let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
+        crate::layout::set_plate_opacity(1.0);
+        crate::color::reload_colors(DESIGNER_LEGACY);
+        assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
+        let legacy = Material::pane();
+        assert!(legacy.frost.is_frosted());
+        assert!((legacy.tint[3] - 0x40 as f32 / 255.0).abs() < 1e-6, "{:?}", legacy.tint);
+
+        crate::color::reload_colors(DESIGNER_NAMED);
+        assert_eq!(crate::color::material_binding(PlateRung::Pane).as_deref(), Some("glass"));
+        let named = Material::pane();
+        assert_eq!(named.tint, legacy.tint);
+        assert_eq!(named.finish, legacy.finish);
+        match (named.frost, legacy.frost) {
+            (Frost::Frosted { compression: c1, refraction: r1, radius: d1 }, Frost::Frosted { compression: c2, refraction: r2, radius: d2 }) => {
+                assert!((c1 - c2).abs() < 1e-6 && (r1 - r2).abs() < 1e-6 && d1 == d2, "{:?} vs {:?}", named.frost, legacy.frost);
+            }
+            other => panic!("{other:?}"),
+        }
+        // The DE recipe follows the bound pane.
+        assert_eq!(Frost::from_style(), named.frost);
+        assert_eq!(Material::named("glass"), Some(named));
+        assert_eq!(Material::named("nope"), None);
+        // The other rungs are unbound and unchanged.
+        assert_eq!(Material::root(), Material::legacy(PlateRung::Root));
+        assert_eq!(Material::control(), Material::legacy(PlateRung::Control));
+        assert_eq!(crate::color::material_names(), vec!["glass".to_string()]);
+        // Bindings and nodes are replaced wholesale by every load, so an
+        // empty document unbinds every rung for the tests that follow.
+        crate::color::reload_colors("");
+        assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
+    }
+
+    /// A binding wins over the legacy keys, a node without `frost` is opaque
+    /// whatever `plate blur` says, unset fields fall back to the rung, and a
+    /// binding to an undefined name degrades to the legacy material.
+    #[test]
+    fn binding_semantics() {
+        let _lock = crate::color::test_color_state_lock();
+        crate::layout::lazy_init_style_registry();
+        let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
+        crate::color::reload_colors(r##"
+            style {
+                surface {
+                    material {
+                        plastic {
+                            finish spec=(f64)0.25 shininess=(f64)12.0
+                        }
+                        matte {
+                            color (rgba)"#20202080"
+                            finish light=(f64)0.3
+                        }
+                    }
+                    plate blur=(bool)true material="plastic"
+                    relief depth=(f64)0.15 spec=(f64)0.5 shininess=(f64)20.0 curvature=(f64)0.1
+                }
+                control material="ghost"
+            }
+        "##);
+        let pane = Material::pane();
+        assert_eq!(pane.frost, Frost::Opaque, "no frost child = opaque, blur flag or not");
+        assert_eq!(pane.tint, Material::legacy(PlateRung::Pane).tint, "no color = the rung's tint");
+        assert_eq!(pane.finish.spec, 0.25);
+        assert_eq!(pane.finish.shininess, 12.0);
+        assert_eq!(pane.finish.curvature, 0.1, "unset finish keys take the DE's (relief curvature)");
+        assert_eq!(Finish::from_style().spec, 0.5, "style.surface.relief.spec is the DE finish");
+        assert_eq!(Material::named("matte").map(|m| m.finish.strength), Some(0.3 / 0.15));
+        assert_eq!(Material::control(), Material::legacy(PlateRung::Control), "unknown name = unbound");
+        assert!(Frost::from_style().is_frosted(), "an opaque bound pane leaves the DE recipe to the keys");
+        crate::color::set_finish_spec(0.4);
+        crate::color::set_finish_shininess(24.0);
+        crate::color::set_finish_curvature(0.2);
+        crate::color::reload_colors("");
+    }
+
     /// A popover is the base colour at menu opacity, frosted — the bytes the
     /// three menu sites used to write by negating an alpha.
     #[test]
     fn popover_is_the_menu_recipe() {
+        let _lock = crate::color::test_color_state_lock();
         let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
         let mut old = [0.1, 0.2, 0.3, 1.0];
         old[3] = -crate::color::menu_opacity();
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 370f470..6fed0b8 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -16,5 +16,5 @@ pub mod relief_shade;
 pub mod tree;
 
 pub use arena::{Arena, Node, NodeId};
-pub use material::{Finish, Frost, Material, PlateRole};
+pub use material::{Finish, Frost, FrostDef, Material, MaterialDef, PlateRole, PlateRung};
 pub use tree::WidgetTree;