Compare commits

...

2 commits

Author SHA1 Message Date
b5a4205c29 feat(style): Mejorar derivePanelPalette con HSL
- Header usa darkenHsl/lightenHsl (preserva tono mejor que RGB)
- Selección sin foco usa desaturate + lightenHsl (más elegante)
- Nueva función contrastTextColor() para texto automático b/n
- Test adicional para contrastTextColor

Z-Design ahora produce paletas más armónicas desde color base.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 15:36:20 +01:00
c330492022 feat(style): Motor HSL para derivación de colores
Añadido espacio de color HSL para transformaciones perceptualmente uniformes:

- Struct Hsl con campos h (0-360), s (0-1), l (0-1)
- Funciones rgbToHsl() y hslToRgb() con algoritmo estándar
- Métodos Hsl: lighten, darken, saturate, desaturate, rotate
- Métodos Color: toHsl, lightenHsl, darkenHsl, saturate, desaturate
- Color.rotateHue, Color.complementary para teoría del color
- 15 tests unitarios para validar conversiones

Esto permite derivar paletas armónicas desde un color base (Z-Design).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 15:33:57 +01:00

View file

@ -134,6 +134,46 @@ pub const Color = struct {
};
}
// =========================================================================
// HSL-based transformations (more perceptually uniform)
// =========================================================================
/// Convert this color to HSL representation
/// Note: Uses forward declaration, actual function defined after Hsl struct
pub fn toHsl(self: Self) Hsl {
return rgbToHsl(self.r, self.g, self.b);
}
/// Lighten using HSL (more perceptually uniform than RGB lighten)
pub fn lightenHsl(self: Self, percent: f32) Self {
return self.toHsl().lighten(percent).toRgb().withAlpha(self.a);
}
/// Darken using HSL (more perceptually uniform than RGB darken)
pub fn darkenHsl(self: Self, percent: f32) Self {
return self.toHsl().darken(percent).toRgb().withAlpha(self.a);
}
/// Increase saturation (make more vivid)
pub fn saturate(self: Self, percent: f32) Self {
return self.toHsl().saturate(percent).toRgb().withAlpha(self.a);
}
/// Decrease saturation (make more gray)
pub fn desaturate(self: Self, percent: f32) Self {
return self.toHsl().desaturate(percent).toRgb().withAlpha(self.a);
}
/// Rotate hue by degrees (color wheel shift)
pub fn rotateHue(self: Self, degrees: f32) Self {
return self.toHsl().rotate(degrees).toRgb().withAlpha(self.a);
}
/// Get complementary color (opposite on color wheel)
pub fn complementary(self: Self) Self {
return self.rotateHue(180);
}
// =========================================================================
// Predefined colors
// =========================================================================
@ -179,6 +219,148 @@ pub const Color = struct {
pub const soft_white = Color.rgb(250, 250, 252); // Not pure white
};
// =============================================================================
// HSL Color Space
// =============================================================================
/// HSL color representation
/// H: Hue (0-360 degrees)
/// S: Saturation (0.0-1.0)
/// L: Lightness (0.0-1.0)
pub const Hsl = struct {
h: f32, // 0-360
s: f32, // 0-1
l: f32, // 0-1
const Self = @This();
/// Convert HSL to RGB Color
pub fn toRgb(self: Self) Color {
return hslToRgb(self.h, self.s, self.l);
}
/// Create HSL with clamped values
pub fn init(h: f32, s: f32, l: f32) Self {
return .{
.h = @mod(h, 360.0),
.s = std.math.clamp(s, 0.0, 1.0),
.l = std.math.clamp(l, 0.0, 1.0),
};
}
/// Increase lightness by percentage (0-100)
pub fn lighten(self: Self, percent: f32) Self {
const delta = percent / 100.0;
return Self.init(self.h, self.s, self.l + (1.0 - self.l) * delta);
}
/// Decrease lightness by percentage (0-100)
pub fn darken(self: Self, percent: f32) Self {
const delta = percent / 100.0;
return Self.init(self.h, self.s, self.l * (1.0 - delta));
}
/// Increase saturation by percentage (0-100)
pub fn saturate(self: Self, percent: f32) Self {
const delta = percent / 100.0;
return Self.init(self.h, self.s + (1.0 - self.s) * delta, self.l);
}
/// Decrease saturation by percentage (0-100)
pub fn desaturate(self: Self, percent: f32) Self {
const delta = percent / 100.0;
return Self.init(self.h, self.s * (1.0 - delta), self.l);
}
/// Rotate hue by degrees
pub fn rotate(self: Self, degrees: f32) Self {
return Self.init(self.h + degrees, self.s, self.l);
}
};
/// Convert RGB (0-255) to HSL
pub fn rgbToHsl(r: u8, g: u8, b: u8) Hsl {
// Normalize to 0-1 range
const rf: f32 = @as(f32, @floatFromInt(r)) / 255.0;
const gf: f32 = @as(f32, @floatFromInt(g)) / 255.0;
const bf: f32 = @as(f32, @floatFromInt(b)) / 255.0;
const max_val = @max(@max(rf, gf), bf);
const min_val = @min(@min(rf, gf), bf);
const delta = max_val - min_val;
// Lightness
const l = (max_val + min_val) / 2.0;
// Achromatic (gray)
if (delta == 0) {
return .{ .h = 0, .s = 0, .l = l };
}
// Saturation
const s = if (l > 0.5)
delta / (2.0 - max_val - min_val)
else
delta / (max_val + min_val);
// Hue
var h: f32 = 0;
if (max_val == rf) {
h = (gf - bf) / delta;
if (gf < bf) h += 6.0;
} else if (max_val == gf) {
h = (bf - rf) / delta + 2.0;
} else {
h = (rf - gf) / delta + 4.0;
}
h *= 60.0;
return .{ .h = h, .s = s, .l = l };
}
/// Convert HSL to RGB Color
pub fn hslToRgb(h: f32, s: f32, l: f32) Color {
// Achromatic (gray)
if (s == 0) {
const gray: u8 = @intFromFloat(l * 255.0);
return Color.rgb(gray, gray, gray);
}
const q = if (l < 0.5)
l * (1.0 + s)
else
l + s - l * s;
const p = 2.0 * l - q;
const hue_norm = h / 360.0;
const r = hueToRgb(p, q, hue_norm + 1.0 / 3.0);
const g = hueToRgb(p, q, hue_norm);
const b = hueToRgb(p, q, hue_norm - 1.0 / 3.0);
return Color.rgb(
@intFromFloat(r * 255.0),
@intFromFloat(g * 255.0),
@intFromFloat(b * 255.0),
);
}
/// Helper for HSL to RGB conversion
fn hueToRgb(p: f32, q: f32, t_in: f32) f32 {
var t = t_in;
if (t < 0) t += 1.0;
if (t > 1) t -= 1.0;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0 / 2.0) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
// =============================================================================
// Style
// =============================================================================
/// Visual style for widgets
pub const Style = struct {
foreground: Color = Color.foreground,
@ -664,6 +846,117 @@ test "ThemeManager setTheme" {
try std.testing.expect(std.mem.eql(u8, tm.current.name, "high_contrast_dark"));
}
// =============================================================================
// HSL Tests
// =============================================================================
test "rgbToHsl pure red" {
const hsl = rgbToHsl(255, 0, 0);
try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.h, 0.1);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01);
}
test "rgbToHsl pure green" {
const hsl = rgbToHsl(0, 255, 0);
try std.testing.expectApproxEqAbs(@as(f32, 120.0), hsl.h, 0.1);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01);
}
test "rgbToHsl pure blue" {
const hsl = rgbToHsl(0, 0, 255);
try std.testing.expectApproxEqAbs(@as(f32, 240.0), hsl.h, 0.1);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01);
}
test "rgbToHsl white" {
const hsl = rgbToHsl(255, 255, 255);
try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation
try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.l, 0.01); // Max lightness
}
test "rgbToHsl black" {
const hsl = rgbToHsl(0, 0, 0);
try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation
try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.l, 0.01); // Min lightness
}
test "rgbToHsl gray" {
const hsl = rgbToHsl(128, 128, 128);
try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation
try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.02); // Mid lightness
}
test "hslToRgb pure red" {
const color = hslToRgb(0, 1.0, 0.5);
try std.testing.expectEqual(@as(u8, 255), color.r);
try std.testing.expectEqual(@as(u8, 0), color.g);
try std.testing.expectEqual(@as(u8, 0), color.b);
}
test "hslToRgb roundtrip" {
// Test that RGB -> HSL -> RGB preserves color (within rounding tolerance)
const original = Color.rgb(200, 100, 50);
const hsl = original.toHsl();
const recovered = hsl.toRgb();
// Allow 1-2 units of rounding error in each channel
try std.testing.expect(@abs(@as(i16, original.r) - @as(i16, recovered.r)) <= 2);
try std.testing.expect(@abs(@as(i16, original.g) - @as(i16, recovered.g)) <= 2);
try std.testing.expect(@abs(@as(i16, original.b) - @as(i16, recovered.b)) <= 2);
}
test "Color.saturate red" {
// Test saturating a color that has hue
const muted_red = Color.rgb(200, 100, 100); // A muted red
const saturated = muted_red.saturate(30);
// Saturating should increase the difference between R and G/B
const orig_diff = @as(i16, muted_red.r) - @as(i16, muted_red.g);
const new_diff = @as(i16, saturated.r) - @as(i16, saturated.g);
try std.testing.expect(new_diff >= orig_diff);
}
test "Color.desaturate" {
const red = Color.rgb(255, 0, 0);
const desaturated = red.desaturate(100); // Full desaturation = gray
// Should become a gray (equal R, G, B)
try std.testing.expectEqual(desaturated.r, desaturated.g);
try std.testing.expectEqual(desaturated.g, desaturated.b);
}
test "Color.rotateHue" {
const red = Color.rgb(255, 0, 0); // Hue = 0
const green = red.rotateHue(120); // Hue = 120 = green
// Should be mostly green
try std.testing.expect(green.g > green.r);
try std.testing.expect(green.g > green.b);
}
test "Color.complementary" {
const red = Color.rgb(255, 0, 0);
const cyan = red.complementary(); // 180 degrees = cyan
// Cyan has high G and B, low R
try std.testing.expect(cyan.g > 200);
try std.testing.expect(cyan.b > 200);
try std.testing.expect(cyan.r < 50);
}
test "Hsl.lighten" {
var hsl = Hsl.init(0, 1.0, 0.5);
hsl = hsl.lighten(50); // Lighten 50%
try std.testing.expect(hsl.l > 0.5);
try std.testing.expect(hsl.l < 1.0);
}
test "Hsl.darken" {
var hsl = Hsl.init(0, 1.0, 0.5);
hsl = hsl.darken(50); // Darken 50%
try std.testing.expect(hsl.l < 0.5);
try std.testing.expect(hsl.l > 0.0);
}
// =============================================================================
// Z-DESIGN: Panel Color Derivation System
// =============================================================================
@ -755,12 +1048,13 @@ fn deriveDarkPalette(base: Color) PanelColorScheme {
.etiquetas = white.darken(30), // ~70% brightness
.placeholder = gray,
// Header: darkened base color
.header = base.darken(60),
// Header: darkened using HSL (preserves hue better than RGB darken)
.header = base.darkenHsl(50),
// Selection: where the base color SHINES
.seleccion_fondo_con_focus = base, // Full color!
.seleccion_fondo_sin_focus = base.blendTowards(gray, 50), // Muted
// Selection: base color at full strength when focused
.seleccion_fondo_con_focus = base,
// Unfocused: desaturated and slightly lightened (HSL-based, more elegant)
.seleccion_fondo_sin_focus = base.desaturate(60).lightenHsl(10),
// Borders: accent on focus, subtle otherwise
.borde_con_focus = base,
@ -786,12 +1080,13 @@ fn deriveLightPalette(base: Color) PanelColorScheme {
.etiquetas = black.lighten(40), // ~60% darkness
.placeholder = gray,
// Header: very light version of base
.header = base.blendTowards(white, 85),
// Header: lightened using HSL (preserves hue better)
.header = base.lightenHsl(40),
// Selection: base color (slightly lightened for readability)
// Selection: base color at full strength when focused
.seleccion_fondo_con_focus = base,
.seleccion_fondo_sin_focus = base.blendTowards(white, 60),
// Unfocused: desaturated and lightened (HSL-based)
.seleccion_fondo_sin_focus = base.desaturate(50).lightenHsl(30),
// Borders: accent on focus, subtle otherwise
.borde_con_focus = base,
@ -799,6 +1094,17 @@ fn deriveLightPalette(base: Color) PanelColorScheme {
};
}
/// Get appropriate text color (black or white) based on background luminosity.
/// Uses the HSL lightness value to determine contrast.
pub fn contrastTextColor(background: Color) Color {
const hsl = background.toHsl();
// If background is light (L > 0.5), use dark text; otherwise use light text
return if (hsl.l > 0.5)
Color.rgb(20, 20, 25) // Dark text for light backgrounds
else
Color.rgb(245, 245, 245); // Light text for dark backgrounds
}
// =============================================================================
// Z-Design Tests
// =============================================================================
@ -856,3 +1162,20 @@ test "derivePanelPalette light mode" {
try std.testing.expect(palette.fondo_con_focus.g > 245);
try std.testing.expect(palette.fondo_con_focus.b > 250);
}
test "contrastTextColor" {
// Dark background should get light text
const dark_bg = Color.rgb(30, 30, 30);
const text_on_dark = contrastTextColor(dark_bg);
try std.testing.expect(text_on_dark.r > 200); // Light text
// Light background should get dark text
const light_bg = Color.rgb(240, 240, 240);
const text_on_light = contrastTextColor(light_bg);
try std.testing.expect(text_on_light.r < 50); // Dark text
// Mid-gray (128/255 = 0.502 > 0.5) gets dark text
const mid_gray = Color.rgb(128, 128, 128);
const text_on_mid = contrastTextColor(mid_gray);
try std.testing.expect(text_on_mid.r < 50); // Dark text (L = 0.502 > 0.5)
}