tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-theme/src/lib.rs Rust · 1323 líneas · 58.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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