Skip to main content

max / makeover

Derive an elevation intent, for surfaces that overlay the page only Wave 2 tier A1. makeover derived the bevel pair and surface-well, both of which describe a surface in the page, and nothing for a surface floating over it. So every consumer reached for a literal: the MNW server's --shadow-1/2/3 at nine sites, and the landing carousel rule that surfaced the gap. Derived from surface-page rather than from surface-raised, because a shadow is not the thing, it is the absence of light on what is behind the thing. Same anchor as the scrim and for the same reason: a tone read off the theme's hue but pinned near-black, so it reads as absent light on a light theme and a dark one alike. A shadow tinted to a dark theme's own lightness would not be one. Two tests hold it, one over every embedded theme and one asserting the scrim and the cast stay the same tone at different weights. The alpha, 0.18, is the only number here that is a look decision rather than a derivation, and it is the one thing worth eyeballing. It sits between the two literal scales it replaces: --shadow-2 at 0.10 reads as nothing under a menu, and --shadow-3 at 0.15 was measured invisible at plate size. SCOPE, which is the point of the rescope this task carries: a surface that overlays takes this, a surface IN the page takes a bevel. Menus, toasts, popovers and dropdowns overlay. A card, a plate and a framed image do not, and .raised already answers them. If this starts appearing on in-flow content the conversion has failed and the literal has been renamed. Also adds SemanticTokens::rgba. The two translucent intents were reachable from a stylesheet, which can take the rgba() string verbatim, and from nowhere else, because rgb() drops anything that is not hex. That made the egui half of this intent unreachable from an app, which is the coupling deriving in the crate exists to avoid. rgb() now says in its docs that it declines translucent tokens rather than silently opaquing them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:41 UTC
Signed with PGP, not checked
Commit: 7383ae30d34a2baf346c38b7a169bad004421bc3
Parent: fb990d1
1 file changed, +129 insertions, -0 deletions
M src/lib.rs +129
@@ -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