Javier Villarroel commited on
Commit
3bc7b1f
Β·
1 Parent(s): 66562ed

feat: nebulosa WebGL en el cielo que reacciona al aplicar, con panel en la tecla M

Browse files
css/componentes/carta.css CHANGED
@@ -109,6 +109,21 @@
109
 
110
  .app[data-fase="hecho"] .pila__despues { clip-path: inset(0 0 0 0); }
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /* Comparar: antes y despuΓ©s lado a lado, en vez de revelado superpuesto. */
113
  .pila[data-comparando="si"] {
114
  display: flex;
 
109
 
110
  .app[data-fase="hecho"] .pila__despues { clip-path: inset(0 0 0 0); }
111
 
112
+ /* La nube sobre la imagen (js/nebulosa.js): mismo campo que la de fondo,
113
+ `screen` para solo aclarar. En reposo no se ve (opacidad 0, la sube el
114
+ JS con la magia); en comparar antes/despuΓ©s no aplica. */
115
+ .pila__nebulosa {
116
+ position: absolute;
117
+ inset: 0;
118
+ width: 100%;
119
+ height: 100%;
120
+ opacity: 0;
121
+ mix-blend-mode: screen;
122
+ pointer-events: none;
123
+ }
124
+
125
+ .pila[data-comparando="si"] .pila__nebulosa { display: none; }
126
+
127
  /* Comparar: antes y despuΓ©s lado a lado, en vez de revelado superpuesto. */
128
  .pila[data-comparando="si"] {
129
  display: flex;
css/componentes/cielo.css CHANGED
@@ -14,6 +14,18 @@
14
  var(--fondo-umbral);
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  .cielo__capa {
18
  position: absolute;
19
  top: 0; left: 0;
 
14
  var(--fondo-umbral);
15
  }
16
 
17
+ /* La nube de js/nebulosa.js: entre el degradado del fondo (que pone .cielo)
18
+ y las estrellas, que van despuΓ©s en el DOM y por eso quedan encima.
19
+ `screen` para que solo aclare, nunca tape una estrella. */
20
+ .cielo__nebulosa {
21
+ position: absolute;
22
+ inset: 0;
23
+ width: 100%;
24
+ height: 100%;
25
+ mix-blend-mode: screen;
26
+ pointer-events: none;
27
+ }
28
+
29
  .cielo__capa {
30
  position: absolute;
31
  top: 0; left: 0;
css/componentes/nebulosa.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Panel de ajuste de la nebulosa. Es una herramienta de desarrollo, no algo
2
+ pensado para quien usa la app: por eso vive fuera de la carta, oculto por
3
+ defecto, y solo se abre con la tecla M (ver js/nebulosa.js). */
4
+
5
+ .panel-nebulosa {
6
+ position: fixed;
7
+ left: var(--e-6);
8
+ bottom: var(--e-6);
9
+ z-index: 10;
10
+ width: min(var(--m-panel-nebulosa-ancho), calc(100% - var(--m-panel-nebulosa-margen)));
11
+ max-height: calc(100dvh - var(--m-panel-nebulosa-margen));
12
+ overflow: auto;
13
+ padding: var(--e-4) var(--e-5);
14
+ border-radius: var(--r-m);
15
+ background: var(--superficie-panel-nebulosa);
16
+ backdrop-filter: blur(var(--m-panel-nebulosa-difuminado));
17
+ border: 1px solid var(--borde-panel-nebulosa);
18
+ color: var(--texto-noche);
19
+ font-size: var(--f-s);
20
+ }
21
+
22
+ .panel-nebulosa__cabecera {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ }
27
+
28
+ .panel-nebulosa__cerrar {
29
+ background: none;
30
+ border: 0;
31
+ color: var(--texto-noche-tenue);
32
+ font-size: var(--f-l);
33
+ line-height: 1;
34
+ }
35
+
36
+ .panel-nebulosa h3 {
37
+ font-size: var(--f-xs);
38
+ text-transform: uppercase;
39
+ letter-spacing: var(--f-espaciado-ancho);
40
+ color: var(--texto-noche-tenue);
41
+ font-weight: var(--f-peso-medio);
42
+ margin: var(--e-4) 0 var(--e-1);
43
+ }
44
+
45
+ .panel-nebulosa__control {
46
+ display: grid;
47
+ grid-template-columns: minmax(0, var(--m-panel-nebulosa-etiqueta)) 1fr var(--m-panel-nebulosa-salida);
48
+ gap: var(--e-2);
49
+ align-items: center;
50
+ margin-top: var(--e-2);
51
+ }
52
+
53
+ .panel-nebulosa__control input[type="range"] {
54
+ width: 100%;
55
+ }
56
+
57
+ .panel-nebulosa__control output {
58
+ text-align: right;
59
+ font-variant-numeric: tabular-nums;
60
+ }
css/tokens.css CHANGED
@@ -199,6 +199,34 @@
199
  --cielo-destello-espera-min: 0.12s;
200
  --cielo-destello-espera-max: 0.9s;
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
  /* ── 2. SEMÁNTICOS ──────────────────────────────────────────────────── */
204
  /* Lo ΓΊnico que pueden usar los componentes. */
@@ -228,9 +256,11 @@
228
  );
229
  --superficie-carta: var(--c-hueso-100);
230
  /* La zona de la imagen es translΓΊcida: el cielo se ve tambiΓ©n dentro de la carta. */
231
- --superficie-escenario: rgb(var(--c-hueso-200-suelto) / 0.4);
232
  --superficie-simbolo: var(--c-blanco);
233
  --superficie-simbolo-activo: var(--c-violeta-900);
 
 
234
 
235
  /* Texto sobre la noche */
236
  --texto-noche: var(--c-noche-texto);
 
199
  --cielo-destello-espera-min: 0.12s;
200
  --cielo-destello-espera-max: 0.9s;
201
 
202
+ /* Nebulosa (lo lee el JS: paletas y parΓ‘metros por defecto de js/nebulosa.js).
203
+ Dos paletas, cuatro paradas cada una, del borde tenue (0) al nΓΊcleo (3):
204
+ reposo = los mismos velos del cielo; magia = un verde-aguamarina de aurora. */
205
+ --nebulosa-reposo-0: #1b2340;
206
+ --nebulosa-reposo-1: #566e96;
207
+ --nebulosa-reposo-2: #7a6896;
208
+ --nebulosa-reposo-3: #f4d6e4;
209
+ --nebulosa-magia-0: #062a2a;
210
+ --nebulosa-magia-1: #1f8f86;
211
+ --nebulosa-magia-2: #5fd1b0;
212
+ --nebulosa-magia-3: #e8fff6;
213
+ --nebulosa-ambiente: 0.5;
214
+ --nebulosa-foco: 0.8;
215
+ --nebulosa-tamano: 0.85;
216
+ --nebulosa-velocidad: 0.5;
217
+ --nebulosa-velocidad-magia: 6;
218
+ --nebulosa-sobre-imagen: 0.85;
219
+ --nebulosa-escala: 0.5;
220
+ /* El velo de la zona de la imagen: separado de --superficie-escenario
221
+ para que el panel de ajuste (tecla M) pueda tocarlo en vivo. */
222
+ --velo-escenario-alfa: 0.15;
223
+ --c-panel-nebulosa: 13 17 25;
224
+ --m-panel-nebulosa-ancho: 300px;
225
+ --m-panel-nebulosa-difuminado: 10px;
226
+ --m-panel-nebulosa-etiqueta: 84px;
227
+ --m-panel-nebulosa-salida: 34px;
228
+ --m-panel-nebulosa-margen: 32px;
229
+
230
 
231
  /* ── 2. SEMÁNTICOS ──────────────────────────────────────────────────── */
232
  /* Lo ΓΊnico que pueden usar los componentes. */
 
256
  );
257
  --superficie-carta: var(--c-hueso-100);
258
  /* La zona de la imagen es translΓΊcida: el cielo se ve tambiΓ©n dentro de la carta. */
259
+ --superficie-escenario: rgb(var(--c-hueso-200-suelto) / var(--velo-escenario-alfa));
260
  --superficie-simbolo: var(--c-blanco);
261
  --superficie-simbolo-activo: var(--c-violeta-900);
262
+ --superficie-panel-nebulosa: rgb(var(--c-panel-nebulosa) / 0.86);
263
+ --borde-panel-nebulosa: rgb(var(--c-estrella) / 0.14);
264
 
265
  /* Texto sobre la noche */
266
  --texto-noche: var(--c-noche-texto);
index.html CHANGED
@@ -18,6 +18,7 @@
18
  <link rel="stylesheet" href="css/componentes/carta.css?v=circulos-fluidos">
19
  <link rel="stylesheet" href="css/componentes/simbolos.css?v=circulos-fluidos">
20
  <link rel="stylesheet" href="css/componentes/controles.css">
 
21
  </head>
22
 
23
  <body>
 
18
  <link rel="stylesheet" href="css/componentes/carta.css?v=circulos-fluidos">
19
  <link rel="stylesheet" href="css/componentes/simbolos.css?v=circulos-fluidos">
20
  <link rel="stylesheet" href="css/componentes/controles.css">
21
+ <link rel="stylesheet" href="css/componentes/nebulosa.css">
22
  </head>
23
 
24
  <body>
js/main.js CHANGED
@@ -9,6 +9,7 @@
9
  import { crearEstado, estaListo } from "./estado.js";
10
  import { crearVista } from "./vista.js";
11
  import { encenderCielo } from "./cielo.js";
 
12
  import { aplicar } from "./proceso.js";
13
  import { desdeArchivo, desdeEjemplo, descargar, liberar,
14
  urlResultado, EXTENSION } from "./imagen.js";
@@ -46,6 +47,7 @@ const vista = crearVista({
46
  const { nodos } = vista;
47
  estado.suscribir(vista.pintar);
48
  encenderCielo();
 
49
 
50
  /* ── Escuchas ────────────────────────────────────────────────────────── */
51
 
 
9
  import { crearEstado, estaListo } from "./estado.js";
10
  import { crearVista } from "./vista.js";
11
  import { encenderCielo } from "./cielo.js";
12
+ import { encenderNebulosa } from "./nebulosa.js";
13
  import { aplicar } from "./proceso.js";
14
  import { desdeArchivo, desdeEjemplo, descargar, liberar,
15
  urlResultado, EXTENSION } from "./imagen.js";
 
47
  const { nodos } = vista;
48
  estado.suscribir(vista.pintar);
49
  encenderCielo();
50
+ encenderNebulosa({ estado });
51
 
52
  /* ── Escuchas ────────────────────────────────────────────────────────── */
53
 
js/nebulosa.js ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ NEBULOSA β€” nube de energΓ­a en el cielo
3
+ ----------------------------------------------------------------------------
4
+ Una nube WebGL translΓΊcida (domain-warped fbm, como en el prototipo de
5
+ diseno/prototipos/2026-09-24-nebulosa-maqueta.html), en dos capas que
6
+ muestrean el mismo campo:
7
+
8
+ Β· Capa de fondo: cubre toda la pantalla, dentro de .cielo, entre el
9
+ degradado y las estrellas (modo `screen`, asΓ­ solo aclara).
10
+ Β· Capa de frente: sobre la imagen (dentro de .pila), tambiΓ©n `screen`,
11
+ con opacidad 0 en reposo β€” solo sube durante la magia.
12
+
13
+ El foco de la nube sigue el centro de la imagen (o de la carta, o de la
14
+ pantalla, si todavΓ­a no hay imagen). La magia es la fase "aplicando" del
15
+ estado: al entrar, la nube se agita y sube sobre la imagen; al terminar,
16
+ se queda en un color fijo (la paleta B) que ya no depende de la energΓ­a.
17
+ Al volver a "eligiendo" desde "hecho" (Hacer otra, otra imagen, otra
18
+ energΓ­a), el color se destiΓ±e de vuelta a la paleta de reposo.
19
+
20
+ Si no hay WebGL, no se hace nada: el cielo se queda como estaba.
21
+
22
+ Solo la parte que no toca el DOM se prueba (ver tests/nebulosa.test.js);
23
+ se exporta en `_paraPruebas`, como en cielo.js.
24
+ ========================================================================== */
25
+
26
+ const VERT = `attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
27
+
28
+ const FRAG = `
29
+ precision highp float;
30
+ uniform vec2 uRes; // tamaΓ±o del canvas, en px
31
+ uniform float uUnit; // px por unidad de nebulosa (compartida por las dos capas)
32
+ uniform vec2 uFocus; // centro del foco, en px del canvas (origen abajo-izquierda)
33
+ uniform vec2 uOffset; // origen del canvas en px de pantalla, para que las dos capas
34
+ // muestreen el mismo campo aunque estΓ©n en lugares distintos
35
+ uniform float uTime, uSize, uAmbient, uFocusAmt, uEnergy, uMix;
36
+ uniform vec3 uA0, uA1, uA2, uA3, uB0, uB1, uB2, uB3;
37
+
38
+ float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
39
+ float noise(vec2 p){
40
+ vec2 i = floor(p), f = fract(p); vec2 u = f*f*(3.0-2.0*f);
41
+ return mix(mix(hash(i), hash(i+vec2(1,0)), u.x), mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), u.x), u.y);
42
+ }
43
+ float fbm(vec2 p){
44
+ float v = 0.0, a = 0.5; mat2 r = mat2(0.8,-0.6,0.6,0.8);
45
+ for (int i = 0; i < 5; i++){ v += a*noise(p); p = r*p*2.02; a *= 0.5; }
46
+ return v;
47
+ }
48
+ vec3 ramp(float t, vec3 c0, vec3 c1, vec3 c2, vec3 c3){
49
+ t = clamp(t, 0.0, 1.0) * 3.0;
50
+ if (t < 1.0) return mix(c0, c1, t);
51
+ if (t < 2.0) return mix(c1, c2, t - 1.0);
52
+ return mix(c2, c3, t - 2.0);
53
+ }
54
+ vec3 pal(float t){
55
+ return mix(ramp(t, uA0, uA1, uA2, uA3), ramp(t, uB0, uB1, uB2, uB3), uMix);
56
+ }
57
+
58
+ void main(){
59
+ float t = uTime;
60
+ vec2 px = gl_FragCoord.xy + uOffset;
61
+ vec2 s = px / uUnit;
62
+ vec2 p = (gl_FragCoord.xy - uFocus) / uUnit;
63
+
64
+ float warp = 1.0 + 1.2 * uEnergy;
65
+ vec2 q = vec2(fbm(s*0.9 + vec2(0.0, t*0.10)), fbm(s*0.9 + vec2(5.2, -t*0.08)));
66
+ vec2 r = vec2(fbm(s*1.1 + 3.0*q + vec2(1.7,9.2) + t*0.12), fbm(s*1.1 + 3.0*q + vec2(8.3,2.8) - t*0.10));
67
+
68
+ float neb = fbm(s*0.7 + 2.2*r + t*0.03);
69
+ neb = smoothstep(0.35, 0.95, neb);
70
+ vec3 col = pal(0.5 + 0.45*neb + 0.2*(r.x-0.5)) * neb * 0.3 * uAmbient;
71
+
72
+ vec2 w = p + warp * 0.55 * (r - 0.5);
73
+ float breathe = 1.0 + 0.06*sin(t*0.6) + 0.03*sin(t*1.7);
74
+ float d = length(w) / (uSize * breathe * (1.0 + 0.35*uEnergy));
75
+ float edge = fbm(w*2.5 - t*0.15);
76
+ float body = exp(-d*d*1.1) * (0.55 + 0.9*edge);
77
+ float idx = clamp(body*0.75 + (r.x-0.5)*0.9 + (edge-0.5)*0.5, 0.0, 1.0);
78
+ col += pal(idx) * body * uFocusAmt * (1.0 + 0.8*uEnergy);
79
+ col += pal(1.0) * exp(-d*d*5.0) * 0.18 * uFocusAmt * (1.0 + uEnergy);
80
+
81
+ col = 1.0 - exp(-col * 1.6);
82
+ col += (hash(gl_FragCoord.xy + fract(t)*100.0) - 0.5) * 0.03;
83
+ gl_FragColor = vec4(max(col, 0.0), 1.0);
84
+ }`;
85
+
86
+ /* ── Utilidades puras (sin DOM, probadas en tests/nebulosa.test.js) ───── */
87
+
88
+ function leerNumero(estilos, nombre, porDefecto) {
89
+ const valor = parseFloat(estilos.getPropertyValue(nombre));
90
+ return Number.isFinite(valor) ? valor : porDefecto;
91
+ }
92
+
93
+ function leerTexto(estilos, nombre, porDefecto) {
94
+ const valor = estilos.getPropertyValue(nombre).trim();
95
+ return valor || porDefecto;
96
+ }
97
+
98
+ /** "#1b2340" β†’ [r, g, b] en 0..1, como quieren los uniformes vec3. */
99
+ function hexAVec3(hex) {
100
+ const numero = parseInt(hex.replace("#", "").slice(0, 6), 16);
101
+ return [
102
+ ((numero >> 16) & 255) / 255,
103
+ ((numero >> 8) & 255) / 255,
104
+ (numero & 255) / 255,
105
+ ];
106
+ }
107
+
108
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
109
+ const mezclar = (a, b, t) => a + (b - a) * t;
110
+ const suavizar = (a, b, x) => {
111
+ const t = clamp01((x - a) / (b - a));
112
+ return t * t * (3 - 2 * t);
113
+ };
114
+
115
+ /**
116
+ * La lΓ­nea de tiempo de la magia. Tres fases reales de la app ("eligiendo",
117
+ * "aplicando", "hecho") mΓ‘s una pseudo-fase propia de la nebulosa,
118
+ * "volviendo": el tramo en el que se deja "hecho" y el color se destiΓ±e de
119
+ * vuelta a la paleta de reposo (al hacer otra, cambiar de energΓ­a o de
120
+ * imagen). `progreso` va de 0 a 1 dentro de la fase actual; en "eligiendo"
121
+ * y "hecho" no se usa, son estados fijos.
122
+ */
123
+ function estadoMagia(fase, progreso = 0) {
124
+ const p = clamp01(progreso);
125
+ if (fase === "eligiendo") return { energia: 0, mezcla: 0, sobre: 0 };
126
+ if (fase === "hecho") return { energia: 0.12, mezcla: 1, sobre: 0 };
127
+ if (fase === "volviendo") {
128
+ const vuelta = suavizar(0, 1, p);
129
+ return { energia: 0.12 * (1 - vuelta), mezcla: 1 - vuelta, sobre: 0 };
130
+ }
131
+ // "aplicando": sube, se sostiene y vuelve a bajar sobre la imagen; el
132
+ // color pasa de A a B a mitad de camino.
133
+ const sube = suavizar(0, 0.25, p);
134
+ const baja = 1 - suavizar(0.7, 1.0, p);
135
+ return {
136
+ energia: mezclar(0.12, 1, sube) * baja + 0.12 * (1 - baja),
137
+ mezcla: suavizar(0.35, 0.75, p),
138
+ sobre: sube * baja,
139
+ };
140
+ }
141
+
142
+ /** ΒΏLa tecla que se acaba de soltar abre o cierra el panel? No, si hay un
143
+ modificador (para no robarle la M a un atajo del sistema) ni si el foco
144
+ estΓ‘ escribiendo en un campo. */
145
+ function esAtajoPanel(evento, elementoActivo) {
146
+ if (String(evento.key ?? "").toLowerCase() !== "m") return false;
147
+ if (evento.ctrlKey || evento.metaKey || evento.altKey) return false;
148
+ if (!elementoActivo) return true;
149
+ if (elementoActivo.isContentEditable) return false;
150
+ return elementoActivo.tagName !== "INPUT" && elementoActivo.tagName !== "TEXTAREA";
151
+ }
152
+
153
+ /* ── DOM y WebGL ───────────────────────────────────────────────────────── */
154
+
155
+ const NOMBRES_PALETA_REPOSO = ["--nebulosa-reposo-0", "--nebulosa-reposo-1", "--nebulosa-reposo-2", "--nebulosa-reposo-3"];
156
+ const NOMBRES_PALETA_MAGIA = ["--nebulosa-magia-0", "--nebulosa-magia-1", "--nebulosa-magia-2", "--nebulosa-magia-3"];
157
+ const UNIFORMES = ["uRes", "uUnit", "uFocus", "uOffset", "uTime", "uSize", "uAmbient", "uFocusAmt", "uEnergy", "uMix",
158
+ "uA0", "uA1", "uA2", "uA3", "uB0", "uB1", "uB2", "uB3"];
159
+
160
+ function crearCapa(canvas) {
161
+ const gl = canvas.getContext("webgl", { antialias: false });
162
+ if (!gl) return null;
163
+ const compilar = (tipo, fuente) => {
164
+ const sombreador = gl.createShader(tipo);
165
+ gl.shaderSource(sombreador, fuente);
166
+ gl.compileShader(sombreador);
167
+ if (!gl.getShaderParameter(sombreador, gl.COMPILE_STATUS)) {
168
+ throw new Error(gl.getShaderInfoLog(sombreador));
169
+ }
170
+ return sombreador;
171
+ };
172
+ const programa = gl.createProgram();
173
+ gl.attachShader(programa, compilar(gl.VERTEX_SHADER, VERT));
174
+ gl.attachShader(programa, compilar(gl.FRAGMENT_SHADER, FRAG));
175
+ gl.linkProgram(programa);
176
+ gl.useProgram(programa);
177
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
178
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
179
+ const posicion = gl.getAttribLocation(programa, "aPos");
180
+ gl.enableVertexAttribArray(posicion);
181
+ gl.vertexAttribPointer(posicion, 2, gl.FLOAT, false, 0, 0);
182
+ const u = {};
183
+ for (const nombre of UNIFORMES) u[nombre] = gl.getUniformLocation(programa, nombre);
184
+ return { gl, u, canvas };
185
+ }
186
+
187
+ function medirCapa(capa, ancho, alto, escala) {
188
+ const dpr = Math.min(window.devicePixelRatio || 1, 2) * escala;
189
+ const cw = Math.max(1, Math.round(ancho * dpr));
190
+ const ch = Math.max(1, Math.round(alto * dpr));
191
+ if (capa.canvas.width !== cw || capa.canvas.height !== ch) {
192
+ capa.canvas.width = cw;
193
+ capa.canvas.height = ch;
194
+ }
195
+ capa.gl.viewport(0, 0, cw, ch);
196
+ return dpr;
197
+ }
198
+
199
+ function dibujar(capa, paletaA, paletaB, estado, tiempo, focoPx, offsetPx, dpr, ambiente, foco, tamano) {
200
+ const { gl, u } = capa;
201
+ gl.uniform2f(u.uRes, capa.canvas.width, capa.canvas.height);
202
+ gl.uniform1f(u.uUnit, (Math.min(window.innerWidth, window.innerHeight) * dpr) / 2);
203
+ gl.uniform2f(u.uFocus, focoPx[0], focoPx[1]);
204
+ gl.uniform2f(u.uOffset, offsetPx[0], offsetPx[1]);
205
+ gl.uniform1f(u.uTime, tiempo);
206
+ gl.uniform1f(u.uSize, tamano);
207
+ gl.uniform1f(u.uAmbient, ambiente);
208
+ gl.uniform1f(u.uFocusAmt, foco);
209
+ gl.uniform1f(u.uEnergy, estado.energia);
210
+ gl.uniform1f(u.uMix, estado.mezcla);
211
+ ["uA0", "uA1", "uA2", "uA3"].forEach((n, i) => gl.uniform3fv(u[n], paletaA[i]));
212
+ ["uB0", "uB1", "uB2", "uB3"].forEach((n, i) => gl.uniform3fv(u[n], paletaB[i]));
213
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
214
+ }
215
+
216
+ /** El centro de la imagen; si todavΓ­a no hay imagen, el de la carta; si
217
+ tampoco, el de la pantalla (la pantalla de umbral). */
218
+ function obtenerFoco(pila, escenario) {
219
+ if (pila && !pila.hidden) {
220
+ const r = pila.getBoundingClientRect();
221
+ if (r.width > 0 && r.height > 0) return r;
222
+ }
223
+ if (escenario) {
224
+ const r = escenario.getBoundingClientRect();
225
+ if (r.width > 0 && r.height > 0) return r;
226
+ }
227
+ return { left: window.innerWidth / 2, top: window.innerHeight / 2, width: 0, height: 0 };
228
+ }
229
+
230
+ /** CuΓ‘nto dura el aplicar, leΓ­do en vivo (el panel puede tocar este mismo
231
+ token). Duplica la lectura de main.js a propΓ³sito: son dos mΓ³dulos que no
232
+ se conocen entre sΓ­, y es una sola lΓ­nea. */
233
+ function duracionMs() {
234
+ const valor = getComputedStyle(document.documentElement).getPropertyValue("--t-aplicando").trim();
235
+ if (valor.endsWith("ms")) return parseFloat(valor);
236
+ if (valor.endsWith("s")) return parseFloat(valor) * 1000;
237
+ return 2800;
238
+ }
239
+
240
+ /* ── El panel de ajuste (tecla M) ─────────────────────────────────────── */
241
+
242
+ function control(campo, etiqueta, min, max, paso, valor) {
243
+ return `<label class="panel-nebulosa__control">
244
+ <span>${etiqueta}</span>
245
+ <input type="range" data-campo="${campo}" min="${min}" max="${max}" step="${paso}" value="${valor}">
246
+ <output>${valor}</output>
247
+ </label>`;
248
+ }
249
+
250
+ function crearPanelNebulosa(ctl, raiz) {
251
+ const panel = document.createElement("div");
252
+ panel.className = "panel-nebulosa";
253
+ panel.hidden = true;
254
+ panel.innerHTML = `
255
+ <div class="panel-nebulosa__cabecera">
256
+ <strong>Nebulosa</strong>
257
+ <button type="button" class="panel-nebulosa__cerrar" aria-label="Cerrar panel de nebulosa">Γ—</button>
258
+ </div>
259
+ <h3>Reposo</h3>
260
+ ${control("ambiente", "Ambiente", 0, 1.5, 0.05, ctl.ambiente)}
261
+ ${control("foco", "Foco", 0, 1.5, 0.05, ctl.foco)}
262
+ ${control("tamano", "TamaΓ±o", 0.2, 1, 0.01, ctl.tamano)}
263
+ ${control("velocidad", "Velocidad", 0, 2, 0.05, ctl.velocidad)}
264
+ ${control("velo", "Velo de la carta", 0, 0.6, 0.01, leerNumero(getComputedStyle(raiz), "--velo-escenario-alfa", 0.15))}
265
+ <h3>Magia</h3>
266
+ ${control("velocidadMagia", "Magia Γ—", 1, 12, 0.5, ctl.velocidadMagia)}
267
+ ${control("sobreImagen", "Sobre la imagen", 0, 1, 0.05, ctl.sobreImagen)}
268
+ ${control("duracion", "DuraciΓ³n (s)", 1, 6, 0.1, (duracionMs() / 1000).toFixed(1))}
269
+ `;
270
+ document.body.appendChild(panel);
271
+
272
+ const CAMPOS = ["ambiente", "foco", "tamano", "velocidad", "velo", "velocidadMagia", "sobreImagen", "duracion"];
273
+ for (const campo of CAMPOS) {
274
+ const entrada = panel.querySelector(`[data-campo="${campo}"]`);
275
+ const salida = entrada.nextElementSibling;
276
+ entrada.addEventListener("input", () => {
277
+ const valor = parseFloat(entrada.value);
278
+ salida.textContent = entrada.value;
279
+ if (campo === "velo") raiz.style.setProperty("--velo-escenario-alfa", String(valor));
280
+ else if (campo === "duracion") raiz.style.setProperty("--t-aplicando", `${Math.round(valor * 1000)}ms`);
281
+ else ctl[campo] = valor;
282
+ });
283
+ }
284
+
285
+ panel.querySelector(".panel-nebulosa__cerrar").addEventListener("click", () => {
286
+ panel.hidden = true;
287
+ });
288
+
289
+ const alTeclear = (evento) => {
290
+ if (!esAtajoPanel(evento, document.activeElement)) return;
291
+ panel.hidden = !panel.hidden;
292
+ };
293
+ document.addEventListener("keydown", alTeclear);
294
+
295
+ return () => {
296
+ document.removeEventListener("keydown", alTeclear);
297
+ panel.remove();
298
+ };
299
+ }
300
+
301
+ /* ── Arranque ──────────────────────────────────────────────────────────── */
302
+
303
+ /**
304
+ * Enciende la nebulosa: crea las dos capas, arranca el ciclo de dibujo y el
305
+ * panel de ajuste. Se suscribe al estado para saber cuΓ‘ndo hay magia.
306
+ * Devuelve una funciΓ³n para apagarlo todo (la usarΓ­an los tests, si algΓΊn
307
+ * dΓ­a se agrega uno de navegador para esto).
308
+ */
309
+ export function encenderNebulosa({ estado, raiz = document.documentElement } = {}) {
310
+ const cielo = document.querySelector(".cielo");
311
+ const pila = document.getElementById("pila");
312
+ const escenario = document.getElementById("escenario");
313
+ if (!cielo || !pila) return () => {};
314
+
315
+ const canvasFondo = document.createElement("canvas");
316
+ canvasFondo.className = "cielo__nebulosa";
317
+ canvasFondo.setAttribute("aria-hidden", "true");
318
+ cielo.insertBefore(canvasFondo, cielo.firstElementChild);
319
+
320
+ const canvasFrente = document.createElement("canvas");
321
+ canvasFrente.className = "pila__nebulosa";
322
+ canvasFrente.setAttribute("aria-hidden", "true");
323
+ pila.appendChild(canvasFrente);
324
+
325
+ const fondo = crearCapa(canvasFondo);
326
+ const frente = crearCapa(canvasFrente);
327
+ if (!fondo || !frente) {
328
+ // Sin WebGL no hay nube: el cielo se queda con su degradado de siempre.
329
+ canvasFondo.remove();
330
+ canvasFrente.remove();
331
+ return () => {};
332
+ }
333
+
334
+ const estilos = getComputedStyle(raiz);
335
+ // El valor por defecto de leerTexto nunca se usa en la prΓ‘ctica (los ocho
336
+ // tokens de paleta siempre estΓ‘n en tokens.css); no es un color de diseΓ±o,
337
+ // solo un respaldo defensivo, asΓ­ que queda exento del guardiΓ‘n.
338
+ const NEGRO = "#000000"; // token-libre
339
+ const paletaReposo = NOMBRES_PALETA_REPOSO.map((n) => hexAVec3(leerTexto(estilos, n, NEGRO)));
340
+ const paletaMagia = NOMBRES_PALETA_MAGIA.map((n) => hexAVec3(leerTexto(estilos, n, NEGRO)));
341
+
342
+ const ctl = {
343
+ ambiente: leerNumero(estilos, "--nebulosa-ambiente", 0.5),
344
+ foco: leerNumero(estilos, "--nebulosa-foco", 0.8),
345
+ tamano: leerNumero(estilos, "--nebulosa-tamano", 0.85),
346
+ velocidad: leerNumero(estilos, "--nebulosa-velocidad", 0.5),
347
+ velocidadMagia: leerNumero(estilos, "--nebulosa-velocidad-magia", 6),
348
+ sobreImagen: leerNumero(estilos, "--nebulosa-sobre-imagen", 0.85),
349
+ escala: leerNumero(estilos, "--nebulosa-escala", 0.5),
350
+ };
351
+
352
+ const reducido = matchMedia("(prefers-reduced-motion: reduce)").matches;
353
+
354
+ let faseEstado = estado?.obtener?.().fase ?? "eligiendo";
355
+ let faseInterna = faseEstado === "hecho" ? "hecho" : "eligiendo";
356
+ let inicioTransicion = performance.now();
357
+ let duracionTransicion = duracionMs();
358
+
359
+ const alCambiarFase = (nuevaFase) => {
360
+ if (nuevaFase === faseEstado) return;
361
+ inicioTransicion = performance.now();
362
+ if (nuevaFase === "aplicando") {
363
+ faseInterna = "aplicando";
364
+ duracionTransicion = duracionMs();
365
+ } else if (nuevaFase === "hecho") {
366
+ faseInterna = "hecho";
367
+ } else {
368
+ // "eligiendo": si se venΓ­a de "hecho", hay que destiΓ±ir el color;
369
+ // si no (p. ej. al arrancar), el reposo ya estaba puesto.
370
+ faseInterna = faseEstado === "hecho" ? "volviendo" : "eligiendo";
371
+ duracionTransicion = duracionMs();
372
+ }
373
+ faseEstado = nuevaFase;
374
+ };
375
+
376
+ const dejarDeEscuchar = estado?.suscribir?.((e) => alCambiarFase(e.fase)) ?? (() => {});
377
+
378
+ let tiempo = 0;
379
+ let ultimo = performance.now();
380
+ let activo = true;
381
+
382
+ function progresoActual() {
383
+ if (faseInterna !== "aplicando" && faseInterna !== "volviendo") return 0;
384
+ if (duracionTransicion <= 0) return 1;
385
+ return (performance.now() - inicioTransicion) / duracionTransicion;
386
+ }
387
+
388
+ function fotograma(ahora) {
389
+ if (!activo) return;
390
+ const dt = Math.min((ahora - ultimo) / 1000, 0.05);
391
+ ultimo = ahora;
392
+
393
+ let p = progresoActual();
394
+ if (p >= 1 && (faseInterna === "aplicando" || faseInterna === "volviendo")) {
395
+ faseInterna = faseInterna === "aplicando" ? "hecho" : "eligiendo";
396
+ p = 0;
397
+ }
398
+ const s = estadoMagia(faseInterna, p);
399
+ tiempo += dt * ctl.velocidad * mezclar(1, ctl.velocidadMagia, s.energia) * (reducido ? 0.15 : 1);
400
+
401
+ const foco = obtenerFoco(pila, escenario);
402
+ const cx = foco.left + foco.width / 2;
403
+ const cy = foco.top + foco.height / 2;
404
+
405
+ const dprFondo = medirCapa(fondo, window.innerWidth, window.innerHeight, ctl.escala);
406
+ dibujar(
407
+ fondo, paletaReposo, paletaMagia, s, tiempo,
408
+ [cx * dprFondo, (window.innerHeight - cy) * dprFondo], [0, 0], dprFondo,
409
+ ctl.ambiente * (1 + 0.6 * s.energia), ctl.foco, ctl.tamano
410
+ );
411
+
412
+ canvasFrente.style.opacity = (s.sobre * ctl.sobreImagen).toFixed(3);
413
+ if (s.sobre > 0.001) {
414
+ const r = pila.getBoundingClientRect();
415
+ const dprFrente = medirCapa(frente, r.width, r.height, ctl.escala);
416
+ dibujar(
417
+ frente, paletaReposo, paletaMagia, s, tiempo,
418
+ [(r.width / 2) * dprFrente, (r.height / 2) * dprFrente],
419
+ [r.left * dprFrente, (window.innerHeight - r.bottom) * dprFrente], dprFrente,
420
+ 0, ctl.foco, ctl.tamano
421
+ );
422
+ }
423
+
424
+ requestAnimationFrame(fotograma);
425
+ }
426
+ requestAnimationFrame(fotograma);
427
+
428
+ const dejarPanel = crearPanelNebulosa(ctl, raiz);
429
+
430
+ return () => {
431
+ activo = false;
432
+ dejarDeEscuchar();
433
+ dejarPanel();
434
+ canvasFondo.remove();
435
+ canvasFrente.remove();
436
+ };
437
+ }
438
+
439
+ export const _paraPruebas = { hexAVec3, estadoMagia, esAtajoPanel, leerNumero, leerTexto };
tests/nebulosa.test.js ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* La nebulosa: solo se prueba lo que no depende del DOM ni de WebGL β€”
2
+ la lΓ­nea de tiempo de la magia, la lectura de tokens y el atajo del
3
+ panel. El resto (canvas, shaders, foco real) se ve a ojo. */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { _paraPruebas } from "../js/nebulosa.js";
8
+
9
+ const { hexAVec3, estadoMagia, esAtajoPanel, leerNumero, leerTexto } = _paraPruebas;
10
+
11
+ /* ── hexAVec3 ────────────────────────────────────────────────────────── */
12
+
13
+ test("hexAVec3 convierte un color hex a componentes 0..1", () => {
14
+ const [r, g, b] = hexAVec3("#1b2340");
15
+ assert.ok(Math.abs(r - 27 / 255) < 1e-6);
16
+ assert.ok(Math.abs(g - 35 / 255) < 1e-6);
17
+ assert.ok(Math.abs(b - 64 / 255) < 1e-6);
18
+ });
19
+
20
+ test("hexAVec3 con blanco y negro", () => {
21
+ assert.deepEqual(hexAVec3("#ffffff"), [1, 1, 1]);
22
+ assert.deepEqual(hexAVec3("#000000"), [0, 0, 0]);
23
+ });
24
+
25
+ /* ── estadoMagia ─────────────────────────────────────────────────────── */
26
+
27
+ test("eligiendo: reposo total, sin energΓ­a ni mezcla ni nube sobre la imagen", () => {
28
+ assert.deepEqual(estadoMagia("eligiendo", 0), { energia: 0, mezcla: 0, sobre: 0 });
29
+ });
30
+
31
+ test("hecho: color fijo en la paleta B, energΓ­a baja y fija, nada sobre la imagen", () => {
32
+ assert.deepEqual(estadoMagia("hecho", 0.7), { energia: 0.12, mezcla: 1, sobre: 0 });
33
+ });
34
+
35
+ test("aplicando al empezar: apenas energΓ­a de reposo, sin mezcla ni nube encima", () => {
36
+ const s = estadoMagia("aplicando", 0);
37
+ assert.ok(Math.abs(s.energia - 0.12) < 1e-6);
38
+ assert.equal(s.mezcla, 0);
39
+ assert.equal(s.sobre, 0);
40
+ });
41
+
42
+ test("aplicando a mitad de camino: energΓ­a y nube en su punto mΓ‘s alto", () => {
43
+ const s = estadoMagia("aplicando", 0.5);
44
+ assert.equal(s.energia, 1);
45
+ assert.equal(s.sobre, 1);
46
+ assert.ok(s.mezcla > 0 && s.mezcla < 1);
47
+ });
48
+
49
+ test("aplicando al terminar: mezcla completa hacia B, la nube ya bajΓ³ de la imagen", () => {
50
+ const s = estadoMagia("aplicando", 1);
51
+ assert.ok(Math.abs(s.energia - 0.12) < 1e-6);
52
+ assert.equal(s.mezcla, 1);
53
+ assert.equal(s.sobre, 0);
54
+ });
55
+
56
+ test("volviendo: empieza donde deja 'hecho' y termina donde empieza 'eligiendo'", () => {
57
+ const inicio = estadoMagia("volviendo", 0);
58
+ assert.ok(Math.abs(inicio.energia - 0.12) < 1e-6);
59
+ assert.equal(inicio.mezcla, 1);
60
+
61
+ const fin = estadoMagia("volviendo", 1);
62
+ assert.equal(fin.energia, 0);
63
+ assert.equal(fin.mezcla, 0);
64
+ });
65
+
66
+ test("volviendo a mitad de camino: a mitad de mezcla y de energΓ­a", () => {
67
+ const s = estadoMagia("volviendo", 0.5);
68
+ assert.equal(s.mezcla, 0.5);
69
+ assert.ok(Math.abs(s.energia - 0.06) < 1e-6);
70
+ });
71
+
72
+ test("el progreso fuera de 0..1 se recorta", () => {
73
+ assert.deepEqual(estadoMagia("aplicando", -1), estadoMagia("aplicando", 0));
74
+ assert.deepEqual(estadoMagia("aplicando", 5), estadoMagia("aplicando", 1));
75
+ });
76
+
77
+ /* ── esAtajoPanel ────────────────────────────────────────────────────── */
78
+
79
+ const cuerpo = { tagName: "BODY", isContentEditable: false };
80
+ const campoTexto = { tagName: "INPUT", isContentEditable: false };
81
+ const areaTexto = { tagName: "TEXTAREA", isContentEditable: false };
82
+ const editable = { tagName: "DIV", isContentEditable: true };
83
+
84
+ test("M sin modificadores, con el foco fuera de un campo, abre el panel", () => {
85
+ assert.equal(esAtajoPanel({ key: "m", ctrlKey: false, metaKey: false, altKey: false }, cuerpo), true);
86
+ });
87
+
88
+ test("mayΓΊscula tambiΓ©n cuenta", () => {
89
+ assert.equal(esAtajoPanel({ key: "M", ctrlKey: false, metaKey: false, altKey: false }, cuerpo), true);
90
+ });
91
+
92
+ test("con un modificador no es el atajo", () => {
93
+ assert.equal(esAtajoPanel({ key: "m", ctrlKey: true, metaKey: false, altKey: false }, cuerpo), false);
94
+ assert.equal(esAtajoPanel({ key: "m", ctrlKey: false, metaKey: true, altKey: false }, cuerpo), false);
95
+ assert.equal(esAtajoPanel({ key: "m", ctrlKey: false, metaKey: false, altKey: true }, cuerpo), false);
96
+ });
97
+
98
+ test("con el foco en un campo de texto no es el atajo", () => {
99
+ assert.equal(esAtajoPanel({ key: "m" }, campoTexto), false);
100
+ assert.equal(esAtajoPanel({ key: "m" }, areaTexto), false);
101
+ assert.equal(esAtajoPanel({ key: "m" }, editable), false);
102
+ });
103
+
104
+ test("otra tecla no es el atajo", () => {
105
+ assert.equal(esAtajoPanel({ key: "a" }, cuerpo), false);
106
+ });
107
+
108
+ /* ── leerNumero / leerTexto ──────────────────────────────────────────── */
109
+
110
+ function estilosFalsos(valores) {
111
+ return { getPropertyValue: (nombre) => valores[nombre] ?? "" };
112
+ }
113
+
114
+ test("leerNumero devuelve el nΓΊmero del token", () => {
115
+ assert.equal(leerNumero(estilosFalsos({ "--x": "0.85" }), "--x", 1), 0.85);
116
+ });
117
+
118
+ test("leerNumero cae al valor por defecto si el token no es un nΓΊmero", () => {
119
+ assert.equal(leerNumero(estilosFalsos({}), "--x", 1), 1);
120
+ assert.equal(leerNumero(estilosFalsos({ "--x": "" }), "--x", 1), 1);
121
+ });
122
+
123
+ test("leerTexto devuelve el texto del token, recortado", () => {
124
+ assert.equal(leerTexto(estilosFalsos({ "--x": " #1b2340 " }), "--x", "#000"), "#1b2340");
125
+ });
126
+
127
+ test("leerTexto cae al valor por defecto si el token estΓ‘ vacΓ­o", () => {
128
+ assert.equal(leerTexto(estilosFalsos({}), "--x", "#000"), "#000");
129
+ });