From f92ecf8a6b5f1f689d8270da8cd12f7743be078b Mon Sep 17 00:00:00 2001 From: soydavidcornejo <165695462+soydavidcornejo@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:50:31 -0300 Subject: [PATCH] Estilo nuevo de la familia: tema 0.4.0 y componentes 0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neutros de la familia (solo queda el naranjo, el rojo y el verde de Alcancía). - En escritorio la barra lateral es oscura (los tokens de adentro en oscuro); en el celular la barra inferior sigue clara. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KpU2asnSL6qDBAJvsugZms --- CHANGELOG.md | 1 + app.json | 3 +- piezas.json | 12 ++++---- ui/app.css | 14 +++++---- ui/componentes.css | 70 +++++++++++++++++++++++++++++++++++++++++++- ui/componentes.js | 16 ++++++++++- ui/idiomas/en.json | 3 +- ui/tema.css | 72 +++++++++++++++++++++++++++++++++++++++------- ui/tema.js | 64 +++++++++++++++++++++++++++++++++-------- 9 files changed, 217 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0ee36..372ac55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Lo que cambia para quien usa Alcancía, en palabras simples. Cada versión, con *What changes for people using Alcancía, in plain words. Every version, with its download and SHA-256 checksum, is in Releases.* ## Sin publicar · Unreleased +- Alcancía tiene el estilo nuevo de la familia: en el computador, barra lateral oscura y fondo cálido. En el celular, la barra de abajo queda igual. · *Alcancía has the family's new look: on the computer, a dark sidebar and a warm background. On the phone, the bottom bar stays the same.* ## 0.2.0 · 2026-11-08 diff --git a/app.json b/app.json index 994aa1e..7b4e8f6 100644 --- a/app.json +++ b/app.json @@ -36,7 +36,8 @@ "tema": [ "sistema", "claro", - "oscuro" + "oscuro", + "vidrio" ] }, "sin_acciones": [ diff --git a/piezas.json b/piezas.json index ce6907f..d83769d 100644 --- a/piezas.json +++ b/piezas.json @@ -79,10 +79,10 @@ } }, "tema": { - "version": "0.2.1", + "version": "0.4.0", "archivos": { - "ui/tema.css": "1d43c1cf027fafcbd282bd90bc87ae3937059f247fb823cd1aab2a26f7c9363e", - "ui/tema.js": "b679ecb7bc80556b70e3f3fe768f0e3af0086bdf44c0d73ed0867da6a8f33f6e" + "ui/tema.css": "0f92833b29256c23f533da70292b53c493eae2da2690b5324f71429ab0ce1c74", + "ui/tema.js": "d476607ea31535a91fd2a81e7eef81558bfbbfc03dd772b447d7b359f9913dd4" }, "ficha": { "requiere": [], @@ -91,10 +91,10 @@ } }, "componentes": { - "version": "0.3.0", + "version": "0.4.0", "archivos": { - "ui/componentes.js": "310712e40f69b674f5d64fc723ccd2dbf2ad114f6ac68ee72a598cf4c36bb76a", - "ui/componentes.css": "101586f0fa7aa509f42b626de378c5ad32d176da5a4c0ad85b081a47908fe56f" + "ui/componentes.js": "fd5eafba3affb59be9506c57f38eff7a712297371621fe070b2b6d0ce3dfdf80", + "ui/componentes.css": "7ed1292d7ecb9fb6674a70808d5caca7611fa5be79578464b86ae32b4fff196a" }, "ficha": { "requiere": [ diff --git a/ui/app.css b/ui/app.css index abf5c97..7c781d1 100644 --- a/ui/app.css +++ b/ui/app.css @@ -5,12 +5,10 @@ * Escala: cifra protagonista (44–52 px) · títulos (20 px) · texto (15 px) · detalle (13 px). Espacios en múltiplos de 4 y 8. * Mobile first: la misma interfaz va a una app de celular; en escritorio la barra inferior pasa a ser una barra lateral. * Los tokens (--fondo, --panel, --panel-2, --texto, --texto-suave, --tenue, --linea, --acento, --rojo, --verde…) son de la pieza - * «tema» (ui/tema.css): aquí solo van los colores de Alcancía como pares claro/oscuro. Botones, diálogos, avisos y filas de Ajustes + * «tema» (ui/tema.css), con los neutros de la familia: aquí solo van el naranjo, el rojo y el verde de Alcancía como pares. En + * escritorio la barra lateral es oscura, como en toda la familia (los tokens de adentro se resuelven en oscuro). Botones, diálogos, avisos y filas de Ajustes * son de la pieza «componentes» (ui/componentes.css, clases pz-); aquí, lo que Alcancía les ajusta. */ :root { - --fondo-claro: #f5f4f2; --fondo-oscuro: #131211; --panel-claro: #ffffff; --panel-oscuro: #1c1a18; --panel-2-claro: #efedea; --panel-2-oscuro: #171615; - --linea-claro: #e5e2de; --linea-oscuro: #2d2a27; --texto-claro: #1c1a18; --texto-oscuro: #f2efeb; --texto-suave-claro: #625c56; --texto-suave-oscuro: #aaa39c; - --tenue-claro: #b3ada6; --tenue-oscuro: #57524d; --acento-claro: #ad4f27; --acento-oscuro: #ec8d5e; --sobre-acento-oscuro: #111111; --rojo-claro: #c0312a; --rojo-oscuro: #f2766e; --verde-claro: #1f7a49; --verde-oscuro: #55c38c; --radio-chico: 10px; --barra-alto: 64px; @@ -60,7 +58,11 @@ p { margin: 0; } .app { display: grid; grid-template-columns: 224px minmax(0, 1fr); height: 100vh; } .principal-area { overflow-y: auto; padding-bottom: 0; } .contenido { padding: 36px 40px 64px; } - .lateral { position: static; display: flex; flex-direction: column; background: var(--panel-2); border-top: 0; border-right: 1px solid var(--linea); padding: 0 12px 16px; } + .lateral { position: static; display: flex; flex-direction: column; border-top: 0; padding: 0 12px 16px; + /* Oscura en los dos modos: lo mismo que .pz-zona-oscura de «componentes», solo en escritorio (en el celular, la barra inferior + sigue clara). */ + color-scheme: dark; --fondo: var(--barra-lateral); --panel: var(--barra-lateral-2); --panel-2: var(--barra-lateral); + --texto: var(--barra-lateral-texto); --texto-suave: var(--barra-lateral-suave); background: var(--barra-lateral); color: var(--texto); } .cabeza { display: flex; padding: 36px 8px 20px; -webkit-app-region: drag; } .nav { display: flex; flex-direction: column; align-items: stretch; height: auto; gap: 2px; margin: 0; } .nav-agregar { order: -1; width: 100%; height: 44px; border-radius: 10px; margin-bottom: 16px; display: flex; justify-content: center; gap: 8px; font-weight: 650; -webkit-app-region: no-drag; } @@ -70,7 +72,7 @@ p { margin: 0; } .nav-item .ico { height: auto; } .nav-item .ico svg { width: 20px; height: 20px; } .nav-nombre { font-size: 15px; font-weight: 600; } - .nav-item:hover { background: rgba(127, 120, 112, .1); color: var(--texto); } + .nav-item:hover { background: var(--barra-lateral-2); color: var(--texto); } .nav-item[aria-current="page"] { background: var(--panel); box-shadow: inset 3px 0 0 var(--acento); } .nav-item[aria-current="page"] .ico::after { display: none; } .nav-item { position: relative; } diff --git a/ui/componentes.css b/ui/componentes.css index 6470e1c..d0191bb 100644 --- a/ui/componentes.css +++ b/ui/componentes.css @@ -14,7 +14,7 @@ /* Botones (el texto largo se parte en dos líneas antes de salirse a 390 px) */ .pz-boton { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: var(--pz-boton-alto); padding: 6px 14px; border: 1px solid var(--linea); border-radius: var(--radio-chico); background: var(--panel); color: var(--texto); font: inherit; - line-height: 1.25; text-align: center; cursor: pointer; max-width: 100%; } + font-weight: 500; line-height: 1.25; text-align: center; cursor: pointer; max-width: 100%; transition: background-color .12s, border-color .12s; } .pz-boton:hover { background: var(--hover); } .pz-boton.pz-principal { background: var(--acento); border-color: var(--acento); color: var(--sobre-acento); font-weight: 600; } .pz-boton.pz-principal:hover { background: color-mix(in srgb, var(--acento) 90%, var(--texto)); } @@ -89,6 +89,74 @@ .pz-giro { width: 18px; height: 18px; flex: none; border: 2px solid var(--linea); border-top-color: var(--acento); border-radius: 50%; animation: pz-giro .8s linear infinite; } @keyframes pz-giro { to { transform: rotate(360deg); } } +/* El marco de la app: barra lateral oscura + contenido. En el celular la barra queda arriba, en una fila: lo que no cabe ahí va con + .pz-solo-ancho (se oculta a 760 px o menos). Una app con barra inferior en el celular usa .pz-lateral solo en escritorio. +
+
…
*/ +.pz-marco { display: flex; min-height: 100vh; min-height: 100dvh; } +/* Una zona oscura en los dos modos (la barra lateral, el panel oscuro, o la barra propia de una app con .pz-zona-oscura): con + color-scheme: dark, todos los tokens de «tema» de adentro se resuelven en oscuro (texto, líneas, acento, campos, botones), y + fondo y paneles pasan a los de la barra lateral. Lo de la app que va adentro se ve bien sin reglas propias. */ +.pz-lateral, .pz-oscuro, .pz-zona-oscura { color-scheme: dark; --fondo: var(--barra-lateral); --panel: var(--barra-lateral-2); + --panel-2: var(--barra-lateral); --texto: var(--barra-lateral-texto); --texto-suave: var(--barra-lateral-suave); + --hover: color-mix(in srgb, var(--barra-lateral-texto) 9%, transparent); background: var(--barra-lateral); color: var(--texto); } +.pz-lateral { flex: none; width: var(--pz-lateral-ancho, 240px); display: flex; flex-direction: column; gap: 4px; padding: 18px 12px; overflow-y: auto; } +.pz-lateral :focus-visible { outline-color: var(--barra-lateral-acento); } +.pz-lateral .pz-boton:not(.pz-principal) { background: var(--barra-lateral-2); border-color: transparent; color: var(--barra-lateral-texto); } +.pz-lateral .pz-boton.pz-principal { background: var(--barra-lateral-acento); border-color: var(--barra-lateral-acento); color: var(--sobre-barra-lateral-acento); } +.pz-lateral > .pz-boton { margin: 8px 0 10px; } +.pz-marca { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 4px 8px; border: 0; background: none; color: inherit; + font: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; text-align: left; cursor: pointer; } +.pz-marca img, .pz-marca svg { width: 28px; height: 28px; border-radius: 7px; flex: none; } +.pz-lateral-titulo { margin: 14px 8px 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--barra-lateral-suave); } +.pz-lateral-enlace { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; padding: 8px 10px; border: 0; border-radius: var(--radio-chico); + background: none; color: var(--barra-lateral-texto); font: inherit; font-size: 14px; text-align: left; text-decoration: none; cursor: pointer; } +.pz-lateral-enlace:hover, .pz-lateral-enlace[aria-current]:not([aria-current="false"]) { background: var(--barra-lateral-2); } +.pz-lateral-enlace[aria-current]:not([aria-current="false"]) { font-weight: 600; box-shadow: inset 3px 0 0 var(--barra-lateral-acento); } +.pz-lateral-enlace small { margin-left: auto; color: var(--barra-lateral-suave); font-size: 12px; font-variant-numeric: tabular-nums; } +.pz-lateral-pie { margin-top: auto; padding-top: 12px; display: grid; gap: 2px; } +.pz-contenido { flex: 1; min-width: 0; padding: 36px 40px 64px; } +:where(.pz-contenido) h1 { font-size: 30px; line-height: 1.15; margin: 0 0 6px; } +@media (max-width: 760px) { + .pz-marco { flex-direction: column; } + .pz-lateral { width: auto; flex-direction: row; flex-wrap: nowrap; align-items: center; gap: 8px; padding: 8px 12px; padding-top: calc(8px + env(safe-area-inset-top, 0px)); overflow: visible; } + .pz-lateral > .pz-boton { margin: 0; } + .pz-lateral-pie { margin: 0 0 0 auto; padding: 0; display: flex; } + .pz-solo-ancho, .pz-lateral-titulo { display: none !important; } + .pz-contenido { padding: 20px 16px 48px; } + :where(.pz-contenido) h1 { font-size: 26px; } +} + +/* Superficies: tarjeta, cifras (una cifra grande con su etiqueta), panel oscuro (lo que pasa en vivo, lo que ahorras) */ +.pz-tarjeta { padding: 16px; background: var(--panel); border: 1px solid var(--linea); border-radius: var(--radio); } +.pz-tarjetas { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 12px; padding: 0; margin: 12px 0; list-style: none; } +.pz-cifras { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr)); gap: 12px; margin: 16px 0; } +.pz-cifra { display: grid; gap: 2px; padding: 14px 16px; background: var(--panel); border: 1px solid var(--linea); border-radius: var(--radio); } +.pz-cifra b { font-size: 26px; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; } +.pz-cifra small { color: var(--texto-suave); font-size: 13px; } +.pz-oscuro { padding: 16px 18px; border-radius: var(--radio-grande); } +.pz-oscuro small, .pz-oscuro .pz-ayuda { color: var(--barra-lateral-suave); } +.pz-oscuro b { color: var(--barra-lateral-acento); } + +/* Etapas: dónde va la persona en un proceso de varios pasos.
  1. 1Idea
  2. +
  3. 2Plan
  4. 3Listo
*/ +.pz-etapas { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 18px; padding: 0; list-style: none; } +.pz-etapas li { display: flex; align-items: center; gap: 8px; padding: 5px 12px 5px 5px; border-radius: 999px; font-size: 13px; color: var(--texto-suave); } +.pz-etapas li > span { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: var(--panel-2); font-size: 12px; font-weight: 600; } +.pz-etapas .pz-hecha { color: var(--verde); } +.pz-etapas .pz-hecha > span { background: var(--verde-suave); } +.pz-etapas .pz-actual { background: var(--texto); color: var(--sobre-texto); font-weight: 600; } +.pz-etapas .pz-actual > span { background: var(--acento); color: var(--sobre-acento); } + +/* Dos columnas: lo principal y un panel al lado (abajo cuando no cabe) */ +.pz-dos-columnas { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 24px; } +.pz-dos-columnas > :first-child { flex: 999 1 460px; min-width: 0; } +.pz-columna-lado { flex: 1 1 280px; max-width: 400px; display: grid; gap: 12px; } +@media (max-width: 760px) { .pz-columna-lado { max-width: none; } } + /* Avisos (toast): uno a la vez, abajo al centro. --pz-toast-abajo para dejar espacio a una barra inferior. Colores sólidos (--texto/--sobre-texto, --rojo/--sobre-rojo): se leen aunque la app tenga --fondo o --panel transparentes. */ .pz-toast { position: fixed; left: 50%; bottom: calc(var(--pz-toast-abajo, 24px) + env(safe-area-inset-bottom, 0px)); z-index: 80; diff --git a/ui/componentes.js b/ui/componentes.js index 868276a..7c4dca6 100644 --- a/ui/componentes.js +++ b/ui/componentes.js @@ -409,8 +409,22 @@ } /** `actualizacionProgreso {paso: "1"|"2"|"3"|"error", codigo, texto, valores, total, pct?}`. */ function actualizacionProgreso(a = {}) { + const antes = act.estado; act.estado = { paso: String(a.paso || ""), texto: String(a.texto || ""), valores: a.valores || {}, total: Number(a.total) || 3, pct: a.pct }; - if (act.estado.paso === "error") aviso(act.estado.texto || "No se pudo actualizar", { valores: act.estado.valores, error: true }); + if (act.estado.paso === "error") { aviso(act.estado.texto || "No se pudo actualizar", { valores: act.estado.valores, error: true }); pintar(); return; } + // Mismo paso, solo cambia el avance: se cambia la fila, no la ventana entera (repintarla cada medio segundo parpadea). + const filas = typeof document !== "undefined" ? document.querySelectorAll(".pz-act") : []; + if (antes && typeof antes === "object" && antes.paso === act.estado.paso && filas.length) { + const v = act.estado, n = Math.max(1, Math.min(v.total, parseInt(v.paso, 10) || 1)); + const pct = typeof v.pct === "number" && Number.isFinite(v.pct) ? Math.max(0, Math.min(100, Math.round(v.pct))) : null; + const texto = (v.texto ? Tp(v.texto, v.valores) : "") + (pct !== null ? ` · ${pct}%` : ""); + for (const f of filas) { + const b = f.querySelector("b"), barra = f.querySelector("progress"); + if (b) b.textContent = texto; + if (barra) barra.value = (n - 1) * 100 + (pct ?? 0); + } + return; + } pintar(); } function filaActualizacion() { diff --git a/ui/idiomas/en.json b/ui/idiomas/en.json index 57bf79c..b9f64ef 100644 --- a/ui/idiomas/en.json +++ b/ui/idiomas/en.json @@ -1095,6 +1095,7 @@ "Ya puedes empezar. Todo lo que elegiste se cambia cuando quieras en {donde}.": "You're ready to go. You can change anything you chose at any time in {donde}.", "Ya puedes empezar.": "You're ready to go.", "Ocultar otras": "Hide Others", - "Abrir {app}": "Open {app}" + "Abrir {app}": "Open {app}", + "Vidrio": "Glass" } } diff --git a/ui/tema.css b/ui/tema.css index 16bddaa..0735c18 100644 --- a/ui/tema.css +++ b/ui/tema.css @@ -8,13 +8,13 @@ cargando su CSS DESPUÉS de este. Nada de la app adentro. Los tokens y su significado: README.md de la pieza. */ :root { /* Pares de color (neutros con contraste AA entre sí: lo revisa pruebas/interfaz.mjs). */ - --fondo-claro: #f6f7f9; --fondo-oscuro: #10141b; - --panel-claro: #ffffff; --panel-oscuro: #161b24; - --panel-2-claro: #eef1f5; --panel-2-oscuro: #1d2430; - --linea-claro: #dde2ea; --linea-oscuro: #2b3446; - --texto-claro: #1b2230; --texto-oscuro: #e8ecf3; - --texto-suave-claro: #556075; --texto-suave-oscuro: #9ba6b9; - --tenue-claro: #b4bccb; --tenue-oscuro: #4a5468; + --fondo-claro: #f6f5f2; --fondo-oscuro: #0f1115; + --panel-claro: #ffffff; --panel-oscuro: #171a20; + --panel-2-claro: #f0eee9; --panel-2-oscuro: #1f232b; + --linea-claro: #e5e2db; --linea-oscuro: #2a2f39; + --texto-claro: #17181c; --texto-oscuro: #ecedf0; + --texto-suave-claro: #5c5e66; --texto-suave-oscuro: #a0a4ae; + --tenue-claro: #bdbab3; --tenue-oscuro: #464b57; --acento-claro: #2f5fd0; --acento-oscuro: #7fa2ff; --sobre-acento-claro: #ffffff; --sobre-acento-oscuro: #0c1120; --rojo-claro: #b42727; --rojo-oscuro: #ff8a8a; @@ -22,9 +22,16 @@ /* Texto sólido sobre un fondo de color (aviso, botón de peligro): nunca --fondo/--panel, que una app puede hacer transparentes. */ --sobre-rojo-claro: #ffffff; --sobre-rojo-oscuro: #1a0b0b; --sobre-texto-claro: #ffffff; --sobre-texto-oscuro: #10141b; - --sombra-claro: 0 8px 28px rgba(20, 30, 50, .14); + --sombra-claro: 0 8px 28px rgba(23, 24, 28, .12); --sombra-oscuro: 0 10px 30px rgba(0, 0, 0, .5); + /* La barra lateral (pieza «componentes»: .pz-lateral) es oscura en los dos modos: sus colores no cambian con el modo. Su acento + es el acento del modo oscuro de la app (se lee sobre fondo oscuro). */ + --barra-lateral: #15171c; --barra-lateral-2: #23262e; + --barra-lateral-texto: #ecedf0; --barra-lateral-suave: #a2a5ae; + --barra-lateral-acento: var(--acento-oscuro); + --sobre-barra-lateral-acento: var(--sobre-acento-oscuro); + /* Los tokens que usa la interfaz (y las piezas). */ color-scheme: light dark; --fondo: light-dark(var(--fondo-claro), var(--fondo-oscuro)); @@ -49,8 +56,9 @@ --velo: color-mix(in srgb, #0f141e 42%, transparent); --foco: var(--acento); /* Forma y letra. */ - --radio: 12px; + --radio: 14px; --radio-chico: 8px; + --radio-grande: 18px; --fuente: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif; --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; /* La bienvenida (pieza «bienvenida») usa sus propias variables: aquí quedan conectadas a los tokens. */ @@ -90,7 +98,9 @@ /* Lo mínimo que todo documento necesita del tema. */ html, body { background: var(--fondo); color: var(--texto); } -body { font-family: var(--fuente); } +body { font-family: var(--fuente); -webkit-font-smoothing: antialiased; } +/* Títulos firmes y apretados (con :where: cualquier regla de la app les gana). */ +:where(h1, h2) { font-weight: 700; letter-spacing: -0.015em; } :focus-visible { outline: 2px solid var(--foco); outline-offset: 2px; } /* Un diálogo recibe el foco cuando lo que lo abrió ya no está: sin anillo alrededor de toda la caja (sí en sus controles). Su título también recibe el foco al abrir (para el lector de pantalla; tabindex="-1", no es un control): sin anillo, en cualquier ventana @@ -98,3 +108,45 @@ body { font-family: var(--fuente); } :where([role="dialog"], [role="alertdialog"])[aria-modal="true"]:focus-visible, :where([role="dialog"], [role="alertdialog"])[aria-modal="true"] :where(h1, h2, h3)[tabindex="-1"]:focus { outline: none; } ::selection { background: color-mix(in srgb, var(--acento) 30%, transparent); } + +/* «Vidrio»: sigue al sistema (claro u oscuro), con luces de color detrás y paneles translúcidos y esmerilados. Las piezas y la app + usan --panel y --panel-2 como siempre; lo que flota (diálogos, barras, tarjetas) se esmerila con :where() (no pisa a la app) o con + la clase .pz-vidrio. Con «reducir transparencia» del sistema, los paneles vuelven a ser sólidos. */ +:root[data-pz-estilo="vidrio"] { + --vidrio-luz-1-claro: rgba(132, 94, 247, .16); --vidrio-luz-1-oscuro: rgba(132, 94, 247, .22); + --vidrio-luz-2-claro: rgba(56, 152, 236, .14); --vidrio-luz-2-oscuro: rgba(56, 152, 236, .14); + --vidrio-luz-3-claro: rgba(255, 176, 32, .12); --vidrio-luz-3-oscuro: rgba(255, 176, 32, .10); + --panel: color-mix(in srgb, light-dark(var(--panel-claro), var(--panel-oscuro)) 66%, transparent); + --panel-2: color-mix(in srgb, light-dark(var(--panel-2-claro), var(--panel-2-oscuro)) 58%, transparent); + --linea: color-mix(in srgb, light-dark(var(--linea-claro), var(--linea-oscuro)) 75%, transparent); + --vidrio-borde: light-dark(rgba(255, 255, 255, .7), rgba(255, 255, 255, .10)); + --vidrio-filtro: blur(18px) saturate(160%); + --hover: color-mix(in srgb, var(--texto) 8%, transparent); +} +:root[data-pz-estilo="vidrio"] body { + background: + radial-gradient(60vmax 40vmax at 10% 10%, light-dark(var(--vidrio-luz-1-claro), var(--vidrio-luz-1-oscuro)), transparent 60%), + radial-gradient(50vmax 36vmax at 95% 40%, light-dark(var(--vidrio-luz-2-claro), var(--vidrio-luz-2-oscuro)), transparent 60%), + radial-gradient(60vmax 40vmax at 40% 100%, light-dark(var(--vidrio-luz-3-claro), var(--vidrio-luz-3-oscuro)), transparent 60%), + var(--fondo); + background-attachment: fixed; +} +:root[data-pz-estilo="vidrio"] :where(.pz-vidrio, .pz-dialogo, .pz-toast, [role="dialog"], header, nav, aside, fieldset) { + -webkit-backdrop-filter: var(--vidrio-filtro); + backdrop-filter: var(--vidrio-filtro); +} +:root[data-pz-estilo="vidrio"] :where(.pz-vidrio, .pz-dialogo) { + border-color: var(--vidrio-borde); + box-shadow: var(--sombra), inset 0 1px 0 light-dark(rgba(255, 255, 255, .8), rgba(255, 255, 255, .06)); +} +@supports not (color: light-dark(#000, #fff)) { + :root[data-pz-estilo="vidrio"] { --panel: color-mix(in srgb, var(--panel-claro) 66%, transparent); } + @media (prefers-color-scheme: dark) { :root[data-pz-estilo="vidrio"] { --panel: color-mix(in srgb, var(--panel-oscuro) 66%, transparent); } } +} +@media (prefers-reduced-transparency: reduce) { + :root[data-pz-estilo="vidrio"] { + --panel: light-dark(var(--panel-claro), var(--panel-oscuro)); + --panel-2: light-dark(var(--panel-2-claro), var(--panel-2-oscuro)); + --vidrio-filtro: none; + } +} diff --git a/ui/tema.js b/ui/tema.js index 4ee70ed..43ecf02 100644 --- a/ui/tema.js +++ b/ui/tema.js @@ -1,9 +1,12 @@ // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Pieza «tema»: sistema, claro u oscuro. Pone (o lo quita para seguir al sistema); los colores +// Pieza «tema»: sistema, claro, oscuro o vidrio. Pone (o lo quita para seguir al sistema); los colores // los resuelve tema.css con light-dark(). El atributo lleva prefijo para no chocar con un `data-tema` de la app (un // closest("[data-tema]") de la app encontraba ). Lo elegido se lee con `tema.valor` y `tema.resuelto()`. +// «Vidrio» sigue al sistema (claro u oscuro) y pone : paneles translúcidos y esmerilados. +// Temas de otros (un universo, la comunidad): `tema.registrar({id, nombre, base, colores})` los suma al selector. Solo colores #hex +// de los tokens conocidos: se validan y se ponen con CSSOM (style.setProperty), nunca como CSS escrito. // Trae el selector de Ajustes («Apariencia») como HTML y su manejador de clics. // Sin dependencias. Script clásico (window.PiezaTema) o Node (require). Sin estilos ni código en línea. // @@ -18,27 +21,52 @@ })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; - const VALORES = ["sistema", "claro", "oscuro"]; + const VALORES = ["sistema", "claro", "oscuro", "vidrio"]; /** El atributo que pone en (tema.css lo lee). Antes de 0.2.0 era data-tema. */ const ATRIBUTO = "data-pz-modo"; /** Las opciones del selector, en español (las traduce la app con traducirDOM o T). */ - const OPCIONES = [["sistema", "Como el sistema"], ["claro", "Claro"], ["oscuro", "Oscuro"]]; + const OPCIONES = [["sistema", "Como el sistema"], ["claro", "Claro"], ["oscuro", "Oscuro"], ["vidrio", "Vidrio"]]; const valido = (v) => (VALORES.includes(v) ? v : null); + /** Los tokens que puede cambiar un tema registrado (cada uno con su par -claro/-oscuro en tema.css). */ + const TOKENS = ["fondo", "panel", "panel-2", "linea", "texto", "texto-suave", "tenue", "acento", "sobre-acento", "rojo", "verde"]; + const MODOS = "claro oscuro".split(" "); // los dos pares de cada token + const HEX = /^#[0-9a-fA-F]{6}$/; + const ID = /^[a-z][a-z0-9-]{1,30}$/; + + /** Un tema de afuera, limpio: {id, nombre, base: "claro"|"oscuro"|"vidrio", colores: {token: "#rrggbb"}}, o null. */ + function validarTema(x) { + if (!x || typeof x !== "object" || !ID.test(x.id || "") || VALORES.includes(x.id)) return null; + if (typeof x.nombre !== "string" || !x.nombre.trim() || x.nombre.length > 40) return null; + const base = ["claro", "oscuro", "vidrio"].includes(x.base) ? x.base : "claro"; + const colores = {}; + for (const [k, v] of Object.entries(x.colores || {})) if (TOKENS.includes(k) && HEX.test(v)) colores[k] = v.toLowerCase(); + return Object.keys(colores).length ? { id: x.id, nombre: x.nombre.trim(), base, colores } : null; + } function crear(opciones = {}) { const ventana = opciones.ventana || (typeof window !== "undefined" ? window : globalThis); const documento = opciones.documento || ventana.document || null; const consulta = ventana.matchMedia ? ventana.matchMedia("(prefers-color-scheme: dark)") : null; let valor = "sistema"; + const extra = new Map(); // los temas registrados (universos, comunidad), por id - const resuelto = () => (valor === "sistema" ? (consulta?.matches ? "oscuro" : "claro") : valor); + const base = (v) => (extra.has(v) ? extra.get(v).base : v); + const sistema = () => (consulta?.matches ? "oscuro" : "claro"); + const resuelto = () => { const b = base(valor); return b === "sistema" || b === "vidrio" ? sistema() : b; }; + const conocido = (v) => valido(v) || (extra.has(v) ? v : null); /** Pone el tema en el documento. Un valor desconocido es «sistema». Devuelve el que quedó. */ function aplicar(v) { - valor = valido(v) || "sistema"; + valor = conocido(v) || "sistema"; const html = documento?.documentElement; + const b = base(valor); if (html?.dataset) { - if (valor === "sistema") delete html.dataset.pzModo; else html.dataset.pzModo = valor; + if (b === "sistema" || b === "vidrio") delete html.dataset.pzModo; else html.dataset.pzModo = b; + if (b === "vidrio") html.dataset.pzEstilo = "vidrio"; else delete html.dataset.pzEstilo; + // Los colores de un tema registrado van en el par del modo (--x-claro o --x-oscuro): light-dark() los elige igual. + for (const k of TOKENS) for (const m of MODOS) html.style?.removeProperty?.(`--${k}-${m}`); + const t = extra.get(valor); + if (t) for (const [k, c] of Object.entries(t.colores)) for (const m of MODOS) html.style?.setProperty?.(`--${k}-${m}`, c); } opciones.alCambiar?.(resuelto(), valor); return valor; @@ -48,21 +76,31 @@ function elegir({ config, url } = {}) { let deUrl = url; if (deUrl === undefined) { try { deUrl = new URLSearchParams(ventana.location?.search || "").get("tema"); } catch { deUrl = null; } } - return valido(deUrl) || valido(config) || "sistema"; + return conocido(deUrl) || conocido(config) || "sistema"; + } + + /** Suma un tema de afuera al selector. Devuelve el tema limpio, o null si no pasó la validación. */ + function registrar(x) { + const t = validarTema(x); + if (t) extra.set(t.id, t); + return t; } /** El selector de Ajustes: un grupo de tres botones con aria-pressed (estilo .pz-seg de «componentes»). Las etiquetas van en * español: las traduce traducirDOM con el resto de la app (sus inglés vienen en `textos` de la pieza). */ function selectorHTML() { + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";"); + const propios = [...extra.values()].map((t) => + ``).join(""); return `
${OPCIONES.map(([v, n]) => - ``).join("")}
`; + ``).join("")}${propios}`; } /** Un clic en el selector: aplica, guarda y devuelve true. Cualquier otro clic, false. */ function manejarClic(ev) { const b = ev?.target?.closest?.("button[data-pz-tema]"); if (!b) return false; - const v = valido(b.getAttribute("data-pz-tema")); + const v = conocido(b.getAttribute("data-pz-tema")); if (!v) return false; ev.preventDefault?.(); aplicar(v); @@ -73,16 +111,18 @@ /** Avisa cuando el sistema cambia de claro a oscuro (solo importa en «sistema»). Devuelve cómo dejar de escuchar. */ function escuchar(fn) { if (!consulta?.addEventListener) return () => {}; - const f = () => { if (valor === "sistema") fn(resuelto()); }; + const f = () => { const b = base(valor); if (b === "sistema" || b === "vidrio") fn(resuelto()); }; consulta.addEventListener("change", f); return () => consulta.removeEventListener("change", f); } return { - aplicar, elegir, selectorHTML, manejarClic, escuchar, resuelto, + aplicar, elegir, selectorHTML, manejarClic, escuchar, resuelto, registrar, get valor() { return valor; }, + get estilo() { return base(valor) === "vidrio" ? "vidrio" : "plano"; }, + get temas() { return [...extra.values()]; }, }; } - return { crear, VALORES, OPCIONES, ATRIBUTO }; + return { crear, VALORES, OPCIONES, ATRIBUTO, TOKENS, validarTema }; });