tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! `llimphi-theme` — paleta compartida entre apps Llimphi.
2//!
3//! Define un set de slots semánticos (`bg_app`, `fg_text`, `accent`, etc.)
4//! que cada widget mapea a su propio `Palette` específico vía
5//! `Palette::from_theme(&theme)`. El analógo Llimphi al `nahual-theme`
6//! GPUI, pero con colores `peniko::Color` y sin macros de Background /
7//! gradiente — Llimphi pinta colores sólidos por ahora.
8//!
9//! Disponer del Theme en un crate aparte permite:
10//! 1. **Consistencia visual**: las apps comparten paleta sin redefinirla.
11//! 2. **Temas intercambiables**: `Theme::dark()` vs `Theme::light()` (o
12//! más adelante, sobreescritos por config del usuario).
13//! 3. **Widgets desacoplados**: cada widget acepta su `Palette` (no el
14//! Theme entero), así un consumidor que sólo necesita un botón con
15//! colores no-temáticos puede construir su `ButtonPalette` a mano.
16
17#![forbid(unsafe_code)]
18// `no_std` salvo bajo test (el arnés de pruebas necesita std). La paleta es
19// datos y aritmética: no había una sola razón para atarla a un sistema
20// operativo, sólo la inercia de heredar `peniko` a través de `llimphi-raster`.
21#![cfg_attr(not(test), no_std)]
22
23extern crate alloc;
24
25use alloc::vec;
26use alloc::vec::Vec;
27
28pub use peniko::Color;
29
30/// Un canal de color en 0,0..=1,0 llevado a byte, redondeando al más cercano.
31///
32/// No usa `f32::round`: ese método lo aporta `std`, y este crate es `no_std`.
33/// La alternativa obvia —`kurbo::common::FloatFuncs`— es una trampa de `cfg`:
34/// kurbo sólo la define cuando SU feature `std` está apagada, y en el build de
35/// Linux vello se la enciende, así que el import compilaría en wawa y fallaría
36/// en Linux. Sumar 0,5 y truncar da el mismo resultado que `round()` para
37/// valores no negativos, que es todo lo que un canal de color puede ser.
38fn a_byte(canal: f32) -> u8 {
39 (canal * 255.0 + 0.5).clamp(0.0, 255.0) as u8
40}
41
42// `core::time::Duration` es EL MISMO tipo que `std::time::Duration` — std sólo
43// lo reexporta. El cambio no toca a ningún consumidor.
44use core::time::Duration;
45
46// =====================================================================
47// Color estable por semilla — avatares, etiquetas, hash-coloring
48// =====================================================================
49
50/// Paleta sobria de 8 tonos para colorear entidades por hash (avatares de
51/// contactos, etiquetas de calendario…). Tonos apagados que conviven con
52/// cualquier `Theme`. Usada vía [`stable_color`].
53pub const ENTITY_PALETTE: [(u8, u8, u8); 8] = [
54 (94, 129, 172), // azul acero
55 (163, 109, 156), // malva
56 (122, 162, 110), // verde salvia
57 (191, 138, 92), // terracota
58 (108, 153, 168), // celeste apagado
59 (170, 120, 120), // rosa viejo
60 (130, 140, 175), // lavanda
61 (150, 150, 110), // oliva
62];
63
64/// Color estable derivado de una semilla: hash FNV-1a del texto → índice en
65/// [`ENTITY_PALETTE`]. La misma semilla da siempre el mismo color, sin estado.
66/// Para avatares (por correo), etiquetas, badges de entidad, etc.
67pub fn stable_color(seed: &str) -> Color {
68 let mut h: u32 = 2_166_136_261;
69 for b in seed.bytes() {
70 h ^= b as u32;
71 h = h.wrapping_mul(16_777_619);
72 }
73 let (r, g, b) = ENTITY_PALETTE[(h as usize) % ENTITY_PALETTE.len()];
74 Color::from_rgba8(r, g, b, 255)
75}
76
77// =====================================================================
78// Tokens transversales — motion, alpha, radius
79// =====================================================================
80//
81// Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …)
82// comparten **duraciones**, **alphas** y **radios** para que el sistema
83// se sienta uno solo. Cada token es `const`: las apps pueden referenciar
84// `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme`
85// vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una
86// future variante por preset lo requiera.
87
88/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
89/// soporífero). Los widgets eligen `MICRO` para tintes de hover/focus
90/// que sólo necesitan suavizar el "salto", `FAST` para microinteracciones
91/// completas (chip que pulsa), `NORMAL` para transiciones principales
92/// (toast entrar, modal abrir), `SLOW` para énfasis o entradas dramáticas
93/// (splash de boot, hero shared-element).
94pub mod motion {
95 use super::Duration;
96
97 /// Tintes hover/focus — apenas perceptible, sólo elimina el "clack".
98 pub const MICRO: Duration = Duration::from_millis(50);
99 pub const FAST: Duration = Duration::from_millis(80);
100 pub const NORMAL: Duration = Duration::from_millis(160);
101 pub const SLOW: Duration = Duration::from_millis(320);
102 /// Entradas dramáticas (splash, hero shared-element).
103 pub const DRAMATIC: Duration = Duration::from_millis(480);
104
105 /// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
106 /// La gran mayoría de transiciones de salida / aparición.
107 #[inline]
108 pub fn ease_out_cubic(t: f32) -> f32 {
109 let inv = 1.0 - t.clamp(0.0, 1.0);
110 1.0 - inv * inv * inv
111 }
112
113 /// Easing énfasis — cubic-in-out. Para movimientos que cruzan la
114 /// pantalla y necesitan acentuar el centro (modales, splashes).
115 #[inline]
116 pub fn ease_in_out_cubic(t: f32) -> f32 {
117 let t = t.clamp(0.0, 1.0);
118 if t < 0.5 {
119 4.0 * t * t * t
120 } else {
121 let f = -2.0 * t + 2.0;
122 1.0 - f * f * f / 2.0
123 }
124 }
125
126 /// Easing fuerte — quint-out. Arranca más rápido que cubic-out y
127 /// asienta más suave. Para elementos que aparecen "lanzados" (toast,
128 /// FAB).
129 #[inline]
130 pub fn ease_out_quint(t: f32) -> f32 {
131 let inv = 1.0 - t.clamp(0.0, 1.0);
132 1.0 - inv * inv * inv * inv * inv
133 }
134
135 /// Overshoot suave — back-out con `c1=1.70158` (Material/Penner
136 /// estándar). El valor pasa de 0 al objetivo, lo sobrepasa ~10 % y
137 /// vuelve. Para entradas que necesitan "ping" (modal, snackbar,
138 /// elemento nuevo en una lista). No usar para hover — la oscilación
139 /// se percibe nerviosa.
140 #[inline]
141 pub fn ease_out_back(t: f32) -> f32 {
142 let t = t.clamp(0.0, 1.0);
143 const C1: f32 = 1.701_58;
144 const C3: f32 = C1 + 1.0;
145 let u = t - 1.0;
146 1.0 + C3 * u * u * u + C1 * u * u
147 }
148
149 /// Lineal — no es elegante pero a veces es lo correcto (barra de
150 /// progreso, valores numéricos crudos).
151 #[inline]
152 pub fn linear(t: f32) -> f32 {
153 t.clamp(0.0, 1.0)
154 }
155}
156
157/// Tokens de **elevación** — sombras escalonadas. Como `Shadow` vive en
158/// `llimphi-compositor` (y `llimphi-theme` no depende de él para
159/// quedarse leaf), cada nivel se expone como `(alpha_u8, blur_px,
160/// dy_px)`. Los widgets construyen su `Shadow` puenteándolo:
161/// `Shadow { color: Color::from_rgba8(0,0,0, a), blur, dy, dx: 0.0, spread: 0.0 }`.
162/// Escala perceptual logarítmica: cada nivel ~×2 de blur.
163pub mod elevation {
164 /// `(alpha 0–255, blur px, dy px)`. dy ≈ blur·0.4 (sombra natural,
165 /// fuente de luz un poco arriba).
166 pub type Elev = (u8, f64, f64);
167
168 /// E1 — chip levantado del fondo (hover button, badge).
169 pub const E1: Elev = (44, 4.0, 1.5);
170 /// E2 — card/tile flotante sobre el panel (default cards).
171 pub const E2: Elev = (60, 10.0, 4.0);
172 /// E3 — superficie destacada (menú contextual, dropdown).
173 pub const E3: Elev = (84, 18.0, 8.0);
174 /// E4 — overlay sobre la app (modal, dialog).
175 pub const E4: Elev = (110, 32.0, 14.0);
176 /// E5 — sello de identidad (FAB, hero, picker activo).
177 pub const E5: Elev = (140, 48.0, 22.0);
178}
179
180/// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre
181/// que se quiera *transparencia coherente*. El widget que improvisa su
182/// propio alpha rompe la firma visual.
183pub mod alpha {
184 /// Scrim que cubre la app cuando hay overlay (menú/modal/picker).
185 /// Apaga el fondo lo justo para que el overlay tenga jerarquía,
186 /// sin ocultar contexto.
187 pub const SCRIM: u8 = 64;
188
189 /// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip,
190 /// status hint). Casi opaco pero deja respirar.
191 pub const GLASS_PANEL: u8 = 232;
192
193 /// Elementos deshabilitados — visibles pero con menos peso.
194 pub const DISABLED: u8 = 140;
195
196 /// Hint sutil (text watermark, ghost) — apenas legible.
197 pub const HINT: u8 = 96;
198}
199
200/// Radios de esquina canónicos. La elegancia se construye en escalera:
201/// `XS` para chips e inputs, `SM` para botones, `MD` para paneles,
202/// `LG` para superficies grandes (toast, modal, card destacada).
203pub mod radius {
204 pub const XS: f64 = 2.0;
205 pub const SM: f64 = 4.0;
206 pub const MD: f64 = 8.0;
207 pub const LG: f64 = 12.0;
208 pub const XL: f64 = 20.0;
209}
210
211/// Paleta de la app. Slots semánticos que cubren los casos comunes
212/// (fondo, texto, hover, foco, acento). Los widgets reusables toman su
213/// `Palette` específico desde aquí vía `Palette::from_theme(&theme)`.
214#[derive(Debug, Clone, Copy)]
215pub struct Theme {
216 /// Nombre legible del preset — alimenta `Theme::by_name`,
217 /// `next_after`, y los UIs que ciclan presets (theme-switcher).
218 pub name: &'static str,
219
220 // --- Fondos ---
221 /// Fondo de la ventana / superficie raíz.
222 pub bg_app: Color,
223 /// Fondo de paneles (sidebars, cards).
224 pub bg_panel: Color,
225 /// Fondo alternativo para barras / strips (tab bar, status bar).
226 pub bg_panel_alt: Color,
227 /// Fondo de campos de input (texto editable).
228 pub bg_input: Color,
229 /// Fondo de input cuando tiene foco.
230 pub bg_input_focus: Color,
231 /// Fondo de botón (chip).
232 pub bg_button: Color,
233 /// Fondo de botón al hover.
234 pub bg_button_hover: Color,
235 /// Fondo de la fila/item seleccionado (lista, tree).
236 pub bg_selected: Color,
237 /// Fondo de fila al hover (sin selección).
238 pub bg_row_hover: Color,
239
240 // --- Foregrounds (texto) ---
241 pub fg_text: Color,
242 pub fg_muted: Color,
243 pub fg_placeholder: Color,
244 pub fg_destructive: Color,
245
246 // --- Bordes y acento ---
247 pub border: Color,
248 pub border_focus: Color,
249 /// Acento primario — divisores activos, borde de input focado,
250 /// underline del tab activo, etc. Tono único de la app.
251 pub accent: Color,
252}
253
254/// Pasos en que se parte una vuelta completa de [`Theme::girado`]: 720, o sea
255/// **medio grado por paso**. Es la unidad en la que el bus cuenta la deriva
256/// (`wawa_config::paso_de_deriva`), y por lo tanto **la cadencia con que las
257/// apps repintan**: una vuelta de 24 h da un repintado cada dos minutos.
258///
259/// El número sale de medir, no de elegir: con medio grado el canal que más se
260/// mueve salta 3 de 255 (invisible), y con un grado ya salta 4.
261pub const PASOS_DERIVA: u32 = 720;
262
263/// Un canal sRGB (0..1) a luz lineal. Es la curva de verdad, no el `x²` de
264/// bolsillo: la luminancia que sale de acá es la que decide si el contraste
265/// sobrevive el giro, y equivocarla por un 10 % es equivocar un 4,5:1 por un
266/// 4,0:1 — la diferencia entre pasar AA y no.
267fn a_lineal(canal: f32) -> f32 {
268 if canal <= 0.040_45 {
269 canal / 12.92
270 } else {
271 libm::powf((canal + 0.055) / 1.055, 2.4)
272 }
273}
274
275/// Luminancia relativa (WCAG/Rec. 709) de un color sRGB.
276fn luminancia(c: Color) -> f32 {
277 let k = c.components;
278 0.2126 * a_lineal(k[0]) + 0.7152 * a_lineal(k[1]) + 0.0722 * a_lineal(k[2])
279}
280
281/// sRGB → HSV. `h` en grados, `s`/`v` en 0..1.
282fn a_hsv(c: Color) -> (f32, f32, f32) {
283 let k = c.components;
284 let (r, g, b) = (k[0], k[1], k[2]);
285 let max = r.max(g).max(b);
286 let min = r.min(g).min(b);
287 let d = max - min;
288 let s = if max <= 0.0 { 0.0 } else { d / max };
289 if d <= 0.0 {
290 return (0.0, s, max);
291 }
292 let h = if max == r {
293 60.0 * (((g - b) / d) % 6.0)
294 } else if max == g {
295 60.0 * ((b - r) / d + 2.0)
296 } else {
297 60.0 * ((r - g) / d + 4.0)
298 };
299 (if h < 0.0 { h + 360.0 } else { h }, s, max)
300}
301
302/// HSV → sRGB, conservando el alfa que se le pase.
303fn de_hsv(h: f32, s: f32, v: f32, alfa: f32) -> Color {
304 let h = {
305 let x = h % 360.0;
306 if x < 0.0 {
307 x + 360.0
308 } else {
309 x
310 }
311 };
312 let c = v * s;
313 let x = c * (1.0 - libm::fabsf((h / 60.0) % 2.0 - 1.0));
314 let m = v - c;
315 let (r, g, b) = match (h / 60.0) as u32 {
316 0 => (c, x, 0.0),
317 1 => (x, c, 0.0),
318 2 => (0.0, c, x),
319 3 => (0.0, x, c),
320 4 => (x, 0.0, c),
321 _ => (c, 0.0, x),
322 };
323 Color::new([r + m, g + m, b + m, alfa])
324}
325
326// ── El acento-gradiente vigente del proceso ─────────────────────────────────
327
328/// Cuántas paradas guarda el acento-gradiente vigente.
329pub const ACENTO_PARADAS: usize = 5;
330
331static ACENTO_ENCENDIDO: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
332static ACENTO_PARADA: [core::sync::atomic::AtomicU32; ACENTO_PARADAS] = [
333 core::sync::atomic::AtomicU32::new(0),
334 core::sync::atomic::AtomicU32::new(0),
335 core::sync::atomic::AtomicU32::new(0),
336 core::sync::atomic::AtomicU32::new(0),
337 core::sync::atomic::AtomicU32::new(0),
338];
339
340/// **Publica el acento-gradiente del proceso** (`None` = el acento es un
341/// color). Lo llama quien arma el tema —`wawa-config-llimphi::theme_from_wawa`
342/// en cada app, y pata encima si su perfil lo apaga— y lo leen los widgets en
343/// su `from_theme` (pestañas, rail), así una app no tiene que cablearlo a mano
344/// (2026-10-12: *«los tabs están ocre sin gradiente»* — sólo pata lo sabía).
345///
346/// Vive acá y no en el `Theme` porque `Theme` es `Copy` y lo construyen como
347/// constante cientos de sitios. En atómicos porque este crate es `no_std`: una
348/// lectura a mitad de una escritura da, a lo sumo, una parada de un paso antes.
349pub fn publicar_acento_gradiente(paradas: Option<&[Color]>) {
350 use core::sync::atomic::Ordering;
351 match paradas {
352 Some(p) if p.len() == ACENTO_PARADAS => {
353 for (k, c) in p.iter().enumerate() {
354 let x = c.to_rgba8();
355 let v = u32::from_be_bytes([x.r, x.g, x.b, x.a]);
356 ACENTO_PARADA[k].store(v, Ordering::Relaxed);
357 }
358 ACENTO_ENCENDIDO.store(true, Ordering::Release);
359 }
360 _ => ACENTO_ENCENDIDO.store(false, Ordering::Release),
361 }
362}
363
364/// Las paradas publicadas, o `None` si el acento vigente es un color.
365pub fn acento_gradiente_vigente() -> Option<[Color; ACENTO_PARADAS]> {
366 use core::sync::atomic::Ordering;
367 if !ACENTO_ENCENDIDO.load(Ordering::Acquire) {
368 return None;
369 }
370 Some(core::array::from_fn(|k| {
371 let [r, g, b, a] = ACENTO_PARADA[k].load(Ordering::Relaxed).to_be_bytes();
372 Color::from_rgba8(r, g, b, a)
373 }))
374}
375
376// ── Dónde cae la superficie en su pantalla ──────────────────────────────────
377
378static TRAMO_ENCENDIDO: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
379static TRAMO_DESDE: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
380static TRAMO_ANCHO: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
381
382/// **Dónde cae la superficie que se está pintando, en su monitor** (2026-10-12,
383/// *«que cada control u objeto decida qué rango de colores aplicar, dependa de
384/// su posición en la pantalla»*): `Some((x, ancho))` = la superficie empieza a
385/// `x` px del borde izquierdo de un monitor de `ancho` px. Con esto el
386/// acento-gradiente es UNO para toda la pantalla y cada pieza muestra el tramo
387/// que le cae encima. Lo fija el que pinta varias superficies (pata, una por
388/// barra) antes de armar la vista de cada una; `None` = no se sabe (una ventana
389/// común en Wayland no sabe dónde está) y el degradé vuelve a ser por pieza.
390pub fn fijar_tramo_de_pantalla(tramo: Option<(f32, f32)>) {
391 use core::sync::atomic::Ordering;
392 match tramo {
393 Some((x, ancho)) if ancho > 0.0 => {
394 TRAMO_DESDE.store(x.to_bits(), Ordering::Relaxed);
395 TRAMO_ANCHO.store(ancho.to_bits(), Ordering::Relaxed);
396 TRAMO_ENCENDIDO.store(true, Ordering::Release);
397 }
398 _ => TRAMO_ENCENDIDO.store(false, Ordering::Release),
399 }
400}
401
402/// Las puntas del degradé de pantalla **en px de la superficie**: de la
403/// izquierda del monitor a su derecha, corridas por donde cae la superficie.
404/// `None` si no se fijó el tramo.
405pub fn puntas_en_superficie() -> Option<((f64, f64), (f64, f64))> {
406 use core::sync::atomic::Ordering;
407 if !TRAMO_ENCENDIDO.load(Ordering::Acquire) {
408 return None;
409 }
410 let x = f32::from_bits(TRAMO_DESDE.load(Ordering::Relaxed)) as f64;
411 let ancho = f32::from_bits(TRAMO_ANCHO.load(Ordering::Relaxed)) as f64;
412 Some(((-x, 0.0), (ancho - x, 0.0)))
413}
414
415/// El acento-gradiente vigente mezclado a la mitad sobre `fondo`: el relleno de
416/// lo ACTIVO (el diente del rail, la pestaña al frente). A la mitad para que el
417/// texto de encima, pensado para `fondo`, se siga leyendo.
418pub fn relleno_activo(fondo: Color) -> Option<[Color; ACENTO_PARADAS]> {
419 let mezcla = |a: Color, b: Color| {
420 let (x, y) = (a.components, b.components);
421 Color::new([
422 (x[0] + y[0]) * 0.5,
423 (x[1] + y[1]) * 0.5,
424 (x[2] + y[2]) * 0.5,
425 x[3],
426 ])
427 };
428 acento_gradiente_vigente().map(|g| g.map(|c| mezcla(fondo, c)))
429}
430
431/// Gira el matiz de UN color conservando su luminancia. El corazón de
432/// [`Theme::girado`]; el porqué de cada paso está en el doc de ese método.
433///
434/// Público desde el 2026-10-12: el acento-gradiente de `wawa-config-llimphi`
435/// pinta cada parada como **el acento del tema con otro matiz** —misma luz,
436/// así que en modo oscuro no encandila y lo escrito encima se lee—.
437pub fn girar_matiz(c: Color, fase_grados: f32) -> Color {
438 let (h, s, _v) = a_hsv(c);
439 // Un gris no tiene matiz que rotar: girarlo sólo le metería ruido de
440 // redondeo, y son justo las ranuras de texto donde eso se ve.
441 if s < 0.02 {
442 return c;
443 }
444 let alfa = c.components[3];
445 let objetivo = luminancia(c);
446 let h2 = h + fase_grados;
447
448 // ¿Le alcanza el croma original para llegar a esa luminancia? Si ni con
449 // V = 1 llega, se baja el croma por bisección hasta que llegue.
450 let mut sat = s;
451 if luminancia(de_hsv(h2, s, 1.0, alfa)) < objetivo {
452 let (mut lo, mut hi) = (0.0f32, s);
453 for _ in 0..20 {
454 let medio = (lo + hi) * 0.5;
455 if luminancia(de_hsv(h2, medio, 1.0, alfa)) >= objetivo {
456 lo = medio;
457 } else {
458 hi = medio;
459 }
460 }
461 sat = lo;
462 }
463
464 // Y ahora el valor que clava la luminancia. La luminancia crece con V de
465 // forma monótona, así que la bisección converge siempre.
466 let (mut lo, mut hi) = (0.0f32, 1.0f32);
467 for _ in 0..20 {
468 let medio = (lo + hi) * 0.5;
469 if luminancia(de_hsv(h2, sat, medio, alfa)) < objetivo {
470 lo = medio;
471 } else {
472 hi = medio;
473 }
474 }
475 de_hsv(h2, sat, (lo + hi) * 0.5, alfa)
476}
477
478impl Default for Theme {
479 fn default() -> Self {
480 Self::dark()
481 }
482}
483
484impl Theme {
485 /// Tema oscuro — el default. Análogo al `nahual-theme` dark en su
486 /// versión Llimphi: tonos azulados profundos, acento azul claro.
487 pub const fn dark() -> Self {
488 Self {
489 name: "Dark",
490 bg_app: Color::from_rgba8(14, 16, 22, 255),
491 bg_panel: Color::from_rgba8(22, 26, 36, 255),
492 bg_panel_alt: Color::from_rgba8(18, 22, 30, 255),
493 bg_input: Color::from_rgba8(16, 20, 28, 255),
494 bg_input_focus: Color::from_rgba8(20, 26, 38, 255),
495 bg_button: Color::from_rgba8(36, 42, 56, 255),
496 bg_button_hover: Color::from_rgba8(54, 64, 86, 255),
497 bg_selected: Color::from_rgba8(58, 78, 128, 255),
498 bg_row_hover: Color::from_rgba8(36, 44, 60, 255),
499 fg_text: Color::from_rgba8(214, 222, 232, 255),
500 fg_muted: Color::from_rgba8(140, 152, 170, 255),
501 fg_placeholder: Color::from_rgba8(95, 105, 122, 255),
502 fg_destructive: Color::from_rgba8(220, 110, 110, 255),
503 border: Color::from_rgba8(46, 54, 70, 255),
504 border_focus: Color::from_rgba8(110, 140, 220, 255),
505 accent: Color::from_rgba8(110, 140, 220, 255),
506 }
507 }
508
509 /// Tema **"Kuychi"** (*arcoíris*) — el preset **de fábrica** del escritorio
510 /// desde el 2026-09-09, y el primero de la rotación.
511 ///
512 /// ## Dos intentos, y por qué el primero no servía
513 ///
514 /// La primera versión repartió ocho matices —los del degradado cónico de la
515 /// chakana, muestreado cada 45°— entre las dieciséis ranuras, todos a media
516 /// saturación. El operador la rechazó en una línea que vale más que el
517 /// diseño: *«son varios colores pero igual un solo tono»*. Tenía razón, y el
518 /// error es de manual: **si todo lleva un poco de color, nada tiene color**.
519 /// Ocho matices apagados sobre fondos apagados se leen como un filtro
520 /// sepia — la variedad de matiz no se percibe cuando la saturación y el
521 /// brillo son los mismos en todas partes.
522 ///
523 /// La corrección fue *«tendé a los colores primarios y sus gradientes»*, y
524 /// se implementa al revés de lo anterior:
525 ///
526 /// * **El suelo se vuelve casi neutro.** `bg_app`, `bg_panel`, `bg_input` y
527 /// sus focos quedan con croma bajísimo (S ≤ 0,20): dejan de competir. Son
528 /// el papel, no el dibujo.
529 /// * **El color va ENTERO donde significa algo.** Azul primario en el acento
530 /// y el foco, rojo primario en el error, ámbar en el hover, verde en los
531 /// botones. Nada de tonos polvorientos: `accent` y `fg_destructive` van a
532 /// S ≥ 0,68 con V = 1.
533 /// * **Y sus gradientes.** Un primario aparece en más de un escalón de su
534 /// propia rampa: el azul es el acento (#337EFF) y también la selección
535 /// (#1D5DCC, el mismo matiz más hundido); el verde son los dos escalones
536 /// del botón (reposo y hover). Eso es lo que hace que se lea como una
537 /// familia y no como una bolsa de colores.
538 ///
539 /// El contraste sube en vez de bajar: con el suelo neutro, `fg_text` da
540 /// 17,3:1 sobre `bg_app` (antes 13,9) y el acento 4,7:1 sobre `bg_panel`.
541 /// El texto sobre la fila seleccionada —el caso que un azul primario suele
542 /// romper— da 5,4:1.
543 pub const fn kuychi() -> Self {
544 Self {
545 name: "Kuychi",
546 bg_app: Color::from_rgba8(13, 14, 15, 255), // el papel: casi neutro
547 bg_panel: Color::from_rgba8(23, 24, 27, 255),
548 bg_panel_alt: Color::from_rgba8(20, 17, 22, 255), // barra, tinte violeta
549 bg_input: Color::from_rgba8(10, 11, 12, 255),
550 bg_input_focus: Color::from_rgba8(18, 20, 23, 255),
551 bg_button: Color::from_rgba8(21, 47, 35, 255), // verde, escalón hondo
552 bg_button_hover: Color::from_rgba8(28, 68, 49, 255), // verde, un escalón arriba
553 bg_row_hover: Color::from_rgba8(55, 46, 8, 255), // ámbar, escalón hondo
554 bg_selected: Color::from_rgba8(29, 93, 204, 255), // AZUL primario hundido
555 fg_text: Color::from_rgba8(245, 242, 233, 255), // marfil
556 fg_muted: Color::from_rgba8(156, 162, 173, 255), // gris apenas azulado
557 fg_placeholder: Color::from_rgba8(103, 108, 117, 255),
558 fg_destructive: Color::from_rgba8(255, 82, 87, 255), // ROJO primario
559 border: Color::from_rgba8(66, 54, 71, 255),
560 border_focus: Color::from_rgba8(51, 126, 255, 255), // AZUL primario
561 accent: Color::from_rgba8(51, 126, 255, 255),
562 }
563 }
564
565 /// **"Kuychi Light"** — el mismo reparto con la luz dada vuelta: papel
566 /// cálido, y los primarios en el escalón que a un fondo claro le sirve.
567 ///
568 /// Dos correcciones que el modo claro obliga y el oscuro no:
569 ///
570 /// * **El acento y el error se hunden.** El azul #337EFF sobre papel da
571 /// 1,9:1 — ilegible. Van a #0A53D1 y #C70A10, que dan 6,0:1 y 5,5:1 sobre
572 /// el panel. El matiz es el mismo; lo que cambia es el escalón.
573 /// * **La selección sube en vez de bajar.** No hay ranura para «el texto
574 /// sobre lo seleccionado»: los widgets pintan `fg_text` encima. Con un
575 /// azul primario de fondo, el texto oscuro da 3,9:1 y no pasa; con el
576 /// escalón claro del MISMO azul (#99BEFF) da 9,4:1. Es la misma rampa,
577 /// leída desde el otro extremo.
578 pub const fn kuychi_light() -> Self {
579 Self {
580 name: "Kuychi Light",
581 bg_app: Color::from_rgba8(255, 253, 247, 255), // papel cálido
582 bg_panel: Color::from_rgba8(240, 244, 251, 255),
583 bg_panel_alt: Color::from_rgba8(240, 228, 245, 255),
584 bg_input: Color::from_rgba8(255, 255, 255, 255),
585 bg_input_focus: Color::from_rgba8(247, 250, 255, 255),
586 bg_button: Color::from_rgba8(183, 237, 212, 255), // verde menta
587 bg_button_hover: Color::from_rgba8(147, 219, 186, 255),
588 bg_row_hover: Color::from_rgba8(252, 236, 169, 255), // ÁMBAR
589 bg_selected: Color::from_rgba8(153, 190, 255, 255), // azul, escalón claro
590 fg_text: Color::from_rgba8(23, 25, 28, 255),
591 fg_muted: Color::from_rgba8(87, 92, 102, 255),
592 fg_placeholder: Color::from_rgba8(133, 140, 153, 255),
593 fg_destructive: Color::from_rgba8(199, 10, 16, 255), // ROJO primario hundido
594 border: Color::from_rgba8(200, 178, 209, 255),
595 border_focus: Color::from_rgba8(10, 83, 209, 255), // AZUL primario hundido
596 accent: Color::from_rgba8(10, 83, 209, 255),
597 }
598 }
599
600 /// Tema **"Tawa"** — el LOOK FIRMA de la suite, el que se publica en
601 /// screenshots. Decisiones de paleta:
602 ///
603 /// - **Base negro cálido, no azul marino.** A diferencia de `dark()` (un
604 /// navy genérico, R<B) aquí el fondo es un casi-negro con temperatura:
605 /// los canales rojo/verde van un punto por encima del azul, así el grafito
606 /// "respira" tibio en vez de frío. Jerarquía de superficies en escalera
607 /// suave: `bg_app` (más profundo) → `bg_panel`/`bg_input` → barras → chips,
608 /// sin saltos bruscos.
609 /// - **Acento teal-eléctrico (#2BD9A6), NO azul.** El mar de unixporn es
610 /// todo azul (Catppuccin/Tokyo Night); elegimos un verde-aguamarina
611 /// vibrante para destacar de inmediato — distintivo pero no chillón, con
612 /// raíz en el teal del CDE / del logo de la suite. `accent` y
613 /// `border_focus` comparten ese tono; la selección lo usa atenuado para
614 /// no encandilar filas enteras.
615 /// - **Texto legible (WCAG AA).** `fg_text` (#E8E6E0, marfil cálido) supera
616 /// 12:1 sobre `bg_app` y ~10:1 sobre `bg_panel`; `fg_muted` ronda 5:1
617 /// (texto secundario cómodo); `fg_placeholder` queda como hint tenue.
618 /// - **Armonía:** todos los grises llevan el mismo tinte cálido y el acento
619 /// es el único color saturado — la paleta se lee como una sola pieza.
620 pub const fn tawa() -> Self {
621 Self {
622 name: "Tawa",
623 bg_app: Color::from_rgba8(20, 19, 17, 255), // grafito cálido casi-negro
624 bg_panel: Color::from_rgba8(30, 28, 26, 255),
625 bg_panel_alt: Color::from_rgba8(25, 24, 22, 255),
626 bg_input: Color::from_rgba8(24, 23, 21, 255),
627 bg_input_focus: Color::from_rgba8(32, 30, 28, 255),
628 bg_button: Color::from_rgba8(42, 40, 37, 255),
629 bg_button_hover: Color::from_rgba8(56, 53, 49, 255),
630 bg_selected: Color::from_rgba8(26, 74, 64, 255), // teal hundido (selección)
631 bg_row_hover: Color::from_rgba8(40, 38, 35, 255),
632 fg_text: Color::from_rgba8(232, 230, 224, 255), // marfil cálido
633 fg_muted: Color::from_rgba8(160, 154, 144, 255),
634 fg_placeholder: Color::from_rgba8(112, 107, 99, 255),
635 fg_destructive: Color::from_rgba8(232, 116, 97, 255), // coral (cálido, no rojo puro)
636 border: Color::from_rgba8(54, 51, 47, 255),
637 border_focus: Color::from_rgba8(43, 217, 166, 255), // teal-eléctrico
638 accent: Color::from_rgba8(43, 217, 166, 255), // #2BD9A6 — la firma
639 }
640 }
641
642 /// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
643 /// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
644 /// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
645 /// fondos claros. `fg_placeholder` queda deliberadamente tenue
646 /// (hint, no contenido).
647 pub const fn light() -> Self {
648 Self {
649 name: "Light",
650 bg_app: Color::from_rgba8(244, 246, 250, 255),
651 bg_panel: Color::from_rgba8(232, 236, 242, 255),
652 bg_panel_alt: Color::from_rgba8(224, 230, 240, 255),
653 bg_input: Color::from_rgba8(255, 255, 255, 255),
654 bg_input_focus: Color::from_rgba8(250, 252, 255, 255),
655 bg_button: Color::from_rgba8(220, 226, 236, 255),
656 bg_button_hover: Color::from_rgba8(200, 210, 226, 255),
657 bg_selected: Color::from_rgba8(160, 180, 220, 255),
658 bg_row_hover: Color::from_rgba8(214, 222, 236, 255),
659 fg_text: Color::from_rgba8(24, 32, 45, 255),
660 fg_muted: Color::from_rgba8(86, 98, 116, 255),
661 fg_placeholder: Color::from_rgba8(140, 150, 168, 255),
662 fg_destructive: Color::from_rgba8(168, 48, 48, 255),
663 border: Color::from_rgba8(190, 199, 214, 255),
664 border_focus: Color::from_rgba8(48, 92, 196, 255),
665 accent: Color::from_rgba8(48, 92, 196, 255),
666 }
667 }
668
669 /// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al
670 /// preset del nahual-theme.
671 pub const fn aurora() -> Self {
672 Self {
673 name: "Aurora",
674 bg_app: Color::from_rgba8(8, 18, 22, 255),
675 bg_panel: Color::from_rgba8(14, 28, 34, 255),
676 bg_panel_alt: Color::from_rgba8(12, 24, 30, 255),
677 bg_input: Color::from_rgba8(10, 22, 28, 255),
678 bg_input_focus: Color::from_rgba8(14, 30, 38, 255),
679 bg_button: Color::from_rgba8(20, 44, 52, 255),
680 bg_button_hover: Color::from_rgba8(30, 66, 78, 255),
681 bg_selected: Color::from_rgba8(30, 90, 100, 255),
682 bg_row_hover: Color::from_rgba8(20, 46, 56, 255),
683 fg_text: Color::from_rgba8(214, 232, 232, 255),
684 fg_muted: Color::from_rgba8(130, 168, 168, 255),
685 fg_placeholder: Color::from_rgba8(90, 120, 120, 255),
686 fg_destructive: Color::from_rgba8(220, 110, 110, 255),
687 border: Color::from_rgba8(38, 70, 78, 255),
688 border_focus: Color::from_rgba8(80, 200, 200, 255),
689 accent: Color::from_rgba8(80, 200, 200, 255),
690 }
691 }
692
693 /// Tema "Sunset" — cálidos con acento naranja, sobre base oscura.
694 pub const fn sunset() -> Self {
695 Self {
696 name: "Sunset",
697 bg_app: Color::from_rgba8(22, 14, 14, 255),
698 bg_panel: Color::from_rgba8(34, 22, 22, 255),
699 bg_panel_alt: Color::from_rgba8(28, 18, 18, 255),
700 bg_input: Color::from_rgba8(28, 18, 18, 255),
701 bg_input_focus: Color::from_rgba8(36, 24, 22, 255),
702 bg_button: Color::from_rgba8(54, 34, 28, 255),
703 bg_button_hover: Color::from_rgba8(78, 50, 38, 255),
704 bg_selected: Color::from_rgba8(120, 64, 38, 255),
705 bg_row_hover: Color::from_rgba8(56, 36, 28, 255),
706 fg_text: Color::from_rgba8(238, 220, 200, 255),
707 fg_muted: Color::from_rgba8(174, 142, 120, 255),
708 fg_placeholder: Color::from_rgba8(120, 96, 80, 255),
709 fg_destructive: Color::from_rgba8(220, 100, 100, 255),
710 border: Color::from_rgba8(70, 46, 36, 255),
711 border_focus: Color::from_rgba8(232, 140, 70, 255),
712 accent: Color::from_rgba8(232, 140, 70, 255),
713 }
714 }
715
716 /// Tema "Print" — blanco y negro de alto contraste para impresión.
717 /// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo
718 /// que se imprime tiene que leerse en una fotocopiadora. `fg_muted`
719 /// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en
720 /// negro puro. Acento y bordes negros — la tinta es una sola.
721 pub const fn print() -> Self {
722 Self {
723 name: "Print",
724 bg_app: Color::from_rgba8(255, 255, 255, 255),
725 bg_panel: Color::from_rgba8(255, 255, 255, 255),
726 bg_panel_alt: Color::from_rgba8(246, 246, 246, 255),
727 bg_input: Color::from_rgba8(255, 255, 255, 255),
728 bg_input_focus: Color::from_rgba8(248, 248, 248, 255),
729 bg_button: Color::from_rgba8(238, 238, 238, 255),
730 bg_button_hover: Color::from_rgba8(224, 224, 224, 255),
731 bg_selected: Color::from_rgba8(220, 220, 220, 255),
732 bg_row_hover: Color::from_rgba8(240, 240, 240, 255),
733 fg_text: Color::from_rgba8(0, 0, 0, 255),
734 fg_muted: Color::from_rgba8(90, 90, 90, 255),
735 fg_placeholder: Color::from_rgba8(140, 140, 140, 255),
736 fg_destructive: Color::from_rgba8(0, 0, 0, 255),
737 border: Color::from_rgba8(0, 0, 0, 255),
738 border_focus: Color::from_rgba8(0, 0, 0, 255),
739 accent: Color::from_rgba8(0, 0, 0, 255),
740 }
741 }
742
743 /// Skin **Windows XP "Luna"** — escritorio azul-gris claro, selección y
744 /// acento en el azul XP (#316AC5), chrome celeste. Para la vista `windows-xp`.
745 pub const fn xp_blue() -> Self {
746 Self {
747 name: "WinXP",
748 bg_app: Color::from_rgba8(236, 240, 249, 255),
749 bg_panel: Color::from_rgba8(214, 223, 247, 255),
750 bg_panel_alt: Color::from_rgba8(60, 100, 190, 255), // franja azul (taskbar)
751 bg_input: Color::from_rgba8(255, 255, 255, 255),
752 bg_input_focus: Color::from_rgba8(248, 250, 255, 255),
753 bg_button: Color::from_rgba8(222, 230, 246, 255),
754 bg_button_hover: Color::from_rgba8(198, 214, 244, 255),
755 bg_selected: Color::from_rgba8(49, 106, 197, 255), // azul de selección XP
756 bg_row_hover: Color::from_rgba8(214, 226, 248, 255),
757 fg_text: Color::from_rgba8(20, 30, 50, 255),
758 fg_muted: Color::from_rgba8(78, 92, 120, 255),
759 fg_placeholder: Color::from_rgba8(130, 142, 168, 255),
760 fg_destructive: Color::from_rgba8(176, 32, 32, 255),
761 border: Color::from_rgba8(122, 152, 206, 255),
762 border_focus: Color::from_rgba8(49, 106, 197, 255),
763 accent: Color::from_rgba8(36, 94, 220, 255), // Luna blue
764 }
765 }
766
767 /// Skin **macOS (Big Sur claro)** — casi blanco, grises sutiles, acento
768 /// azul de sistema (#0A84FF). Para la vista `mac`.
769 pub const fn mac_light() -> Self {
770 Self {
771 name: "macOS",
772 bg_app: Color::from_rgba8(246, 246, 248, 255),
773 bg_panel: Color::from_rgba8(236, 236, 240, 255),
774 bg_panel_alt: Color::from_rgba8(242, 242, 245, 235), // menubar translúcida
775 bg_input: Color::from_rgba8(255, 255, 255, 255),
776 bg_input_focus: Color::from_rgba8(252, 252, 255, 255),
777 bg_button: Color::from_rgba8(228, 228, 233, 255),
778 bg_button_hover: Color::from_rgba8(214, 214, 221, 255),
779 bg_selected: Color::from_rgba8(10, 132, 255, 255),
780 bg_row_hover: Color::from_rgba8(232, 234, 240, 255),
781 fg_text: Color::from_rgba8(28, 28, 32, 255),
782 fg_muted: Color::from_rgba8(110, 110, 120, 255),
783 fg_placeholder: Color::from_rgba8(160, 160, 170, 255),
784 fg_destructive: Color::from_rgba8(215, 58, 50, 255),
785 border: Color::from_rgba8(208, 208, 215, 255),
786 border_focus: Color::from_rgba8(10, 132, 255, 255),
787 accent: Color::from_rgba8(10, 132, 255, 255),
788 }
789 }
790
791 /// Skin **KDE Plasma "Breeze" (claro)** — gris papel (#eff0f1), acento
792 /// azul Breeze (#3daee9). Para la vista `kde`.
793 pub const fn kde_breeze() -> Self {
794 Self {
795 name: "Breeze",
796 bg_app: Color::from_rgba8(239, 240, 241, 255),
797 bg_panel: Color::from_rgba8(252, 252, 252, 255),
798 bg_panel_alt: Color::from_rgba8(49, 54, 59, 255), // panel oscuro Breeze
799 bg_input: Color::from_rgba8(255, 255, 255, 255),
800 bg_input_focus: Color::from_rgba8(248, 252, 254, 255),
801 bg_button: Color::from_rgba8(224, 226, 228, 255),
802 bg_button_hover: Color::from_rgba8(208, 211, 214, 255),
803 bg_selected: Color::from_rgba8(61, 174, 233, 255),
804 bg_row_hover: Color::from_rgba8(227, 229, 231, 255),
805 fg_text: Color::from_rgba8(35, 38, 41, 255),
806 fg_muted: Color::from_rgba8(99, 104, 109, 255),
807 fg_placeholder: Color::from_rgba8(150, 155, 160, 255),
808 fg_destructive: Color::from_rgba8(218, 68, 83, 255),
809 border: Color::from_rgba8(188, 192, 196, 255),
810 border_focus: Color::from_rgba8(61, 174, 233, 255),
811 accent: Color::from_rgba8(61, 174, 233, 255),
812 }
813 }
814
815 /// Skin **Windows 3.1**: gris Motif (#c0c0c0) con barra de título azul
816 /// marino (#000080) y escritorio teal. La era de los biseles. Para la vista
817 /// `windows-3.1`.
818 pub const fn win31() -> Self {
819 Self {
820 name: "Win3.1",
821 bg_app: Color::from_rgba8(0, 128, 128, 255), // escritorio teal clásico
822 bg_panel: Color::from_rgba8(192, 192, 192, 255), // gris ventana
823 bg_panel_alt: Color::from_rgba8(0, 0, 128, 255), // barra de título azul marino
824 bg_input: Color::from_rgba8(255, 255, 255, 255),
825 bg_input_focus: Color::from_rgba8(255, 255, 255, 255),
826 bg_button: Color::from_rgba8(192, 192, 192, 255),
827 bg_button_hover: Color::from_rgba8(208, 208, 208, 255),
828 bg_selected: Color::from_rgba8(0, 0, 128, 255),
829 bg_row_hover: Color::from_rgba8(200, 200, 200, 255),
830 fg_text: Color::from_rgba8(0, 0, 0, 255),
831 fg_muted: Color::from_rgba8(64, 64, 64, 255),
832 fg_placeholder: Color::from_rgba8(112, 112, 112, 255),
833 fg_destructive: Color::from_rgba8(128, 0, 0, 255),
834 border: Color::from_rgba8(128, 128, 128, 255),
835 border_focus: Color::from_rgba8(0, 0, 128, 255),
836 accent: Color::from_rgba8(0, 0, 128, 255), // azul Win3.1
837 }
838 }
839
840 /// Skin **Solaris CDE** (era dorada): gris-beige Motif con acento teal —
841 /// el Common Desktop Environment. Para la vista `solaris`.
842 pub const fn cde() -> Self {
843 Self {
844 name: "CDE",
845 bg_app: Color::from_rgba8(45, 70, 90, 255), // fondo azul-gris CDE
846 bg_panel: Color::from_rgba8(174, 178, 195, 255), // gris-lila Motif
847 bg_panel_alt: Color::from_rgba8(120, 130, 150, 255),
848 bg_input: Color::from_rgba8(220, 222, 230, 255),
849 bg_input_focus: Color::from_rgba8(235, 237, 244, 255),
850 bg_button: Color::from_rgba8(160, 166, 185, 255),
851 bg_button_hover: Color::from_rgba8(176, 182, 200, 255),
852 bg_selected: Color::from_rgba8(90, 130, 130, 255),
853 bg_row_hover: Color::from_rgba8(168, 174, 192, 255),
854 fg_text: Color::from_rgba8(20, 24, 32, 255),
855 fg_muted: Color::from_rgba8(64, 72, 84, 255),
856 fg_placeholder: Color::from_rgba8(100, 108, 120, 255),
857 fg_destructive: Color::from_rgba8(140, 40, 40, 255),
858 border: Color::from_rgba8(108, 116, 134, 255),
859 border_focus: Color::from_rgba8(64, 132, 132, 255),
860 accent: Color::from_rgba8(64, 132, 132, 255), // teal CDE
861 }
862 }
863
864 /// Superficie "hundida" — un escalón más profunda que `bg_app`, para
865 /// áreas de lectura intensa (output de terminal, viewports de log,
866 /// IDE-text) que deben recibir el texto con más contraste que el chrome
867 /// y leerse recesadas respecto del marco. En temas oscuros oscurece
868 /// `bg_app` hacia el negro; en claros lo aleja un paso del blanco. Las
869 /// cards/strips (`bg_panel`, `bg_panel_alt`) quedan flotando por encima.
870 /// Derivada de la paleta — no inventa un color suelto.
871 ///
872 /// **Hereda el alfa de `bg_app`**, y no es un detalle: si el tema es de
873 /// vidrio (un cajón translúcido, un panel sobre wallpaper), la superficie
874 /// hundida es *ese mismo fondo un escalón más hondo*, no una tapa opaca.
875 /// Clavaba `255` a mano, y por ahí se moría la transparencia justo donde
876 /// más superficie hay —el área de lectura, que en el cajón de shuma es casi
877 /// todo—: el tema decía 0.92 y esto devolvía 1.0 sin que nadie lo notara.
878 /// Quien necesite la variante sólida la pide explícita (`.with_alpha(1.0)`),
879 /// como hacen las tarjetas de código, que sí deben leerse sobre color pleno.
880 pub fn sunken(&self) -> Color {
881 let c = self.bg_app.components;
882 // Luminancia relativa aproximada en sRGB (sin linealizar — alcanza
883 // para decidir oscuro/claro).
884 let lum = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
885 let factor = if lum < 0.5 { 0.5 } else { 0.93 };
886 Color::from_rgba8(
887 a_byte(c[0] * factor),
888 a_byte(c[1] * factor),
889 a_byte(c[2] * factor),
890 a_byte(c[3]),
891 )
892 }
893
894 /// **La deriva**: el mismo tema con el matiz de todas sus ranuras girado
895 /// `fase_grados`. Es una función pura del ángulo — no lee reloj, no guarda
896 /// estado, y la misma fase da siempre el mismo tema.
897 ///
898 /// ## Qué gira y qué no
899 ///
900 /// Giran las quince ranuras que llevan matiz. **`fg_destructive` no gira**:
901 /// rojo-es-error es una convención, no un matiz cualquiera, y se ve al
902 /// primer intento — en la fase 90° el mensaje de error queda verde. Una
903 /// ranura casi gris (saturación < 2 %) tampoco: no tiene matiz que rotar.
904 ///
905 /// ## Por qué no alcanza con sumarle grados al matiz
906 ///
907 /// A saturación y valor fijos, **el amarillo es cuatro veces más luminoso
908 /// que el azul**. Girar sin más haría que el contraste del tema cambiara
909 /// con la fase: medido sobre la paleta de fábrica, el acento cae a 2,6:1 en
910 /// la fase 22° y el error a 3,4:1 en la 242° — o sea, el escritorio deja de
911 /// ser legible dos veces por vuelta, y a una hora distinta cada día.
912 ///
913 /// Así que cada color se gira **conservando su luminancia**: se busca el
914 /// valor (V) que devuelve la luminancia relativa original en el matiz
915 /// nuevo. Cuando ni con V = 1 alcanza —el azul saturado no puede ser tan
916 /// luminoso como el amarillo: el gamut sRGB no da— se **baja el croma**
917 /// hasta que llegue, que es lo que hace cualquier mapeo a gamut.
918 ///
919 /// Ese descenso de croma va por **bisección, no a saltos**. Bajarlo
920 /// multiplicando por un factor hasta que entre da escalones de 6 % de
921 /// saturación, y en una deriva lenta eso no se ve como deriva: se ve como
922 /// un **tirón**. Medido, el paso de 0,125° saltaba 13/255 en el acento con
923 /// el descenso a saltos y salta **1/255** con la bisección.
924 ///
925 /// ## El paso
926 ///
927 /// Con [`PASOS_DERIVA`] pasos por vuelta (0,5° cada uno) el canal que más
928 /// se mueve salta **3 de 255** — por debajo de lo que un ojo distingue en
929 /// un color de interfaz. Quien mueva ese número lo mide de nuevo: el test
930 /// `la_deriva_no_da_tirones` falla si un paso salta más de 4.
931 pub fn girado(&self, fase_grados: f32) -> Self {
932 let g = |c: Color| girar_matiz(c, fase_grados);
933 Self {
934 name: self.name,
935 bg_app: g(self.bg_app),
936 bg_panel: g(self.bg_panel),
937 bg_panel_alt: g(self.bg_panel_alt),
938 bg_input: g(self.bg_input),
939 bg_input_focus: g(self.bg_input_focus),
940 bg_button: g(self.bg_button),
941 bg_button_hover: g(self.bg_button_hover),
942 bg_selected: g(self.bg_selected),
943 bg_row_hover: g(self.bg_row_hover),
944 fg_text: g(self.fg_text),
945 fg_muted: g(self.fg_muted),
946 fg_placeholder: g(self.fg_placeholder),
947 // El rojo del error se queda donde está. Ver el doc de arriba.
948 fg_destructive: self.fg_destructive,
949 border: g(self.border),
950 border_focus: g(self.border_focus),
951 accent: g(self.accent),
952 }
953 }
954
955 /// Todos los presets del repo, en el orden canónico de rotación
956 /// (Kuychi → Kuychi Light → Tawa → Dark → Light → Aurora → Sunset →
957 /// Kuychi…). `kuychi()` va **al frente** desde el 2026-09-09: es el preset
958 /// de fábrica del escritorio, y su variante clara va pegada atrás para que
959 /// un solo paso del switcher lleve del día a la noche del **mismo** look.
960 /// `tawa()` queda tercero — sigue siendo el look firma de las capturas.
961 /// El theme-switcher los consume vía [`Theme::next_after`]. `print()` queda
962 /// fuera de la rotación a propósito — es un modo deliberado (imprimir), no
963 /// un gusto estético que se cicle por accidente.
964 pub fn all() -> Vec<Self> {
965 vec![
966 Self::kuychi(),
967 Self::kuychi_light(),
968 Self::tawa(),
969 Self::dark(),
970 Self::light(),
971 Self::aurora(),
972 Self::sunset(),
973 ]
974 }
975
976 /// Busca un preset por nombre exacto. Incluye los modos deliberados que
977 /// quedan fuera de la rotación casual (`print` y los skins de vista
978 /// `WinXP`/`macOS`/`Breeze`), para que `Config::theme` los resuelva.
979 pub fn by_name(name: &str) -> Option<Self> {
980 Self::all()
981 .into_iter()
982 .chain([
983 Self::print(),
984 Self::xp_blue(),
985 Self::mac_light(),
986 Self::kde_breeze(),
987 Self::win31(),
988 Self::cde(),
989 ])
990 .find(|t| t.name == name)
991 }
992
993 /// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
994 /// se encuentra, retorna el primero — el switcher nunca se traba.
995 pub fn next_after(current: &str) -> Self {
996 let all = Self::all();
997 let idx = all
998 .iter()
999 .position(|t| t.name == current)
1000 .map(|i| (i + 1) % all.len())
1001 .unwrap_or(0);
1002 all[idx]
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 #[test]
1011 fn presets_have_unique_names() {
1012 let all = Theme::all();
1013 let mut names: Vec<&str> = all.iter().map(|t| t.name).collect();
1014 let n_before = names.len();
1015 names.sort();
1016 names.dedup();
1017 assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()");
1018 }
1019
1020 #[test]
1021 fn by_name_finds_each_preset() {
1022 for t in Theme::all() {
1023 let by = Theme::by_name(t.name).expect("preset registrado");
1024 assert_eq!(by.name, t.name);
1025 }
1026 }
1027
1028 #[test]
1029 fn by_name_returns_none_for_unknown() {
1030 assert!(Theme::by_name("ThisDoesNotExist").is_none());
1031 }
1032
1033 #[test]
1034 fn next_after_cycles_through_all_presets() {
1035 let all = Theme::all();
1036 let mut current = all[0].name;
1037 let mut visited = vec![current];
1038 for _ in 0..all.len() - 1 {
1039 current = Theme::next_after(current).name;
1040 visited.push(current);
1041 }
1042 let names: Vec<&str> = all.iter().map(|t| t.name).collect();
1043 assert_eq!(visited, names);
1044 // El siguiente debe volver al primero.
1045 let wrapped = Theme::next_after(current).name;
1046 assert_eq!(wrapped, all[0].name);
1047 }
1048
1049 #[test]
1050 fn next_after_unknown_falls_back_to_first() {
1051 let n = Theme::next_after("Nope").name;
1052 assert_eq!(n, Theme::all()[0].name);
1053 }
1054
1055 #[test]
1056 fn dark_is_the_default() {
1057 assert_eq!(Theme::default().name, "Dark");
1058 }
1059
1060 /// "Kuychi" —el preset de fábrica— va al frente de la rotación, con su
1061 /// variante clara pegada atrás; "Tawa" —el look firma— sigue en la rotación
1062 /// y `by_name` resuelve a los tres.
1063 #[test]
1064 fn kuychi_es_el_primero_y_tawa_sigue_en_la_rotacion() {
1065 let all = Theme::all();
1066 assert_eq!(all[0].name, "Kuychi", "Kuychi debe ir al frente de la rotación");
1067 assert_eq!(all[1].name, "Kuychi Light", "y su variante clara, pegada atrás");
1068 assert!(all.iter().any(|t| t.name == "Tawa"), "Tawa no se sale de la rotación");
1069 for n in ["Kuychi", "Kuychi Light", "Tawa"] {
1070 assert_eq!(Theme::by_name(n).expect("registrado").name, n);
1071 }
1072 }
1073
1074 /// Lo que hace de Kuychi lo que el operador pidió: **ningún matiz manda**.
1075 /// Se cuentan los matices de las ranuras que llevan color de verdad (los
1076 /// fondos casi-neutros no entran) y tienen que ser cuatro o más, separados
1077 /// entre sí. Un preset de un acento solo da uno o dos y falla acá.
1078 ///
1079 /// El matiz se calcula a mano (HSV) porque el crate es `no_std` y no tiene
1080 /// de dónde sacarlo — son ocho líneas y evitan una dependencia.
1081 #[test]
1082 fn kuychi_no_tiene_un_solo_color_predominante() {
1083 fn matiz(c: Color) -> f32 {
1084 let k = c.components;
1085 let (r, g, b) = (k[0], k[1], k[2]);
1086 let max = r.max(g).max(b);
1087 let min = r.min(g).min(b);
1088 let d = max - min;
1089 if d < 1e-6 {
1090 return -1.0;
1091 }
1092 let h = if max == r {
1093 60.0 * (((g - b) / d) % 6.0)
1094 } else if max == g {
1095 60.0 * ((b - r) / d + 2.0)
1096 } else {
1097 60.0 * ((r - g) / d + 4.0)
1098 };
1099 if h < 0.0 {
1100 h + 360.0
1101 } else {
1102 h
1103 }
1104 }
1105 for t in [Theme::kuychi(), Theme::kuychi_light()] {
1106 let matices: Vec<f32> = [
1107 t.bg_button,
1108 t.bg_row_hover,
1109 t.bg_selected,
1110 t.fg_muted,
1111 t.fg_destructive,
1112 t.accent,
1113 ]
1114 .iter()
1115 .map(|c| matiz(*c))
1116 .collect();
1117 // Distintos de a pares: ningún par a menos de 25° (y el círculo da
1118 // la vuelta, así que la distancia es la corta).
1119 let mut distintos = 0;
1120 for (i, a) in matices.iter().enumerate() {
1121 let choca = matices.iter().take(i).any(|b| {
1122 let d = (a - b).abs();
1123 d.min(360.0 - d) < 25.0
1124 });
1125 if !choca {
1126 distintos += 1;
1127 }
1128 }
1129 assert!(
1130 distintos >= 4,
1131 "{}: sólo {distintos} matices distintos entre estado, acento y error — \
1132 eso es un preset de un color, no Kuychi",
1133 t.name
1134 );
1135
1136 // Y la lección que costó la primera versión: la variedad de matiz no
1137 // se ve si todo lleva el MISMO poco de color. El suelo tiene que ser
1138 // casi neutro y el color tiene que ir entero donde significa algo.
1139 // Este par de asserts es lo que la v1 no pasaba (su panel iba a
1140 // S=0,42) y es la razón de que el operador la viera «de un solo
1141 // tono» teniendo ocho matices adentro.
1142 let sat = |c: Color| {
1143 let k = c.components;
1144 let max = k[0].max(k[1]).max(k[2]);
1145 let min = k[0].min(k[1]).min(k[2]);
1146 if max < 1e-6 {
1147 0.0
1148 } else {
1149 (max - min) / max
1150 }
1151 };
1152 for (nombre, c) in [("bg_app", t.bg_app), ("bg_panel", t.bg_panel), ("bg_input", t.bg_input)] {
1153 assert!(
1154 sat(c) <= 0.25,
1155 "{}: el suelo {nombre} lleva croma {:.2} — si el fondo tiene color, el color no se ve",
1156 t.name,
1157 sat(c)
1158 );
1159 }
1160 for (nombre, c) in [("accent", t.accent), ("fg_destructive", t.fg_destructive)] {
1161 assert!(
1162 sat(c) >= 0.60,
1163 "{}: {nombre} lleva croma {:.2} — un primario a media saturación es un tono polvoriento",
1164 t.name,
1165 sat(c)
1166 );
1167 }
1168 }
1169 }
1170
1171 /// La deriva **no da tirones**: un paso de los 720 no mueve ningún canal
1172 /// más de 4 de 255. Es el test que atrapó el descenso de croma a saltos
1173 /// (multiplicar por 0,94 hasta que entre), que saltaba 13/255 en el acento
1174 /// y se veía como un tic, no como una deriva.
1175 #[test]
1176 fn la_deriva_no_da_tirones() {
1177 let paso = 360.0 / PASOS_DERIVA as f32;
1178 for base in [Theme::kuychi(), Theme::kuychi_light()] {
1179 let mut peor = 0i32;
1180 let mut donde = "";
1181 // Una vuelta entera, paso a paso. Son 720 × 16 comparaciones: barato.
1182 for i in 0..PASOS_DERIVA {
1183 let a = base.girado(i as f32 * paso);
1184 let b = base.girado((i + 1) as f32 * paso);
1185 for (nombre, x, y) in ranuras(&a).iter().zip(ranuras(&b).iter()).map(
1186 |((n, x), (_, y))| (*n, *x, *y),
1187 ) {
1188 let (x, y) = (x.to_rgba8(), y.to_rgba8());
1189 let d = (x.r as i32 - y.r as i32)
1190 .abs()
1191 .max((x.g as i32 - y.g as i32).abs())
1192 .max((x.b as i32 - y.b as i32).abs());
1193 if d > peor {
1194 peor = d;
1195 donde = nombre;
1196 }
1197 }
1198 }
1199 assert!(
1200 peor <= 4,
1201 "{}: un paso de la deriva salta {peor}/255 en {donde} — eso se ve como un tirón",
1202 base.name
1203 );
1204 }
1205 }
1206
1207 /// Y **no rompe el contraste en ninguna de las 720 fases**. Sin conservar
1208 /// la luminancia esto falla feo: el acento cae a 2,6:1 alrededor de la fase
1209 /// 22°. Es la razón de que `girar_matiz` haga bisección en vez de sumarle
1210 /// grados al matiz y listo.
1211 #[test]
1212 fn la_deriva_no_rompe_el_contraste() {
1213 let paso = 360.0 / PASOS_DERIVA as f32;
1214 let contraste = |a: Color, b: Color| {
1215 let (la, lb) = (luminancia(a), luminancia(b));
1216 let (alto, bajo) = if la >= lb { (la, lb) } else { (lb, la) };
1217 (alto + 0.05) / (bajo + 0.05)
1218 };
1219 for base in [Theme::kuychi(), Theme::kuychi_light()] {
1220 for i in 0..PASOS_DERIVA {
1221 let t = base.girado(i as f32 * paso);
1222 let pruebas = [
1223 ("texto/fondo", t.fg_text, t.bg_app, 7.0),
1224 ("secundario/panel", t.fg_muted, t.bg_panel, 4.5),
1225 ("acento/panel", t.accent, t.bg_panel, 4.5),
1226 ("error/panel", t.fg_destructive, t.bg_panel, 4.5),
1227 ("texto/seleccionada", t.fg_text, t.bg_selected, 4.5),
1228 ("texto/hover", t.fg_text, t.bg_row_hover, 4.5),
1229 ];
1230 for (que, a, b, minimo) in pruebas {
1231 let r = contraste(a, b);
1232 assert!(
1233 r >= minimo,
1234 "{}: en la fase {:.1}° el contraste {que} cae a {r:.2} (mínimo {minimo})",
1235 base.name,
1236 i as f32 * paso
1237 );
1238 }
1239 }
1240 }
1241 }
1242
1243 /// El rojo del error **no gira**, y el resto sí. Sin esto, en la fase 90°
1244 /// el mensaje de error queda verde — se ve en el primer render.
1245 #[test]
1246 fn el_rojo_del_error_no_gira_y_el_resto_si() {
1247 let base = Theme::kuychi();
1248 let girado = base.girado(90.0);
1249 assert_eq!(
1250 girado.fg_destructive.to_rgba8().to_u32(),
1251 base.fg_destructive.to_rgba8().to_u32(),
1252 "el error cambió de color: rojo-es-error no es un matiz cualquiera"
1253 );
1254 assert_ne!(
1255 girado.accent.to_rgba8().to_u32(),
1256 base.accent.to_rgba8().to_u32(),
1257 "el acento NO giró: entonces no hay deriva"
1258 );
1259 // Y el nombre no cambia: un tema girado sigue siendo el mismo preset,
1260 // o `by_name` dejaría de encontrarlo y la config no se podría guardar.
1261 assert_eq!(girado.name, base.name);
1262 }
1263
1264 /// Una vuelta entera vuelve al mismo lugar (±1 de redondeo). Es lo que hace
1265 /// que la deriva sea un ciclo y no un paseo: si el error se acumulara, a los
1266 /// tres días el escritorio estaría en cualquier parte.
1267 #[test]
1268 fn una_vuelta_entera_vuelve_al_mismo_lugar() {
1269 for base in [Theme::kuychi(), Theme::kuychi_light()] {
1270 let vuelta = base.girado(360.0);
1271 for ((nombre, a), (_, b)) in ranuras(&base).iter().zip(ranuras(&vuelta).iter()) {
1272 let (a, b) = (a.to_rgba8(), b.to_rgba8());
1273 let d = (a.r as i32 - b.r as i32)
1274 .abs()
1275 .max((a.g as i32 - b.g as i32).abs())
1276 .max((a.b as i32 - b.b as i32).abs());
1277 assert!(d <= 1, "{}: {nombre} no volvió tras una vuelta (Δ{d})", base.name);
1278 }
1279 }
1280 }
1281
1282 /// Las dieciséis ranuras con su nombre, para que un test que las recorra
1283 /// pueda decir CUÁL falló en vez de «alguna».
1284 fn ranuras(t: &Theme) -> [(&'static str, Color); 16] {
1285 [
1286 ("bg_app", t.bg_app),
1287 ("bg_panel", t.bg_panel),
1288 ("bg_panel_alt", t.bg_panel_alt),
1289 ("bg_input", t.bg_input),
1290 ("bg_input_focus", t.bg_input_focus),
1291 ("bg_button", t.bg_button),
1292 ("bg_button_hover", t.bg_button_hover),
1293 ("bg_selected", t.bg_selected),
1294 ("bg_row_hover", t.bg_row_hover),
1295 ("fg_text", t.fg_text),
1296 ("fg_muted", t.fg_muted),
1297 ("fg_placeholder", t.fg_placeholder),
1298 ("fg_destructive", t.fg_destructive),
1299 ("border", t.border),
1300 ("border_focus", t.border_focus),
1301 ("accent", t.accent),
1302 ]
1303 }
1304
1305 /// En temas oscuros la superficie hundida es más oscura que el chrome
1306 /// (`bg_app`); en claros, también desciende (se lee recesada). En ambos
1307 /// casos difiere de `bg_app` — no es un no-op.
1308 #[test]
1309 fn sunken_is_deeper_than_bg_app() {
1310 let lum = |c: Color| {
1311 let k = c.components;
1312 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2]
1313 };
1314 for t in Theme::all() {
1315 assert!(
1316 lum(t.sunken()) < lum(t.bg_app),
1317 "{}: sunken debe ser más oscura que bg_app",
1318 t.name
1319 );
1320 }
1321 }
1322}
1323