kaizen-lib 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-lib",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
5
5
  "license": "MIT",
6
6
  "author": "Nicolás Sandobal",
@@ -1,81 +1,101 @@
1
- 'use client';
2
-
3
- import { useId } from 'react';
4
- import styled from 'styled-components';
5
- import { CAMISETAS_HAXBALL, type CamisetaHaxball, type NombreCamiseta } from './camisetasHaxball';
6
-
7
- export { CAMISETAS_HAXBALL } from './camisetasHaxball';
8
- export type { CamisetaHaxball, NombreCamiseta } from './camisetasHaxball';
9
-
10
- type Props = {
11
- /** Una clave de `CAMISETAS_HAXBALL`, o una camiseta suelta para previsualizar valores que todavía no están en el catálogo. */
12
- camiseta: NombreCamiseta | CamisetaHaxball;
13
- /** Lado del cuadrado, en px. */
14
- size?: number;
15
- className?: string;
16
- };
17
-
18
- /** Ancho de cada franja, en unidades del `viewBox` (0 a 100). No sale de ningún dato de Haxball: es la aproximación que arma un patrón legible sobre un disco de ese tamaño. */
19
- const GROSOR_FRANJA = 16;
20
-
21
- /** Alto de sobra del patrón para que, rotado a cualquier ángulo, siga cubriendo el círculo entero (diagonal de un cuadrado de 100 ≈ 141). */
22
- const ALTO_PATRON = 200;
23
-
24
- function resolverCamiseta(camiseta: Props['camiseta']): CamisetaHaxball {
25
- return typeof camiseta === 'string' ? CAMISETAS_HAXBALL[camiseta] : camiseta;
26
- }
27
-
28
- const Svg = styled.svg`
29
- display: block;
30
- flex-shrink: 0;
31
- `;
32
-
33
- /**
34
- * El disco de Haxball: la ficha con la que un jugador aparece en la cancha,
35
- * pintada con las franjas que arma `room.setTeamColors(equipo, angulo,
36
- * colorTexto, colores)` — la única personalización visual que da la API del
37
- * juego, y la fuente de `CAMISETAS_HAXBALL` (copiada de
38
- * `kaizen-bot/structure/base.js`).
39
- *
40
- * **Es una aproximación, no un renderer pixel-perfect.** No hay documentación
41
- * pública del algoritmo exacto de Haxball (grosor real de franja, radio real
42
- * del disco): esto reproduce el modelo general —franjas paralelas rotadas por
43
- * `angulo`, cicladas por `colores`— calibrado a ojo. Si en algún momento hace
44
- * falta fidelidad exacta, hay que calibrarlo contra capturas del juego real,
45
- * no contra esta implementación.
46
- *
47
- * No dibuja `colorTexto`: en el juego es el color del número de jugador sobre
48
- * el disco, y acá no hay ningún número que mostrar (esto es un selector fuera
49
- * de partida, no una ficha en cancha). El dato queda en `CamisetaHaxball` por
50
- * si el día de mañana hace falta superponer una inicial o un número.
51
- */
52
- export function DiscoHaxball({ camiseta, size = 64, className }: Props) {
53
- const { angulo, colores, nombre } = resolverCamiseta(camiseta);
54
- const patternId = `disco-haxball-${useId()}`;
55
-
56
- return (
57
- <Svg
58
- viewBox="0 0 100 100"
59
- width={size}
60
- height={size}
61
- role="img"
62
- aria-label={`Camiseta de ${nombre}`}
63
- className={className}
64
- >
65
- <defs>
66
- <pattern
67
- id={patternId}
68
- patternUnits="userSpaceOnUse"
69
- width={GROSOR_FRANJA * colores.length}
70
- height={ALTO_PATRON}
71
- patternTransform={`rotate(${angulo} 50 50)`}
72
- >
73
- {colores.map((color, indice) => (
74
- <rect key={indice} x={indice * GROSOR_FRANJA} y={0} width={GROSOR_FRANJA} height={ALTO_PATRON} fill={color} />
75
- ))}
76
- </pattern>
77
- </defs>
78
- <circle cx={50} cy={50} r={48} fill={`url(#${patternId})`} />
79
- </Svg>
80
- );
81
- }
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
4
+ import styled from 'styled-components';
5
+ import { CAMISETAS_HAXBALL, type CamisetaHaxball, type NombreCamiseta } from './camisetasHaxball';
6
+ import { recortarAvatarHaxball } from './avatarHaxball';
7
+
8
+ export { CAMISETAS_HAXBALL } from './camisetasHaxball';
9
+ export type { CamisetaHaxball, NombreCamiseta } from './camisetasHaxball';
10
+ export { recortarAvatarHaxball, LUGARES_AVATAR_HAXBALL } from './avatarHaxball';
11
+
12
+ type Props = {
13
+ /** Una clave de `CAMISETAS_HAXBALL`, o una camiseta suelta para previsualizar valores que todavía no están en el catálogo. */
14
+ camiseta: NombreCamiseta | CamisetaHaxball;
15
+ /** El avatar del jugador sobre el disco, en `colorTexto`: lo que no entra se corta con `recortarAvatarHaxball`. */
16
+ texto?: string;
17
+ /** Lado del cuadrado, en px. */
18
+ size?: number;
19
+ className?: string;
20
+ };
21
+
22
+ /** Ancho del `viewBox`: un solo ciclo de colores cubre el disco de punta a punta, como en Haxball — 3 colores son 3 franjas, nunca un patrón que se repite. */
23
+ const ANCHO_DISCO = 100;
24
+
25
+ /** Alto de sobra del patrón para que, rotado a cualquier ángulo, siga cubriendo el círculo entero (diagonal de un cuadrado de 100 ≈ 141). */
26
+ const ALTO_PATRON = 200;
27
+
28
+
29
+ function resolverCamiseta(camiseta: Props['camiseta']): CamisetaHaxball {
30
+ return typeof camiseta === 'string' ? CAMISETAS_HAXBALL[camiseta] : camiseta;
31
+ }
32
+
33
+ const Svg = styled.svg`
34
+ display: block;
35
+ flex-shrink: 0;
36
+ `;
37
+
38
+ /**
39
+ * El disco de Haxball: la ficha con la que un jugador aparece en la cancha,
40
+ * pintada con las franjas que arma `room.setTeamColors(equipo, angulo,
41
+ * colorTexto, colores)` — la única personalización visual que da la API del
42
+ * juego, y la fuente de `CAMISETAS_HAXBALL` (copiada de
43
+ * `kaizen-bot/structure/base.js`).
44
+ *
45
+ * **Es una aproximación, no un renderer pixel-perfect.** No hay documentación
46
+ * pública del algoritmo exacto de Haxball (grosor real de franja, radio real
47
+ * del disco): esto reproduce el modelo general —franjas paralelas rotadas por
48
+ * `angulo`, una por color y de igual ancho, repartidas sobre el disco—
49
+ * calibrado a ojo. Si en algún momento hace falta fidelidad exacta, hay que
50
+ * calibrarlo contra capturas del juego real, no contra esta implementación.
51
+ *
52
+ * `texto` se dibuja en `colorTexto`, el mismo color que usa el juego para el
53
+ * avatar del jugador sobre el disco.
54
+ */
55
+ export function DiscoHaxball({ camiseta, texto, size = 64, className }: Props) {
56
+ const { angulo, colorTexto, colores, nombre } = resolverCamiseta(camiseta);
57
+ const caracteres = texto ? recortarAvatarHaxball(texto) : '';
58
+ const patternId = `disco-haxball-${useId()}`;
59
+ const grosorFranja = ANCHO_DISCO / colores.length;
60
+
61
+ return (
62
+ <Svg
63
+ viewBox={`0 0 ${ANCHO_DISCO} ${ANCHO_DISCO}`}
64
+ width={size}
65
+ height={size}
66
+ role="img"
67
+ aria-label={`Camiseta de ${nombre}`}
68
+ className={className}
69
+ >
70
+ <defs>
71
+ <pattern
72
+ id={patternId}
73
+ patternUnits="userSpaceOnUse"
74
+ width={ANCHO_DISCO}
75
+ height={ALTO_PATRON}
76
+ patternTransform={`rotate(${angulo} 50 50)`}
77
+ >
78
+ {colores.map((color, indice) => (
79
+ <rect key={indice} x={indice * grosorFranja} y={0} width={grosorFranja} height={ALTO_PATRON} fill={color} />
80
+ ))}
81
+ </pattern>
82
+ </defs>
83
+ <circle cx={50} cy={50} r={48} fill={`url(#${patternId})`} />
84
+ {caracteres && (
85
+ <text
86
+ x={50}
87
+ y={50}
88
+ textAnchor="middle"
89
+ dominantBaseline="central"
90
+ fontFamily="Arial, Helvetica, sans-serif"
91
+ fontWeight={700}
92
+ fontSize={44}
93
+ fill={colorTexto}
94
+ aria-hidden="true"
95
+ >
96
+ {caracteres}
97
+ </text>
98
+ )}
99
+ </Svg>
100
+ );
101
+ }
@@ -0,0 +1,299 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useState, type ReactNode } from 'react';
4
+ import styled from 'styled-components';
5
+ import { fg, font, hairline, motion, radius, ring, shadow, space, surface } from '../tokens';
6
+
7
+ /**
8
+ * Un **panel** de una pantalla: una elevación partida en dos. Elicitado de
9
+ * valle-verde (v1.3.13), donde ya lo usaban siete pantallas.
10
+ *
11
+ * - **Cabecera**: el título y, bien a la vista, el **indicador** que resume lo
12
+ * que hay adentro, con una leyenda chica debajo que dice qué es ese número.
13
+ * En el extremo, el chevron.
14
+ * - **Cuerpo**: el contenido. Se pliega y se despliega.
15
+ *
16
+ * El indicador es lo que justifica plegar: con el panel cerrado la cabecera
17
+ * sigue diciendo lo que importa, y la pantalla entra entera sin scrollear.
18
+ *
19
+ * ## Decisiones
20
+ *
21
+ * - **Plegado no desmonta.** El cuerpo queda en el DOM con `hidden`: una tabla
22
+ * con filas seleccionadas o filtros cargados los conserva al reabrir, y no
23
+ * vuelve a pedir sus datos.
24
+ * - **El control accesible es el chevron**, un botón con `aria-expanded` y
25
+ * `aria-controls`. Toda la cabecera responde al click por comodidad de mouse
26
+ * y de dedo, pero no es un botón: adentro lleva un encabezado y un número, y
27
+ * un botón no puede contener un encabezado.
28
+ * - **Controlado o no**: `abierto` + `onCambiar`, o `abiertoInicial`.
29
+ * - **Se compacta, no desborda.** El plegable es un grid con la columna en
30
+ * `minmax(0, 1fr)`: sin columna declarada, la implícita medía el ancho
31
+ * máximo del contenido y una tabla ancha empujaba el panel fuera de la
32
+ * pantalla (valle-verde 1.4.3).
33
+ *
34
+ * ```tsx
35
+ * <PanelExpandible titulo="Comprometido" indicador={12} leyenda="pacientes sin entrega">
36
+ * <Tabla />
37
+ * </PanelExpandible>
38
+ * ```
39
+ */
40
+
41
+ const Raiz = styled.section`
42
+ border-radius: ${radius.lg};
43
+ background: ${surface[1]};
44
+ box-shadow: ${shadow.sm};
45
+ min-width: 0;
46
+ `;
47
+
48
+ const Cabecera = styled.div<{ $abierto: boolean; $plegable: boolean }>`
49
+ display: flex;
50
+ align-items: center;
51
+ gap: ${space[4]};
52
+ padding: ${space[4]} ${space[5]};
53
+ border-radius: ${({ $abierto }) => ($abierto ? `${radius.lg} ${radius.lg} 0 0` : radius.lg)};
54
+ cursor: ${({ $plegable }) => ($plegable ? 'pointer' : 'default')};
55
+ user-select: none;
56
+ transition: background ${motion.fast} ${motion.ease};
57
+
58
+ &:hover {
59
+ background: ${({ $plegable }) => ($plegable ? surface.hover : 'transparent')};
60
+ }
61
+ `;
62
+
63
+ const Titulo = styled.h2`
64
+ flex: 1 1 auto;
65
+ min-width: 0;
66
+ margin: 0;
67
+ font-size: ${font.xl};
68
+ font-weight: 600;
69
+ line-height: 1.2;
70
+ color: ${fg.DEFAULT};
71
+ overflow-wrap: anywhere;
72
+ `;
73
+
74
+ const Indicador = styled.div`
75
+ flex: 0 1 auto;
76
+ min-width: 0;
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: flex-end;
80
+ text-align: right;
81
+ `;
82
+
83
+ const Valor = styled.strong`
84
+ font-size: ${font['2xl']};
85
+ font-weight: 600;
86
+ line-height: 1.1;
87
+ font-variant-numeric: tabular-nums;
88
+ color: ${fg.DEFAULT};
89
+ white-space: nowrap;
90
+ `;
91
+
92
+ const Leyenda = styled.span`
93
+ font-size: ${font.xs};
94
+ color: ${fg.subtle};
95
+ `;
96
+
97
+ const Indicadores = styled.div`
98
+ flex: 0 1 auto;
99
+ min-width: 0;
100
+ display: flex;
101
+ flex-wrap: wrap;
102
+ justify-content: flex-end;
103
+ gap: ${space[2]} ${space[5]};
104
+ `;
105
+
106
+ const Chevron = styled.button<{ $abierto: boolean; $duracion: number }>`
107
+ flex: 0 0 auto;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ width: 36px;
112
+ height: 36px;
113
+ padding: 0;
114
+ border: none;
115
+ border-radius: ${radius.md};
116
+ background: transparent;
117
+ color: ${fg.muted};
118
+ cursor: pointer;
119
+ outline: 2px solid transparent;
120
+ outline-offset: 2px;
121
+
122
+ &:focus-visible { outline-color: ${ring}; }
123
+
124
+ svg {
125
+ transform: rotate(${({ $abierto }) => ($abierto ? '180deg' : '0deg')});
126
+ transition: transform ${({ $duracion }) => Math.min($duracion, 300)}ms ${motion.ease};
127
+ }
128
+
129
+ @media (prefers-reduced-motion: reduce) {
130
+ svg { transition: none; }
131
+ }
132
+ `;
133
+
134
+ const Cuerpo = styled.div`
135
+ padding: ${space[5]};
136
+ border-top: 1px solid ${hairline.DEFAULT};
137
+ min-width: 0;
138
+ `;
139
+
140
+ /*
141
+ * El alto se anima con `grid-template-rows` de 0fr a 1fr: anima hasta el alto
142
+ * real del contenido sin medirlo, que con `height` no se puede (no anima a
143
+ * `auto`). El hijo directo recorta lo que no entra mientras tanto.
144
+ */
145
+ const Plegable = styled.div<{ $expandido: boolean; $duracion: number }>`
146
+ display: grid;
147
+ grid-template-columns: minmax(0, 1fr);
148
+ grid-template-rows: ${({ $expandido }) => ($expandido ? '1fr' : '0fr')};
149
+ transition: grid-template-rows ${({ $duracion }) => $duracion}ms ${motion.ease};
150
+
151
+ > div {
152
+ min-height: 0;
153
+ overflow: hidden;
154
+ }
155
+
156
+ &[hidden] { display: none; }
157
+
158
+ @media (prefers-reduced-motion: reduce) {
159
+ transition: none;
160
+ }
161
+ `;
162
+
163
+ /**
164
+ * ¿Se anima? Con `prefers-reduced-motion`, y donde el navegador no sabe
165
+ * preguntarlo, el panel se abre y se cierra de una (el estado seguro).
166
+ */
167
+ const puedeAnimar = (): boolean =>
168
+ typeof window !== 'undefined'
169
+ && typeof window.matchMedia === 'function'
170
+ && typeof window.requestAnimationFrame === 'function'
171
+ && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
172
+
173
+ export type PanelExpandibleProps = {
174
+ /** El nombre del panel. Se escribe como se lee: "Comprometido", no "COMPROMETIDO". */
175
+ titulo: string;
176
+ /** El número (o dato) que resume el contenido. Va grande, a la derecha de la cabecera. */
177
+ indicador?: ReactNode;
178
+ /** Qué es el indicador, en una línea corta. */
179
+ leyenda?: ReactNode;
180
+ /** Más de un indicador, cada uno con su leyenda. Con esto, `indicador` y `leyenda` no se miran. */
181
+ indicadores?: { valor: ReactNode; leyenda?: ReactNode }[];
182
+ /**
183
+ * Si el panel se pliega (por defecto, sí). Con `false` conserva el formato pero no
184
+ * tiene chevron ni responde al click: para la pantalla que tiene **un solo** panel.
185
+ */
186
+ plegable?: boolean;
187
+ /** Arranca abierto (por defecto) o plegado, cuando el panel maneja su propio estado. */
188
+ abiertoInicial?: boolean;
189
+ /** Controlado desde afuera. Con esto, `abiertoInicial` no se mira. */
190
+ abierto?: boolean;
191
+ onCambiar?: (abierto: boolean) => void;
192
+ /** Cuánto tarda en plegarse o desplegarse, en ms. Default 300. */
193
+ duracionMs?: number;
194
+ /**
195
+ * Nombre accesible del chevron, sin el título (que se agrega solo). Defaults
196
+ * `'Plegar'` y `'Desplegar'`: un consumidor con más de un idioma los pisa.
197
+ */
198
+ etiquetaPlegar?: string;
199
+ etiquetaDesplegar?: string;
200
+ className?: string;
201
+ children: ReactNode;
202
+ };
203
+
204
+ export function PanelExpandible({
205
+ titulo,
206
+ indicador,
207
+ leyenda,
208
+ indicadores,
209
+ plegable = true,
210
+ abiertoInicial = true,
211
+ abierto: abiertoControlado,
212
+ onCambiar,
213
+ duracionMs = 300,
214
+ etiquetaPlegar = 'Plegar',
215
+ etiquetaDesplegar = 'Desplegar',
216
+ className,
217
+ children,
218
+ }: PanelExpandibleProps) {
219
+ const [abiertoPropio, setAbiertoPropio] = useState(abiertoInicial);
220
+ const abierto = !plegable || (abiertoControlado ?? abiertoPropio);
221
+
222
+ /*
223
+ * Dos estados además de `abierto`: si el cuerpo está en el árbol visible
224
+ * (`montado`) y si está desplegado (`expandido`). Al abrir, primero se
225
+ * muestra plegado y en el cuadro siguiente se despliega, para que haya algo
226
+ * que animar. Al cerrar, se pliega y recién al terminar se oculta: así lo
227
+ * plegado no queda enfocable ni lo lee un lector de pantalla. Por eso la
228
+ * duración es una prop y no sólo CSS: el temporizador tiene que coincidir.
229
+ */
230
+ const [montado, setMontado] = useState(abierto);
231
+ const [expandido, setExpandido] = useState(abierto);
232
+ useEffect(() => {
233
+ if (!puedeAnimar()) {
234
+ setMontado(abierto);
235
+ setExpandido(abierto);
236
+ return undefined;
237
+ }
238
+ if (abierto) {
239
+ setMontado(true);
240
+ const cuadro = window.requestAnimationFrame(() => setExpandido(true));
241
+ return () => window.cancelAnimationFrame(cuadro);
242
+ }
243
+ setExpandido(false);
244
+ const espera = window.setTimeout(() => setMontado(false), duracionMs);
245
+ return () => window.clearTimeout(espera);
246
+ }, [abierto, duracionMs]);
247
+ const idCuerpo = useId();
248
+
249
+ const alternar = () => {
250
+ if (!plegable) return;
251
+ if (abiertoControlado === undefined) setAbiertoPropio(!abierto);
252
+ onCambiar?.(!abierto);
253
+ };
254
+
255
+ const hayIndicador = indicador !== undefined && indicador !== null;
256
+
257
+ return (
258
+ <Raiz className={className}>
259
+ <Cabecera $abierto={abierto} $plegable={plegable} onClick={alternar}>
260
+ <Titulo>{titulo}</Titulo>
261
+ {indicadores ? (
262
+ <Indicadores>
263
+ {indicadores.map((it, i) => (
264
+ <Indicador key={i}>
265
+ <Valor>{it.valor}</Valor>
266
+ {it.leyenda && <Leyenda>{it.leyenda}</Leyenda>}
267
+ </Indicador>
268
+ ))}
269
+ </Indicadores>
270
+ ) : (hayIndicador || leyenda) && (
271
+ <Indicador>
272
+ {hayIndicador && <Valor>{indicador}</Valor>}
273
+ {leyenda && <Leyenda>{leyenda}</Leyenda>}
274
+ </Indicador>
275
+ )}
276
+ {plegable && (
277
+ <Chevron
278
+ type="button"
279
+ $abierto={abierto}
280
+ $duracion={duracionMs}
281
+ aria-expanded={abierto}
282
+ aria-controls={idCuerpo}
283
+ aria-label={`${abierto ? etiquetaPlegar : etiquetaDesplegar} ${titulo}`}
284
+ // El click sube a la cabecera, que es la que alterna: acá no se repite.
285
+ >
286
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
287
+ <polyline points="6 9 12 15 18 9" />
288
+ </svg>
289
+ </Chevron>
290
+ )}
291
+ </Cabecera>
292
+ <Plegable id={idCuerpo} $expandido={expandido} $duracion={duracionMs} hidden={!montado}>
293
+ <div>
294
+ <Cuerpo>{children}</Cuerpo>
295
+ </div>
296
+ </Plegable>
297
+ </Raiz>
298
+ );
299
+ }
@@ -0,0 +1,23 @@
1
+ /** Lugares del avatar de Haxball sobre el disco: dos caracteres, o un emoji que ocupa los dos. */
2
+ export const LUGARES_AVATAR_HAXBALL = 2;
3
+
4
+ const EMOJI = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
5
+
6
+ /**
7
+ * Recorta un texto a lo que entra en el avatar: cuenta por grafemas (una
8
+ * bandera o una familia es uno solo), un emoji ocupa los 2 lugares y lo que
9
+ * no entra entero se descarta, en orden. Es la regla que valida quien guarda
10
+ * el avatar y la que aplica `DiscoHaxball` al dibujarlo.
11
+ */
12
+ export function recortarAvatarHaxball(texto: string): string {
13
+ const grafemas = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(texto.trim());
14
+ let resultado = '';
15
+ let ocupados = 0;
16
+ for (const { segment } of grafemas) {
17
+ const lugares = EMOJI.test(segment) ? LUGARES_AVATAR_HAXBALL : 1;
18
+ if (ocupados + lugares > LUGARES_AVATAR_HAXBALL) break;
19
+ resultado += segment;
20
+ ocupados += lugares;
21
+ }
22
+ return resultado;
23
+ }