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.