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

commitc674a2772a16af6287a4fb6f5def7e20578e4a00
parent4b04f31f13
authorLucas Galante <[email protected]>
date2026-09-20 01:10
docs(plate): the tint that backdrop_compression converges on, and the hex trap

A 24-cell sweep (k x tint x backdrop) says the control only works with a
tint dark enough to converge on, which the earlier note did not.

The stock #595969 cannot be rescued at any k: it never clears 4.5:1 on a
bright backdrop, and on a DARK one it gets WORSE as k rises (6.65 ->
4.34), because the tint is lighter than the scene and compression lifts
the plate toward it. At k=0 the three tints measure 1.16/1.23/1.24 --
indistinguishable, because at 0.25 opacity the tint barely participates.
That is the decoupling stated as a measurement: k buys independence from
the backdrop, the tint picks the key it becomes independent at, and
neither does anything useful alone. k ~ 0.6 is the knee -- both dark
tints clear the floor on both backdrops with a third of the backdrop
variation still reading, where 0.85 doubles contrast for 95% of the
remaining glass.

Also documents the trap that cost the first run of that sweep: a config
hex is gamma-decoded and a built-in constant is not, so the hex pinning
a default is NOT its floats times 255. PARAM_BG [0.10,0.10,0.14] is
#595969; the naive #1a1a24 decodes ten times darker, silently, both
being valid config. A sweep meant to hold the tint constant was quietly
sweeping it, and its two halves disagreed 3x on plate luminance.

the_hex_that_pins_a_default_round_trips_through_the_gamma_decode pins
it, along with the two smaller edges: alpha is not decoded, and a
6-digit hex means OPAQUE rather than "alpha unchanged".

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md    | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++----------
 src/color.rs | 33 ++++++++++++++++++++++++++++
 2 files changed, 92 insertions(+), 12 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a03f379..cc12b61 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -282,30 +282,77 @@ What this buys, and where the code is heading:
   the textbox) falls back to it when the widget's own `corner_radius` key is
   unset, so a per-widget key is an override, not a requirement. Do not give a
   new control-scale radius getter a literal default; fall back to the rung.
+- **A config hex is gamma-decoded; a built-in default colour is not.** The
+  style loader's `parse_hex` runs every channel through `srgb_to_linear`
+  (alpha excepted), so `"#595969"` arrives as `[0.10, 0.10, 0.14]` — which is
+  exactly `PARAM_BG`'s default. The constants in `color.rs` are already
+  linear, so **the hex that pins a default is not that default's floats times
+  255.** `PARAM_BG = [0.10, 0.10, 0.14]` reads as `#1a1a24` if you scale it
+  naively, and `#1a1a24` decodes to `[0.010, 0.010, 0.018]` — a plate ten
+  times darker than the one you were trying to preserve, silently, because
+  both spellings are valid config.
+
+  Round-trip a default with `l2s(c) = 1.055·c^(1/2.4) − 0.055` (the inverse of
+  `srgb_to_linear`) before writing it into a config, or read the value back
+  out of the running app. This cost a measurement round on 2026-09-19: a
+  `backdrop_compression` sweep meant to hold the tint constant was silently
+  sweeping the tint too, and the two halves of the experiment disagreed by 3x
+  on the plate's luminance.
+
+  Two smaller edges of the same knife: an **8-digit** hex keeps its alpha raw
+  (`a/255`, no decode), so `#05050840` really is a quarter opacity; a
+  **6-digit** hex sets alpha to **1.0**, so dropping the last byte off a
+  translucent plate colour makes it fully opaque rather than leaving it
+  alone.
 - **A frosted plate's legibility is `backdrop_compression`, not opacity.**
   Blur destroys a backdrop's spatial DETAIL and preserves its mean LUMINANCE,
   and text contrast is a mean-luminance property — so `resolve_blur`'s closing
   `mix(backdrop, plate, opacity)` hands the backdrop's brightness through at
   `1 - opacity` whatever the kernel does. At the designer dialog's 0.25 that is
   75% of whatever is behind it. Measured on a row label (`#ccccd4`) over the
