diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx
index f0e4248..2dbd666 100644
--- a/web/src/components/icons.tsx
+++ b/web/src/components/icons.tsx
@@ -30,6 +30,7 @@ export const IconCheck = (p: P) => base({ size: 11, strokeWidth: 3.2, ...p }, base(p, <> >);
export const IconUpload = (p: P) => base(p, <> >);
export const IconCode = (p: P) => base(p, <> >);
+export const IconBlock = (p: P) => base(p, <> >);
export const IconShield = (p: P) => base(p, );
export const IconDownload = (p: P) => base(p, <> >);
export const IconList = (p: P) => base(p, <> >);
diff --git a/web/src/index.css b/web/src/index.css
index 76eca56..3c8bf55 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -176,14 +176,15 @@ aside.sidebar.collapsed .sideactions a{justify-content:center;padding:9px 0;flex
}
/* ---- Apariencia: layout horizontal (tema izq ~50% + controles der) ---- */
-.aprow{display:flex;flex-direction:column;gap:18px}
-@media(min-width:700px){.aprow{flex-direction:row;gap:28px}}
-.ap-theme{flex-shrink:0;display:grid;grid-template-columns:repeat(3,1fr);gap:10px;min-width:0}
-@media(min-width:700px){.ap-theme{width:50%}}
-.ap-controls{flex:1;display:flex;flex-direction:column;gap:16px;min-width:0}
-.ap-accent-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
-.ap-accent-row .lbl{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text2);margin-bottom:6px;display:block}
-.ap-anim{margin-left:auto;display:flex;align-items:center;gap:9px;min-width:0}
+.aprow{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-end}
+.ap-field{display:flex;flex-direction:column;gap:8px;min-width:0}
+.ap-field .lbl{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text2)}
+.ap-theme{flex:2;min-width:200px}
+.ap-mode{flex:1.3;min-width:190px}
+.ap-density{flex:1;min-width:190px}
+.ap-accent{display:flex;align-items:center;gap:12px;flex-wrap:wrap;flex:1.4;min-width:190px}
+.ap-accent .lbl{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text2)}
+.ap-anim{display:flex;align-items:center;gap:9px;min-width:0}
.ap-anim .lbl{font-size:13px;color:var(--text2);white-space:nowrap}
/* tiles de tema (mini-preview con variables reales) */
@@ -397,10 +398,10 @@ header.top .sub{font-size:13px;color:var(--text2);margin-top:2px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}
.card.pad{--cp:16px;padding:var(--cp)}
.kpi{padding:15px 16px}
-.kpi .lbl{font-size:12px;color:var(--text2);font-weight:600;text-transform:uppercase;letter-spacing:.05em}
-.kpi .val{font-size:24px;font-weight:700;margin-top:5px;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
-.kpi .val small{font-size:13px;font-weight:500;color:var(--text2)}
-.kpi .foot{font-size:12px;color:var(--text2);margin-top:4px}
+.kpi .lbl{font-size:13px;color:var(--text2);font-weight:600;text-transform:uppercase;letter-spacing:.05em}
+.kpi .val{font-size:26px;font-weight:700;margin-top:5px;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
+.kpi .val small{font-size:14px;font-weight:500;color:var(--text2)}
+.kpi .foot{font-size:13px;color:var(--text2);margin-top:4px}
/* Select propio (listbox) — el nativo pinta el popup con el tema del SO */
.sel{position:relative}
@@ -443,9 +444,9 @@ header.top .sub{font-size:13px;color:var(--text2);margin-top:2px}
.rowitem{display:flex;align-items:center;gap:13px;padding:14px 16px;border-bottom:1px solid var(--border)}
.rowitem:last-child{border-bottom:none}
.rowitem .grow{flex:1;min-width:0}
-.rowitem .t1{font-weight:600;font-size:14.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
-.rowitem .t2{font-size:12.5px;color:var(--text2);margin-top:2px}
-.mono{font-family:var(--font-mono);font-size:12.5px}
+.rowitem .t1{font-weight:600;font-size:15.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.rowitem .t2{font-size:13.5px;color:var(--text2);margin-top:2px}
+.mono{font-family:var(--font-mono);font-size:13.5px}
/* botones */
.btn{
@@ -465,7 +466,7 @@ header.top .sub{font-size:13px;color:var(--text2);margin-top:2px}
/* sección */
.sect{margin-top:22px}
-.sect h2{font-size:15px;font-weight:700;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;gap:10px}
+.sect h2{font-size:16.5px;font-weight:700;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;gap:10px}
.sect h2 .actions{display:flex;gap:7px}
/* pool card */
@@ -486,11 +487,11 @@ header.top .sub{font-size:13px;color:var(--text2);margin-top:2px}
/* tabla */
.tblwrap{overflow-x:auto}
-table.data{width:100%;border-collapse:collapse;font-size:13.5px}
+table.data{width:100%;border-collapse:collapse;font-size:14.5px}
/* columna "elástica": absorbe el espacio sobrante en pantallas anchas en
vez de repartir huecos entre TODAS las columnas */
table.data th.slack{width:100%}
-table.data th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--text2);padding:10px 14px;border-bottom:1px solid var(--border);white-space:nowrap}
+table.data th{text-align:left;font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text2);padding:10px 14px;border-bottom:1px solid var(--border);white-space:nowrap}
table.data td{padding:11px 14px;border-bottom:1px solid var(--border);white-space:nowrap}
table.data tr:last-child td{border-bottom:none}
/* Discos: la celda SMART rompe a 2 líneas en vez de ensanchar toda la tabla
@@ -520,7 +521,7 @@ table.data td.actions .btn{margin:2px 4px 2px 0}
}
tr.clickable{cursor:pointer} tr.clickable:hover td{background:var(--surface2)}
table.data th.num,table.data td.num{text-align:right;font-variant-numeric:tabular-nums}
-table.data td.num{font-family:var(--font-mono);font-size:12.5px}
+table.data td.num{font-family:var(--font-mono);font-size:13.5px}
/* snapshot tree: cada dataset es una tarjeta; en pantalla ancha las
tarjetas se disponen en grid (filas cortas → varias por horizontal) */
@@ -642,7 +643,7 @@ table.data td.num{font-family:var(--font-mono);font-size:12.5px}
.rp-warn{margin-top:10px;padding:10px 12px;border:1px solid var(--warn);border-left-width:3px;border-radius:10px;background:var(--warn-soft);color:var(--text);font-size:12.5px;font-weight:600}
/* alertas */
-.alert{display:flex;gap:12px;padding:13px 16px;border-bottom:1px solid var(--border);font-size:13.5px}
+.alert{display:flex;gap:12px;padding:13px 16px;border-bottom:1px solid var(--border);font-size:14.5px}
.alert:last-child{border-bottom:none}
.alert .ico{flex-shrink:0;width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-weight:700}
.alert .btn.sm{white-space:nowrap}
@@ -817,7 +818,7 @@ a.abouttile:hover svg{color:var(--accent)}
.chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:12px}
.chip{padding:6px 13px;border-radius:99px;border:1px solid var(--border);background:var(--surface);font-size:12.5px;font-weight:600;color:var(--text2)}
.chip.on{background:var(--accent-soft);color:var(--accent);border-color:transparent}
-.empty{padding:34px 20px;text-align:center;color:var(--text2);font-size:13.5px}
+.empty{padding:34px 20px;text-align:center;color:var(--text2);font-size:14px}
svg{flex-shrink:0}
/* panel de alertas (popover) */
@@ -835,7 +836,7 @@ svg{flex-shrink:0}
.splash{min-height:100vh;display:grid;place-items:center;color:var(--text2);font-size:14px;gap:10px}
/* clases de apoyo reutilizables */
-.muted{font-size:12.5px;color:var(--text2)}
+.muted{font-size:13.5px;color:var(--text2)}
.checklabel{display:flex;align-items:center;gap:9px;text-transform:none;font-size:13.5px;font-weight:500;color:var(--text)}
.checklabel input{width:auto}
@@ -947,3 +948,213 @@ html.reduce-motion *{transition:none!important;animation:none!important}
}
.toast .toast-x:hover{color:var(--text);background:var(--surface2)}
@keyframes toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
+
+/* ================================================================
+ SKINS — temas completos con identidad propia.
+ Cada skin define TODOS los tokens del sistema + una capa
+ estructural gated por [data-theme=""] (tipografía, radios,
+ bordes, sombras, materiales). Los componentes no se modifican.
+ El picker de acento no aplica a los skins: el acento es parte de
+ la identidad de cada uno (ver ui/theme.ts).
+ ================================================================ */
+
+/* ==================== MODERN-DARK ==================== */
+[data-theme="modern-dark"]{
+ color-scheme:dark;
+ --bg:#0d0e12; --surface:#15171d; --surface2:#1b1e26; --border:#262a36;
+ --text:#e8eaf0; --text2:#8b90a0; --text3:#565b6a;
+ --accent:#7c5cfc; --accent-soft:rgba(124,92,252,.16);
+ --ok:#2bd97c; --warn:#f5a524; --warn-soft:rgba(245,165,36,.14);
+ --err:#f2555a; --err-soft:rgba(242,85,90,.14);
+ --info:#38bdf8; --info-soft:rgba(56,189,248,.14);
+ --on-accent:#ffffff;
+ --radius:14px; --radius-sm:10px;
+ --shadow:0 12px 32px rgba(0,0,0,.42);
+}
+/* ==================== MODERN-LIGHT (gemelo) ==================== */
+[data-theme="modern-light"]{
+ color-scheme:light;
+ --bg:#f4f5f8; --surface:#ffffff; --surface2:#eef0f5; --border:#e2e4ec;
+ --text:#16181f; --text2:#66707f; --text3:#a4aab6;
+ --accent:#6d3df5; --accent-soft:rgba(109,61,245,.10);
+ --ok:#12a15c; --warn:#c47d0a; --warn-soft:rgba(196,125,10,.12);
+ --err:#dd3542; --err-soft:rgba(221,53,66,.10);
+ --info:#0b8fd4; --info-soft:rgba(11,143,212,.10);
+ --on-accent:#ffffff;
+ --radius:14px; --radius-sm:10px;
+ --shadow:0 1px 2px rgba(18,20,28,.05), 0 12px 28px rgba(18,20,28,.09);
+}
+/* capa estructural modern (ambos): halo de acento, cards SIN borde con
+ elevación por sombra, nav activo sólido, glow en primarios y foco */
+[data-theme="modern-dark"] body,
+[data-theme="modern-light"] body{
+ background-image:radial-gradient(ellipse 70% 40% at 50% -10%, var(--accent-soft), transparent 65%);
+ background-repeat:no-repeat;
+}
+[data-theme="modern-dark"] .card,
+[data-theme="modern-light"] .card{border-color:transparent;box-shadow:0 1px 2px rgba(10,12,18,.06), 0 10px 28px rgba(10,12,18,.12)}
+[data-theme="modern-dark"] .card{box-shadow:0 10px 28px rgba(0,0,0,.28)}
+[data-theme="modern-dark"] .btn.primary,
+[data-theme="modern-light"] .btn.primary{box-shadow:0 4px 16px var(--accent-soft), 0 2px 6px rgba(0,0,0,.18)}
+[data-theme="modern-dark"] aside.sidebar nav a.active,
+[data-theme="modern-light"] aside.sidebar nav a.active{
+ background:var(--accent);color:var(--on-accent);
+ box-shadow:0 4px 14px var(--accent-soft);
+}
+[data-theme="modern-dark"] input:focus,
+[data-theme="modern-dark"] select:focus,
+[data-theme="modern-light"] input:focus,
+[data-theme="modern-light"] select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
+/* banner de rebuild en Modern: acento azul (info) en vez de ámbar (warn) */
+[data-theme="modern-dark"] .rebuildbar,
+[data-theme="modern-light"] .rebuildbar{
+ background:var(--info-soft);color:var(--info);
+ border-color:color-mix(in srgb,var(--info) 35%,transparent);
+}
+
+/* ==================== PHOSPHOR (terminal CRT) ==================== */
+[data-theme="phosphor"]{
+ color-scheme:dark;
+ --bg:#050810; --surface:#0c1220; --surface2:#101828; --border:#1a2540;
+ --text:#c9d6e8; --text2:#5b6b85; --text3:#33415c;
+ --accent:#00f48e; --accent-soft:rgba(0,244,142,.08);
+ --ok:#00f48e; --warn:#fbbf24; --warn-soft:rgba(251,191,36,.10);
+ --err:#f87171; --err-soft:rgba(248,113,113,.10);
+ --info:#22d3ee; --info-soft:rgba(34,211,238,.10);
+ --on-accent:#00120a;
+ --radius:0; --radius-sm:0;
+ --shadow:none;
+}
+/* tipografía: TODO mono */
+[data-theme="phosphor"] body,
+[data-theme="phosphor"] button,
+[data-theme="phosphor"] input,
+[data-theme="phosphor"] select,
+[data-theme="phosphor"] textarea{font-family:var(--font-mono)}
+/* scanlines CRT */
+[data-theme="phosphor"] body::after{
+ content:"";position:fixed;inset:0;z-index:9000;pointer-events:none;
+ background:repeating-linear-gradient(0deg, rgba(0,0,0,.10) 0 1px, transparent 1px 3px);
+}
+/* rejilla tenue de fondo */
+[data-theme="phosphor"] body{
+ background-image:
+ linear-gradient(rgba(26,37,64,.20) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(26,37,64,.20) 1px, transparent 1px);
+ background-size:44px 44px;
+}
+/* esquinas HUD en cards */
+[data-theme="phosphor"] .card{position:relative;background:linear-gradient(180deg,var(--surface),#070c16)}
+[data-theme="phosphor"] .card::before,
+[data-theme="phosphor"] .card::after{
+ content:"";position:absolute;width:14px;height:14px;pointer-events:none;z-index:2;
+}
+[data-theme="phosphor"] .card::before{top:-1px;left:-1px;border-top:1px solid var(--accent);border-left:1px solid var(--accent)}
+[data-theme="phosphor"] .card::after{bottom:-1px;right:-1px;border-bottom:1px solid var(--accent);border-right:1px solid var(--accent)}
+/* nav activo: bloque de acento invertido con glow */
+[data-theme="phosphor"] aside.sidebar nav a{border-radius:0;border-left:2px solid transparent}
+[data-theme="phosphor"] aside.sidebar nav a.active{
+ background:var(--accent);color:var(--on-accent);font-weight:700;
+ box-shadow:0 0 18px rgba(0,244,142,.4);
+}
+[data-theme="phosphor"] .sideactions a{border-radius:0}
+[data-theme="phosphor"] .sideactions a.active{background:var(--accent);color:var(--on-accent);box-shadow:0 0 18px rgba(0,244,142,.4)}
+/* medidores segmentados tipo terminal */
+[data-theme="phosphor"] .meter{border-radius:0;height:12px;border:1px solid var(--border);background:#080e1c}
+[data-theme="phosphor"] .meter>i{
+ border-radius:0;
+ background:repeating-linear-gradient(90deg, var(--ok) 0 7px, transparent 7px 10px);
+}
+[data-theme="phosphor"] .meter.warn>i{background:repeating-linear-gradient(90deg, var(--warn) 0 7px, transparent 7px 10px)}
+[data-theme="phosphor"] .meter.crit>i{background:repeating-linear-gradient(90deg, var(--err) 0 7px, transparent 7px 10px)}
+/* badges: etiquetas técnicas, no pills */
+[data-theme="phosphor"] .badge{
+ border-radius:0;border:1px solid currentColor;background:transparent;
+ text-transform:uppercase;letter-spacing:.12em;font-weight:700;
+}
+/* botones como comandos [ BOTÓN ] */
+[data-theme="phosphor"] .btn{
+ border-radius:0;text-transform:uppercase;letter-spacing:.14em;font-weight:700;font-size:12px;
+}
+[data-theme="phosphor"] .btn:hover{border-color:var(--accent);color:var(--accent);box-shadow:0 0 12px rgba(0,244,142,.25);background:transparent}
+[data-theme="phosphor"] .btn.primary{box-shadow:none}
+[data-theme="phosphor"] .btn.primary:hover{box-shadow:0 0 18px rgba(0,244,142,.55);color:var(--on-accent);background:var(--accent)}
+[data-theme="phosphor"] .btn.danger:hover{border-color:var(--err);box-shadow:0 0 12px rgba(248,113,113,.3)}
+/* inputs y selects terminales */
+[data-theme="phosphor"] input,
+[data-theme="phosphor"] select,
+[data-theme="phosphor"] .sel-btn{border-radius:0}
+[data-theme="phosphor"] input:focus,
+[data-theme="phosphor"] select:focus{outline:1px solid var(--accent);outline-offset:-1px;box-shadow:0 0 10px rgba(0,244,142,.25)}
+/* KPIs con glow de fósforo */
+[data-theme="phosphor"] .kpi .val{text-shadow:0 0 14px rgba(0,244,142,.35)}
+/* seg control cuadrado */
+[data-theme="phosphor"] .seg{border-radius:0}
+[data-theme="phosphor"] .seg button{border-radius:0}
+[data-theme="phosphor"] .seg button.on{background:var(--accent);color:var(--on-accent);box-shadow:none}
+
+/* ==================== BRUTALIST ==================== */
+[data-theme="brutalist"]{
+ color-scheme:light;
+ --bg:#f2ede1; --surface:#fffdf5; --surface2:#f7f2e4; --border:#141414;
+ --text:#141414; --text2:#5c5648; --text3:#9a917d;
+ --accent:#2b59ff; --accent-soft:rgba(43,89,255,.12);
+ --ok:#0a8f4d; --warn:#c98a00; --warn-soft:rgba(201,138,0,.14);
+ --err:#e5352b; --err-soft:rgba(229,53,43,.10);
+ --info:#0f7fd4; --info-soft:rgba(15,127,212,.10);
+ --on-accent:#ffffff;
+ --radius:0; --radius-sm:0;
+ --shadow:5px 5px 0 #141414;
+}
+/* papel punteado */
+[data-theme="brutalist"] body{
+ background-image:radial-gradient(rgba(20,20,20,.10) 1px, transparent 1px);
+ background-size:22px 22px;
+}
+/* bordes gruesos + sombra dura en cards */
+[data-theme="brutalist"] .card{border-width:2px;box-shadow:5px 5px 0 #141414}
+/* botones pegatina */
+[data-theme="brutalist"] .btn{
+ border-width:2px;border-color:#141414;box-shadow:3px 3px 0 #141414;
+ text-transform:uppercase;letter-spacing:.05em;font-weight:700;font-size:12.5px;
+ transition:transform .1s, box-shadow .1s;
+}
+[data-theme="brutalist"] .btn:hover{transform:translate(-1px,-1px);box-shadow:4px 4px 0 #141414;background:var(--surface)}
+[data-theme="brutalist"] .btn:active{transform:translate(2px,2px);box-shadow:1px 1px 0 #141414}
+[data-theme="brutalist"] .btn.primary{background:#ffd23f;color:#141414;border-color:#141414}
+[data-theme="brutalist"] .btn.danger{color:var(--err);border-color:var(--err)}
+[data-theme="brutalist"] .btn.solid-danger{background:var(--err);color:#fff;border-color:#141414}
+/* badges etiqueta */
+[data-theme="brutalist"] .badge{border-radius:0;border:2px solid currentColor;background:transparent;font-weight:700}
+/* inputs y selects */
+[data-theme="brutalist"] input,
+[data-theme="brutalist"] select,
+[data-theme="brutalist"] .sel-btn{border-width:2px;border-color:#141414}
+[data-theme="brutalist"] input:focus,
+[data-theme="brutalist"] select:focus{outline:3px solid #ffd23f;outline-offset:-1px}
+/* nav activo: bloque negro con texto señal */
+[data-theme="brutalist"] aside.sidebar{border-right:2px solid #141414}
+[data-theme="brutalist"] aside.sidebar nav a{border-radius:0}
+[data-theme="brutalist"] aside.sidebar nav a.active{background:#141414;color:#ffd23f}
+[data-theme="brutalist"] .sideactions a{border-radius:0}
+[data-theme="brutalist"] .sideactions a.active{background:#141414;color:#ffd23f}
+/* medidores con borde */
+[data-theme="brutalist"] .meter{border-radius:0;border:2px solid #141414;height:12px}
+[data-theme="brutalist"] .meter>i{border-radius:0}
+/* seg control */
+[data-theme="brutalist"] .seg{border-radius:0;border:2px solid #141414}
+[data-theme="brutalist"] .seg button{border-radius:0}
+[data-theme="brutalist"] .seg button.on{background:#141414;color:#ffd23f;box-shadow:none}
+/* títulos de card con subrayado marcador */
+[data-theme="brutalist"] .cardtitle{
+ display:inline-block;background:linear-gradient(transparent 62%, #ffd23f 62%);
+ padding:0 4px;text-transform:uppercase;letter-spacing:.01em;
+}
+
+/* ---------- selector de tema: rejilla flexible para 7 tiles ---------- */
+.ap-theme{grid-template-columns:repeat(auto-fit,minmax(104px,1fr))}
+/* previews de skin: el scope ya lo da [data-theme] global, solo hay que
+ asegurar que el tile muestra UNA variante a ancho completo */
+.tpv-scope:only-child{flex:1}
+/* nota en lugar del picker de acento cuando hay skin activo */
+.accent-note{font-size:12px;color:var(--text3);font-style:italic}
diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts
index aa7c600..b55d2f0 100644
--- a/web/src/ui/i18n.ts
+++ b/web/src/ui/i18n.ts
@@ -453,8 +453,13 @@ const es = {
s_general: 'General', s_lang: 'Idioma', s_lang_auto: 'Automático',
s_appear: 'Apariencia', s_accent: 'Color de acento',
acc_cyan: 'Cian', acc_steel: 'Acero', acc_emerald: 'Esmeralda', acc_amber: 'Ámbar',
+ acc_violet: 'Violeta', acc_green: 'Fósforo', acc_blue: 'Azul', acc_yellow: 'Amarillo',
s_density: 'Densidad', s_density_cozy: 'Cómoda', s_density_compact: 'Compacta',
- s_theme: 'Tema', s_theme_auto: 'Sistema', s_theme_light: 'Claro', s_theme_dark: 'Oscuro',
+ s_theme: 'Tema', s_theme_mode: 'Modo', s_theme_auto: 'Sistema', s_theme_light: 'Claro', s_theme_dark: 'Oscuro',
+ s_theme_modern: 'Moderno', s_theme_modern_dark: 'Moderno oscuro', s_theme_modern_light: 'Moderno claro',
+ s_theme_phosphor: 'Fósforo', s_theme_brutalist: 'Brutalista',
+ s_theme_clasico: 'Clásico',
+ s_accent_skin: 'El acento lo define el tema',
s_theme_toLight: 'Cambiar a tema claro', s_theme_toDark: 'Cambiar a tema oscuro',
s_users: 'Usuarios', s_newuser: 'Nuevo usuario',
s_roles_d: 'Admin: acceso total (usuarios, ajustes, acciones destructivas). Usuario: consulta y operaciones del día a día (snapshots, scrubs, datasets).',
@@ -1049,8 +1054,13 @@ const en: Record = {
s_general: 'General', s_lang: 'Language', s_lang_auto: 'Automatic',
s_appear: 'Appearance', s_accent: 'Accent color',
acc_cyan: 'Cyan', acc_steel: 'Steel', acc_emerald: 'Emerald', acc_amber: 'Amber',
+ acc_violet: 'Violet', acc_green: 'Phosphor', acc_blue: 'Blue', acc_yellow: 'Yellow',
s_density: 'Density', s_density_cozy: 'Comfortable', s_density_compact: 'Compact',
- s_theme: 'Theme', s_theme_auto: 'System', s_theme_light: 'Light', s_theme_dark: 'Dark',
+ s_theme: 'Theme', s_theme_mode: 'Mode', s_theme_auto: 'System', s_theme_light: 'Light', s_theme_dark: 'Dark',
+ s_theme_modern: 'Modern', s_theme_modern_dark: 'Modern dark', s_theme_modern_light: 'Modern light',
+ s_theme_phosphor: 'Phosphor', s_theme_brutalist: 'Brutalist',
+ s_theme_clasico: 'Classic',
+ s_accent_skin: 'Accent is set by the theme',
s_theme_toLight: 'Switch to light theme', s_theme_toDark: 'Switch to dark theme',
s_users: 'Users', s_newuser: 'New user',
s_roles_d: 'Admin: full access (users, settings, destructive actions). User: read-only plus day-to-day operations (snapshots, scrubs, datasets).',
diff --git a/web/src/ui/store.tsx b/web/src/ui/store.tsx
index d8c9ea4..f169774 100644
--- a/web/src/ui/store.tsx
+++ b/web/src/ui/store.tsx
@@ -10,8 +10,8 @@ import { AUTH_EXPIRED_EVENT, connectSSE, disconnectSSE } from '../data/events';
import { ApiError } from '../data/types';
import { initLang, onLangChange, setLangMode, t as translate, getLangMode } from './i18n';
import type { LangMode, I18nKey } from './i18n';
-import { applyTheme, applyDensity, applyReduceMotion, onThemeChange, startThemeWatcher, effectiveTheme, setThemeMode, getThemeMode } from './theme';
-import type { ThemeMode } from './theme';
+import { applyTheme, applyDensity, applyReduceMotion, onThemeChange, startThemeWatcher, effectiveTheme, getFamily, getMode, setThemeFamily, setThemeMode } from './theme';
+import type { ThemeFamily, ThemeMode } from './theme';
export type ViewId = 'dash' | 'pools' | 'data' | 'snaps' | 'tasks' | 'disks' | 'trends' | 'settings';
export const VIEWS: ViewId[] = ['dash', 'pools', 'data', 'snaps', 'tasks', 'disks', 'trends', 'settings'];
@@ -44,9 +44,11 @@ interface AppCtx {
t: ((k: I18nKey, vars?: Record) => string) & ((k: string) => string);
langMode: LangMode;
setLang: (m: LangMode) => void;
- themeMode: ThemeMode;
+ family: ThemeFamily;
+ mode: ThemeMode;
themeEff: 'light' | 'dark';
- setTheme: (m: ThemeMode) => void;
+ setFamily: (f: ThemeFamily) => void;
+ setMode: (m: ThemeMode) => void;
isAdmin: boolean;
// Capacidades de OpenZFS del host (feature-gating; null hasta conocerlas)
caps: Capabilities | null;
@@ -70,7 +72,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [route, setRoute] = useState(parseHash());
const routeRef = useRef(parseHash());
const [langMode, setLangModeState] = useState(getLangMode());
- const [themeMode, setThemeModeState] = useState(getThemeMode());
+ const [family, setFamilyState] = useState(getFamily());
+ const [mode, setModeState] = useState(getMode());
const [themeEff, setThemeEff] = useState<'light' | 'dark'>(effectiveTheme());
const [dataVersion, setDataVersion] = useState(0);
const [caps, setCaps] = useState(null);
@@ -92,7 +95,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
startThemeWatcher();
const offLang = onLangChange(() => setLangModeState(getLangMode()));
const offTheme = onThemeChange(() => {
- setThemeModeState(getThemeMode());
+ setFamilyState(getFamily());
+ setModeState(getMode());
setThemeEff(effectiveTheme());
});
(async () => {
@@ -231,7 +235,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const s = stateRef.current;
if (s.user && !s.demo) getProvider().setMyLanguage(m).catch(() => {});
}, []);
- const setTheme = useCallback((m: ThemeMode) => setThemeMode(m), []);
+ const setFamily = useCallback((f: ThemeFamily) => setThemeFamily(f), []);
+ const setMode = useCallback((m: ThemeMode) => setThemeMode(m), []);
const refresh = useCallback(() => setDataVersion((v) => v + 1), []);
const reloadUser = useCallback(() => {
getProvider().me().then(setUser).catch(() => {});
@@ -251,12 +256,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
const value = useMemo(() => ({
ready, demo, user, route, navigate, login, login2FA, logout, enterDemo, exitDemo,
- t, langMode, setLang, themeMode, themeEff, setTheme,
+ t, langMode, setLang, family, mode, themeEff, setFamily, setMode,
isAdmin: user?.role === 'admin',
caps,
refresh, dataVersion, reloadUser,
toasts, notify, dismissToast,
- }), [ready, demo, user, route, navigate, login, login2FA, logout, enterDemo, exitDemo, t, langMode, setLang, themeMode, themeEff, setTheme, caps, refresh, dataVersion, reloadUser, toasts, notify, dismissToast]);
+ }), [ready, demo, user, route, navigate, login, login2FA, logout, enterDemo, exitDemo, t, langMode, setLang, family, mode, themeEff, setFamily, setMode, caps, refresh, dataVersion, reloadUser, toasts, notify, dismissToast]);
return {children} ;
}
diff --git a/web/src/ui/theme.ts b/web/src/ui/theme.ts
index 536cd51..e160a24 100644
--- a/web/src/ui/theme.ts
+++ b/web/src/ui/theme.ts
@@ -1,25 +1,39 @@
-// Apariencia: tema claro/oscuro/sistema, color de acento, densidad y
+// Apariencia: tema (familia), modo claro/oscuro/sistema, acento, densidad y
// reducción de animaciones.
-// - Tema: 'light' | 'dark' | 'auto' (= sigue prefers-color-scheme del SO).
+// - Tema (familia): 'classic' | 'modern' | 'phosphor' | 'brutalist'.
+// - Modo: 'light' | 'dark' | 'system' (= sigue prefers-color-scheme del SO).
+// Aplica a classic y modern; phosphor y brutalist son fijos (ignoran el modo).
// - Acento: 4 colores con valores distintos para claro/oscuro; se aplican
-// como variables CSS (--accent, --accent-soft) en .
+// como variables CSS (--accent, --accent-soft) en . Disponible para
+// todos los temas (en no-classic se persiste por familia).
// - Densidad: 'cozy' | 'compact' (compacta = html font-size 13.5px + zoom).
// - Reduce-motion: clase .reduce-motion en (además de la media query
// prefers-reduced-motion del SO, ya cubierta en index.css).
-// Persistencia canónica webapp-shell (-*): easyzfs-theme-mode,
-// easyzfs-accent, easyzfs-density, easyzfs-reduce-motion. Las claves legacy
-// zfc-* (era zfsctl) se migran una vez y se borran.
-export type ThemeMode = 'auto' | 'light' | 'dark';
+// Persistencia canónica webapp-shell (-*): easyzfs-theme (familia),
+// easyzfs-theme-mode (modo), easyzfs-accent, easyzfs-density,
+// easyzfs-reduce-motion. Las claves legacy zfc-* (era zfsctl) migran una vez.
+export type ThemeFamily = 'classic' | 'modern' | 'phosphor' | 'brutalist';
+export type ThemeMode = 'light' | 'dark' | 'system';
export type AccentId = 'cyan' | 'steel' | 'emerald' | 'amber';
export type Density = 'cozy' | 'compact';
-const THEME_KEY = 'easyzfs-theme-mode';
+export const FAMILIES: readonly ThemeFamily[] = ['classic', 'modern', 'phosphor', 'brutalist'];
+// Fondo de cada data-theme para el meta theme-color / anti-FOUC.
+export const SKIN_BG: Record = {
+ 'modern-dark': '#0d0e12',
+ 'modern-light': '#f4f5f8',
+ phosphor: '#050810',
+ brutalist: '#f2ede1',
+};
+
+const THEME_KEY = 'easyzfs-theme'; // familia
+const THEMODE_KEY = 'easyzfs-theme-mode'; // modo (clave legacy reutilizada)
const ACCENT_KEY = 'easyzfs-accent';
const DENSITY_KEY = 'easyzfs-density';
const RM_KEY = 'easyzfs-reduce-motion';
// Legacy (pre-rebrand zfsctl): leer una vez y migrar.
const LEGACY: Record = {
- [THEME_KEY]: 'zfc-theme',
+ [THEMODE_KEY]: 'zfc-theme',
[ACCENT_KEY]: 'zfc-accent',
[DENSITY_KEY]: 'zfc-density',
};
@@ -40,6 +54,24 @@ function getKey(key: string): string | null {
return null;
}
+// Migración un-time desde el modelo combinado anterior (easyzfs-theme-mode
+// guardaba 'auto'|'light'|'dark'|'modern-dark'|'modern-light'|'phosphor'|
+// 'brutalist'). Una vez migrado, easyzfs-theme = familia y
+// easyzfs-theme-mode = modo limpio.
+function migrateLegacy(): void {
+ if (localStorage.getItem(THEME_KEY) !== null) return;
+ const legacy = getKey(THEMODE_KEY);
+ let family: ThemeFamily = 'modern';
+ let mode: ThemeMode = 'system';
+ if (legacy === 'light' || legacy === 'dark' || legacy === 'auto') {
+ family = 'classic'; mode = legacy === 'auto' ? 'system' : legacy;
+ } else if (legacy === 'modern-dark') { family = 'modern'; mode = 'dark'; }
+ else if (legacy === 'modern-light') { family = 'modern'; mode = 'light'; }
+ else if (legacy === 'phosphor' || legacy === 'brutalist') { family = legacy; mode = 'system'; }
+ localStorage.setItem(THEME_KEY, family);
+ localStorage.setItem(THEMODE_KEY, mode);
+}
+
const subs = new Set<() => void>();
// [color, soft] por tema. emerald = verde original de la app.
@@ -50,40 +82,107 @@ export const ACCENTS: Record = {
+ classic: {
+ default: 'emerald',
+ options: [
+ { v: 'cyan', light: ACCENTS.cyan.light, dark: ACCENTS.cyan.dark, labelKey: 'acc_cyan' },
+ { v: 'steel', light: ACCENTS.steel.light, dark: ACCENTS.steel.dark, labelKey: 'acc_steel' },
+ { v: 'emerald', light: ACCENTS.emerald.light, dark: ACCENTS.emerald.dark, labelKey: 'acc_emerald' },
+ { v: 'amber', light: ACCENTS.amber.light, dark: ACCENTS.amber.dark, labelKey: 'acc_amber' },
+ ],
+ },
+ modern: {
+ default: 'violet',
+ options: [
+ { v: 'violet', light: ['#6d3df5', 'rgba(109,61,245,.11)'], dark: ['#7c5cfc', 'rgba(124,92,252,.15)'], labelKey: 'acc_violet' },
+ { v: 'emerald', light: ACCENTS.emerald.light, dark: ACCENTS.emerald.dark, labelKey: 'acc_emerald' },
+ { v: 'cyan', light: ACCENTS.cyan.light, dark: ACCENTS.cyan.dark, labelKey: 'acc_cyan' },
+ { v: 'amber', light: ACCENTS.amber.light, dark: ACCENTS.amber.dark, labelKey: 'acc_amber' },
+ ],
+ },
+ phosphor: {
+ default: 'green',
+ options: [
+ { v: 'green', light: ['#00f48e', 'rgba(0,244,142,.08)'], dark: ['#00f48e', 'rgba(0,244,142,.08)'], labelKey: 'acc_green' },
+ { v: 'amber', light: ['#ffb000', 'rgba(255,176,0,.12)'], dark: ['#ffb000', 'rgba(255,176,0,.12)'], labelKey: 'acc_amber' },
+ { v: 'cyan', light: ['#22d3ee', 'rgba(34,211,238,.12)'], dark: ['#22d3ee', 'rgba(34,211,238,.12)'], labelKey: 'acc_cyan' },
+ ],
+ },
+ brutalist: {
+ default: 'blue',
+ options: [
+ { v: 'blue', light: ['#2b59ff', 'rgba(43,89,255,.12)'], dark: ['#2b59ff', 'rgba(43,89,255,.12)'], labelKey: 'acc_blue' },
+ { v: 'yellow', light: ['#ffd23f', 'rgba(255,210,63,.22)'], dark: ['#ffd23f', 'rgba(255,210,63,.22)'], labelKey: 'acc_yellow' },
+ { v: 'green', light: ['#0a8f4d', 'rgba(10,143,77,.14)'], dark: ['#0a8f4d', 'rgba(10,143,77,.14)'], labelKey: 'acc_green' },
+ ],
+ },
+};
+
+// ---- Tema (familia) ----
+// DEFAULT sin preferencia guardada: Modern + modo system (sigue al SO).
+export function getFamily(): ThemeFamily {
+ migrateLegacy();
+ const f = localStorage.getItem(THEME_KEY) as ThemeFamily | null;
+ return f && FAMILIES.includes(f) ? f : 'modern';
+}
+
+export function getMode(): ThemeMode {
+ migrateLegacy();
+ const m = localStorage.getItem(THEMODE_KEY);
+ return m === 'light' || m === 'dark' ? m : 'system';
}
export function systemPrefersDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
-export function effectiveTheme(mode: ThemeMode = getThemeMode()): 'light' | 'dark' {
- if (mode === 'auto') return systemPrefersDark() ? 'dark' : 'light';
- return mode;
+// ¿El modo efectivo es oscuro? (system = sigue al SO).
+export function isDarkMode(mode: ThemeMode = getMode()): boolean {
+ if (mode === 'system') return systemPrefersDark();
+ return mode === 'dark';
+}
+
+// Luminosidad efectiva (para pares claro/oscuro de acentos, iconos sol/luna).
+export function effectiveTheme(): 'light' | 'dark' {
+ const family = getFamily();
+ if (family === 'modern' || family === 'classic') return isDarkMode() ? 'dark' : 'light';
+ return family === 'brutalist' ? 'light' : 'dark'; // phosphor fijo oscuro
}
export function applyTheme(): void {
- const eff = effectiveTheme();
- document.documentElement.dataset.theme = eff === 'dark' ? 'dark' : '';
- // theme-color dinámico según el tema efectivo
+ const family = getFamily();
+ const mode = getMode();
+ const el = document.documentElement;
const meta = document.querySelector('meta[name="theme-color"]');
- if (meta) meta.setAttribute('content', eff === 'dark' ? '#0e1210' : '#f6f6f3');
- applyAccent(); // el acento tiene valores distintos por tema
+ let dt: string;
+ if (family === 'classic') dt = isDarkMode(mode) ? 'dark' : '';
+ else if (family === 'modern') dt = isDarkMode(mode) ? 'modern-dark' : 'modern-light';
+ else dt = family; // phosphor | brutalist (fijos)
+ el.dataset.theme = dt;
+ if (meta) meta.setAttribute('content', SKIN_BG[dt] ?? (dt === 'dark' ? '#0e1210' : '#f6f6f3'));
+ applyAccent(); // acento en todos los temas
+}
+
+export function setThemeFamily(f: ThemeFamily): void {
+ localStorage.setItem(THEME_KEY, f);
+ applyTheme();
+ subs.forEach((fn) => fn());
}
-export function setThemeMode(mode: ThemeMode): void {
- if (mode === 'auto') localStorage.removeItem(THEME_KEY);
- else localStorage.setItem(THEME_KEY, mode);
+export function setThemeMode(m: ThemeMode): void {
+ localStorage.setItem(THEMODE_KEY, m);
applyTheme();
- subs.forEach((f) => f());
+ subs.forEach((fn) => fn());
}
-// Botón del header: alterna claro/oscuro manualmente (override)
+// Botón del header: alterna claro/oscuro manualmente (override sobre system)
export function toggleTheme(): void {
- setThemeMode(effectiveTheme() === 'dark' ? 'light' : 'dark');
+ setThemeMode(isDarkMode() ? 'light' : 'dark');
}
export function onThemeChange(fn: () => void): () => void {
@@ -91,13 +190,13 @@ export function onThemeChange(fn: () => void): () => void {
return () => subs.delete(fn);
}
-// En modo sistema, re-aplica el tema cuando cambia prefers-color-scheme
+// Re-aplica el tema cuando cambia prefers-color-scheme en modo system.
export function startThemeWatcher(): void {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
- if (getThemeMode() === 'auto') {
+ if (getMode() === 'system') {
applyTheme();
- subs.forEach((f) => f());
+ subs.forEach((fn) => fn());
}
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
@@ -110,8 +209,30 @@ export function getAccent(): AccentId {
return v && ACCENTS[v] ? v : 'emerald';
}
+// Acento activo de una familia: override persistido (easyzfs-accent-)
+// o, por defecto, el acento identidad de la familia.
+export function getFamilyAccent(family: ThemeFamily): string | null {
+ const v = localStorage.getItem('easyzfs-accent-' + family);
+ return v && FAMILY_ACCENTS[family].options.some((o) => o.v === v) ? v : null;
+}
+
+export function currentFamilyAccentId(family: ThemeFamily): string {
+ return getFamilyAccent(family) ?? FAMILY_ACCENTS[family].default;
+}
+
+// Devuelve el par [color, soft] del acento efectivo para la luminosidad actual.
+function accentColors(): [string, string] {
+ const family = getFamily();
+ const eff = effectiveTheme();
+ if (family === 'classic') return ACCENTS[getAccent()][eff];
+ const opts = FAMILY_ACCENTS[family];
+ const id = currentFamilyAccentId(family);
+ const opt = opts.options.find((o) => o.v === id) ?? opts.options[0];
+ return eff === 'dark' ? opt.dark : opt.light;
+}
+
export function applyAccent(): void {
- const [accent, soft] = ACCENTS[getAccent()][effectiveTheme()];
+ const [accent, soft] = accentColors();
const st = document.documentElement.style;
st.setProperty('--accent', accent);
st.setProperty('--accent-soft', soft);
@@ -119,8 +240,10 @@ export function applyAccent(): void {
// acento amber un estado sano y un aviso serían indistinguibles.
}
-export function setAccent(id: AccentId): void {
- localStorage.setItem(ACCENT_KEY, id);
+export function setAccent(id: string): void {
+ const family = getFamily();
+ if (family !== 'classic') localStorage.setItem('easyzfs-accent-' + family, id);
+ else localStorage.setItem(ACCENT_KEY, id);
applyAccent();
}
diff --git a/web/src/views/Settings.tsx b/web/src/views/Settings.tsx
index ca40e67..540e61a 100644
--- a/web/src/views/Settings.tsx
+++ b/web/src/views/Settings.tsx
@@ -11,15 +11,15 @@ import { getProvider } from '../data';
import { errorMessage, useApp } from '../ui/store';
import { fmtBytes, timeAgo } from '../ui/format';
import { Seg, Select, Spinner, Switch, Badge } from '../components/ui';
-import { Logo, IconCode, IconList, IconHeart, IconShield, IconCheck, IconUpload, IconCamera, IconChev, IconData, IconUser, IconX, IconTrash, IconLock, IconBell, IconMail, IconPencil, IconLogout, IconSun, IconMoon, IconMonitor, IconLanguages } from '../components/icons';
+import { Logo, IconCode, IconList, IconHeart, IconShield, IconCheck, IconUpload, IconCamera, IconChev, IconData, IconUser, IconX, IconTrash, IconLock, IconBell, IconMail, IconPencil, IconLogout, IconLanguages } from '../components/icons';
import { useModal } from '../components/Modal';
import { AvatarCropDialog } from '../components/AvatarCropDialog';
import { TwoFAPanel } from '../components/TwoFA';
import { APIKeysPanel } from '../components/APIKeysPanel';
import { usePush } from '../data/push';
import { useReleaseCheck, refreshUpdateState } from '../ui/releasecheck';
-import { ACCENTS, getAccent, setAccent, getDensity, setDensity, getReduceMotion, setReduceMotion } from '../ui/theme';
-import type { AccentId, Density, ThemeMode } from '../ui/theme';
+import { FAMILY_ACCENTS, getAccent, setAccent, getDensity, setDensity, getReduceMotion, setReduceMotion, currentFamilyAccentId } from '../ui/theme';
+import type { Density, ThemeFamily, ThemeMode } from '../ui/theme';
import type { I18nKey } from '../ui/i18n';
import type {
BackupStatus, Lang, PushAlertTipo, PushPreference,
@@ -41,29 +41,6 @@ function isStandalone(): boolean {
|| (navigator as { standalone?: boolean }).standalone === true;
}
-// Mini-preview de tema con las VARIABLES CSS REALES scopeadas por data-theme
-// (cero hex duplicados). Mismo patrón que el asset webapp-shell.
-function ThemePreview({ mode }: { mode: ThemeMode }) {
- const half = (
-
- );
- return (
-
- {mode !== 'dark' &&
{half}
}
- {mode !== 'light' &&
{half}
}
-
- );
-}
-
// Etiqueta traducida de cada tipo de alerta (exhaustivo sobre PushAlertTipo).
const TIPO_LABEL: Record = {
pool_capacity: 's_pt_pool_capacity',
@@ -715,14 +692,14 @@ function ProfileCard() {
}
export default function Settings() {
- const { t, themeMode, themeEff, setTheme, isAdmin, user, refresh, reloadUser, logout, setLang, notify } = useApp();
+ const { t, family, mode, themeEff, setFamily, setMode, isAdmin, user, refresh, reloadUser, logout, setLang, notify } = useApp();
const { openModal } = useModal();
const [settings, setSettings] = useState(null);
const [users, setUsers] = useState['getUsers']>> | null>(null);
const [version, setVersion] = useState['getVersion']>> | null>(null);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
- const [accent, setAccentState] = useState(getAccent());
+ const [accent, setAccentState] = useState(getAccent());
const [density, setDensityState] = useState(getDensity());
const [reduceMotion, setReduceMotionState] = useState(getReduceMotion());
const [installEvt, setInstallEvt] = useState(null);
@@ -734,6 +711,14 @@ export default function Settings() {
const [threshMsg, setThreshMsg] = useState('');
const [threshErr, setThreshErr] = useState('');
+ // El acento seleccionado depende del tema: en familias no-classic vale el
+ // override por familia (easyzfs-accent-) o ninguno (usa el acento
+ // identidad de su CSS); en clásico vale el acento guardado.
+ useEffect(() => {
+ setAccentState(family !== 'classic' ? currentFamilyAccentId(family) : getAccent());
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [family]);
+
useEffect(() => {
let alive = true;
getProvider().getSettings().then((s) => {
@@ -799,11 +784,19 @@ export default function Settings() {
&& settings.disk_temp_c >= 20 && settings.disk_temp_c <= 90;
const threshOk = capOk && tempOk;
- const themeOpts: { v: ThemeMode; label: string; icon: typeof IconSun }[] = [
- { v: 'light', label: t('s_theme_light'), icon: IconSun },
- { v: 'dark', label: t('s_theme_dark'), icon: IconMoon },
- { v: 'auto', label: t('s_theme_auto'), icon: IconMonitor },
+ const themeOpts: { v: ThemeFamily; label: string }[] = [
+ { v: 'classic', label: t('s_theme_clasico') },
+ { v: 'modern', label: t('s_theme_modern') },
+ { v: 'phosphor', label: t('s_theme_phosphor') },
+ { v: 'brutalist', label: t('s_theme_brutalist') },
];
+ const modeOpts: { v: ThemeMode; label: string }[] = [
+ { v: 'system', label: t('s_theme_auto') },
+ { v: 'light', label: t('s_theme_light') },
+ { v: 'dark', label: t('s_theme_dark') },
+ ];
+ // El modo (claro/oscuro/sistema) solo aplica a familias con variante.
+ const modeApplies = family === 'classic' || family === 'modern';
return (
@@ -844,55 +837,56 @@ export default function Settings() {
{t('s_appear')}
- {/* Tiles de tema (izquierda ~50%) */}
-
- {themeOpts.map((o) => {
- const Ico = o.icon;
- return (
-
setTheme(o.v)}>
-
- {o.label}
- {themeMode === o.v && }
-
- );
- })}
+ {/* Tema (familia) */}
+
+ {t('s_theme')}
+ setFamily(f)}
+ options={themeOpts} />
- {/* Controles (derecha, flex-1) */}
-
-
-
{t('s_accent')}
-
-
- {(Object.keys(ACCENTS) as AccentId[]).map((id) => (
- { setAccent(id); setAccentState(id); }}>
-
-
- ))}
-
-
- {t('s_rm')}
- { setReduceMotion(!v); setReduceMotionState(!v); }} />
-
-
+ {/* Modo (solo si la familia tiene variante claro/oscuro) */}
+ {modeApplies && (
+
+ {t('s_theme_mode')}
+ setMode(m)}
+ options={modeOpts} />
+ )}
-
-
{t('s_density')}
-
{ setDensity(d); setDensityState(d); }}
- options={[
- { v: 'cozy', label: t('s_density_cozy') },
- { v: 'compact', label: t('s_density_compact') },
- ]} />
+ {/* Acento de color — cada familia ofrece su propia paleta */}
+
+
{t('s_accent')}
+
+ {FAMILY_ACCENTS[family].options.map((opt) => (
+ { setAccent(opt.v); setAccentState(opt.v); }}>
+
+
+ ))}
+
+ {/* Activar animaciones */}
+
+ {t('s_rm')}
+ { setReduceMotion(!v); setReduceMotionState(!v); }} />
+
+
+ {/* Densidad */}
+
+ {t('s_density')}
+ { setDensity(d); setDensityState(d); }}
+ options={[
+ { v: 'cozy', label: t('s_density_cozy') },
+ { v: 'compact', label: t('s_density_compact') },
+ ]} />
+