feat(colors): Add perceptual luminance correction (Z-Design V3)

E1 task from PLAN_REFINAMIENTO_UI_2025-12-30.md:
- Add Color.perceptualLuminance() using ITU-R BT.709 weights
- Add global perceptual_correction_enabled flag (default: true)
- deriveDarkPalette/deriveLightPalette now boost low-luminance colors
- Colors like red/magenta now have comparable contrast to blue
- Correction capped at 0.7 to avoid excessive boost

Algorithm: if base_lum < 0.45, reduce blend % by ratio (more color visible)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
R.Eugenio 2025-12-30 20:15:48 +01:00
parent ded8946702
commit 16fc528415

View file

@ -37,6 +37,25 @@ pub fn isFancy() bool {
return global_render_mode == .fancy;
}
// =============================================================================
// Perceptual Color Correction
// =============================================================================
/// Enable perceptual correction for panel colors.
/// When enabled, colors with low perceived luminance (e.g., red, magenta)
/// get a subtle boost to match the visual contrast of brighter colors (e.g., blue).
var perceptual_correction_enabled: bool = true;
/// Get whether perceptual correction is enabled
pub fn isPerceptualCorrectionEnabled() bool {
return perceptual_correction_enabled;
}
/// Set perceptual correction mode
pub fn setPerceptualCorrection(enabled: bool) void {
perceptual_correction_enabled = enabled;
}
/// RGBA Color
pub const Color = struct {
r: u8,
@ -134,6 +153,21 @@ pub const Color = struct {
};
}
// =========================================================================
// Perceptual luminance (ITU-R BT.709)
// =========================================================================
/// Calculate perceived luminance using ITU-R BT.709 weights.
/// Returns 0.0 (black) to 1.0 (white).
/// Human eyes perceive green as brightest, red medium, blue darkest.
/// Weights: R=0.2126, G=0.7152, B=0.0722
pub fn perceptualLuminance(self: Self) f32 {
const r_norm = @as(f32, @floatFromInt(self.r)) / 255.0;
const g_norm = @as(f32, @floatFromInt(self.g)) / 255.0;
const b_norm = @as(f32, @floatFromInt(self.b)) / 255.0;
return r_norm * 0.2126 + g_norm * 0.7152 + b_norm * 0.0722;
}
// =========================================================================
// HSL-based transformations (more perceptually uniform)
// =========================================================================
@ -1034,6 +1068,7 @@ pub fn derivePanelPalette(base: Color, mode: ThemeMode) PanelColorScheme {
/// Z-Design V2 + Liquid UI: Mayor contraste para transiciones perceptibles
/// - fondo_sin_focus: 4% base color (más oscuro, punto de partida bajo)
/// - fondo_con_focus: 20% base color (brilla al ganar foco, destino alto)
/// Z-Design V3: Perceptual correction boosts low-luminance colors (red/magenta)
fn deriveDarkPalette(base: Color) PanelColorScheme {
// Reference colors for dark mode
const black = Color.soft_black; // RGB(17, 17, 20) - not pure black
@ -1041,12 +1076,28 @@ fn deriveDarkPalette(base: Color) PanelColorScheme {
const gray = Color.rgb(128, 128, 128);
const dark_border = Color.rgb(60, 60, 65);
// Perceptual correction: boost low-luminance colors to match visual contrast
// Reference: laravel_blue has luminance ~0.48, we use 0.45 as target
const base_lum = base.perceptualLuminance();
const target_lum: f32 = 0.45; // Reference luminance (approximately blue)
// Calculate correction factor: if luminance is lower than target, reduce blend %
// This makes more of the base color visible, compensating for lower brightness
const correction: f32 = if (perceptual_correction_enabled and base_lum < target_lum)
@max(0.7, base_lum / target_lum) // Cap at 0.7 to avoid excessive boost
else
1.0;
// Apply correction to blend percentages (lower % = more base color visible)
const focus_blend: u8 = @intFromFloat(80.0 * correction);
const unfocus_blend: u8 = @intFromFloat(96.0 * correction);
return .{
// Backgrounds: Liquid UI V2 - mayor recorrido para transición perceptible
// Focus: 20% base, 80% black (destino luminoso)
.fondo_con_focus = base.blendTowards(black, 80),
// Sin focus: 4% base, 96% black (punto de partida oscuro)
.fondo_sin_focus = base.blendTowards(black, 96),
// Focus: 20% base, 80% black (destino luminoso) - adjusted by correction
.fondo_con_focus = base.blendTowards(black, focus_blend),
// Sin focus: 4% base, 96% black (punto de partida oscuro) - adjusted
.fondo_sin_focus = base.blendTowards(black, unfocus_blend),
// Text: high contrast
.datos = white,
@ -1071,6 +1122,7 @@ fn deriveDarkPalette(base: Color) PanelColorScheme {
/// Z-Design V2 + Liquid UI: Mayor contraste para transiciones perceptibles
/// - fondo_sin_focus: 1% base (casi blanco, punto de partida)
/// - fondo_con_focus: 6% base (brilla al ganar foco, destino)
/// Z-Design V3: Perceptual correction boosts low-luminance colors (red/magenta)
fn deriveLightPalette(base: Color) PanelColorScheme {
// Reference colors for light mode
const white = Color.soft_white; // RGB(250, 250, 252) - slight cool tint
@ -1078,12 +1130,26 @@ fn deriveLightPalette(base: Color) PanelColorScheme {
const gray = Color.rgb(128, 128, 128);
const light_border = Color.rgb(220, 220, 225);
// Perceptual correction: boost low-luminance colors to match visual contrast
// In light mode, lower blend % means more base color visible
const base_lum = base.perceptualLuminance();
const target_lum: f32 = 0.45;
const correction: f32 = if (perceptual_correction_enabled and base_lum < target_lum)
@max(0.7, base_lum / target_lum)
else
1.0;
// Apply correction (lower = more color visible on light background)
const focus_blend: u8 = @intFromFloat(94.0 * correction);
const unfocus_blend: u8 = @intFromFloat(99.0 * correction);
return .{
// Backgrounds: Liquid UI V2 - mayor recorrido para transición perceptible
// Focus: 6% base, 94% white (destino más tintado)
.fondo_con_focus = base.blendTowards(white, 94),
// Sin focus: 1% base, 99% white (punto de partida neutro)
.fondo_sin_focus = base.blendTowards(white, 99),
// Focus: 6% base, 94% white (destino más tintado) - adjusted by correction
.fondo_con_focus = base.blendTowards(white, focus_blend),
// Sin focus: 1% base, 99% white (punto de partida neutro) - adjusted
.fondo_sin_focus = base.blendTowards(white, unfocus_blend),
// Text: high contrast
.datos = black,
@ -1151,16 +1217,47 @@ test "derivePanelPalette dark mode" {
try std.testing.expectEqual(Color.laravel_red.g, palette.seleccion_fondo_con_focus.g);
try std.testing.expectEqual(Color.laravel_red.b, palette.seleccion_fondo_con_focus.b);
// Background should be dark with visible tint (Liquid UI V2: 20% base color)
// 80% blend towards black = dark with noticeable tint for transitions
try std.testing.expect(palette.fondo_con_focus.r < 65); // Red tint visible
try std.testing.expect(palette.fondo_con_focus.g < 35);
try std.testing.expect(palette.fondo_con_focus.b < 35);
// Background should be dark with visible tint
// Z-Design V3: With perceptual correction, red gets boosted (less blend towards black)
// so the red component can be higher than before (~80 instead of ~60)
try std.testing.expect(palette.fondo_con_focus.r < 100); // Still dark
try std.testing.expect(palette.fondo_con_focus.g < 45);
try std.testing.expect(palette.fondo_con_focus.b < 45);
// The red component should be slightly higher than G/B (tint visible)
// The red component should be higher than G/B (tint visible)
try std.testing.expect(palette.fondo_con_focus.r >= palette.fondo_con_focus.g);
}
test "perceptualLuminance" {
// Pure red: 0.2126
const red = Color.rgb(255, 0, 0);
try std.testing.expect(red.perceptualLuminance() > 0.2);
try std.testing.expect(red.perceptualLuminance() < 0.22);
// Pure green: 0.7152
const green = Color.rgb(0, 255, 0);
try std.testing.expect(green.perceptualLuminance() > 0.71);
try std.testing.expect(green.perceptualLuminance() < 0.72);
// Pure blue: 0.0722
const blue = Color.rgb(0, 0, 255);
try std.testing.expect(blue.perceptualLuminance() > 0.07);
try std.testing.expect(blue.perceptualLuminance() < 0.08);
// White: 1.0
const white = Color.rgb(255, 255, 255);
try std.testing.expect(white.perceptualLuminance() > 0.99);
// Black: 0.0
const black = Color.rgb(0, 0, 0);
try std.testing.expect(black.perceptualLuminance() < 0.01);
// Blue has higher perceived luminance than red (at same saturation)
const laravel_red_lum = Color.laravel_red.perceptualLuminance();
const laravel_blue_lum = Color.laravel_blue.perceptualLuminance();
try std.testing.expect(laravel_blue_lum > laravel_red_lum);
}
test "derivePanelPalette light mode" {
const palette = derivePanelPalette(Color.laravel_blue, .light);