feat(style): Z-Design panel color derivation system

Laravel-inspired color system for semantic panel coloring:

- Add blendTowards(target, percent) for color washing
- Add Laravel color constants (red, blue, green, amber, cyan, etc.)
- Add soft_black/soft_white for better aesthetics
- Add ThemeMode enum (dark/light)
- Add PanelColorScheme with 10 documented fields
- Implement derivePanelPalette(base, mode) to derive full palette
- deriveDarkPalette: 5% tint on dark background
- deriveLightPalette: 3% tint on light background
- Tests for blendTowards and derivePanelPalette

This enables deriving a complete 10-color panel palette from
a single base color, achieving Laravel Forge/Nova aesthetics.
This commit is contained in:
reugenio 2025-12-27 22:23:49 +01:00
parent 5c7964bacc
commit 9c9c53afea

View file

@ -110,6 +110,20 @@ pub const Color = struct {
}; };
} }
/// 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 /// Return same color with different alpha
pub fn withAlpha(self: Self, alpha: u8) Self { pub fn withAlpha(self: Self, alpha: u8) Self {
return .{ return .{
@ -146,6 +160,23 @@ pub const Color = struct {
pub const warning = Color.rgb(255, 152, 0); pub const warning = Color.rgb(255, 152, 0);
pub const danger = Color.rgb(244, 67, 54); pub const danger = Color.rgb(244, 67, 54);
pub const border = Color.rgb(80, 80, 80); 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
}; };
/// Visual style for widgets /// Visual style for widgets
@ -632,3 +663,196 @@ test "ThemeManager setTheme" {
tm.setTheme(&Theme.high_contrast_dark); tm.setTheme(&Theme.high_contrast_dark);
try std.testing.expect(std.mem.eql(u8, tm.current.name, "high_contrast_dark")); try std.testing.expect(std.mem.eql(u8, tm.current.name, "high_contrast_dark"));
} }
// =============================================================================
// 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)
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);
return .{
// Backgrounds: subtle tint of base color (5% base, 95% black)
.fondo_con_focus = base.blendTowards(black, 95),
.fondo_sin_focus = black,
// Text: high contrast
.datos = white,
.etiquetas = white.darken(30), // ~70% brightness
.placeholder = gray,
// Header: darkened base color
.header = base.darken(60),
// Selection: where the base color SHINES
.seleccion_fondo_con_focus = base, // Full color!
.seleccion_fondo_sin_focus = base.blendTowards(gray, 50), // Muted
// Borders: accent on focus, subtle otherwise
.borde_con_focus = base,
.borde_sin_focus = dark_border,
};
}
/// Derive palette for light mode (light backgrounds, dark text)
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);
return .{
// Backgrounds: subtle tint of base color (3% base, 97% white)
.fondo_con_focus = base.blendTowards(white, 97),
.fondo_sin_focus = white,
// Text: high contrast
.datos = black,
.etiquetas = black.lighten(40), // ~60% darkness
.placeholder = gray,
// Header: very light version of base
.header = base.blendTowards(white, 85),
// Selection: base color (slightly lightened for readability)
.seleccion_fondo_con_focus = base,
.seleccion_fondo_sin_focus = base.blendTowards(white, 60),
// Borders: accent on focus, subtle otherwise
.borde_con_focus = base,
.borde_sin_focus = light_border,
};
}
// =============================================================================
// 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 very dark (close to black, slight red tint)
// 95% blend towards black = mostly black with 5% of base color
try std.testing.expect(palette.fondo_con_focus.r < 35); // Red tint visible
try std.testing.expect(palette.fondo_con_focus.g < 25);
try std.testing.expect(palette.fondo_con_focus.b < 25);
// The red component should be slightly higher than G/B (tint visible)
try std.testing.expect(palette.fondo_con_focus.r >= palette.fondo_con_focus.g);
}
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 very light (close to white, slight blue tint)
try std.testing.expect(palette.fondo_con_focus.r > 240);
try std.testing.expect(palette.fondo_con_focus.g > 245);
try std.testing.expect(palette.fondo_con_focus.b > 250);
}