-  designer's Alt+D plate: **1.20:1 over a white viewport, 6.18:1 over the dark
-  one** — a 5x swing, the bright end of it not a contrast ratio so much as its
-  absence. More blur moves neither number, which is the whole of the "liquid
-  glass" legibility problem, and why refraction and specular cannot help: they
-  are shape cues, and legibility is a luminance budget.
+  designer's Alt+D plate at the stock tint: **1.16:1 over a white viewport,
+  6.65:1 over the dark one** — the bright end not a contrast ratio so much as
+  its absence. More blur moves neither number, which is the whole of the
+  "liquid glass" legibility problem, and why refraction and specular cannot
+  help: they are shape cues, and legibility is a luminance budget.
 
   `style.surface.plate.backdrop_compression` (0..1, `color::plate_backdrop_
   compression`, **default 0** — every existing config keeps today's look)
   remaps the blurred backdrop's luminance toward the plate's own key before
   the tint, holding its chromaticity. It is not opacity and not "darken": it
   is SYMMETRIC, pulling a bright backdrop down and a dark one UP, so both ends
-  converge on the plate's key. The same measurement at 0.85: **4.24:1 and
-  4.33:1** — the contrast stops depending on what is behind the window, which
-  is the actual goal. Hue, chroma and movement still read through it.
-
-  Raising it past ~0.9 flattens the view through the glass without buying much
-  more contrast; the convergence point is set by the plate's tint and opacity,
-  so if both ends need to clear 4.5:1 that is the tint to change, not this.
+  converge on the plate's key. The contrast stops depending on what is behind
+  the window, which is the actual goal; hue, chroma and movement still read
+  through it.
+
+  **It only works with a tint dark enough to converge ON.** A 24-cell sweep
+  (k x tint x backdrop, 2026-09-20) — contrast on the bright/dark viewports,
+  with `show` the luminance sigma across bare plate (x100), a proxy for how
+  much backdrop still reads through:
+
+  | tint | k=0 | k=0.4 | k=0.6 | k=0.85 |
+  |---|---|---|---|---|
+  | `#595969` (stock) | 1.16 / 6.65 | 2.25 / 4.87 | 3.10 / 4.54 | 4.10 / 4.34 |
+  | `#1a1a24` | 1.23 / 9.97 | 2.99 / 10.34 | **5.08 / 10.53** | 9.36 / 10.70 |
+  | `#050508` | 1.24 / 10.47 | 3.08 / 11.67 | **5.41 / 12.14** | 10.76 / 12.60 |
+  | *show* (bright/dark) | 7.3 / 0.9 | 3.7 / 0.5 | 2.3 / 0.2 | 0.4 / 0.1 |
+
+  Three readings. **The stock tint cannot be rescued at any k** — it never
+  clears 4.5:1 on the bright backdrop, and on the DARK one it gets WORSE as k
+  rises (6.65 -> 4.34), because `#595969` is lighter than the scene and
+  compression lifts the plate toward it. **Tint does nothing without k**: at
+  k=0 the three tints read 1.16/1.23/1.24, indistinguishable, because at 0.25
+  opacity the tint barely participates — which is why "just darken it" was a
+  dead end before this existed. And **k ~ 0.6 is the knee**: both dark tints
+  clear the floor on both backdrops with a third of the backdrop variation
+  intact, where 0.85 doubles contrast for 95% of the remaining glass.
+
+  So the pair is orthogonal, and that is the point: **k buys independence from
+  the backdrop, the tint picks the key it becomes independent at.** cce-designer
+  ships `#05050840` at k=0.6 (5.41:1 / 12.14:1). Note `show` is 0.2-0.9 on a
+  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 `scene/` core rebuild (read `docs/rfc-core-rebuild.md` before touching it)
 
diff --git a/src/color.rs b/src/color.rs
index 3e2e3ff..97beb38 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1904,6 +1904,39 @@ mod color_tests {
 mod tests {
     use super::*;
 
+    /// The hex that pins a built-in colour is NOT its floats times 255.
+    ///
+    /// The style loader gamma-decodes every config hex; the constants in this
+    /// file are already linear. So `PARAM_BG = [0.10, 0.10, 0.14]` is spelled
+    /// `#595969` in config, and the naive `#1a1a24` decodes to a plate ten
+    /// times darker — silently, both being valid config. Pinned here because
+    /// it cost a measurement round: a sweep meant to hold the tint constant
+    /// was quietly sweeping it, and the two halves disagreed by 3x.
+    #[test]
+    fn the_hex_that_pins_a_default_round_trips_through_the_gamma_decode() {
+        let decoded = parse_hex_rgba_linear("#595969").expect("valid hex");
+        // RGB only — a 6-digit hex carries no alpha, which the asserts at the
+        // bottom cover as its own hazard.
+        for (got, want) in decoded.iter().take(3).zip(PARAM_BG.iter().take(3)) {
+            assert!(
+                (got - want).abs() < 0.005,
+                "#595969 decodes to {decoded:?}, PARAM_BG is {PARAM_BG:?}"
+            );
+        }
+
+        // The naive spelling, and how far off it lands.
+        let naive = parse_hex_rgba_linear("#1a1a24").expect("valid hex");
+        assert!(
+            naive[0] < PARAM_BG[0] / 5.0,
+            "#1a1a24 was supposed to be nowhere near PARAM_BG, got {naive:?}"
+        );
+
+        // Alpha is NOT decoded, and a 6-digit hex means OPAQUE — dropping the
+        // last byte off a translucent plate colour does not leave it alone.
+        assert!((parse_hex_rgba_linear("#05050840").unwrap()[3] - 0.251).abs() < 0.002);
+        assert_eq!(parse_hex_rgba_linear("#050508").unwrap()[3], 1.0);
+    }
+
     /// The plate's backdrop compression defaults OFF and clamps.
     ///
     /// Off is load-bearing: it is the behaviour every config already in the