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

commit2e1abb3d230d287ae3870c143cfe18dbacd063bb
parent8e63dade29
authorLucas Galante <[email protected]>
date2026-09-20 14:26
The plate prims carry a Material (RFC material, step 2b)

Prim::Plate, Bevel, Sphere, Droplet and DropletScrim carry a Material
instead of an encoded colour, and PaintCtx::plate / plate_shaped / bevel
/ bevel_tinted / sphere / droplet / droplet_scrim take one. The
tessellator reads each prim's vertex colour as material.fill(Nested)
and its push-constant finish as material.finish — the DE finish
(plate_mat) is now only the carves', which shade whatever is beneath
them. plate_spec emits spec.material.for_role(role): a root plate's
frost is the compositor's, so its prim is opaque on this side.

The Droplet stops being the hand-packed exception: DropletSpec::finish()
puts gleam / shine / rim in the specular / shininess / curvature slots
of a Finish, the status bar emits its drops with that finish on the
material, and the runner pushes material.finish like any plate's.

Material::from_fill is the bridge for a call site still holding an
encoded colour (a negative alpha decodes to Frosted at the DE recipe):
from_fill(c).fill(Nested) == c for every c the old API took.
Material::for_role resolves the root regime and agrees with fill().

Prim-identical: tests/plate_golden.rs against the dump taken before
step 2a — identical, 148518 lines.

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

 src/backend/window_runner.rs          | 100 +++++++++++++++++++++-------------
 src/bin/cce-ramp.rs                   |   2 +-
 src/bin/cce-relief.rs                 |   4 +-
 src/layout.rs                         |   3 +-
 src/scene/material.rs                 |  39 +++++++++++++
 src/scene/paint.rs                    |  82 ++++++++++++++++------------
 src/scene/painter.rs                  |   4 +-
 src/widget/container/parameters_bg.rs |   2 +-
 src/widget/container/scroll_box.rs    |   2 +-
 src/widget/core.rs                    |   2 +-
 src/widget/display/info_box.rs        |   2 +-
 src/widget/mod.rs                     |   2 +-
 tests/plate_golden.rs                 |  20 +++----
 13 files changed, 171 insertions(+), 93 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index b1ee2e9..a48b17c 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1794,6 +1794,7 @@ pub fn tessellate_display_list(
     sh: f32,
     scale: f32,
 ) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
+    use crate::scene::material::PlateRole;
     use crate::scene::paint::{Cap, Prim};
     let mut verts: Vec<Vertex> = Vec::new();
     let mut batches: Vec<DlBatch> = Vec::new();
@@ -1833,10 +1834,13 @@ pub fn tessellate_display_list(
     // draw a different material than the renderer applies.
     let plate_light = crate::scene::relief_shade::light_vector();
     // [shading strength (1.0 at the default bevel_depth), specular strength,
-    // shininess, curvature/AO strength] — the plastic material. Curvature is
-    // kept near the raised path's crest amplitude: the recess shoulder's
-    // brightening lands on the same pixels as its specular line, and the two
-    // stack — at 0.5 the step read several times hotter than a plate roll.
+    // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
+    // which shade whatever is beneath them and so take the host's. A prim
+    // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
+    // `material.finish` instead. Curvature is kept near the raised path's
+    // crest amplitude: the recess shoulder's brightening lands on the same
+    // pixels as its specular line, and the two stack — at 0.5 the step read
+    // several times hotter than a plate roll.
     let plate_mat = crate::scene::material::Finish::from_style().to_array();
 
     for item in &dl.items {
@@ -1853,11 +1857,14 @@ pub fn tessellate_display_list(
         // context menu (Plate) worked.
         let blur_behind = matches!(
             &item.prim,
-            crate::scene::paint::Prim::Bevel { color, .. }
-            | crate::scene::paint::Prim::Plate { color, .. }
-            | crate::scene::paint::Prim::Droplet { color, .. }
-            | crate::scene::paint::Prim::Quad { color, .. }
+            crate::scene::paint::Prim::Quad { color, .. }
             | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
+        ) || matches!(
+            &item.prim,
+            crate::scene::paint::Prim::Bevel { material, .. }
+            | crate::scene::paint::Prim::Plate { material, .. }
+            | crate::scene::paint::Prim::Droplet { material, .. }
+                if material.fill(PlateRole::Nested)[3] < 0.0
         ) || matches!(
             &item.prim,
             crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
@@ -1905,15 +1912,17 @@ pub fn tessellate_display_list(
             Prim::Glow { rect, radius, reach, color } => {
                 push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
             }
-            Prim::Bevel { rect, radii, color, depth, tint } if shader_plates => {
+            Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
+                let color = material.fill(PlateRole::Nested);
+                let mat = material.finish.to_array();
                 // SDF-lit raised plate: one cover quad; the shader owns fill,
                 // roll shading, corners, and silhouette AA. Nominal corner
                 // radii (scale_corners false): a Bevel is a WIDGET-scale plate
                 // whose silhouette must match the nominal-radius squircles of
                 // the controls around it — only window-scale `Plate`s get the
                 // curvature-matched span.
-                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
-                let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
+                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+                let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
                 // w = 1 marks an accent-tinted plate (the focused-pane
                 // treatment): the shader then colors the WHOLE rolled edge
                 // with the tint, not just the specular glint — matching the
@@ -1924,7 +1933,9 @@ pub fn tessellate_display_list(
                 plate = Some(p);
                 made_plate = Some(*rect);
             }
-            Prim::Plate { rect, radii, color, depth, shape } if shader_plates => {
+            Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
+                let color = material.fill(PlateRole::Nested);
+                let mat = material.finish.to_array();
                 if *depth < 0.0 {
                     // Negative depth = fill-less roll overlay (MODE_ROLL): the
                     // window-edge roll shading alone, screened over whatever is
@@ -1933,14 +1944,14 @@ pub fn tessellate_display_list(
                     // and the batch is NOT opened as a carve host: an overlay
                     // owns no surface for a CSG feature to cut into.
                     verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
-                    let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, plate_mat, true, *shape);
+                    let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
                     p.mode = 11.0; // MODE_ROLL
                     plate = Some(p);
                 } else {
                     // Same lit-plate branch; the cover quad is the exact rect so the
                     // silhouette and the compositor's rounded window corners agree.
-                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
-                    plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, true, *shape));
+                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+                    plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape));
                     made_plate = Some(*rect);
                 }
             }
@@ -2197,7 +2208,8 @@ pub fn tessellate_display_list(
                 ];
                 plate = Some(p);
             }
-            Prim::Bevel { rect, radii, color, depth, tint: _ } => {
+            Prim::Bevel { rect, radii, material, depth, tint: _ } => {
+                let color = material.fill(PlateRole::Nested);
                 // Full-size fill: the lip is now a shading overlay, not a paint of the
                 // outer ring, so the fill must cover the whole rect (the old inset fill
                 // would leave the ring showing whatever lay beneath).
@@ -2205,10 +2217,11 @@ pub fn tessellate_display_list(
                     top_left: radii.0, top_right: radii.1,
                     bottom_right: radii.2, bottom_left: radii.3,
                 };
-                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, *color, no, None, &mut verts);
-                push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, *color, no, &mut verts);
+                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
+                push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
             }
