GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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>();
+}