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

commit48e2ccbb045d4ee665cd64f840c3b8ccdfec9924
parent6b55ee1a3c
authorLucas Galante <[email protected]>
date2026-09-20 15:09
docs: RFC material step 3 done, measured; examples/frost_pair is the visual test

Three plates of the designer's tint over a white/black checker in one
window — compression 0, compression 0.85, and a clear plate (radius 0)
— run in a scale-2 shadow and measured as the mean sRGB luminance
under the bright and dark columns: swings 0.795 / 0.329 / 0.878 against
the bare checker's 0.984, and the clear plate's edge step five times the
blurred one's. The RFC records the built deviations: the no-recipe
fallback branch survives for raw vertices, the pack is 12-bit,
DEFAULT_RADIUS is 5.5 logical px (the old 5.5-physical-px stride on the
scale-2 panel), promoted fills are zero-depth Bevels in the tessellator,
and an opaque host's well floor keeps its exact darkening overlay.

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

 CLAUDE.md              | 13 +++++++
 docs/rfc-material.md   | 77 +++++++++++++++++++++++++++------------
 examples/frost_pair.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 165 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6adf1cf..c77063c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -368,6 +368,19 @@ What this buys, and where the code is heading:
   dark backdrop at EVERY k: there is little luminance variation behind the
   plate there to begin with, so "glass" on a dark desktop is carried by the rim
   and bevel, not by the backdrop.
+- **The recipe is per plate.** Since 2026-09-20 (RFC material step 3) compression,
+  refraction and the blur radius are a plate's own `Material.frost`
+  (`Frost::Frosted { compression, refraction, radius }`), packed into `p_host.zw` of
+  its push block by `Frost::pack` — the two style keys above are the DEFAULT
+  material's values (`Frost::from_style`), not a window setting, and two plates in
+  one window can differ. `radius` is the kernel sigma in logical px;
+  `Frost::DEFAULT_RADIUS` (5.5) reproduces the old fixed 5.5-physical-px stride on
+  the scale-2 panel; 0 is a clear plate. A frosted FLAT fill of any kind (Quad,
+  RoundedRect, Border fill — a `Flat` face, a menu, a popover) is promoted by the
+  tessellator to a zero-depth plate batch so it carries its recipe too; only a raw
+  vertex from outside the display list falls to the no-recipe branch.
+  `examples/frost_pair.rs` is the visual test: three recipes in one window, run in a
+  shadow, measured in the RFC's step-3 note.
 - **`style.surface.plate.refraction` (0..1, default 0) is the rim, and it buys
   no legibility.** It is the answer to the other half of the question — not
   "can I read this" but "is this an object". The roll is a real surface with a
diff --git a/docs/rfc-material.md b/docs/rfc-material.md
index 2a7ac90..8592f67 100644
--- a/docs/rfc-material.md
+++ b/docs/rfc-material.md
@@ -315,17 +315,22 @@ Two free floats, three frost scalars. **The layout (decided, § 11 (2)):**
 
 | Slot | Carries | Encoding |
 |---|---|---|
