Skip to main content

max / makeover

Derive a surface-well intent, 2.3.0 Platinum puts list content in a well cut into the raised surface, so rows read as content in a container rather than as bands on a panel. Both webviews have the inset bevel already and neither has the fill. surface-sunken cannot serve. A theme is free to author it darker than raised, and goingson does (#BAC2E6 against #D9DDF4), where a well has to go the other way. This is the one derivation here that inverts by theme, since a well is lighter than its face on a light theme and darker on a dark one. The direction comes off content rather than off the variant field: a theme whose text is dark is a theme whose surfaces are light, whatever its metadata claims, so a mislabelled theme still resolves correctly and the branch stays on measured colour. Deltas are 0.07 up and 0.09 down in OKLab L, asymmetric for the same reason the bevel's are and smaller because a well is an area rather than an edge. The step up is the specimen's, measured. Two tests rather than one. Distinct-from-its-face mirrors the bevel's and bites the same two themes, both of which author a white raised surface. Visible-against-its-face is separate because a face near the top of the ramp clamps partway instead of exactly: rosepine-dawn gets 0.009 of the 0.07 it asked for, which is distinct and unreadable. The fixes differ, so the lists do too. Not published. The version is bumped in-tree only.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 19:37 UTC
Signed with PGP, not checked
Commit: ca7c3f024f1193e441c8ebe0fc5b7fb4a79c3315
Parent: c6e53bf
3 files changed, +144 insertions, -5 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.2.0"
3 + version = "2.3.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M README.md +11 -4
@@ -78,14 +78,21 @@
78 78 OKLab, so theme files stay small and every consuming app derives them
79 79 identically rather than each recomputing its own: `action-hover`,
80 80 `content-on-action`, `focus-ring`, `hover-surface`, `border-strong`, the
81 - translucent `overlay` scrim, and the `bevel-light` / `bevel-dark` pair that a
82 - raised surface is lit and shadowed with.
81 + translucent `overlay` scrim, the `bevel-light` / `bevel-dark` pair that a raised
82 + surface is lit and shadowed with, and `surface-well`, the content surface cut
83 + into a raised one.
83 84
84 85 Each is emitted only when the intents it reads from are present, so a partial
85 86 theme resolves to a partial token set rather than failing.
86 87
87 - Bevel geometry is not derived here. Thickness, radius and which side takes which
88 - edge are the consuming app's, and only the two tones are shared.
88 + `surface-well` is the one derivation that inverts by theme: a well is lighter
89 + than its face on a light theme and darker on a dark one. The direction is read
90 + off `content`, not off the theme's `variant` field, so a theme whose text is
91 + dark resolves as a light theme whatever its metadata says.
92 +
93 + Bevel and well geometry is not derived here. Thickness, radius, inset depth and
94 + which side takes which edge are the consuming app's, and only the tones are
95 + shared.
89 96
90 97 `intent_css_vars()` renders a resolved theme as a `:root { … }` block for web
91 98 consumers; native consumers read RGB tuples off the same resolved tokens.
M src/lib.rs +132
@@ -599,6 +599,39 @@
599 599 // shipped themes it currently bites.
600 600 derived.push(("bevel-light".into(), lighten(raised, 0.14)));
601 601 derived.push(("bevel-dark".into(), darken(raised, 0.18)));
602 +
603 + // An inset well: the content surface inside a raised container, so a
604 + // list reads as content in a container rather than as bands on a panel.
605 + // `surface-sunken` cannot serve, because a theme is free to author it
606 + // darker than raised (goingson does) and a well has to go the other way.
607 + //
608 + // Which way is "the other way" depends on the theme, and this is the one
609 + // derivation here that inverts. A well is lighter than its face on a
610 + // light theme and darker on a dark one, where the bevel pair sidesteps
611 + // the question by emitting both directions at once.
612 + //
613 + // Read the direction off `content` rather than off `Variant`. A theme
614 + // whose text is dark is a theme whose surfaces are light, whatever its
615 + // `variant` field claims, so this resolves correctly even when that
616 + // field is wrong and it keeps the branch on measured color rather than
617 + // on metadata.
618 + //
619 + // Deltas are asymmetric for the same reason the bevel's are, and smaller
620 + // than the bevel's because a well is an area rather than an edge. The
621 + // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
622 + //
623 + // A face at the top of its ramp cannot hold a lighter well, the same
624 + // clamp `bevel-light` hits; `well_is_visible_against_its_face` names the
625 + // shipped themes where it bites.
626 + if let Some(content) = get(&intents, "content") {
627 + let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
628 + let well = if content_is_darker {
629 + lighten(raised, 0.07)
630 + } else {
631 + darken(raised, 0.09)
632 + };
633 + derived.push(("surface-well".into(), well));
634 + }
602 635 }
603 636 if let Some(sunken) = get(&intents, "surface-sunken") {
604 637 derived.push(("hover-surface".into(), sunken));
@@ -1593,6 +1626,105 @@
1593 1626 );
1594 1627 }
1595 1628
1629 + // The well inverts by theme, so assert both directions explicitly rather
1630 + // than only the one the light themes happen to take.
1631 + #[test]
1632 + fn resolve_well_intent_follows_the_content_direction() {
1633 + // nord is dark: light text on a dark raised surface, so the well goes
1634 + // down and away from the text.
1635 + let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
1636 + let dark_raised = Rgb::from_hex("#3b4252").unwrap();
1637 + assert_eq!(
1638 + dark.hex("surface-well").unwrap(),
1639 + darken(dark_raised, 0.09).to_hex()
1640 + );
1641 +
1642 + // The shipped light themes take the other branch.
1643 + let goingson = embedded_themes()
1644 + .into_iter()
1645 + .find(|(id, _)| *id == "goingson")
1646 + .expect("goingson is embedded")
1647 + .1;
1648 + let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
1649 + let light_raised = light
1650 + .hex("surface-raised")
1651 + .and_then(Rgb::from_hex)
1652 + .expect("goingson authors a raised surface");
1653 + assert_eq!(
1654 + light.hex("surface-well").unwrap(),
1655 + lighten(light_raised, 0.07).to_hex()
1656 + );
1657 + }
1658 +
1659 + // A well is a fill, not an edge, so the only thing that makes it read is
1660 + // being a different color from the surface it is cut into.
1661 + //
1662 + // Same shape and the same asserted-list discipline as
1663 + // `bevel_edges_are_distinct_from_their_face`, and it bites the same two
1664 + // themes for the same reason: a raised surface already at the top of the
1665 + // ramp has nothing lighter to go to.
1666 + #[test]
1667 + fn well_is_distinct_from_its_face() {
1668 + const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
1669 +
1670 + let mut degenerate: Vec<String> = Vec::new();
1671 + for (id, source) in embedded_themes() {
1672 + let theme = parse_theme_str(id, source, false).unwrap();
1673 + let t = resolve(&theme);
1674 + let Some(raised) = t.hex("surface-raised") else {
1675 + continue;
1676 + };
1677 + let well = t.hex("surface-well").expect("raised implies surface-well");
1678 + if well == raised {
1679 + degenerate.push(id.to_string());
1680 + }
1681 + }
1682 + degenerate.sort();
1683 +
1684 + assert_eq!(
1685 + degenerate, CANNOT_WELL,
1686 + "themes whose raised surface cannot hold a well"
1687 + );
1688 + }
1689 +
1690 + // Distinct is not the same as visible. A face near the top of the ramp
1691 + // clamps partway rather than exactly, which yields a well that differs from
1692 + // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
1693 + // authors raised at L=0.987 and gets 0.009 of the 0.07 it asked for.
1694 + //
1695 + // Worth a separate test from the one above because the fix differs: an
1696 + // exactly-degenerate theme needs its raised surface off the ramp end, while
1697 + // these need it merely lowered. Both fixes are the theme's, not this
1698 + // derivation's, which is why the list is asserted rather than warned about.
1699 + #[test]
1700 + fn well_is_visible_against_its_face() {
1701 + // Below this, the well and its face are the same surface to a reader.
1702 + const MIN_DELTA_L: f32 = 0.02;
1703 + const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
1704 + &["neobrute", "oxocarbon-light", "rosepine-dawn"];
1705 +
1706 + let mut invisible: Vec<String> = Vec::new();
1707 + for (id, source) in embedded_themes() {
1708 + let theme = parse_theme_str(id, source, false).unwrap();
1709 + let t = resolve(&theme);
1710 + let (Some(raised), Some(well)) = (
1711 + t.hex("surface-raised").and_then(Rgb::from_hex),
1712 + t.hex("surface-well").and_then(Rgb::from_hex),
1713 + ) else {
1714 + continue;
1715 + };
1716 + if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
1717 + invisible.push(id.to_string());
1718 + }
1719 + }
1720 + invisible.sort();
1721 +
1722 + assert_eq!(
1723 + invisible, CANNOT_HOLD_A_VISIBLE_WELL,
1724 + "themes whose well is too close to its face to read as one"
1725 + );
1726 + }
1727 +
1596 1728 // What the bevel pair does on a sixteen-color terminal, measured across the
1597 1729 // shipped set rather than assumed. Two results, both load-bearing for a
1598 1730 // consumer that has to render one there.