-            Prim::Plate { rect, radii, color, depth, .. } => {
+            Prim::Plate { rect, radii, material, depth, .. } => {
+                let color = material.fill(PlateRole::Nested);
                 if *depth < 0.0 {
                     // Fill-less roll overlay (negative-depth sentinel): the banded
                     // legacy tessellation has no overlay compositing, so the roll
@@ -2225,12 +2238,12 @@ pub fn tessellate_display_list(
                     bottom_right: radii.2, bottom_left: radii.3,
                 };
                 push_rounded_rect_vertices_corners(
-                    rect.x, rect.y, rect.width, rect.height, corners, sw, sh, *color, no, None, &mut verts,
+                    rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
                 );
                 push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
                 push_bevel_edge_vertices_radii(
                     rect.x, rect.y, rect.width, rect.height, *radii, *depth,
-                    sw, sh, *color, no, 1.0, &mut verts,
+                    sw, sh, color, no, 1.0, &mut verts,
                 );
             }
             Prim::Recess { rect, radii, depth, edges, .. } => {
@@ -2322,36 +2335,41 @@ pub fn tessellate_display_list(
                     verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
                 }
             }
-            Prim::Sphere { cx, cy, radius, color } if shader_plates => {
+            Prim::Sphere { cx, cy, radius, material } if shader_plates => {
+                let color = material.fill(PlateRole::Nested);
+                let mat = material.finish.to_array();
                 // A hemisphere lit per pixel by the plate branch (mode 5): one
                 // cover quad, its own never-merged batch. The quad overhangs
                 // the disc by 1px for the shader's silhouette anti-aliasing.
                 let d = *radius + 1.0;
-                verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color));
+                verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
                 plate = Some(crate::vk::PlatePush {
                     // Center + radius in physical px; the SDF box machinery is
                     // unused in this mode, so .w is free.
                     rect: [cx * scale, cy * scale, radius * scale, 0.0],
                     radii: [0.0; 4],
                     light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
-                    material: plate_mat,
+                    material: mat,
                     host: [0.0; 4],
                     specular_tint: [1.0, 1.0, 1.0, 0.0],
                     mode: 5.0,
                     shape: 2.0,
                 });
             }
-            Prim::Sphere { cx, cy, radius, color } => {
+            Prim::Sphere { cx, cy, radius, material } => {
+                let color = material.fill(PlateRole::Nested);
                 // Legacy path: the flat disc, exactly a Circle.
-                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
+                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
             }
-            Prim::DropletScrim { rect, color, spec, feather } if shader_plates => {
+            Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
+                let color = material.fill(PlateRole::Nested);
+                let mat = material.finish.to_array();
                 // Shader mode 12: the droplet's own SDF, filled flat and
                 // feathered inward. No contact shadow, so unlike the lit drop
                 // the cover quad is exactly the box — a scrim never draws
                 // outside the silhouette.
                 let g = droplet_geom(rect, spec);
-                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
                 plate = Some(crate::vk::PlatePush {
                     rect: [
                         (rect.x + rect.width * 0.5) * scale,
@@ -2363,14 +2381,16 @@ pub fn tessellate_display_list(
                     // p_light.w carries the FEATHER here; mode 12 returns
                     // before the shading band it otherwise holds is read.
                     light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
-                    material: [plate_mat[0], 0.0, 0.0, 0.0],
+                    material: [mat[0], 0.0, 0.0, 0.0],
                     host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
                     specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
                     mode: 12.0,
                     shape: spec.curve.clamp(2.0, 6.0),
                 });
             }
-            Prim::Droplet { rect, color, spec } if shader_plates => {
+            Prim::Droplet { rect, material, spec } if shader_plates => {
+                let color = material.fill(PlateRole::Nested);
+                let mat = material.finish.to_array();
                 // A water droplet lit by shader mode 10: one cover quad; the
                 // shader owns silhouette (sheet ∪smin belly), dome shading,
                 // fresnel rim and thin-edge clarity. The spec's height
@@ -2388,7 +2408,7 @@ pub fn tessellate_display_list(
                     rect.y,
                     rect.width + 2.0 * sh_reach,
                     rect.height + sh_reach,
-                    sw, sh, *color,
+                    sw, sh, color,
                 ));
                 plate = Some(crate::vk::PlatePush {
                     rect: [
@@ -2400,9 +2420,11 @@ pub fn tessellate_display_list(
                     radii: [sag * scale, br * scale, bw * scale, k * scale],
                     light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
                     // Slots y/z/w feed roll_spec and the rim term directly:
-                    // droplets carry their own gleam/shine/rim instead of the
-                    // DE material's (a drop is wetter than the DE's plates).
-                    material: [plate_mat[0], spec.gleam, spec.shine, spec.rim],
+                    // a droplet's material carries its own gleam/shine/rim
+                    // there (`DropletSpec::finish`; a drop is wetter than the
+                    // DE's plates), so this is the material's finish like any
+                    // plate's.
+                    material: mat,
                     host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
                     // Droplet glints are always white, so the tint RGB slots
                     // carry droplet params instead: x = core density,
@@ -2417,7 +2439,8 @@ pub fn tessellate_display_list(
                     shape: spec.curve.clamp(2.0, 6.0),
                 });
             }
-            Prim::DropletScrim { rect, color, spec, .. } => {
+            Prim::DropletScrim { rect, material, spec, .. } => {
+                let color = material.fill(PlateRole::Nested);
                 // Legacy banded path: no SDF to feather against, so the scrim
                 // degrades to the same flat outline the drop itself does —
                 // hard-edged, but present. A prim with no arm here VANISHES.
@@ -2425,9 +2448,10 @@ pub fn tessellate_display_list(
                 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
                 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
                 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
-                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
+                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
             }
-            Prim::Droplet { rect, color, spec } => {
+            Prim::Droplet { rect, material, spec } => {
+                let color = material.fill(PlateRole::Nested);
                 // Legacy banded path: the flat drop outline — attach-tapered
                 // top, round bottom. Degrades the material but keeps the
                 // silhouette (a prim with no arm here VANISHES, it doesn't
@@ -2436,7 +2460,7 @@ pub fn tessellate_display_list(
                 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
                 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
                 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
-                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
+                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
             }
             Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
                 // A quarter-arc carve wall (shader mode 6/7): one cover quad
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index 26bf998..397769a 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -258,7 +258,7 @@ impl Application for RampPopup {
         pc.plate(
             Rect { x: m, y: m, width: w - 2.0 * m, height: h - 2.0 * m },
             (radius, radius, radius, radius),
-            plate,
+            &cce_ui::scene::Material::from_fill(plate),
             bevel,
         );
 
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index cbc22bf..d224e1a 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -911,7 +911,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
                     Shape::EdgeRoll => pc.plate(
                         Rect { x: x1 - 4000.0, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
                         sq,
-                        [plate[0], plate[1], plate[2], 1.0],
+                        &cce_ui::scene::Material::from_fill([plate[0], plate[1], plate[2], 1.0]),
                         unit,
                     ),
                 }
@@ -1563,7 +1563,7 @@ impl Application for BevelPopup {
         pc.plate(
             Rect { x: 0.0, y: 0.0, width: w, height: h },
             (radius, radius, radius, radius),
-            plate,
+            &cce_ui::scene::Material::from_fill(plate),
             bevel,
         );
 
diff --git a/src/layout.rs b/src/layout.rs
index 8488572..8e9ccf4 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4223,7 +4223,8 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
                     None => pc.inset_plate(face, rect.x, rect.y, rect.width, rect.height, r, depth),
                 }
             }
-            Prim::Bevel { rect, radii, color, .. } => {
+            Prim::Bevel { rect, radii, material, .. } => {
+                let color = material.fill(crate::scene::material::PlateRole::Nested);
                 if color[3].abs() > 0.001 {
                     emit_rounded(pc, rect, radii, color);
                 }
diff --git a/src/scene/material.rs b/src/scene/material.rs
index f860e8e..8b493af 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -182,6 +182,29 @@ impl Material {
         Self::opaque(crate::color::button_background_color())
     }
 
+    /// 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
+    /// as is. `from_fill(c).fill(Nested) == c` for every `c` a caller could
+    /// hand the old API. For a call site that holds an encoded colour; a
+    /// site that knows what it means says `Material::opaque` / `with_frost`.
+    pub fn from_fill(encoded: [f32; 4]) -> Self {
+        let a = encoded[3];
+        let m = Self::opaque([encoded[0], encoded[1], encoded[2], a.abs()]);
+        if a < 0.0 { m.with_frost(Frost::from_style()) } else { m }
+    }
+
+    /// This material as a plate in `role` carries it: a root plate's frost
+    /// is the COMPOSITOR's, so under [`PlateRole::Root`] the client-side
+    /// material is opaque — the prim a `PlateSpec` emits carries this, and
+    /// the tessellator encodes every prim as nested.
+    pub fn for_role(&self, role: PlateRole) -> Self {
+        match role {
+            PlateRole::Root => Material { frost: Frost::Opaque, ..*self },
+            PlateRole::Nested => *self,
+        }
+    }
+
     // ---- derived materials -------------------------------------------------
 
     /// The well floor cut into this plate: the same material with the tint
@@ -329,6 +352,22 @@ mod tests {
         assert_eq!(m.flat_control(), m);
     }
 
+    /// The legacy bridge round-trips every fill the old API accepted, and
+    /// the role resolution agrees with the encoding function.
+    #[test]
+    fn from_fill_round_trips_and_for_role_matches_fill() {
+        for c in [[0.1, 0.2, 0.3, 0.8], [0.1, 0.2, 0.3, -0.8], [0.0; 4], [0.5, 0.5, 0.5, 1.0]] {
+            let m = Material::from_fill(c);
+            assert_eq!(m.fill(PlateRole::Nested), c, "{c:?}");
+            assert_eq!(m.frost.is_frosted(), c[3] < 0.0);
+            assert!(m.tint[3] >= 0.0, "tint alpha is never negative");
+            for role in [PlateRole::Root, PlateRole::Nested] {
+                assert_eq!(m.for_role(role).fill(PlateRole::Nested), m.fill(role), "{c:?} {role:?}");
+            }
+        }
+        assert_eq!(Material::from_fill([0.0, 0.0, 0.0, -0.5]).frost, Frost::from_style());
+    }
+
     /// The control-face rule: opaque or nothing.
     #[test]
     fn control_face_is_opaque_or_none() {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 6a28d3a..e6b988f 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -18,7 +18,7 @@
 //! follow-ups.
 
 use crate::scene::layout::Rect;
-use crate::scene::material::{Material, PlateRole};
+use crate::scene::material::{Finish, Material, PlateRole};
 
 /// End-cap style for a [`Prim::Vector`], mirroring the toolkit's line caps.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -414,6 +414,18 @@ impl DropletSpec {
     }
 }
 
+impl DropletSpec {
+    /// The drop's finish: its own gleam, shine and rim in the specular,
+    /// shininess and curvature slots of a [`Finish`] (a drop is wetter than
+    /// the DE's plates), the shading strength the DE's. The material a
+    /// droplet is emitted with carries this — `Material::from_fill(c)
+    /// .with_finish(spec.finish())` — and the tessellator reads it from
+    /// there like any plate's, instead of packing the slots by hand.
+    pub fn finish(&self) -> Finish {
+        Finish { spec: self.gleam, shininess: self.shine, curvature: self.rim, ..Finish::from_style() }
+    }
+}
+
 impl Default for DropletSpec {
     fn default() -> Self {
         // The oval dewdrop: no belly, no sag — one continuous curve from a
@@ -458,7 +470,7 @@ pub enum Prim {
     /// highlight color to mark the plate (the focused-pane treatment) without a
     /// separate border ring. Shader-plates path only; the legacy banded
     /// tessellation ignores it.
-    Bevel { rect: Rect, radii: Radii, color: [f32; 4], depth: f32, tint: [f32; 3] },
+    Bevel { rect: Rect, radii: Radii, material: Material, depth: f32, tint: [f32; 3] },
     /// A recess carved into whatever is already painted underneath — the inverse of
     /// `Bevel`. Emits ONLY the shaded edges, never a fill, so the surface below shows
     /// through the middle: a relief cut into the root plate rather than a plate laid on
@@ -528,7 +540,7 @@ pub enum Prim {
     /// for this one plate — `Some(2.0)` is circular arcs, so a plate whose
     /// radii reach its half-extent is a true circle regardless of the
     /// squircle the rest of the DE wears. `None` follows the DE.
-    Plate { rect: Rect, radii: Radii, color: [f32; 4], depth: f32, shape: Option<f32> },
+    Plate { rect: Rect, radii: Radii, material: Material, depth: f32, shape: Option<f32> },
     Arc { cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4] },
     /// A ring band with radial color interpolation — inner rim → crest
     /// (centerline) → outer rim — for rounded rim bevels (the Ramp's key
@@ -550,7 +562,7 @@ pub enum Prim {
     /// color is the sphere's face color exactly at the lit center, like a
     /// plate's face keeps the app's color. Falls back to a flat circle on the
     /// legacy (`bevel_shader 0`) path.
-    Sphere { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
+    Sphere { cx: f32, cy: f32, radius: f32, material: Material },
     /// A hanging water droplet clinging to the TOP edge of `rect`, lit per pixel
     /// by shader mode 10: the silhouette is a smooth union of a film "sheet"
     /// attached to the top edge (square top corners — the attach line) and a
@@ -563,7 +575,7 @@ pub enum Prim {
     /// plastic). Shape knobs in [`DropletSpec`]. On the legacy (`bevel_shader
     /// 0`) path it degrades to the flat hanging capsule — square top, round
     /// bottom — rather than vanishing.
-    Droplet { rect: Rect, color: [f32; 4], spec: DropletSpec },
+    Droplet { rect: Rect, material: Material, spec: DropletSpec },
     /// The same silhouette as [`Prim::Droplet`] under the same [`DropletSpec`],
     /// filled FLAT and feathered inward: opaque through the interior, fading
     /// to nothing over `feather` px as it approaches the drop's edge. A
@@ -575,7 +587,7 @@ pub enum Prim {
     /// outline with a rounded rect, so the two can never disagree about where
     /// the drop's edge is. On the legacy (`bevel_shader 0`) path it degrades
     /// to the same flat rounded-rect outline `Prim::Droplet` falls back to.
-    DropletScrim { rect: Rect, color: [f32; 4], spec: DropletSpec, feather: f32 },
+    DropletScrim { rect: Rect, material: Material, spec: DropletSpec, feather: f32 },
     /// A concave inside-corner fillet for composed carves: a quarter-arc wall
     /// whose centre `(cx, cy)` sits out in the corner's pocket, shaded with the
     /// same step profile as a `Recess`/`Boss` wall (`raised` flips the sign).
@@ -941,23 +953,24 @@ impl PaintCtx {
     }
 
     /// A sphere-lit circle — see `Prim::Sphere`.
-    pub fn sphere(&mut self, cx: f32, cy: f32, radius: f32, color: [f32; 4]) {
+    pub fn sphere(&mut self, cx: f32, cy: f32, radius: f32, material: &Material) {
         let (ox, oy) = self.offset;
-        self.push(Prim::Sphere { cx: cx + ox, cy: cy + oy, radius, color });
+        self.push(Prim::Sphere { cx: cx + ox, cy: cy + oy, radius, material: *material });
     }
 
     /// A hanging water droplet clinging to `rect`'s top edge — see
     /// [`Prim::Droplet`] and [`DropletSpec`].
     /// See [`Prim::DropletScrim`]. `feather` is how far in from the drop's
     /// edge the fill reaches full opacity, in logical px.
-    pub fn droplet_scrim(&mut self, rect: Rect, color: [f32; 4], spec: DropletSpec, feather: f32) {
+    pub fn droplet_scrim(&mut self, rect: Rect, material: &Material, spec: DropletSpec, feather: f32) {
         let rect = self.apply_offset(rect);
-        self.push(Prim::DropletScrim { rect, color, spec, feather });
+        self.push(Prim::DropletScrim { rect, material: *material, spec, feather });
     }
 
-    pub fn droplet(&mut self, rect: Rect, color: [f32; 4], spec: DropletSpec) {
+    /// The drop's finish is the material's (see [`DropletSpec::finish`]).
+    pub fn droplet(&mut self, rect: Rect, material: &Material, spec: DropletSpec) {
         let rect = self.apply_offset(rect);
-        self.push(Prim::Droplet { rect, color, spec });
+        self.push(Prim::Droplet { rect, material: *material, spec });
     }
 
     /// A concave inside-corner fillet — see `Prim::ConcaveFillet`. `start` is
@@ -991,8 +1004,8 @@ impl PaintCtx {
             Prim::Border { rect, radii, fill, border, thickness } => {
                 self.border(rect, radii, fill, border, thickness)
             }
-            Prim::Bevel { rect, radii, color, depth, tint } => {
-                self.bevel_tinted(rect, radii, color, depth, tint)
+            Prim::Bevel { rect, radii, material, depth, tint } => {
+                self.bevel_tinted(rect, radii, &material, depth, tint)
             }
             Prim::Recess { rect, radii, depth, edges, tint } => match tint {
                 Some(t) => self.recess_tinted(rect, radii, depth, t),
@@ -1007,8 +1020,8 @@ impl PaintCtx {
                 Some(t) => self.trough_tinted(rect, radii, depth, t),
                 None => self.trough_edges(rect, radii, depth, edges),
             },
-            Prim::Plate { rect, radii, color, depth, shape } => {
-                self.plate_shaped(rect, radii, color, depth, shape)
+            Prim::Plate { rect, radii, material, depth, shape } => {
+                self.plate_shaped(rect, radii, &material, depth, shape)
             }
             Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
                 self.arc(cx, cy, radius, thickness, start, end, color)
@@ -1020,11 +1033,11 @@ impl PaintCtx {
                 self.vector(x1, y1, x2, y2, thickness, color, cap)
             }
             Prim::Circle { cx, cy, radius, color } => self.circle(cx, cy, radius, color),
-            Prim::Sphere { cx, cy, radius, color } => self.sphere(cx, cy, radius, color),
+            Prim::Sphere { cx, cy, radius, material } => self.sphere(cx, cy, radius, &material),
             Prim::Glow { rect, radius, reach, color } => self.glow(rect, radius, reach, color),
-            Prim::Droplet { rect, color, spec } => self.droplet(rect, color, spec),
-            Prim::DropletScrim { rect, color, spec, feather } => {
-                self.droplet_scrim(rect, color, spec, feather)
+            Prim::Droplet { rect, material, spec } => self.droplet(rect, &material, spec),
+            Prim::DropletScrim { rect, material, spec, feather } => {
+                self.droplet_scrim(rect, &material, spec, feather)
             }
             Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
                 self.concave_fillet(cx, cy, radius, depth, start, raised)
@@ -1085,14 +1098,14 @@ impl PaintCtx {
         self.push(Prim::Border { rect, radii, fill, border, thickness });
     }
 
-    pub fn bevel(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32) {
-        self.bevel_tinted(rect, radii, color, depth, [1.0, 1.0, 1.0]);
+    pub fn bevel(&mut self, rect: Rect, radii: Radii, material: &Material, depth: f32) {
+        self.bevel_tinted(rect, radii, material, depth, [1.0, 1.0, 1.0]);
     }
 
     /// `bevel` with a specular tint — see `Prim::Bevel::tint`.
-    pub fn bevel_tinted(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32, tint: [f32; 3]) {
+    pub fn bevel_tinted(&mut self, rect: Rect, radii: Radii, material: &Material, depth: f32, tint: [f32; 3]) {
         let rect = self.apply_offset(rect);
-        self.push(Prim::Bevel { rect, radii, color, depth, tint });
+        self.push(Prim::Bevel { rect, radii, material: *material, depth, tint });
     }
 
     /// Carve a recess into the already-painted surface below. Unlike `bevel`, this fills
@@ -1152,9 +1165,10 @@ impl PaintCtx {
             PlateStance::Raised => {
                 // abs(): a negative alpha is the frost sentinel, a real face.
                 if plate.face[3].abs() > 0.001 {
+                    let face = Material::from_fill(plate.face);
                     match plate.tint {
-                        Some(t) => self.bevel_tinted(plate.rect, plate.radii, plate.face, plate.depth, t),
-                        None => self.bevel(plate.rect, plate.radii, plate.face, plate.depth),
+                        Some(t) => self.bevel_tinted(plate.rect, plate.radii, &face, plate.depth, t),
+                        None => self.bevel(plate.rect, plate.radii, &face, plate.depth),
                     }
                 } else {
                     let (plateau, radii) = crate::layout::carve_inside(plate.rect, plate.radii, plate.depth);
@@ -1438,18 +1452,18 @@ impl PaintCtx {
     /// rolled perimeter (width `-depth`) renders as an overlay — translucent
     /// white screen / black multiply — over whatever is beneath, for a root
     /// plate whose face is not a fill (the designer's full-bleed 3D canvas).
-    /// `color` is ignored; the roll profile, crest and specular are exactly the
+    /// `material` is ignored; the roll profile, crest and specular are exactly the
     /// positive-depth plate's.
-    pub fn plate(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32) {
-        self.plate_shaped(rect, radii, color, depth, None);
+    pub fn plate(&mut self, rect: Rect, radii: Radii, material: &Material, depth: f32) {
+        self.plate_shaped(rect, radii, material, depth, None);
     }
 
     /// [`plate`](Self::plate) with an explicit corner exponent — see
     /// [`Prim::Plate`]'s `shape`. `Some(2.0)` on a plate whose radii are its
     /// half-extent draws a circle; `None` is exactly `plate`.
-    pub fn plate_shaped(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32, shape: Option<f32>) {
+    pub fn plate_shaped(&mut self, rect: Rect, radii: Radii, material: &Material, depth: f32, shape: Option<f32>) {
         let rect = self.apply_offset(rect);
-        self.push(Prim::Plate { rect, radii, color, depth, shape });
+        self.push(Prim::Plate { rect, radii, material: *material, depth, shape });
     }
 
     /// Emit the plate a [`PlateSpec`] describes: role-resolved per-corner
@@ -1472,7 +1486,7 @@ impl PaintCtx {
     pub fn plate_spec(&mut self, spec: &PlateSpec) {
         let f = crate::layout::corner_span_factor();
         let (tl, tr, br, bl) = spec.radii();
-        self.plate(spec.rect, (tl / f, tr / f, br / f, bl / f), spec.fill(), spec.depth);
+        self.plate(spec.rect, (tl / f, tr / f, br / f, bl / f), &spec.material.for_role(spec.role()), spec.depth);
     }
 
     pub fn arc(&mut self, cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4]) {
@@ -1863,7 +1877,7 @@ mod tests {
         ctx.quad(r(0.0, 0.0, 1.0, 1.0), [0.0; 4]);
         ctx.rounded_rect(r(0.0, 0.0, 1.0, 1.0), 2.0, (true, false, true, false), [0.0; 4]);
         ctx.border(r(0.0, 0.0, 10.0, 10.0), (2.0, 2.0, 2.0, 2.0), [0.1; 4], [0.9; 4], 1.5);
-        ctx.bevel(r(0.0, 0.0, 10.0, 10.0), (2.0, 2.0, 2.0, 2.0), [0.3; 4], 2.0);
+        ctx.bevel(r(0.0, 0.0, 10.0, 10.0), (2.0, 2.0, 2.0, 2.0), &Material::opaque([0.3; 4]), 2.0);
         ctx.arc(5.0, 5.0, 4.0, 1.0, 0.0, 3.14, [0.0; 4]);
         ctx.vector(0.0, 0.0, 10.0, 0.0, 1.0, [0.0; 4], Cap::Arrow);
         ctx.circle(5.0, 5.0, 3.0, [0.0; 4]);
@@ -1876,7 +1890,7 @@ mod tests {
         let mut ctx = PaintCtx::new();
         ctx.translate(10.0, 20.0, |ctx| {
             ctx.border(r(0.0, 0.0, 5.0, 5.0), (1.0, 1.0, 1.0, 1.0), [0.0; 4], [1.0; 4], 1.0);
-            ctx.bevel(r(0.0, 0.0, 5.0, 5.0), (1.0, 1.0, 1.0, 1.0), [0.0; 4], 1.0);
+            ctx.bevel(r(0.0, 0.0, 5.0, 5.0), (1.0, 1.0, 1.0, 1.0), &Material::opaque([0.0; 4]), 1.0);
         });
         let list = ctx.finish();
         assert!(matches!(list.items[0].prim, Prim::Border { rect, .. } if rect.x == 10.0 && rect.y == 20.0));
diff --git a/src/scene/painter.rs b/src/scene/painter.rs
index 21314f8..a3f216a 100644
--- a/src/scene/painter.rs
+++ b/src/scene/painter.rs
@@ -112,10 +112,10 @@ pub fn append_widget_plate_radii(w: &dyn WidgetHost, pc: &mut PaintCtx, tint: Op
     let rect = Rect { x, y, width: ww, height: h };
     let tint = tint.unwrap_or([1.0, 1.0, 1.0]);
     if let Some(thickness) = w.plate_bevel() {
-        pc.bevel_tinted(rect, radii_tuple, w.color(), thickness, tint);
+        pc.bevel_tinted(rect, radii_tuple, &crate::scene::material::Material::from_fill(w.color()), thickness, tint);
     } else if let Some((border_color, thickness)) = w.solid_border() {
         if crate::layout::control_relief() {
-            pc.bevel_tinted(rect, radii_tuple, w.color(), crate::colors::plate_bevel_width(), tint);
+            pc.bevel_tinted(rect, radii_tuple, &crate::scene::material::Material::from_fill(w.color()), crate::colors::plate_bevel_width(), tint);
         } else {
             pc.border(rect, radii_tuple, w.color(), border_color, thickness);
         }
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index d92cdcf..ac9909a 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1506,7 +1506,7 @@ impl Paint for ParametersBg {
             ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
         }
         for (scx, scy, sr, sc) in self.spheres() {
-            ctx.sphere(scx, scy, sr, sc);
+            ctx.sphere(scx, scy, sr, &crate::scene::material::Material::from_fill(sc));
         }
         self.paint_scene_rows(ctx);
         for (qx, qy, qw, qh, qc) in self.plain_quads() {
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index f9175ab..124df75 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -49,7 +49,7 @@ pub fn paint_relief_scrollbar(
     pc.bevel(
         Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
         radii,
-        crate::color::scrollbar_thumb_color(),
+        &crate::scene::material::Material::from_fill(crate::color::scrollbar_thumb_color()),
         depth,
     );
 }
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 8128dcf..297d855 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -522,7 +522,7 @@ pub mod context_menu {
                 // menu_opacity, not the color's own alpha: an opaque page
                 // color resolved the frost to a solid tint (invisible).
                 frosted[3] = -crate::color::menu_opacity();
-                ctx.plate(rect, (r, r, r, r), frosted, depth);
+                ctx.plate(rect, (r, r, r, r), &crate::scene::material::Material::from_fill(frosted), depth);
             } else {
                 let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
                 ctx.boss(plateau, radii, depth);
diff --git a/src/widget/display/info_box.rs b/src/widget/display/info_box.rs
index 601e223..6b2ccad 100644
--- a/src/widget/display/info_box.rs
+++ b/src/widget/display/info_box.rs
@@ -42,7 +42,7 @@ impl Paint for InfoBox {
             // control wall, capped by its height like every plate's.
             let fill = colors::plate_color().unwrap_or_else(|| colors::active_theme().surface_bg);
             let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-            ctx.plate(rect, radii, fill, depth);
+            ctx.plate(rect, radii, &crate::scene::material::Material::from_fill(fill), depth);
         } else {
             // Flat: the themed surface in a hairline frame.
             let theme = colors::active_theme();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 03b9166..88499e2 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -409,7 +409,7 @@ pub trait WidgetHost {
             let cr = self.corner_radii();
             let radii = (cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left);
             if let Some(depth) = self.plate_bevel() {
-                ctx.bevel(rect, radii, color, depth);
+                ctx.bevel(rect, radii, &crate::scene::material::Material::from_fill(color), depth);
             } else if let Some((border_color, thickness)) = self.solid_border() {
                 ctx.border(rect, radii, color, border_color, thickness);
             } else if color[3].abs() > 0.001 {
diff --git a/tests/plate_golden.rs b/tests/plate_golden.rs
index 51aea06..13c259e 100644
--- a/tests/plate_golden.rs
+++ b/tests/plate_golden.rs
@@ -82,17 +82,17 @@ fn scene(sw: f32, sh: f32) -> cce_ui::scene::paint::DisplayList {
         window_corners: (true, true, true, true),
         depth: -depth,
     });
-    pc.plate_shaped(r(30.0, 170.0, 40.0, 40.0), (20.0, 20.0, 20.0, 20.0), [0.3, 0.3, 0.35, 1.0], 4.0, Some(2.0));
+    pc.plate_shaped(r(30.0, 170.0, 40.0, 40.0), (20.0, 20.0, 20.0, 20.0), &cce_ui::scene::Material::from_fill([0.3, 0.3, 0.35, 1.0]), 4.0, Some(2.0));
 
     // Bare plates the legacy callers emit: an opaque face, a frosted one
     // (negative alpha handed straight in, the menu idiom).
-    pc.plate(r(80.0, 170.0, 60.0, 30.0), rr, [0.13, 0.14, 0.16, 1.0], 4.0);
-    pc.plate(r(150.0, 170.0, 60.0, 30.0), rr, [0.13, 0.14, 0.16, -0.8], 4.0);
+    pc.plate(r(80.0, 170.0, 60.0, 30.0), rr, &cce_ui::scene::Material::from_fill([0.13, 0.14, 0.16, 1.0]), 4.0);
+    pc.plate(r(150.0, 170.0, 60.0, 30.0), rr, &cce_ui::scene::Material::from_fill([0.13, 0.14, 0.16, -0.8]), 4.0);
 
     // Bevels, plain and tinted (the focused-pane ring).
-    pc.bevel(r(220.0, 170.0, 60.0, 30.0), rr, [0.25, 0.25, 0.3, 1.0], 4.0);
-    pc.bevel_tinted(r(290.0, 170.0, 60.0, 30.0), rr, [0.25, 0.25, 0.3, 1.0], 4.0, [0.4, 0.6, 1.0]);
-    pc.bevel_tinted(r(290.0, 205.0, 60.0, 30.0), rr, [0.0; 4], 4.0, [0.4, 0.6, 1.0]);
+    pc.bevel(r(220.0, 170.0, 60.0, 30.0), rr, &cce_ui::scene::Material::from_fill([0.25, 0.25, 0.3, 1.0]), 4.0);
+    pc.bevel_tinted(r(290.0, 170.0, 60.0, 30.0), rr, &cce_ui::scene::Material::from_fill([0.25, 0.25, 0.3, 1.0]), 4.0, [0.4, 0.6, 1.0]);
+    pc.bevel_tinted(r(290.0, 205.0, 60.0, 30.0), rr, &cce_ui::scene::Material::from_fill([0.0; 4]), 4.0, [0.4, 0.6, 1.0]);
 
     // Every control stance × face × tint.
     let faces: [[f32; 4]; 3] = [[0.3, 0.3, 0.36, 1.0], [0.0; 4], [0.3, 0.3, 0.36, -0.6]];
@@ -120,11 +120,11 @@ fn scene(sw: f32, sh: f32) -> cce_ui::scene::paint::DisplayList {
 
     // The lit ball and the water: default spec, and one with its own finish
     // and depth terms.
-    pc.sphere(40.0, 340.0, 10.0, [0.4, 0.4, 0.5, 1.0]);
-    pc.droplet(r(80.0, 330.0, 120.0, 30.0), [0.1, 0.1, 0.12, -0.7], DropletSpec::default());
+    pc.sphere(40.0, 340.0, 10.0, &cce_ui::scene::Material::from_fill([0.4, 0.4, 0.5, 1.0]));
+    pc.droplet(r(80.0, 330.0, 120.0, 30.0), &cce_ui::scene::Material::from_fill([0.1, 0.1, 0.12, -0.7]).with_finish(DropletSpec::default().finish()), DropletSpec::default());
     let wet = DropletSpec::parse("gleam=1.0 shine=16 rim=0.3 clarity=0.5 core=0.4 shadow=0.3 refr=2");
-    pc.droplet(r(220.0, 330.0, 120.0, 30.0), [0.1, 0.1, 0.12, 0.9], wet);
-    pc.droplet_scrim(r(220.0, 365.0, 120.0, 30.0), [0.1, 0.1, 0.12, 0.9], wet, 3.0);
+    pc.droplet(r(220.0, 330.0, 120.0, 30.0), &cce_ui::scene::Material::from_fill([0.1, 0.1, 0.12, 0.9]).with_finish(wet.finish()), wet);
+    pc.droplet_scrim(r(220.0, 365.0, 120.0, 30.0), &cce_ui::scene::Material::from_fill([0.1, 0.1, 0.12, 0.9]), wet, 3.0);
 
     pc.finish()
 }