fix(console): the focus recede ran backwards on all six pale palettes

Caught on the screenshot pass, which is the only place it could have been
caught: every unit test and every dark-palette render agreed the recede was
fine.

`recede_matrix` ported Apple's `.brightness(-0.24·d)` literally, and that is
dark-mode arithmetic — on a dark field, "down" is "away". This crate ships
thirteen palettes and six of them are PALE, where darkening a card increases its
contrast against a light ground. On `holo` the effect was not subtle: the
UNFOCUSED host tile rendered as the heaviest object on screen, a grey slab
beside the focused card it was supposed to be receding behind.

The recede now moves a card toward its GROUND rather than always toward black,
taking the direction from the scrim — which already encodes which way the field
leans, because it tends to black on a dark palette and white on a pale one. The
saturation drain is unchanged in both directions; colour has no handedness.

The new test asserts the two poles disagree in SIGN — a receded card sinks on
`violet` and lifts on `mint` — because that is the property, and any test
written against one palette would have passed before this fix.
This commit is contained in:
2026-08-16 16:21:10 +02:00
parent d927fcb2b0
commit 451f95079e
+41 -1
View File
@@ -221,7 +221,14 @@ pub(crate) fn panel(
pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
let d = d.clamp(0.0, 1.0);
let sat = (1.0 - RECEDE_SATURATION * d) as f32;
let bright = -(RECEDE_BRIGHTNESS * d) as f32;
// Toward the GROUND, not simply darker. Apple's `.brightness(-0.24·d)` is dark-mode
// arithmetic: on a dark field, down is away. On one of this crate's six PALE palettes
// it is exactly backwards — a darkened tile gains contrast against a light ground and
// the UNFOCUSED card becomes the heaviest thing on screen. The scrim already knows
// which way the field leans (it tends to black on a dark palette, white on a pale
// one), so the recede borrows its direction.
let toward_light = ink().scrim.r > 0.5;
let bright = (RECEDE_BRIGHTNESS * d) as f32 * if toward_light { 1.0 } else { -1.0 };
const LR: f32 = 0.2126;
const LG: f32 = 0.7152;
const LB: f32 = 0.0722;
@@ -633,4 +640,37 @@ mod tests {
// recede that reached alpha would let them show through each other.
assert_eq!(out[3], 1.0);
}
/// The recede must move a card TOWARD ITS GROUND, which is the opposite direction on a
/// pale palette. Getting this wrong is not subtle and is not caught by any dark-palette
/// screenshot: on `mint` or `holo` a darkened neighbour gains contrast against the
/// light field, so the UNFOCUSED tile becomes the loudest thing on screen.
#[test]
fn recede_moves_toward_the_ground_on_a_pale_palette() {
let lum = |v: [f32; 4]| 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
let card = [0.55f32, 0.55, 0.6, 1.0];
set_ink(Ink::of(crate::library::palette("violet")));
assert!(ink().scrim.r < 0.5, "violet is a dark field");
let dark_side = apply(&recede_matrix(1.0), card);
set_ink(Ink::of(crate::library::palette("mint")));
assert!(ink().scrim.r > 0.5, "mint is a pale field");
let pale_side = apply(&recede_matrix(1.0), card);
assert!(
lum(dark_side) < lum(card),
"on a dark field a receded card sinks"
);
assert!(
lum(pale_side) > lum(card),
"on a pale field it must LIFT, not sink: {pale_side:?}"
);
// Both drain colour, whichever way the light goes — saturation has no handedness.
let spread = |v: [f32; 4]| v[0].max(v[1]).max(v[2]) - v[0].min(v[1]).min(v[2]);
assert!(spread(dark_side) < spread(card));
assert!(spread(pale_side) < spread(card));
set_ink(DARK_INK);
}
}