-| `p_host.z` | `compression` and `refraction` | 10-bit fixed point each: `floor(c·1023)·1024 + floor(r·1023)`, an integer < 2²⁰, exact in f32 (24-bit mantissa). Shader: `hi = floor(v / 1024)`, `lo = v − hi·1024`, both `/ 1023`. |
-| `p_host.w` | `radius` | physical px (logical × scale); `0` = one clean sample, no kernel. |
-
-A `Frost::pack() -> [f32; 2]` / `unpack` pair lives beside the type with a round-trip test
-over the 0..1 grid at 1/1023 steps, and the `relief_shade`-style shader-text test checks
-the two literals (`1024`, `1023`) against `shader2d.wgsl`. 10 bits is 0.001 resolution on
-knobs whose measured useful range is 0.3–0.85 in steps nobody sets finer than 0.05.
-
-`resolve_blur` takes the three as arguments (it already takes `refract` and `clarity` from
-the plate branch); the plate branch unpacks `p_host.zw`. The window uniform's
-`backdrop_meta` is retired in the same step — § 6.2 leaves no path without a push block.
+| `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`. |
+| `p_host.w` | `radius` | the kernel sigma in physical px (logical × scale); `0` = one clean sample, no kernel. |
+
+A `Frost::pack(scale) -> [f32; 2]` / `unpack` pair lives beside the type with a round-trip
+test over the grid, and the `relief_shade`-style shader-text test checks the literals
+(`FROST_PACK_BASE`, `FROST_PACK_MAX`, `LEGACY_STRIDE`) against `shader2d.wgsl`. 12 bits is
+0.00024 resolution: the designer's 0.6 / 0.3 survive to better than a 1/255 step (10 bits
+was the first draft; 12 costs nothing since the sum still fits an exact f32 integer).
+
+`resolve_blur` takes `k` and the kernel stride as arguments (it already took `refract` and
+`clarity` from the plate branch); the plate branch unpacks `p_host.zw`. The window
+uniform's `backdrop_meta` is retired in the same step. The negative-alpha branch for a
+batch with NO plate block survives as the no-recipe fallback — a raw vertex pushed from
+outside the display list (a legacy host's own quads, or the `bevel_shader 0` path): the
+kernel default (`LEGACY_STRIDE`) and no compression. Everything the display list frosts is
+a plate batch (§ 6.2) and never reaches it.
 
 **The Droplet is the exception.** Mode 10 uses every slot: `p_host` is the sheet radius,
 clarity, dome and attach radius, `p_spec_tint` the core, shadow reach, shadow strength and
@@ -365,13 +370,19 @@ Frost at the control rung is `Flat` only. This is today's rule stated as a type.
 
 `resolve_blur`'s 49 taps are the most expensive path in the shader, and every frosted plate
 pays them. `Frost::Frosted.radius` (§ 3) is the kernel's sigma in logical px, carried in
-`p_host.w` as the tap stride (stride = radius / 2 at the current 7×7 kernel, so today's
-5.5 px stride is `radius ≈ 11`). It joins in step 3 with the other two scalars so `Frost`
-changes shape once (§ 11 (2)). A small control plate blurring at a third of the body's
-radius reads the same and samples a tighter footprint; `radius = 0` short-circuits to one
-clean sample — a *clear* plate, the tint over an unblurred backdrop, which nothing can
-express today. Config exposure (`frost radius=`) waits for step 4; until then every
-material carries the default and no pixel moves.
+`p_host.w` in physical px; the 7×7 kernel's tap stride is half of it. It joins in step 3
+with the other two scalars so `Frost` changes shape once (§ 11 (2)). A small control plate
+blurring at a third of the body's radius reads the same and samples a tighter footprint;
+`radius = 0` short-circuits to one clean sample — a *clear* plate, the tint over an
+unblurred backdrop, which nothing could express before. Config exposure (`frost radius=`)
+waits for step 4.
+
+**`Frost::DEFAULT_RADIUS` is 5.5 logical px, and why it is not 11.** The old kernel was a
+fixed 5.5 PHYSICAL px stride (sigma 11 physical) — half the blur on a scale-2 panel that
+it was on a scale-1 one, and the panel every frosted surface was tuned on is scale 2. A
+material cannot know the scale, so the default is stated in logical px at the value that
+reproduces that panel exactly: 5.5 logical = 11 physical at scale 2. A scale-1 display now
+gets the same logical blur instead of twice it; only headless scale-1 shadows notice.
 
 ---
 
@@ -418,7 +429,28 @@ left alone. `Material::popover` is the menu recipe the three menu sites had each
 - `Graph`'s `graph_blur` and `TreeList`'s `tree_blur` become materials on their plates.
 - *Exit:* prim dump identical, including the status bar's droplet.
 
-**Step 3 — per-plate frost.**
+**Step 3 — per-plate frost.** *DONE 2026-09-20* as 3a–3c plus the measurement below.
+As specified except: the no-recipe fallback branch survives for raw vertices (§ 6.1);
+the pack is 12-bit; `DEFAULT_RADIUS` is 5.5 logical (§ 6.3); promoted fills are
+zero-depth **Bevels** in the tessellator, not `Prim::Plate`s in the display list — a
+widget-scale fill wants nominal radii and circular corners (`shape` 2), and leaving the
+list untouched keeps the legacy bridges that extract `RoundedRect`s working; and the
+well floor (deferred from step 2) is its host's material only for a FROSTED host, an
+opaque host keeping the exact darkening overlay (`PaintCtx::well_floor`).
+Batch count: a frosted quad was already its own never-merged batch (the blur snapshot),
+so promotion adds none; a frosted `Border` splits fill and stroke, +1.
+**Measured** (`examples/frost_pair.rs` in a scale-2 shadow: three plates of the
+designer's tint over a white/black checker, mean sRGB luminance under the bright and
+dark columns, and the largest per-pixel luminance step across a column edge):
+
+| plate | bright | dark | swing | edge step |
+|---|---|---|---|---|
+| bare checker | 0.989 | 0.005 | 0.984 | — |
+| compression 0, default radius | 0.873 | 0.078 | 0.795 | 0.173 |
+| compression 0.85, default radius | 0.367 | 0.039 | 0.329 | 0.075 |
+| clear (radius 0) | 0.882 | 0.004 | 0.878 | 0.878 |
+
+Three recipes in one window, each doing what its own numbers say.
 - `Frost::pack` into `p_host.zw` in mode 1 (§ 6.1's layout); `resolve_blur` reads its
   three arguments; the Droplet clamps to the kernel default.
 - `Flat` faces, `Menu`, `Graph` and the status bar fill promoted to zero-depth plates
@@ -500,12 +532,13 @@ All four resolved 2026-09-20; see § 11. Kept here so the alternatives stay on r
 ## 11. Decisions (resolved 2026-09-20)
 
 1. **Flat frosted quads become zero-depth plates.** One frost path in the shader; `Flat`
-   is literally the pane's material at control scale, and the `MODE_NONE` sentinel branch
-   is retired with the `backdrop_meta` uniform. The batch-count measurement at step 3 is a
+   is literally the pane's material at control scale, and the `backdrop_meta` uniform is
+   retired. (Built: the `MODE_NONE` negative-alpha branch stays as the no-recipe fallback
+   for raw vertices from outside the display list — § 6.1.) The batch-count measurement at step 3 is a
    check on cost, not a vote on the design: a client that measures badly drops frost on
    its flat faces per widget, and the uniform path does not come back.
 2. **`radius` joins `Frost::Frosted` at step 3, in `p_host.w`; compression and refraction
-   share `p_host.z` as 10-bit fixed point.** `Frost` changes shape once. Config exposure of
+   share `p_host.z` as fixed point** (12-bit as built; 10 was the draft). `Frost` changes shape once. Config exposure of
    `radius` waits for step 4. The Droplet, whose push block is full, honours `Frosted` as
    on/off at the kernel default — what it draws today.
 3. **A well floor carries its host's frost.** `floor()` copies the material and darkens the
diff --git a/examples/frost_pair.rs b/examples/frost_pair.rs
new file mode 100644
index 0000000..5da31c5
--- /dev/null
+++ b/examples/frost_pair.rs
@@ -0,0 +1,97 @@
+//! Three frosted plates, one recipe each, over a bright/dark checker — the
+//! per-plate frost exit test of `docs/rfc-material.md` § 7 step 3.
+//!
+//! Left: compression 0 (the backdrop's luminance passes through). Middle:
+//! compression 0.85 (the plate holds its own key). Right: a CLEAR plate
+//! (radius 0 — one clean sample, tinted). Same tint on all three, the
+//! designer's `#05050840`. Measured from a shadow screenshot as the swing in
+//! mean luminance between the bright and dark checker columns under each
+//! plate: the middle plate must swing far less than the left one, and the
+//! right one must show the checker's edges sharp.
+//!
+//! Run it ONLY in a shadow session (`cce-shadow spawn`), never from the live
+//! shell.
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx, PlateSpec};
+use cce_ui::scene::{Frost, Material};
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+use wayland_client::QueueHandle;
+
+/// Logical layout: 12 checker columns of `COL` px; three plates in thirds.
+pub const COL: f32 = 35.0;
+pub const W: f32 = 12.0 * COL;
+pub const H: f32 = 210.0;
+pub const PLATE_Y: f32 = 30.0;
+pub const PLATE_H: f32 = H - 60.0;
+pub const PLATE_INSET: f32 = 10.0;
+
+/// The three recipes, left to right: (compression, radius).
+pub const RECIPES: [(f32, f32); 3] = [(0.0, Frost::DEFAULT_RADIUS), (0.85, Frost::DEFAULT_RADIUS), (0.0, 0.0)];
+
+/// The designer's tint, `#05050840` gamma-decoded.
+pub const TINT: [f32; 4] = [0.0015, 0.0015, 0.0024, 0.25];
+
+pub fn plate_rect(i: usize) -> Rect {
+    let third = W / 3.0;
+    Rect { x: PLATE_INSET + i as f32 * third, y: PLATE_Y, width: third - 2.0 * PLATE_INSET, height: PLATE_H }
+}
+
+struct FrostPair;
+
+impl Application for FrostPair {
+    type Message = ();
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
+        FrostPair
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "frost pair".to_string(),
+            app_id: "cce-frost-pair".to_string(),
+            width: W as u32,
+            height: H as u32,
+            fullscreen: false,
+            min_size: None,
+        }
+    }
+
+    fn update(&mut self, _msg: Self::Message, _needs_rebuild: &mut bool, _exit: &mut bool) {}
+    fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        cce_ui::scale::set_scale_factor(scale as f32);
+        let (w, h) = (size.width as f32, size.height as f32);
+        let mut pc = PaintCtx::new();
+        pc.plate_spec(&PlateSpec {
+            rect: Rect { x: 0.0, y: 0.0, width: w, height: h },
+            material: Material::opaque([0.2, 0.2, 0.22, 1.0]),
+            window_corners: (true, true, true, true),
+            depth: cce_ui::layout::bevel_width(),
+        });
+        for i in 0..12 {
+            let c = if i % 2 == 0 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] };
+            pc.rounded_rect(Rect { x: i as f32 * COL, y: 0.0, width: COL, height: h }, 0.0, (false, false, false, false), c);
+        }
+        for (i, (k, radius)) in RECIPES.iter().enumerate() {
+            let m = Material::opaque(TINT).with_frost(Frost::Frosted { compression: *k, refraction: 0.0, radius: *radius });
+            pc.plate_spec(&PlateSpec { rect: plate_rect(i), material: m, window_corners: (false, false, false, false), depth: 6.0 });
+        }
+        Some(pc.finish())
+    }
+
+    fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+    fn handle_mouse_input(&mut self, _b: MouseButton, _s: ElementState, _p: LogicalPosition, _n: &mut bool) -> Option<Self::Message> {
+        None
+    }
+    fn handle_mouse_wheel(&mut self, _d: &MouseScrollDelta, _p: LogicalPosition, _n: &mut bool) {}
+    fn handle_key_input(&mut self, _e: &KeyEvent, _n: &mut bool) -> Option<Self::Message> {
+        None
+    }
+}
+
+fn main() {
+    cce_ui::engine::run::<FrostPair>();
+}