zcatgui/src/core/style.zig
R.Eugenio 31362b6d48 style: Z-Design V6 - Colores más vivos (30%/20%)
Cambios en deriveDarkPalette y derivePanelFrameColors:
- focus_blend: 80 → 70 (30% color visible, antes 20%)
- unfocus_blend: 88 → 80 (20% color visible, antes 12%)

Objetivo: Mejor identificación visual de tipos de documento/cliente
sin perder la estética oscura profesional.

Tests actualizados para reflejar nuevos valores.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 13:28:44 +01:00

1437 lines
50 KiB
Zig

//! Style - Colors and visual styling
//!
//! Based on zcatui's style system, adapted for GUI with RGBA colors.
const std = @import("std");
// =============================================================================
// Render Mode - Simple vs Fancy
// =============================================================================
/// Render mode controls visual quality vs performance tradeoff
pub const RenderMode = enum {
/// Fast rendering: rectangles, no AA on shapes, no shadows
/// Best for: low-end hardware, SSH, WASM with limited resources
simple,
/// Pretty rendering: rounded corners, edge-fade AA, shadows
/// Best for: desktop with good CPU, visual polish needed
fancy,
};
/// Global render mode - widgets check this to decide how to render
var global_render_mode: RenderMode = .fancy;
/// Get current render mode
pub fn getRenderMode() RenderMode {
return global_render_mode;
}
/// Set render mode
pub fn setRenderMode(mode: RenderMode) void {
global_render_mode = mode;
}
/// Check if fancy rendering is enabled
pub fn isFancy() bool {
return global_render_mode == .fancy;
}
// =============================================================================
// Perceptual Color Correction - Sistema de Compensación de Colores
// =============================================================================
//
// PROBLEMA A RESOLVER:
// --------------------
// Cuando se oscurecen colores para fondos de paneles (deriveDarkPalette),
// algunos colores pierden su identidad y "van a negro" más rápido que otros:
// - Panel AZUL: al oscurecerse, parece casi negro (pierde identidad)
// - Panel ROJO: al oscurecerse, sigue viéndose "rojo oscuro" (mantiene identidad)
//
// CAUSA TÉCNICA:
// --------------
// La luminosidad percibida por el ojo humano sigue la fórmula ITU-R BT.709:
// L = 0.2126*R + 0.7152*G + 0.0722*B
//
// Valores para colores puros:
// - Azul puro (0,0,255): L = 0.0722 (MUY baja)
// - Rojo puro (255,0,0): L = 0.2126 (baja pero ~3x más que azul)
// - Verde puro (0,255,0): L = 0.7152 (alta)
//
// El azul tiene la menor contribución a la luminosidad percibida, por eso
// al oscurecerlo pierde rápidamente su identidad visual.
//
// SOLUCIÓN IMPLEMENTADA:
// ----------------------
// Umbral de corrección: 0.15
// - Colores con L < 0.15 (como azul ~0.07): reciben boost
// - Colores con L >= 0.15 (como rojo ~0.21): NO se modifican
//
// El "boost" consiste en REDUCIR el porcentaje de blend hacia negro,
// dejando más del color base visible en el fondo oscurecido.
//
// Factor de corrección = max(0.75, L / 0.15)
// - Azul: max(0.75, 0.07/0.15) = max(0.75, 0.47) = 0.75
// - Rojo: L=0.21 >= 0.15, no aplica corrección (factor = 1.0)
//
// Aplicación en deriveDarkPalette:
// - focus_blend = 80% * factor (sin corrección: 80%, con: 60%)
// - unfocus_blend = 96% * factor (sin corrección: 96%, con: 72%)
//
// ESTADO ACTUAL (2025-12-30):
// ---------------------------
// - Activado por defecto (perceptual_correction_enabled = true)
// - Umbral: 0.15
// - PENDIENTE DE CONSENSO: El usuario R.Eugenio considera que necesita
// más opiniones antes de decidir si este algoritmo es el correcto.
// Los colores son subjetivos y requieren consenso del equipo.
//
// CÓMO DESACTIVAR:
// ----------------
// En runtime: Style.setPerceptualCorrection(false)
// O cambiar el default aquí abajo a false.
//
// =============================================================================
/// Enable perceptual correction for panel colors.
/// When enabled, colors with VERY low perceived luminance (e.g., blue ~0.07)
/// get a boost to avoid going to black when darkened.
/// Colors like red (~0.21) are NOT affected as they darken well naturally.
///
/// PENDIENTE DE CONSENSO - ver documentación arriba.
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,
g: u8,
b: u8,
a: u8 = 255,
const Self = @This();
/// Create a color from RGB values
pub fn rgb(r: u8, g: u8, b: u8) Self {
return .{ .r = r, .g = g, .b = b, .a = 255 };
}
/// Create a color from RGBA values
pub fn rgba(r: u8, g: u8, b: u8, a: u8) Self {
return .{ .r = r, .g = g, .b = b, .a = a };
}
/// Convert to u32 (RGBA format)
pub fn toU32(self: Self) u32 {
return (@as(u32, self.r) << 24) |
(@as(u32, self.g) << 16) |
(@as(u32, self.b) << 8) |
@as(u32, self.a);
}
/// Convert to u32 (ABGR format for SDL)
pub fn toABGR(self: Self) u32 {
return (@as(u32, self.a) << 24) |
(@as(u32, self.b) << 16) |
(@as(u32, self.g) << 8) |
@as(u32, self.r);
}
/// Blend this color over another
pub fn blend(self: Self, bg_color: Self) Self {
if (self.a == 255) return self;
if (self.a == 0) return bg_color;
const alpha = @as(u16, self.a);
const inv_alpha = 255 - alpha;
return .{
.r = @intCast((@as(u16, self.r) * alpha + @as(u16, bg_color.r) * inv_alpha) / 255),
.g = @intCast((@as(u16, self.g) * alpha + @as(u16, bg_color.g) * inv_alpha) / 255),
.b = @intCast((@as(u16, self.b) * alpha + @as(u16, bg_color.b) * inv_alpha) / 255),
.a = 255,
};
}
/// Darken color by percentage (0-100)
pub fn darken(self: Self, percent: u8) Self {
const factor = @as(u16, 100 - @min(percent, 100));
return .{
.r = @intCast((@as(u16, self.r) * factor) / 100),
.g = @intCast((@as(u16, self.g) * factor) / 100),
.b = @intCast((@as(u16, self.b) * factor) / 100),
.a = self.a,
};
}
/// Lighten color by percentage (0-100)
pub fn lighten(self: Self, percent: u8) Self {
const factor = @as(u16, @min(percent, 100));
return .{
.r = @intCast(@as(u16, self.r) + ((@as(u16, 255) - self.r) * factor) / 100),
.g = @intCast(@as(u16, self.g) + ((@as(u16, 255) - self.g) * factor) / 100),
.b = @intCast(@as(u16, self.b) + ((@as(u16, 255) - self.b) * factor) / 100),
.a = self.a,
};
}
/// Blend this color towards a target color by percentage (0-100).
/// Useful for "washing" colors: base.blendTowards(white, 95) = 95% white + 5% base
/// This is the key function for Laravel-style "subtle tint" backgrounds.
pub fn blendTowards(self: Self, target: Self, percent: u8) Self {
const p = @as(u16, @min(percent, 100));
const inv_p = 100 - p;
return .{
.r = @intCast((@as(u16, target.r) * p + @as(u16, self.r) * inv_p) / 100),
.g = @intCast((@as(u16, target.g) * p + @as(u16, self.g) * inv_p) / 100),
.b = @intCast((@as(u16, target.b) * p + @as(u16, self.b) * inv_p) / 100),
.a = 255,
};
}
/// Return same color with different alpha
pub fn withAlpha(self: Self, alpha: u8) Self {
return .{
.r = self.r,
.g = self.g,
.b = self.b,
.a = alpha,
};
}
// =========================================================================
// 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)
// =========================================================================
/// 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
// =========================================================================
pub const transparent = Color.rgba(0, 0, 0, 0);
pub const black = Color.rgb(0, 0, 0);
pub const white = Color.rgb(255, 255, 255);
pub const red = Color.rgb(255, 0, 0);
pub const green = Color.rgb(0, 255, 0);
pub const blue = Color.rgb(0, 0, 255);
pub const yellow = Color.rgb(255, 255, 0);
pub const cyan = Color.rgb(0, 255, 255);
pub const magenta = Color.rgb(255, 0, 255);
pub const gray = Color.rgb(128, 128, 128);
pub const dark_gray = Color.rgb(64, 64, 64);
pub const light_gray = Color.rgb(192, 192, 192);
// UI colors
pub const background = Color.rgb(30, 30, 30);
pub const foreground = Color.rgb(220, 220, 220);
pub const primary = Color.rgb(66, 135, 245);
pub const secondary = Color.rgb(100, 100, 100);
pub const success = Color.rgb(76, 175, 80);
pub const warning = Color.rgb(255, 152, 0);
pub const danger = Color.rgb(244, 67, 54);
pub const border = Color.rgb(80, 80, 80);
// =========================================================================
// Laravel-inspired colors (from Forge/Nova/Vapor)
// Used as base colors for semantic panel derivation (Z-Design)
// =========================================================================
pub const laravel_red = Color.rgb(239, 68, 68); // #EF4444 - Facturas, alertas
pub const laravel_blue = Color.rgb(59, 130, 246); // #3B82F6 - Clientes, links
pub const laravel_green = Color.rgb(34, 197, 94); // #22C55E - Exito, pagado
pub const laravel_amber = Color.rgb(245, 158, 11); // #F59E0B - Pedidos, warning
pub const laravel_cyan = Color.rgb(6, 182, 212); // #06B6D4 - Albaranes, info
pub const laravel_gray = Color.rgb(107, 114, 128); // #6B7280 - Presupuestos, neutral
pub const laravel_purple = Color.rgb(139, 92, 246); // #8B5CF6 - Especial
pub const laravel_pink = Color.rgb(236, 72, 153); // #EC4899 - Destacado
// Base colors for derivation (soft black/white for better aesthetics)
pub const soft_black = Color.rgb(17, 17, 20); // Not pure black
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,
background: Color = Color.background,
border: ?Color = null,
border_radius: u8 = 0,
const Self = @This();
/// Set foreground color
pub fn fg(self: Self, color: Color) Self {
var s = self;
s.foreground = color;
return s;
}
/// Set background color
pub fn bg(self: Self, color: Color) Self {
var s = self;
s.background = color;
return s;
}
/// Set border color
pub fn withBorder(self: Self, color: Color) Self {
var s = self;
s.border = color;
return s;
}
};
// =============================================================================
// Theme
// =============================================================================
/// A theme defines colors for all UI elements
pub const Theme = struct {
/// Theme name
name: []const u8 = "custom",
// Base colors
background: Color,
foreground: Color,
primary: Color,
secondary: Color,
success: Color,
warning: Color,
danger: Color,
border: Color,
// Surface colors (panels, cards)
surface: Color,
surface_variant: Color,
// Text variants
text_primary: Color,
text_secondary: Color,
text_disabled: Color,
// Button colors
button_bg: Color,
button_fg: Color,
button_hover: Color,
button_active: Color,
button_disabled_bg: Color,
button_disabled_fg: Color,
// Input colors
input_bg: Color,
input_fg: Color,
input_border: Color,
input_focus_border: Color,
input_placeholder: Color,
// Selection colors
selection_bg: Color,
selection_fg: Color,
// Header/menu bar
header_bg: Color,
header_fg: Color,
// Table colors
table_header_bg: Color,
table_row_even: Color,
table_row_odd: Color,
table_row_hover: Color,
table_row_selected: Color,
// Scrollbar
scrollbar_track: Color,
scrollbar_thumb: Color,
scrollbar_thumb_hover: Color,
// Modal/dialog
modal_overlay: Color,
modal_bg: Color,
const Self = @This();
/// Dark theme (default)
pub const dark = Self{
.name = "dark",
.background = Color.rgb(30, 30, 30),
.foreground = Color.rgb(220, 220, 220),
.primary = Color.rgb(66, 135, 245),
.secondary = Color.rgb(100, 100, 100),
.success = Color.rgb(76, 175, 80),
.warning = Color.rgb(255, 152, 0),
.danger = Color.rgb(244, 67, 54),
.border = Color.rgb(80, 80, 80),
.surface = Color.rgb(40, 40, 40),
.surface_variant = Color.rgb(50, 50, 50),
.text_primary = Color.rgb(220, 220, 220),
.text_secondary = Color.rgb(160, 160, 160),
.text_disabled = Color.rgb(100, 100, 100),
.button_bg = Color.rgb(60, 60, 60),
.button_fg = Color.rgb(220, 220, 220),
.button_hover = Color.rgb(80, 80, 80),
.button_active = Color.rgb(50, 50, 50),
.button_disabled_bg = Color.rgb(45, 45, 45),
.button_disabled_fg = Color.rgb(100, 100, 100),
.input_bg = Color.rgb(45, 45, 45),
.input_fg = Color.rgb(220, 220, 220),
.input_border = Color.rgb(80, 80, 80),
.input_focus_border = Color.rgb(66, 135, 245),
.input_placeholder = Color.rgb(120, 120, 120),
.selection_bg = Color.rgb(66, 135, 245),
.selection_fg = Color.rgb(255, 255, 255),
.header_bg = Color.rgb(35, 35, 40),
.header_fg = Color.rgb(200, 200, 200),
.table_header_bg = Color.rgb(50, 50, 50),
.table_row_even = Color.rgb(35, 35, 35),
.table_row_odd = Color.rgb(40, 40, 40),
.table_row_hover = Color.rgb(50, 50, 60),
.table_row_selected = Color.rgb(66, 135, 245),
.scrollbar_track = Color.rgb(40, 40, 40),
.scrollbar_thumb = Color.rgb(80, 80, 80),
.scrollbar_thumb_hover = Color.rgb(100, 100, 100),
.modal_overlay = Color.rgba(0, 0, 0, 180),
.modal_bg = Color.rgb(45, 45, 50),
};
/// Light theme
pub const light = Self{
.name = "light",
.background = Color.rgb(245, 245, 245),
.foreground = Color.rgb(30, 30, 30),
.primary = Color.rgb(33, 150, 243),
.secondary = Color.rgb(158, 158, 158),
.success = Color.rgb(76, 175, 80),
.warning = Color.rgb(255, 152, 0),
.danger = Color.rgb(244, 67, 54),
.border = Color.rgb(200, 200, 200),
.surface = Color.rgb(255, 255, 255),
.surface_variant = Color.rgb(240, 240, 240),
.text_primary = Color.rgb(30, 30, 30),
.text_secondary = Color.rgb(100, 100, 100),
.text_disabled = Color.rgb(180, 180, 180),
.button_bg = Color.rgb(230, 230, 230),
.button_fg = Color.rgb(30, 30, 30),
.button_hover = Color.rgb(210, 210, 210),
.button_active = Color.rgb(190, 190, 190),
.button_disabled_bg = Color.rgb(240, 240, 240),
.button_disabled_fg = Color.rgb(180, 180, 180),
.input_bg = Color.rgb(255, 255, 255),
.input_fg = Color.rgb(30, 30, 30),
.input_border = Color.rgb(180, 180, 180),
.input_focus_border = Color.rgb(33, 150, 243),
.input_placeholder = Color.rgb(160, 160, 160),
.selection_bg = Color.rgb(33, 150, 243),
.selection_fg = Color.rgb(255, 255, 255),
.header_bg = Color.rgb(255, 255, 255),
.header_fg = Color.rgb(50, 50, 50),
.table_header_bg = Color.rgb(240, 240, 240),
.table_row_even = Color.rgb(255, 255, 255),
.table_row_odd = Color.rgb(248, 248, 248),
.table_row_hover = Color.rgb(235, 245, 255),
.table_row_selected = Color.rgb(33, 150, 243),
.scrollbar_track = Color.rgb(240, 240, 240),
.scrollbar_thumb = Color.rgb(200, 200, 200),
.scrollbar_thumb_hover = Color.rgb(180, 180, 180),
.modal_overlay = Color.rgba(0, 0, 0, 120),
.modal_bg = Color.rgb(255, 255, 255),
};
/// High contrast dark theme
pub const high_contrast_dark = Self{
.name = "high_contrast_dark",
.background = Color.rgb(0, 0, 0),
.foreground = Color.rgb(255, 255, 255),
.primary = Color.rgb(0, 200, 255),
.secondary = Color.rgb(180, 180, 180),
.success = Color.rgb(0, 255, 0),
.warning = Color.rgb(255, 255, 0),
.danger = Color.rgb(255, 0, 0),
.border = Color.rgb(255, 255, 255),
.surface = Color.rgb(20, 20, 20),
.surface_variant = Color.rgb(40, 40, 40),
.text_primary = Color.rgb(255, 255, 255),
.text_secondary = Color.rgb(200, 200, 200),
.text_disabled = Color.rgb(128, 128, 128),
.button_bg = Color.rgb(40, 40, 40),
.button_fg = Color.rgb(255, 255, 255),
.button_hover = Color.rgb(60, 60, 60),
.button_active = Color.rgb(20, 20, 20),
.button_disabled_bg = Color.rgb(30, 30, 30),
.button_disabled_fg = Color.rgb(100, 100, 100),
.input_bg = Color.rgb(0, 0, 0),
.input_fg = Color.rgb(255, 255, 255),
.input_border = Color.rgb(255, 255, 255),
.input_focus_border = Color.rgb(0, 200, 255),
.input_placeholder = Color.rgb(150, 150, 150),
.selection_bg = Color.rgb(0, 200, 255),
.selection_fg = Color.rgb(0, 0, 0),
.header_bg = Color.rgb(0, 0, 0),
.header_fg = Color.rgb(255, 255, 255),
.table_header_bg = Color.rgb(30, 30, 30),
.table_row_even = Color.rgb(0, 0, 0),
.table_row_odd = Color.rgb(20, 20, 20),
.table_row_hover = Color.rgb(40, 40, 60),
.table_row_selected = Color.rgb(0, 200, 255),
.scrollbar_track = Color.rgb(20, 20, 20),
.scrollbar_thumb = Color.rgb(150, 150, 150),
.scrollbar_thumb_hover = Color.rgb(200, 200, 200),
.modal_overlay = Color.rgba(0, 0, 0, 200),
.modal_bg = Color.rgb(20, 20, 20),
};
/// Solarized Dark theme
pub const solarized_dark = Self{
.name = "solarized_dark",
.background = Color.rgb(0, 43, 54), // base03
.foreground = Color.rgb(131, 148, 150), // base0
.primary = Color.rgb(38, 139, 210), // blue
.secondary = Color.rgb(88, 110, 117), // base01
.success = Color.rgb(133, 153, 0), // green
.warning = Color.rgb(181, 137, 0), // yellow
.danger = Color.rgb(220, 50, 47), // red
.border = Color.rgb(88, 110, 117), // base01
.surface = Color.rgb(7, 54, 66), // base02
.surface_variant = Color.rgb(0, 43, 54), // base03
.text_primary = Color.rgb(147, 161, 161), // base1
.text_secondary = Color.rgb(131, 148, 150), // base0
.text_disabled = Color.rgb(88, 110, 117), // base01
.button_bg = Color.rgb(7, 54, 66),
.button_fg = Color.rgb(147, 161, 161),
.button_hover = Color.rgb(88, 110, 117),
.button_active = Color.rgb(0, 43, 54),
.button_disabled_bg = Color.rgb(0, 43, 54),
.button_disabled_fg = Color.rgb(88, 110, 117),
.input_bg = Color.rgb(0, 43, 54),
.input_fg = Color.rgb(147, 161, 161),
.input_border = Color.rgb(88, 110, 117),
.input_focus_border = Color.rgb(38, 139, 210),
.input_placeholder = Color.rgb(88, 110, 117),
.selection_bg = Color.rgb(38, 139, 210),
.selection_fg = Color.rgb(253, 246, 227),
.header_bg = Color.rgb(7, 54, 66),
.header_fg = Color.rgb(147, 161, 161),
.table_header_bg = Color.rgb(7, 54, 66),
.table_row_even = Color.rgb(0, 43, 54),
.table_row_odd = Color.rgb(7, 54, 66),
.table_row_hover = Color.rgb(88, 110, 117),
.table_row_selected = Color.rgb(38, 139, 210),
.scrollbar_track = Color.rgb(0, 43, 54),
.scrollbar_thumb = Color.rgb(88, 110, 117),
.scrollbar_thumb_hover = Color.rgb(101, 123, 131),
.modal_overlay = Color.rgba(0, 0, 0, 180),
.modal_bg = Color.rgb(7, 54, 66),
};
/// Solarized Light theme
pub const solarized_light = Self{
.name = "solarized_light",
.background = Color.rgb(253, 246, 227), // base3
.foreground = Color.rgb(101, 123, 131), // base00
.primary = Color.rgb(38, 139, 210), // blue
.secondary = Color.rgb(147, 161, 161), // base1
.success = Color.rgb(133, 153, 0), // green
.warning = Color.rgb(181, 137, 0), // yellow
.danger = Color.rgb(220, 50, 47), // red
.border = Color.rgb(147, 161, 161), // base1
.surface = Color.rgb(238, 232, 213), // base2
.surface_variant = Color.rgb(253, 246, 227), // base3
.text_primary = Color.rgb(88, 110, 117), // base01
.text_secondary = Color.rgb(101, 123, 131), // base00
.text_disabled = Color.rgb(147, 161, 161), // base1
.button_bg = Color.rgb(238, 232, 213),
.button_fg = Color.rgb(88, 110, 117),
.button_hover = Color.rgb(147, 161, 161),
.button_active = Color.rgb(253, 246, 227),
.button_disabled_bg = Color.rgb(253, 246, 227),
.button_disabled_fg = Color.rgb(147, 161, 161),
.input_bg = Color.rgb(253, 246, 227),
.input_fg = Color.rgb(88, 110, 117),
.input_border = Color.rgb(147, 161, 161),
.input_focus_border = Color.rgb(38, 139, 210),
.input_placeholder = Color.rgb(147, 161, 161),
.selection_bg = Color.rgb(38, 139, 210),
.selection_fg = Color.rgb(253, 246, 227),
.header_bg = Color.rgb(238, 232, 213),
.header_fg = Color.rgb(88, 110, 117),
.table_header_bg = Color.rgb(238, 232, 213),
.table_row_even = Color.rgb(253, 246, 227),
.table_row_odd = Color.rgb(238, 232, 213),
.table_row_hover = Color.rgb(147, 161, 161),
.table_row_selected = Color.rgb(38, 139, 210),
.scrollbar_track = Color.rgb(253, 246, 227),
.scrollbar_thumb = Color.rgb(147, 161, 161),
.scrollbar_thumb_hover = Color.rgb(131, 148, 150),
.modal_overlay = Color.rgba(0, 0, 0, 120),
.modal_bg = Color.rgb(238, 232, 213),
};
};
// =============================================================================
// Theme Manager
// =============================================================================
/// Global theme manager
pub const ThemeManager = struct {
/// Current theme
current: *const Theme,
const Self = @This();
/// Initialize with default dark theme
pub fn init() Self {
return Self{
.current = &Theme.dark,
};
}
/// Set current theme
pub fn setTheme(self: *Self, theme: *const Theme) void {
self.current = theme;
}
/// Get current theme
pub fn getTheme(self: Self) *const Theme {
return self.current;
}
/// Switch to dark theme
pub fn setDark(self: *Self) void {
self.current = &Theme.dark;
}
/// Switch to light theme
pub fn setLight(self: *Self) void {
self.current = &Theme.light;
}
/// Toggle between dark and light
pub fn toggle(self: *Self) void {
if (std.mem.eql(u8, self.current.name, "dark")) {
self.current = &Theme.light;
} else {
self.current = &Theme.dark;
}
}
};
/// Global theme manager instance
var global_theme_manager: ?ThemeManager = null;
/// Get global theme manager
pub fn getThemeManager() *ThemeManager {
if (global_theme_manager == null) {
global_theme_manager = ThemeManager.init();
}
return &global_theme_manager.?;
}
/// Get current theme (convenience function)
pub fn currentTheme() *const Theme {
return getThemeManager().current;
}
// =============================================================================
// Tests
// =============================================================================
test "Color creation" {
const c = Color.rgb(100, 150, 200);
try std.testing.expectEqual(@as(u8, 100), c.r);
try std.testing.expectEqual(@as(u8, 150), c.g);
try std.testing.expectEqual(@as(u8, 200), c.b);
try std.testing.expectEqual(@as(u8, 255), c.a);
}
test "Color darken" {
const white = Color.white;
const darkened = white.darken(50);
try std.testing.expectEqual(@as(u8, 127), darkened.r);
}
test "Color blend" {
const fg = Color.rgba(255, 0, 0, 128);
const bg = Color.rgb(0, 0, 255);
const blended = fg.blend(bg);
// Should be purple-ish
try std.testing.expect(blended.r > 100);
try std.testing.expect(blended.b > 100);
}
test "Theme dark" {
const theme = Theme.dark;
try std.testing.expect(std.mem.eql(u8, theme.name, "dark"));
try std.testing.expectEqual(@as(u8, 30), theme.background.r);
}
test "Theme light" {
const theme = Theme.light;
try std.testing.expect(std.mem.eql(u8, theme.name, "light"));
try std.testing.expectEqual(@as(u8, 245), theme.background.r);
}
test "ThemeManager toggle" {
var tm = ThemeManager.init();
try std.testing.expect(std.mem.eql(u8, tm.current.name, "dark"));
tm.toggle();
try std.testing.expect(std.mem.eql(u8, tm.current.name, "light"));
tm.toggle();
try std.testing.expect(std.mem.eql(u8, tm.current.name, "dark"));
}
test "ThemeManager setTheme" {
var tm = ThemeManager.init();
tm.setTheme(&Theme.solarized_dark);
try std.testing.expect(std.mem.eql(u8, tm.current.name, "solarized_dark"));
tm.setTheme(&Theme.high_contrast_dark);
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
// =============================================================================
//
// Inspired by Laravel Forge/Nova/Vapor aesthetic.
// Derives a complete 10-color panel palette from a single base color.
//
// Architecture:
// 1. Base color (e.g., laravel_red for invoices)
// 2. ThemeMode (dark or light)
// 3. derivePanelPalette() generates all 10 colors mathematically
//
// This enables "semantic coloring" where entity type determines panel appearance.
/// Theme mode for panel derivation
pub const ThemeMode = enum {
dark, // Dark backgrounds, light text
light, // Light backgrounds, dark text
};
/// Complete color scheme for a panel (10 colors)
/// Designed to be derived from a single base color.
pub const PanelColorScheme = struct {
/// Background when panel has focus (subtle tint of base color)
fondo_con_focus: Color,
/// Background when panel doesn't have focus (neutral)
fondo_sin_focus: Color,
/// Data/content text color (high contrast)
datos: Color,
/// Label text color (secondary text)
etiquetas: Color,
/// Header/title background
header: Color,
/// Placeholder text color
placeholder: Color,
/// Selection background when focused (base color shines here)
seleccion_fondo_con_focus: Color,
/// Selection background when unfocused (muted)
seleccion_fondo_sin_focus: Color,
/// Border when focused (accent)
borde_con_focus: Color,
/// Border when unfocused (subtle)
borde_sin_focus: Color,
};
/// Derives a complete 10-color panel palette from a single base color.
///
/// The base color "tints" the panel subtly, creating visual cohesion
/// while maintaining readability. Selection and borders use the base
/// color at full strength as the accent.
///
/// Example:
/// ```zig
/// const invoice_palette = derivePanelPalette(Color.laravel_red, .dark);
/// // invoice_palette.fondo_con_focus = subtle red-tinted dark background
/// // invoice_palette.seleccion_fondo_con_focus = full laravel_red
/// ```
pub fn derivePanelPalette(base: Color, mode: ThemeMode) PanelColorScheme {
return switch (mode) {
.dark => deriveDarkPalette(base),
.light => deriveLightPalette(base),
};
}
/// Derive palette for dark mode (dark backgrounds, light text)
/// Z-Design V5: Sincronía Atmosférica (2025-12-31)
/// - SIN compensación perceptual (causaba más problemas que soluciones)
/// - Blend fijo para TODOS los colores:
/// - fondo_con_focus: 20% base color / 80% negro
/// - fondo_sin_focus: 12% base color / 88% negro
fn deriveDarkPalette(base: Color) PanelColorScheme {
// Reference colors for dark mode
const black = Color.soft_black; // RGB(17, 17, 20) - not pure black
const white = Color.rgb(245, 245, 245); // Off-white for softer look
const gray = Color.rgb(128, 128, 128);
const dark_border = Color.rgb(60, 60, 65);
// Z-Design V6: Más color visible para mejor identificación (2026-01-01)
// Focus: 30% color, Unfocus: 20% color
const focus_blend: u8 = 70; // 70% hacia negro = 30% color
const unfocus_blend: u8 = 80; // 80% hacia negro = 20% color
return .{
// Backgrounds: Z-Design V6 - blend fijo con más color
.fondo_con_focus = base.blendTowards(black, focus_blend),
.fondo_sin_focus = base.blendTowards(black, unfocus_blend),
// Text: high contrast
.datos = white,
.etiquetas = white.darken(30), // ~70% brightness
.placeholder = gray,
// Header: darkened using HSL (preserves hue better than RGB darken)
.header = base.darkenHsl(50),
// 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,
.borde_sin_focus = dark_border,
};
}
/// Derive palette for light mode (light backgrounds, dark text)
/// 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
const black = Color.rgb(20, 20, 25);
const gray = Color.rgb(128, 128, 128);
const light_border = Color.rgb(220, 220, 225);
// Perceptual correction: only for colors with VERY low luminance (like pure blue ~0.07)
// Red (~0.21) is above threshold and won't be affected
const base_lum = base.perceptualLuminance();
const threshold: f32 = 0.15; // Only affect colors below this (blue=0.07, red=0.21)
const correction: f32 = if (perceptual_correction_enabled and base_lum < threshold)
@max(0.75, base_lum / threshold) // Subtle boost for very dark colors
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) - 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,
.etiquetas = black.lighten(40), // ~60% darkness
.placeholder = gray,
// Header: lightened using HSL (preserves hue better)
.header = base.lightenHsl(40),
// Selection: base color at full strength when focused
.seleccion_fondo_con_focus = base,
// 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,
.borde_sin_focus = light_border,
};
}
/// 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
// =============================================================================
test "blendTowards basic" {
const red = Color.rgb(255, 0, 0);
const white = Color.rgb(255, 255, 255);
// 50% blend towards white
const result = red.blendTowards(white, 50);
try std.testing.expectEqual(@as(u8, 255), result.r); // Red stays 255
try std.testing.expectEqual(@as(u8, 127), result.g); // 0 -> 127
try std.testing.expectEqual(@as(u8, 127), result.b); // 0 -> 127
}
test "blendTowards extremes" {
const base = Color.rgb(100, 100, 100);
const target = Color.rgb(200, 200, 200);
// 0% = pure base
const zero = base.blendTowards(target, 0);
try std.testing.expectEqual(@as(u8, 100), zero.r);
// 100% = pure target
const hundred = base.blendTowards(target, 100);
try std.testing.expectEqual(@as(u8, 200), hundred.r);
}
test "derivePanelPalette dark mode" {
const palette = derivePanelPalette(Color.laravel_red, .dark);
// Selection should be the full base color
try std.testing.expectEqual(Color.laravel_red.r, palette.seleccion_fondo_con_focus.r);
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
// 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 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);
// Selection should be the full base color
try std.testing.expectEqual(Color.laravel_blue.r, palette.seleccion_fondo_con_focus.r);
// Background should be light with visible tint (Liquid UI V2: 6% base color)
// 94% blend towards white = light with noticeable tint for transitions
try std.testing.expect(palette.fondo_con_focus.r > 230);
try std.testing.expect(palette.fondo_con_focus.g > 235);
try std.testing.expect(palette.fondo_con_focus.b > 240);
}
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)
}
// =============================================================================
// SMART PANEL V2: Derivación Genérica para drawPanelFrame
// =============================================================================
//
// Fórmula matemática genérica (consensuada 2025-12-31):
// L = 0.2126*R + 0.7152*G + 0.0722*B (luminosidad percibida)
// blend_factor = base_blend + (max_blend - base_blend) * (1.0 - L)
//
// El blend es INVERSAMENTE proporcional a la luminosidad:
// - Colores oscuros (L baja, ej: azul 0.07) → más color visible (blend alto ~20%)
// - Colores brillantes (L alta, ej: amarillo 0.93) → menos color (blend bajo ~10%)
//
// Esto es puramente matemático, funciona para CUALQUIER color del círculo cromático.
// =============================================================================
/// Colors derived from a single base color for panel frame rendering.
/// Used by Context.drawPanelFrame() when base_color is provided.
pub const DerivedPanelColors = struct {
/// Background when focused
focus_bg: Color,
/// Background when unfocused
unfocus_bg: Color,
/// Border when focused (full base color)
border_focus: Color,
/// Border when unfocused (darkened)
border_unfocus: Color,
/// Title color (lightened base)
title_color: Color,
};
/// Derive panel frame colors from a single base color.
///
/// Z-Design V5: Sincronía Atmosférica (2025-12-31)
/// - SIN compensación perceptual
/// - Blend fijo para TODOS los colores:
/// - Fondo con focus: 20% base / 80% negro
/// - Fondo sin focus: 12% base / 88% negro
///
/// Títulos Adaptativos (2025-12-31):
/// - El title_color se calcula para máximo contraste
/// - Fondo oscuro → blanco teñido (lightenHsl 90)
/// - Fondo claro → negro teñido (darkenHsl 90)
///
/// Los widgets usan bg_transition.current DIRECTAMENTE (mismo fondo que panel)
/// con bisel de 1px para verse como "huecos" o "relieves" integrados.
pub fn derivePanelFrameColors(base: Color) DerivedPanelColors {
const black = Color.soft_black;
// Z-Design V6: 30% color con focus, 20% sin focus (2026-01-01)
const focus_bg = base.blendTowards(black, 70); // 30% color
// Título: BLANCO con tinte sutil del color base para identidad
// El contraste viene del blanco, el tinte da coherencia visual
// Fondos oscuros → blanco teñido, claros → negro teñido
const bg_luminance = focus_bg.perceptualLuminance();
const title_color = if (bg_luminance < 0.5)
Color.soft_white.blendTowards(base, 15) // 85% blanco + 15% tinte del panel
else
Color.soft_black.blendTowards(base, 15); // 85% negro + 15% tinte
return .{
.focus_bg = focus_bg,
.unfocus_bg = base.blendTowards(black, 80), // 20% color
.border_focus = base,
.border_unfocus = base.darken(30),
.title_color = title_color,
};
}
test "derivePanelFrameColors fixed blend" {
// Z-Design V6: All colors use same fixed blend (30% focus, 20% unfocus)
const blue = Color.rgb(0, 0, 255);
const derived = derivePanelFrameColors(blue);
// Border focus should be the original color
try std.testing.expectEqual(blue.r, derived.border_focus.r);
try std.testing.expectEqual(blue.g, derived.border_focus.g);
try std.testing.expectEqual(blue.b, derived.border_focus.b);
// Focus bg: 30% blue + 70% soft_black(17,17,20)
// 0.30 * 255 + 0.70 * 20 = 76.5 + 14 = ~90
try std.testing.expect(derived.focus_bg.b > 75);
try std.testing.expect(derived.focus_bg.b < 105);
}
test "derivePanelFrameColors same blend for all colors" {
// V6: No perceptual correction - same blend for red and blue
const blue = Color.rgb(0, 0, 255);
const red = Color.rgb(255, 0, 0);
const blue_derived = derivePanelFrameColors(blue);
const red_derived = derivePanelFrameColors(red);
// Both should have ~30% of their primary color channel
const blue_intensity = blue_derived.focus_bg.b;
const red_intensity = red_derived.focus_bg.r;
// Should be approximately equal (within tolerance for soft_black blend)
const diff = if (blue_intensity > red_intensity)
blue_intensity - red_intensity
else
red_intensity - blue_intensity;
try std.testing.expect(diff < 10); // Close enough
}