max / makeover
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+129 insertions,
-0 deletions
| @@ -600,12 +600,46 @@ | |||
| 600 | 600 | } | |
| 601 | 601 | ||
| 602 | 602 | /// Resolved RGB tuple for a token key (for egui / native consumers). | |
| 603 | + | /// | |
| 604 | + | /// `None` for a translucent token. Two intents are emitted as `rgba(...)` | |
| 605 | + | /// rather than hex, `overlay` and `elevation`, and dropping the alpha would | |
| 606 | + | /// hand a native consumer an opaque near-black where it asked for a scrim. | |
| 607 | + | /// Those want [`rgba`](Self::rgba). | |
| 603 | 608 | pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> { | |
| 604 | 609 | self.intents | |
| 605 | 610 | .get(key) | |
| 606 | 611 | .and_then(|h| Rgb::from_hex(h)) | |
| 607 | 612 | .map(Rgb::tuple) | |
| 608 | 613 | } | |
| 614 | + | ||
| 615 | + | /// Resolved RGBA tuple for a token key, alpha as 0-255. | |
| 616 | + | /// | |
| 617 | + | /// Reads both spellings, so a caller that does not care whether an intent | |
| 618 | + | /// happens to be translucent can use this for everything: an opaque token | |
| 619 | + | /// comes back at 255. | |
| 620 | + | /// | |
| 621 | + | /// It exists because a CSS consumer can take `rgba(...)` as a string | |
| 622 | + | /// straight out of [`hex`](Self::hex) and a native one cannot. Without it | |
| 623 | + | /// the two translucent intents are reachable from a stylesheet and from | |
| 624 | + | /// nowhere else, which is the coupling deriving in the crate was meant to | |
| 625 | + | /// avoid. | |
| 626 | + | pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> { | |
| 627 | + | let value = self.intents.get(key)?; | |
| 628 | + | if let Some(rgb) = Rgb::from_hex(value) { | |
| 629 | + | let (r, g, b) = rgb.tuple(); | |
| 630 | + | return Some((r, g, b, 255)); | |
| 631 | + | } | |
| 632 | + | let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?; | |
| 633 | + | let mut parts = inner.split(',').map(str::trim); | |
| 634 | + | let r = parts.next()?.parse().ok()?; | |
| 635 | + | let g = parts.next()?.parse().ok()?; | |
| 636 | + | let b = parts.next()?.parse().ok()?; | |
| 637 | + | let alpha: f32 = parts.next()?.parse().ok()?; | |
| 638 | + | if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) { | |
| 639 | + | return None; | |
| 640 | + | } | |
| 641 | + | Some((r, g, b, (alpha * 255.0).round() as u8)) | |
| 642 | + | } | |
| 609 | 643 | } | |
| 610 | 644 | ||
| 611 | 645 | /// Resolve an authored theme into the full intent token set. | |
| @@ -653,6 +687,35 @@ | |||
| 653 | 687 | "overlay".into(), | |
| 654 | 688 | format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b), | |
| 655 | 689 | ); | |
| 690 | + | ||
| 691 | + | // What a surface that FLOATS OVER the page is cast onto it with. | |
| 692 | + | // | |
| 693 | + | // The one intent here about a surface's relationship to the page rather | |
| 694 | + | // than about the surface itself, which is why it is derived from `page` | |
| 695 | + | // and not from `surface-raised`. A shadow is not the thing, it is the | |
| 696 | + | // absence of light on what is behind the thing. | |
| 697 | + | // | |
| 698 | + | // SCOPE, and it is the whole point of this intent existing rather than | |
| 699 | + | // a general "shadow": a surface that overlays the page takes this, a | |
| 700 | + | // surface IN the page takes a bevel. Menus, toasts, popovers and | |
| 701 | + | // dropdowns overlay. A card, a plate and a framed image do not, and | |
| 702 | + | // reaching for this on one of those is how a pre-Platinum look survives | |
| 703 | + | // a conversion wearing a token's name. `.raised` is the answer there. | |
| 704 | + | // | |
| 705 | + | // Same anchor as the scrim above and for the same reason: a tone read | |
| 706 | + | // off the theme's hue but pinned very dark, so it reads as absence of | |
| 707 | + | // light on a light theme and on a dark one alike. A shadow tinted to a | |
| 708 | + | // dark theme's own lightness would not be a shadow. | |
| 709 | + | // | |
| 710 | + | // The alpha is the only number here that is a look decision rather than | |
| 711 | + | // a derivation. 0.18 sits between the two literal scales it replaces: | |
| 712 | + | // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and | |
| 713 | + | // its --shadow-3 (0.15) was measured invisible at plate size. Geometry | |
| 714 | + | // stays with the consumer, the way bevel thickness does. | |
| 715 | + | intents.insert( | |
| 716 | + | "elevation".into(), | |
| 717 | + | format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b), | |
| 718 | + | ); | |
| 656 | 719 | } | |
| 657 | 720 | if let Some(raised) = get(&intents, "surface-raised") { | |
| 658 | 721 | // The two edges of a bevel: a raised control is lit from the top left, | |
| @@ -2082,6 +2145,72 @@ | |||
| 2082 | 2145 | assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black"); | |
| 2083 | 2146 | } | |
| 2084 | 2147 | ||
| 2148 | + | /// Every shipped theme derives it, on both polarities, and it is always a | |
| 2149 | + | /// near-black translucent tone. A shadow tinted to a dark theme's own | |
| 2150 | + | /// lightness would not read as one. | |
| 2151 | + | #[test] | |
| 2152 | + | fn elevation_is_a_near_black_cast_on_every_theme() { | |
| 2153 | + | for (id, source) in embedded_themes() { | |
| 2154 | + | let theme = parse_theme_str(id, source, false).unwrap(); | |
| 2155 | + | let t = resolve(&theme); | |
| 2156 | + | let Some(elevation) = t.hex("elevation") else { | |
| 2157 | + | panic!("{id} derives no elevation"); | |
| 2158 | + | }; | |
| 2159 | + | assert!( | |
| 2160 | + | elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"), | |
| 2161 | + | "{id}: elevation is translucent: {elevation}" | |
| 2162 | + | ); | |
| 2163 | + | let inner = elevation | |
| 2164 | + | .trim_start_matches("rgba(") | |
| 2165 | + | .trim_end_matches(", 0.18)"); | |
| 2166 | + | let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect(); | |
| 2167 | + | let cast = Rgb { | |
| 2168 | + | r: parts[0], | |
| 2169 | + | g: parts[1], | |
| 2170 | + | b: parts[2], | |
| 2171 | + | }; | |
| 2172 | + | assert!( | |
| 2173 | + | cast.to_oklab().l < 0.2, | |
| 2174 | + | "{id}: a cast shadow must be near-black, got {elevation}" | |
| 2175 | + | ); | |
| 2176 | + | } | |
| 2177 | + | } | |
| 2178 | + | ||
| 2179 | + | /// The scrim and the cast share an anchor and differ only in weight. Stated | |
| 2180 | + | /// as a test because the two are easy to drift apart, and a scrim that | |
| 2181 | + | /// stopped matching the shadow under the thing it dims would show. | |
| 2182 | + | #[test] | |
| 2183 | + | fn elevation_and_the_scrim_are_the_same_tone() { | |
| 2184 | + | let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); | |
| 2185 | + | let t = resolve(&theme); | |
| 2186 | + | let scrim = t.hex("overlay").unwrap(); | |
| 2187 | + | let cast = t.hex("elevation").unwrap(); | |
| 2188 | + | assert_eq!( | |
| 2189 | + | scrim.trim_end_matches(", 0.5)"), | |
| 2190 | + | cast.trim_end_matches(", 0.18)"), | |
| 2191 | + | ); | |
| 2192 | + | } | |
| 2193 | + | ||
| 2194 | + | /// The accessor that makes a translucent intent reachable from something | |
| 2195 | + | /// that is not a stylesheet. Both spellings, and an opaque token answers | |
| 2196 | + | /// 255 so a caller need not know which kind it asked for. | |
| 2197 | + | #[test] | |
| 2198 | + | fn rgba_reads_both_spellings() { | |
| 2199 | + | let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); | |
| 2200 | + | let t = resolve(&theme); | |
| 2201 | + | ||
| 2202 | + | let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token"); | |
| 2203 | + | assert_eq!(opaque, 255); | |
| 2204 | + | ||
| 2205 | + | let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent"); | |
| 2206 | + | assert_eq!(alpha, 46, "0.18 of 255"); | |
| 2207 | + | assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha"); | |
| 2208 | + | ||
| 2209 | + | let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent"); | |
| 2210 | + | assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights"); | |
| 2211 | + | assert_eq!(scrim, 128); | |
| 2212 | + | } | |
| 2213 | + | ||
| 2085 | 2214 | #[test] | |
| 2086 | 2215 | fn resolve_drops_non_hex_base_intent() { | |
| 2087 | 2216 | // A base intent that isn't a hex color must never reach the resolved |