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

docs/rfc-material.md (33.7K)

  1 # RFC: Material — what a plate is made of
  2 
  3 **Status:** Draft / proposal (2026-09-20)
  4 **Scope:** The substance of a surface in `cce-ui` — tint, frost and finish — as one value
  5 that a plate carries, at every rung (root, pane, control) and in config.
  6 **Appetite:** Additive first, breaking later. Every step leaves the tree building and the
  7 pixels identical until the step that is *meant* to change them. Every crate must continue
  8 to build standalone.
  9 **Reads before this:** `CLAUDE.md` § "Plates, wells and seams" (the vocabulary this RFC
 10 gives a type to), `docs/rfc-core-rebuild.md` § 7 (the plate rungs, RFC 7a–7c).
 11 
 12 ---
 13 
 14 ## 1. Why
 15 
 16 The toolkit already talks about materials. `PlateStance::Flat` is documented as "a control
 17 made of its pane's material". `ControlPlate::face_from_fill` forces a face opaque because a
 18 translucent one "would read as a second material". A canvas well's floor is "cut from the
 19 same material" as its plate. The `Droplet` carries its own gleam, shine and rim because "a
 20 drop is wetter than the DE's plates". None of these has a type to point at. What a plate is
 21 made of is spread over five mechanisms with four different scopes:
 22 
 23 | Property | Where it lives today | Scope |
 24 |---|---|---|
 25 | Tint + opacity | `PlateSpec.color`, `color::param_plate_fill`, `plate_color`, the menubar/statusbar colours | per prim (vertex colour) |
 26 | Frost on/off | `PlateSpec.blur`, `color::plate_blur`, `Menu::with_blur`, `layout::graph_blur` | per prim (negative-alpha sentinel) |
 27 | Frost recipe: `backdrop_compression`, `refraction` | `WindowInfo.backdrop_meta` uniform | per **window** |
 28 | Blur kernel | `resolve_blur`: 7×7 taps at 5.5 px stride, sigma ≈ 11 px | global, literal |
 29 | Finish: shading strength, specular, shininess, curvature | `relief_shade::Material` → `PlatePush.material` (`p_mat`) | per prim, but only `strength` is configurable (`bevel_depth`); 0.4 / 24 / 0.2 are literals |
 30 | Edge: roll width | `Prim::Plate.depth` | per prim |
 31 | Edge: roll height, carve drop, profiles | `relief_meta`, `profile`, `roll_profile` uniforms | per window |
 32 | Corner exponent | `corner_shape` uniform; `Prim::Plate.shape` override | per window / per prim |
 33 
 34 Three consequences, each of which has already cost something:
 35 
 36 - **The DE has exactly one substance.** Every frosted plate in a window shares one glass
 37   recipe. cce-designer's Alt+D dialog wanted `#05050840` at compression 0.6, and got it —
 38   for every frosted surface in the process, because the knob is a window uniform. A menu
 39   and a dialog cannot differ; a control cannot be plastic on a glass pane.
 40 - **Three of the four finish numbers cannot be set.** `cce-relief` edits the edge geometry
 41   and the light strength and calls itself a material editor; it cannot touch specular or
 42   shininess because nothing can. The Droplet needed different values and got them by
 43   overwriting the push-constant slots per prim (`window_runner.rs`, the `material:
 44   [plate_mat[0], spec.gleam, spec.shine, spec.rim]` line) — the second material, done by
 45   hand, once.
 46 - **The sentinel is decided in three places.** `PlateSpec::fill` negates alpha for a nested
 47   frosted plate; `color::param_plate_fill` does the same for the params plate; `Graph`
 48   does it again with `graph_blur`. A fourth surface that wants frost copies the trick, and
 49   the root-versus-nested regime (the compositor's blur, not ours, on a root plate) is only
 50   encoded in one of the three.
 51 
 52 ### What is already good (keep it)
 53 
 54 - **The rung ladder.** Root, pane and control plates are one object at three scales
 55   (`PlateSpec` for root and pane, `ControlPlate` for control), and `PaintCtx::control_plate`
 56   is the one place a control face's relief is composed. A material slots into that ladder;
 57   it does not replace it.
 58 - **The lighting model is shared and predictable.** `relief_shade` is the Rust twin of the
 59   shader's arithmetic, checked against the shader source by test. The finish stays there.
 60 - **The per-prim push block is already the carrier for per-plate lighting.** `p_mat` is per
 61   batch. The frost recipe is the odd one out, not the rule.
 62 - **Frost has a measured theory now.** Compression is the legibility axis, refraction the
 63   objecthood axis, and the two are orthogonal (the 24-cell sweep in `CLAUDE.md`). A
 64   material is the right place to *hold* that pair; nothing about the pair changes.
 65 
 66 ---
 67 
 68 ## 2. Goals / non-goals
 69 
 70 **Goals**
 71 
 72 1. One type, `Material`, that says what a surface is made of: tint, frost, finish.
 73 2. Every plate rung carries one by value. The sentinel encoding happens in exactly one
 74    function, given the plate's role.
 75 3. Per-plate frost: compression and refraction travel with the plate, not the window.
 76 4. The finish is fully configurable, and the Droplet stops being a special case.
 77 5. Named materials in config, bound per rung, with every existing key surviving as an
 78    alias — **no configured appearance changes until a config opts in.**
 79 6. Derived materials are functions of a material, not colour constants: a well floor is
 80    its plate's material darkened; a `Flat` control is its pane's material verbatim.
 81 
 82 **Non-goals**
 83 
 84 - **The light is not material.** Azimuth and elevation are the scene's (`light_vector`,
 85   `window_manager.light_source_position`) and stay per window.
 86 - **Roll width is not material.** It is geometry — already per prim as `depth`, already
 87   capped per control by the plate's own height. It stays where it is.
 88 - **Profiles are not material, yet.** The carve and roll profiles are 8-vec4 uniform LUTs;
 89   making them per prim is a renderer redesign this RFC does not attempt. See § 9.
 90 - **No new shader modes, no new blur kernel** in the first three steps. The kernel becomes
 91   a material knob in step 4, not before.
 92 - **The compositor's blur is not re-implemented.** A root plate's frost is the compositor's
 93   and remains so; § 5 says what the compositor is *given*, not what it must draw.
 94 
 95 ---
 96 
 97 ## 3. The type
 98 
 99 ```rust
100 // src/scene/material.rs
101 
102 /// What a surface is made of: its colour, whether and how it frosts what is
103 /// behind it, and how it answers the DE's light. Carried BY VALUE on the plate
104 /// made of it; ~14 floats, copied freely.
105 #[derive(Clone, Copy, Debug, PartialEq)]
106 pub struct Material {
107     /// Linear RGBA. Alpha is opacity and is ALWAYS non-negative here — the
108     /// blur-behind sentinel is an encoding detail of `fill`, never state.
109     pub tint: [f32; 4],
110     pub frost: Frost,
111     pub finish: Finish,
112 }
113 
114 #[derive(Clone, Copy, Debug, PartialEq)]
115 pub enum Frost {
116     /// The tint alone, composited at its alpha. Not a sample of the backdrop.
117     Opaque,
118     /// Frosted glass: the backdrop blurred, luminance-compressed toward the
119     /// tint's key, tinted at the tint's alpha; the rim refracts.
120     Frosted {
121         /// `style.surface.plate.backdrop_compression` today. 0..1.
122         compression: f32,
123         /// `style.surface.plate.refraction` today. 0..1.
124         refraction: f32,
125         /// Blur radius in logical px (the kernel's sigma). Default
126         /// `Frost::DEFAULT_RADIUS` = today's literal (≈ 11 px at scale 1).
127         /// 0 is a CLEAR plate: one clean sample, tinted — expressible for the
128         /// first time. Decided in § 11 (2).
129         radius: f32,
130     },
131 }
132 
133 /// Today's `relief_shade::Material`, renamed: how the surface answers light.
134 /// `[strength, spec, shininess, curvature]` as `PlatePush.material`, plus the
135 /// two depth ratios the shader reads from `WindowInfo`.
136 pub struct Finish { strength, spec, shininess, curvature, carve_depth, roll_height }
137 ```
138 
139 **Why `Frost` is an enum and not two floats with a bool.** An opaque plate has no
140 compression and no refraction — not zero of each, none. Making the recipe unreachable when
141 the plate is not frosted is what keeps `Material::fill` from having to ask two questions.
142 
143 **Why the finish is inside the material and not beside it.** Because the Droplet already
144 proved a surface's gleam belongs to the surface: water and plastic under the same light are
145 different *materials*, not different lights.
146 
147 ### 3.1 The one encoding function
148 
149 ```rust
150 impl Material {
151     /// The vertex colour the renderer consumes, for a plate in `role`:
152     /// - `Root`  → alpha positive whatever `frost` says. A root plate's frost
153     ///             is the compositor's blur-behind, never the in-app pass.
154     /// - nested + `Frosted` → the in-app frost pass's negative-alpha sentinel.
155     /// - nested + `Opaque`  → alpha as is.
156     pub fn fill(&self, role: PlateRole) -> [f32; 4];
157 }
158 ```
159 
160 This absorbs `PlateSpec::fill` and `color::param_plate_fill`'s negation, and it is the only
161 place a negative alpha is ever written. `Graph`'s `graph_blur` becomes a `Frosted` material
162 on the graph's plate. `PlateRole` is `PlateSpec::is_root()` given a name: `Root` when all
163 four corners are window corners, `Nested` otherwise; `ControlPlate` is always nested.
164 
165 ### 3.2 Derived materials
166 
167 ```rust
168 impl Material {
169     /// The well floor cut into this plate: the same material, darkened by
170     /// `WELL_FLOOR`'s 18% (10% lifted). Frost and finish carried through, so
171     /// a well in a glass pane is a deeper piece of the same glass — decided,
172     /// § 11 (3). Under `Frost::Opaque` this is today's overlay exactly.
173     pub fn floor(&self, lifted: bool) -> Material;
174     /// A `Flat`-stance control on this pane: the material verbatim.
175     /// Exists so the call site says what it means.
176     pub fn flat_control(&self) -> Material { *self }
177     /// A control face from a configured fill under today's opacity rule
178     /// (`ControlPlate::face_from_fill`): opaque, or `None` for edges-only.
179     pub fn control_face(raw: [f32; 4], finish: Finish) -> Option<Material>;
180 }
181 ```
182 
183 `colors::WELL_FLOOR` and `WELL_FLOOR_LIFTED` become the two constants `floor` uses and stop
184 being drawn directly. `PaintCtx::well_floor` / `canvas_well` take the host plate's material.
185 
186 ### 3.3 The defaults
187 
188 ```rust
189 impl Material {
190     /// The rung defaults, resolved from the style registry — the SAME
191     /// getters the rungs read today, so step 1 changes no pixel:
192     pub fn root()    -> Self   // root_plate colour + opacity, root blur flag, Finish::from_style
193     pub fn pane()    -> Self   // param_bg_color × plate_opacity, plate_blur, compression, refraction
194     pub fn control() -> Self   // control fill getters, never frosted (§ 6.2)
195 }
196 ```
197 
198 `Finish::from_style` is `relief_shade::Material::from_style` unchanged — `strength` from
199 `bevel_depth`, the other three from new keys (§ 5) with today's literals as defaults.
200 
201 ---
202 
203 ## 4. Where it is carried
204 
205 | Today | After |
206 |---|---|
207 | `PlateSpec { rect, color, blur, window_corners, depth }` | `PlateSpec { rect, material, window_corners, depth }` |
208 | `ControlPlate { rect, radii, stance, face: [f32;4], depth, tint }` | `ControlPlate { …, face: Option<Material>, … }` — `None` is today's transparent face (edges only) |
209 | `Prim::Plate { rect, radii, color, depth, shape }` | `Prim::Plate { rect, radii, material, depth, shape }` |
210 | `Prim::Bevel { rect, radii, color, depth, tint }` | `Prim::Bevel { rect, radii, material, depth, tint }` |
211 | `PaintCtx::plate(rect, radii, color, depth)` | `PaintCtx::plate(rect, radii, &Material, depth)` |
212 | `PaintCtx::inset_plate(rect, radii, color, depth)` | takes `&Material` |
213 | `PaintCtx::well_floor(rect, radius, lifted)` | `well_floor(rect, radius, &host_material, lifted)` |
214 | `PaintCtx::plate_spec(&spec)` | unchanged signature; calls `spec.material.fill(spec.role())` |
215 
216 Carves (`Recess`, `Boss`, `Ridge`, `Trough`, `Groove`, `Lattice`, `CarveUnion`,
217 `ConcaveFillet`) emit shading only and have no material of their own; they shade *whatever
218 is beneath*. They take the host's `Finish` for their lighting response, which is what
219 `plate_mat` already gives them — unchanged in kind, now sourced from the host plate.
220 
221 `Sphere` and `Droplet` carry a material like a plate. The Droplet's `gleam` / `shine` /
222 `rim` fields move into its `Finish` (spec / shininess / curvature); `DropletSpec` keeps its
223 shape fields and loses its finish fields, and the hand-written override in
224 `window_runner.rs` becomes the general path.
225 
226 **Client call sites** (from a grep on 2026-09-20): `PlateSpec` is built at one site each in
227 cce-authenticator, cce-cloud, cce-designer (`render.rs`), cce-files, cce-data-editor,
228 cce-lock, cce-list, cce-system-interface, cce-terminal, and the demo (`src/main.rs`) — ten
229 sites, all of the form `color: …, blur: …`. Each becomes `material: Material::root()` or
230 `Material::pane()` unless it had its own colour, in which case `Material::pane().with_tint(c)`.
231 
232 ---
233 
234 ## 5. Config
235 
236 ### 5.1 Named materials, bound per rung
237 
238 ```kdl
239 style {
240     surface {
241         material {
242             glass {
243                 color (rgba)"#05050840"
244                 frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
245                 finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
246             }
247             plastic {
248                 color (rgba)"#26263380"
249                 finish light=(f64)0.15 spec=(f64)0.25 shininess=(f64)12.0
250             }
251         }
252         plate material="glass" {        // the pane rung
253             root material="glass"
254         }
255     }
256     control material="plastic"
257 }
258 ```
259 
260 (As built: the materials are CHILDREN of one `material` node, named by node name, not
261 `material "glass"` with a string argument — the config converter keys objects by node
262 name and a node's argument would be lost; and `control` is `style.control`, the node the
263 control rung's other keys already live under.)
264 
265 A `material` node with no `frost` child is `Opaque`. Missing `finish` keys take the rung
266 default; a missing `color` keeps the rung's tint. A rung with no `material=` binding reads
267 its material from the legacy keys below — which is how every existing config keeps its
268 look. `Material::named(name)` hands an app any defined material for its own surfaces.
269 
270 ### 5.2 Aliases: every existing key survives
271 
272 | Existing key | Resolves into |
273 |---|---|
274 | `style.surface.plate.color` / `.blur` / `.backdrop_compression` / `.refraction` / `.radius` | the pane rung's default material (`radius`, new: the default frost's blur sigma) |
275 | `style.surface.param.color`, `style.surface.plate.opacity` | the pane rung's tint |
276 | `style.surface.plate.root.color` / `.blur` | the root rung's default material |
277 | `style.surface.relief.depth` / `.light` | every rung's `finish.strength` (and the free carves') |
278 | `style.surface.relief.spec` / `.shininess` / `.curvature` | the DE finish's other three terms (new; were literals) — every rung's, and the carves' |
279 | `style.surface.relief.width` / `.height` / `.edge_height` | unchanged: geometry, not material |
280 | `style.surface.control.fill` (and the per-widget fills) | the control rung's tint |
281 
282 The registry key names (`bevel_depth` etc.) are untouched; the alias table in
283 `layout.rs` (the `"style.surface.relief.depth" => "bevel_depth"` block) is where the new
284 paths join. **Precedence:** a `material=` binding wins over the legacy keys; a legacy key
285 never overrides a bound material. Unbound is the default, so a config written today parses
286 into exactly the material it draws today.
287 
288 ### 5.3 The compositor is given the root material, not the frost pass
289 
290 A root plate's frost is scenefx blur-behind, driven from the compositor's own reading of
291 `plate.root` (`cce-compositor/src/server/config.rs`: `window_blur`,
292 `window_backdrop_blur_ignore_transparent`, …). The compositor does not link the toolkit's
293 paint path, so nothing here changes what it draws. What changes is that the root rung's
294 material is now *nameable*: the compositor can read `plate.root.material` and the named
295 node under it, and its tint/opacity/blur derive from the same recipe the client's nested
296 plates use.
297 
298 This matters for RFC 7c. A detached pane flips regimes (nested sentinel → compositor blur)
299 and today that flip changes the look, because the two blurs share nothing. With a material
300 the compositor has a recipe to approximate — compression in particular is a per-pixel
301 luminance remap scenefx could carry. **Out of scope here**; recorded so the config shape is
302 not chosen in a way that forecloses it.
303 
304 ---
305 
306 ## 6. Renderer
307 
308 ### 6.1 The push-constant budget is the constraint
309 
310 `PlatePush` is exactly **128 bytes**, the Vulkan-guaranteed minimum, and `renderer.rs`
311 asserts it at compile time (`PUSH_CONSTANT_BYTES <= 128`). It cannot grow without a
312 device-limit query and a fallback path. So per-plate frost has to fit in slots that are
313 free *in the plate mode*:
314 
315 - `p_host` in `MODE_PLATE` (mode 1) uses only `x`/`y` (feature offset and count). **`z` and
316   `w` are free.** Mode 2's use of `p_host` as the host-box fade, mode 13's as the lattice
317   period and mode 14's as the union run are other modes and unaffected.
318 - `rect1` is spoken for: `.x`/`.y` the clip radius and flag, `.z` the mode, `.w` the shape.
319 - `p_mat` is full and stays `[strength, spec, shininess, curvature]` — the `Finish`.
320 - `p_spec_tint.xyz` is the accent RGB, `.w` the tinted-plate flag: untouched.
321 
322 Two free floats, three frost scalars. **The layout (decided, § 11 (2)):**
323 
324 | Slot | Carries | Encoding |
325 |---|---|---|
326 | `p_host.z` | `compression` and `refraction` | 12-bit fixed point each: `round(c·4095)·4096 + round(r·4095)`, an integer < 2²⁴ — the largest range f32 holds exactly. Shader: `hi = floor(v / 4096)`, `lo = v − hi·4096`, both `/ 4095`. |
327 | `p_host.w` | `radius` | the kernel sigma in physical px (logical × scale); `0` = one clean sample, no kernel. |
328 
329 A `Frost::pack(scale) -> [f32; 2]` / `unpack` pair lives beside the type with a round-trip
330 test over the grid, and the `relief_shade`-style shader-text test checks the literals
331 (`FROST_PACK_BASE`, `FROST_PACK_MAX`, `LEGACY_STRIDE`) against `shader2d.wgsl`. 12 bits is
332 0.00024 resolution: the designer's 0.6 / 0.3 survive to better than a 1/255 step (10 bits
333 was the first draft; 12 costs nothing since the sum still fits an exact f32 integer).
334 
335 `resolve_blur` takes `k` and the kernel stride as arguments (it already took `refract` and
336 `clarity` from the plate branch); the plate branch unpacks `p_host.zw`. The window
337 uniform's `backdrop_meta` is retired in the same step. The negative-alpha branch for a
338 batch with NO plate block survives as the no-recipe fallback — a raw vertex pushed from
339 outside the display list (a legacy host's own quads, or the `bevel_shader 0` path): the
340 kernel default (`LEGACY_STRIDE`) and no compression. Everything the display list frosts is
341 a plate batch (§ 6.2) and never reaches it.
342 
343 **The Droplet is the exception.** Mode 10 uses every slot: `p_host` is the sheet radius,
344 clarity, dome and attach radius, `p_spec_tint` the core, shadow reach, shadow strength and
345 bow, `p_mat` the finish. There is no room for a frost recipe, so a Droplet's
346 `Frost::Frosted` is honoured as on/off at the kernel default — exactly what it draws today
347 (`resolve_blur(frag, vcol, vec2f(0.0), 0.0)`, no compression, its refraction being the
348 compositor's `refr`). `Material::fill` does not care; the runner documents the clamp.
349 Freeing a slot by packing `core` (0..2) with `shadow` (0..1) is possible and deferred (§ 9).
350 
351 ### 6.2 The plain frosted quad has no push block
352 
353 The non-plate blur-behind branch at the bottom of `fs_main` (a negative-alpha vertex colour
354 with `mode == MODE_NONE`) carries no push constants. It is what a `Flat`-stance control,
355 the `Menu`, the `Graph` and the status bar's frosted fill use today. Three ways to give it a
356 per-plate recipe; **decided: the first** (§ 11 (1)):
357 
358 1. **Promote it to a plate.** A `Flat` face is `Prim::Plate` with `depth = 0` — the shader's
359    plate path with `t = 0.001` shades nothing at the rim and the fill is a rounded rect,
360    which is exactly what `Flat` draws now. Cost: one batch per flat frosted quad instead of
361    sharing the vertex stream, which the Menu and status bar already pay for their rolls.
362    Benefit: one frost path in the shader, and § 6.3 comes for free.
363 2. Keep the quad path on the window uniform. Cheap, but then a `Flat` control cannot be a
364    different glass from its window, which contradicts § 2 goal 3 for the one stance whose
365    whole point is "the pane's material at control scale".
366 3. A second vertex attribute. Touches the vertex layout every client's `custom_vertices`
367    emits; not worth it for two floats.
368 
369 Options 2 and 3 are recorded as considered, not as fallbacks: if step 3's batch-count
370 measurement shows a real cost on some client, that client's widget drops frost on its flat
371 faces rather than reintroducing the uniform path.
372 
373 Control faces under `Raised` / `Flush` are laid through a stroke the sentinel cannot reach
374 (`CLAUDE.md`, the `Flat` entry); they stay `Opaque` and `Material::control()` asserts it.
375 Frost at the control rung is `Flat` only. This is today's rule stated as a type.
376 
377 ### 6.3 Blur radius as a material knob (step 3)
378 
379 `resolve_blur`'s 49 taps are the most expensive path in the shader, and every frosted plate
380 pays them. `Frost::Frosted.radius` (§ 3) is the kernel's sigma in logical px, carried in
381 `p_host.w` in physical px; the 7×7 kernel's tap stride is half of it. It joins in step 3
382 with the other two scalars so `Frost` changes shape once (§ 11 (2)). A small control plate
383 blurring at a third of the body's radius reads the same and samples a tighter footprint;
384 `radius = 0` short-circuits to one clean sample — a *clear* plate, the tint over an
385 unblurred backdrop, which nothing could express before. Config exposure (`frost radius=`)
386 waits for step 4.
387 
388 **`Frost::DEFAULT_RADIUS` is 5.5 logical px, and why it is not 11.** The old kernel was a
389 fixed 5.5 PHYSICAL px stride (sigma 11 physical) — half the blur on a scale-2 panel that
390 it was on a scale-1 one, and the panel every frosted surface was tuned on is scale 2. A
391 material cannot know the scale, so the default is stated in logical px at the value that
392 reproduces that panel exactly: 5.5 logical = 11 physical at scale 2. A scale-1 display now
393 gets the same logical blur instead of twice it; only headless scale-1 shadows notice.
394 
395 ---
396 
397 ## 7. Migration plan (staged; every stage leaves the tree building)
398 
399 Each step's exit test is named. Steps 1–2 must be **prim-identical** against a display-list
400 dump of every client's default view — the technique the `ControlPlate` migration used.
401 
402 **Step 1 — the type, the rename, the encoding.** *DONE 2026-09-20.* As specified, plus
403 one thing found on the way: five plate getters (`plate_blur` among them) read their
404 `RwLock` directly and so could not see a test's per-thread write; they go through
405 `style_read` now. `Finish` lives in `material.rs`; `relief_shade` re-exports it and
406 keeps the `Material` alias. `Frost::Frosted` already carries `radius` at
407 `DEFAULT_RADIUS`, unread until step 3.
408 - Add `scene/material.rs`: `Material`, `Frost`, `Finish` (= `relief_shade::Material`
409   moved and renamed; `relief_shade` keeps a `pub use` so `cce-relief`, `vk/rt.rs`,
410   `cce-designer/geometry.rs` and `vk_smoke.rs` need no edit until they choose to).
411 - `Material::fill(role)`, `Material::{root,pane,control}()`, `floor`, `control_face`.
412 - `PlateSpec::fill` and `color::param_plate_fill` become one-line calls into it.
413 - New `finish` getters in `color.rs` for spec / shininess / curvature, defaults 0.4 / 24 /
414   0.2; `Finish::from_style` reads them.
415 - *Exit:* `cargo test -p cce-ui scene::`; the `relief_shade` shader-constant test still
416   passes; every client builds; prim dump identical.
417 
418 **Step 2 — thread it through the rungs.** *DONE 2026-09-20*, as four commits (2a–2d),
419 each prim-identical against a tessellation dump taken before 2a (`tests/plate_golden.rs`,
420 148518 lines, two scales × both edge paths). Deviations from the text below, each
421 deliberate: `DropletSpec` KEEPS `gleam` / `shine` / `rim` as the config spelling and
422 `DropletSpec::finish()` derives the `Finish` the material carries — the runner's hand-packed
423 slots are gone, which was the point. `PaintCtx::inset_plate` takes `Option<&Material>`
424 (`None` = the surface below is the face), as does `ControlPlate.face`. `well_floor` keeps
425 today's overlay emission: switching it to `host.floor()` is not prim-identical for a
426 translucent or frosted pane (a darkened fill at the pane's alpha composites differently
427 from a darkening overlay on the resolved plate), so it moves to step 3, where pixels may
428 change. `Material::from_fill` / `face` bridge the colour-typed flat path
429 (`layout::RenderTarget`), which is unchanged. `layout::tree_blur` has no reader and was
430 left alone. `Material::popover` is the menu recipe the three menu sites had each spelled.
431 - `PlateSpec`, `ControlPlate`, `Prim::Plate`, `Prim::Bevel`, `Prim::Sphere`,
432   `Prim::Droplet` carry a `Material`; `PaintCtx` signatures per § 4.
433 - `DropletSpec` loses `gleam` / `shine` / `rim` to its material's `Finish`; the runner's
434   hand-packed `material:` line goes.
435 - The ten client sites and the toolkit's own pane widgets (`ParametersBg`, `Spreadsheet`,
436   `InfoBox`, `Menu`, `StatusBar`, `Graph`).
437 - `Graph`'s `graph_blur` and `TreeList`'s `tree_blur` become materials on their plates.
438 - *Exit:* prim dump identical, including the status bar's droplet.
439 
440 **Step 3 — per-plate frost.** *DONE 2026-09-20* as 3a–3c plus the measurement below.
441 As specified except: the no-recipe fallback branch survives for raw vertices (§ 6.1);
442 the pack is 12-bit; `DEFAULT_RADIUS` is 5.5 logical (§ 6.3); promoted fills are
443 zero-depth **Bevels** in the tessellator, not `Prim::Plate`s in the display list — a
444 widget-scale fill wants nominal radii and circular corners (`shape` 2), and leaving the
445 list untouched keeps the legacy bridges that extract `RoundedRect`s working; and the
446 well floor (deferred from step 2) is its host's material only for a FROSTED host, an
447 opaque host keeping the exact darkening overlay (`PaintCtx::well_floor`).
448 Batch count: a frosted quad was already its own never-merged batch (the blur snapshot),
449 so promotion adds none; a frosted `Border` splits fill and stroke, +1.
450 **Measured** (`examples/frost_pair.rs` in a scale-2 shadow: three plates of the
451 designer's tint over a white/black checker, mean sRGB luminance under the bright and
452 dark columns, and the largest per-pixel luminance step across a column edge):
453 
454 | plate | bright | dark | swing | edge step |
455 |---|---|---|---|---|
456 | bare checker | 0.989 | 0.005 | 0.984 | — |
457 | compression 0, default radius | 0.873 | 0.078 | 0.795 | 0.173 |
458 | compression 0.85, default radius | 0.367 | 0.039 | 0.329 | 0.075 |
459 | clear (radius 0) | 0.882 | 0.004 | 0.878 | 0.878 |
460 
461 Three recipes in one window, each doing what its own numbers say. **Control:** the same
462 example built at the pre-step-3 commit (c86d6f6) in its own shadow draws all three plates
463 as the window-wide default (swing 0.795 each); HEAD's default-recipe plate is
464 pixel-identical to it (max |diff| 0/255 over the plate), so the default moved nothing on
465 the scale-2 panel and the other two plates differ only by their own recipes.
466 - `Frost::pack` into `p_host.zw` in mode 1 (§ 6.1's layout); `resolve_blur` reads its
467   three arguments; the Droplet clamps to the kernel default.
468 - `Flat` faces, `Menu`, `Graph` and the status bar fill promoted to zero-depth plates
469   (§ 6.2); the `MODE_NONE` sentinel branch and `backdrop_meta` go in the same commit. The
470   `window_info_layout_matches_the_uniform_size` test shrinks with it.
471 - `radius` carried at its default; the pack round-trip and shader-literal tests land here.
472 - *Exit:* a shadow sweep that gives one plate compression 0.85 and another 0 in the same
473   window and diffs the two against the 2026-09-20 sweep's numbers; `CCE_FRAME_DEBUG`
474   batch count before/after on cce-designer's default view (expect +N for the flat frosted
475   quads, N small).
476 
477 **Step 4 — config and editor.** *DONE 2026-09-20.* The named-material nodes and the three
478 rung bindings (§ 5.1, in the child-node shape), `MaterialDef::resolve` over the rung's
479 legacy material, `Material::named`, the DE finish keys `relief.spec / shininess /
480 curvature` and the default frost's `plate.radius`; live reload replaces nodes and bindings
481 wholesale. cce-relief grew two columns — Finish (Specular / Shininess / Curvature) and
482 Frost (Compression / Refraction / Blur radius) — seeded from the pane rung's effective
483 material, applied live, and saved into the bound material's node when the pane is bound,
484 else into the DE keys: **Save never restructures a config that has no materials**; the
485 named form is opted into by writing the binding. In `--key` mode the material sliders
486 are not part of a `(relief)` value and are not written. *Exit:* no live config or backup
487 carries a `material` node or binding (grep), so all resolve as before by construction;
488 `named_material_round_trips_the_legacy_spelling` pins the designer's `#05050840` / 0.6 /
489 0.3 as a bound `glass` to the SAME `Material` as the legacy keys (same Material, same
490 bytes — steps 2–3), and `material_keys_write_as_frost_and_finish_props` pins the
491 writer's shape. Not exercised: a Save click in the shadow (the utility window is taller
492 than the headless output).
493 - `material "<name>"` nodes; per-rung `material=` bindings; the alias table (§ 5.2).
494 - `cce-relief` grows a Finish section (spec / shininess / curvature) and a Frost section,
495   and Save writes a named material.
496 - Optionally `radius` (§ 6.3).
497 - *Exit:* every config under `~/.config/cce` and its backups round-trips to the same
498   materials with no `material=` binding present; the designer's `#05050840` / 0.6 / 0.3
499   config re-expressed as `material "glass"` draws pixel-identical.
500 
501 ---
502 
503 ## 8. Risks & mitigations
504 
505 - **`relief_shade` drift.** The Rust twin of the shader is checked against shader source
506   text by test. Renaming `Material` → `Finish` keeps the struct and the constants; the test
507   is untouched. A new `Frost` field never enters `relief_shade`, since it predicts shading,
508   not compositing.
509 - **The hex trap, again.** `Material::pane()` reads the same gamma-decoded getters as
510   today; a `material` node's `color` goes through the same `parse_hex`. Nothing new is
511   decoded differently — but the § 7 step-4 exit test exists because "same look" was
512   claimed and wrong once already (`CLAUDE.md`, the compression sweep).
513 - **Batch count.** § 6.2 option 1 adds a batch per flat frosted quad. Measured, not
514   assumed, at step 3; if a client shows a real cost, its flat faces fall back to option 2
515   per widget, not DE-wide.
516 - **Stale clients.** A toolkit change is three stages (commit, rebuild dependents,
517   relaunch — `WORKSPACE.md`). Step 2 changes public signatures, so every client fails to
518   build until migrated, which is the safe failure. Step 1 does not, so a client can run
519   the old encoding beside a toolkit that has the new one; both produce the same alpha.
520 - **Concurrent sessions in cce-ui.** Steps 1 and 2 touch `paint.rs`, `color.rs` and
521   `window_runner.rs`, the files every plate change touches. Small commits, explicit
522   paths, and the log checked before each.
523 
524 ---
525 
526 ## 9. Deferred (designed-for, not built now)
527 
528 - **Profiles per material.** The carve and roll profile LUTs are 8-vec4 uniforms. A
529   material-owned profile means an SSBO of profiles indexed from the push block, with the
530   index in `p_light.w`'s spare precision or a real slot. A `Material` grows a
531   `profile: Option<ProfileId>` then; nothing in § 3 forecloses it.
532 - **The compositor matching the client's frost** (§ 5.3): compression in scenefx.
533 - **A frost recipe for the Droplet.** Packing `core` (0..2) with `shadow` (0..1) in
534   `p_spec_tint.x` frees `.z` for `Frost::pack`'s first float; the radius would still need
535   a second. Only worth it if a droplet ever wants compression.
536 - **Texture.** A material with a normal map or a grain is a `Finish` with a sampler; the
537   push block cannot carry one, so it waits on the profile SSBO.
538 - **Materials as widget theme.** Once controls read `Material::control()`, a per-widget
539   material (`Button::with_material`) is the obvious next override and should replace the
540   per-widget `corner_radius`-style fill keys rather than join them. Not in this RFC.
541 
542 ---
543 
544 ## 10. Open questions
545 
546 All four resolved 2026-09-20; see § 11. Kept here so the alternatives stay on record.
547 
548 1. § 6.2: promote flat frosted quads to zero-depth plates, or keep them on the window
549    uniform? → promote.
550 2. Should `Frost` carry the blur `radius` from step 3, or wait for step 4? → step 3, slot
551    decided in § 6.1.
552 3. Does a **well floor** frost? The alternative — floors always `Opaque` — is what
553    `WELL_FLOOR` does today, a darkening overlay on an already-resolved plate. → carry the
554    host's frost through.
555 4. Naming for the lighting response. Alternatives considered: `Surface` (taken by the
556    config node), `Lighting` (the light's, not the material's), `Shading` (what the shader
557    does, not what the material is), `Sheen` (reads as specular alone). → `Finish`.
558 
559 ## 11. Decisions (resolved 2026-09-20)
560 
561 1. **Flat frosted quads become zero-depth plates.** One frost path in the shader; `Flat`
562    is literally the pane's material at control scale, and the `backdrop_meta` uniform is
563    retired. (Built: the `MODE_NONE` negative-alpha branch stays as the no-recipe fallback
564    for raw vertices from outside the display list — § 6.1.) The batch-count measurement at step 3 is a
565    check on cost, not a vote on the design: a client that measures badly drops frost on
566    its flat faces per widget, and the uniform path does not come back.
567 2. **`radius` joins `Frost::Frosted` at step 3, in `p_host.w`; compression and refraction
568    share `p_host.z` as fixed point** (12-bit as built; 10 was the draft). `Frost` changes shape once. Config exposure of
569    `radius` waits for step 4. The Droplet, whose push block is full, honours `Frosted` as
570    on/off at the kernel default — what it draws today.
571 3. **A well floor carries its host's frost.** `floor()` copies the material and darkens the
572    tint; under `Frost::Opaque` this is today's `WELL_FLOOR` overlay exactly, and under
573    `Frosted` a well in glass is deeper glass rather than the one opaque patch in a frosted
574    pane. Cost: a frosted well is a second 49-tap plate inside its pane; wells are small.
575 4. **The lighting response is `Finish`.** `relief_shade::Material` is renamed; `Material`
576    is the composite (tint, frost, finish). `relief_shade` keeps `pub use Finish as
577    Material` through step 2 so cce-relief, `vk/rt.rs` and cce-designer's geometry build
578    untouched until they choose to update, then the alias goes.