kaizen-lib 0.2.10 → 0.2.12
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/README.md +318 -318
- package/package.json +1 -1
- package/src/ui/BottomNav.tsx +3 -1
- package/src/ui/DiscoHaxball.tsx +101 -81
- package/src/ui/avatarHaxball.ts +23 -0
- package/src/ui/camisetasHaxball.ts +128 -128
- package/src/ui/index.ts +17 -17
package/README.md
CHANGED
|
@@ -1,318 +1,318 @@
|
|
|
1
|
-
# kaizen-lib
|
|
2
|
-
|
|
3
|
-
Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con
|
|
4
|
-
subpath exports: cada parte se importa por separado y el resto no entra al bundle.
|
|
5
|
-
|
|
6
|
-
Se publica en **TypeScript crudo**, sin build. Los consumidores son Next + React 19 +
|
|
7
|
-
styled-components con `moduleResolution: "bundler"`, así que lo transpilan ellos.
|
|
8
|
-
|
|
9
|
-
## Estado
|
|
10
|
-
|
|
11
|
-
| Subpath | Qué es | Estado |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
|
|
14
|
-
| `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
|
|
15
|
-
| `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`, `Badge`, `BottomNav`, `DiscoHaxball`) | en curso |
|
|
16
|
-
|
|
17
|
-
## ¿Qué uso para qué?
|
|
18
|
-
|
|
19
|
-
Antes de escribir un `styled.button` a mano o un ciclador de idioma propio, revisá esta
|
|
20
|
-
tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no
|
|
21
|
-
alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del harness:
|
|
22
|
-
"un duplicado local de algo que ya existe compartido se reemplaza por el compartido").
|
|
23
|
-
|
|
24
|
-
| Necesito... | Uso | No es... |
|
|
25
|
-
|---|---|---|
|
|
26
|
-
| Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | `IconButton` | — |
|
|
27
|
-
| Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | `IconButton` con `active` | `ReactionButton` (ver abajo, distinción a propósito) |
|
|
28
|
-
| Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — **sin** fondo teñido | `ReactionButton` | `IconButton` con `active` |
|
|
29
|
-
| El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un `IconButton` | `Relieve` envolviendo el `IconButton` | una prop de `IconButton` — ver por qué en su docblock |
|
|
30
|
-
| Un ciclador de idioma con banderas en la topbar | `LangSelector` | reconstruir el ciclado a mano — ya lo resuelve, con `Relieve` incluido |
|
|
31
|
-
| Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | `Modal` | un overlay propio — portal, foco atrapado, `Escape`, scroll bloqueado y anidamiento ya están resueltos ahí |
|
|
32
|
-
| Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
|
|
33
|
-
| Una píldora de estado (activo, vencido, un rol, una etiqueta) con fondo lavado y texto del mismo tono | `Badge` | un `styled.span` propio por producto — es exactamente lo que ya había triplicado |
|
|
34
|
-
| La navegación principal fija abajo en el teléfono (ícono + rótulo por destino) | `BottomNav`, y `bottomNavOffset` de padding inferior en lo que no debe quedar tapado | una barra propia — ya estaba cuadruplicada |
|
|
35
|
-
| Espaciado, radios, color, tipografía, sombra | `kaizen-lib/tokens` (`space`, `radius`, `fg`, `accent`, `font`, `shadow`, `relief`...) | un valor a mano — cada uno es una referencia `var(--kz-*)` |
|
|
36
|
-
| Mostrar la ficha/disco de Haxball de un equipo (para elegir un equipo representativo, o para lucir el catálogo de camisetas) | `DiscoHaxball` + `CAMISETAS_HAXBALL` | una imagen propia — es una aproximación visual generada por SVG, no requiere ningún asset |
|
|
37
|
-
|
|
38
|
-
Ningún componente trae íconos ni banderas: los recibe como `children`/`icono`, y el
|
|
39
|
-
producto elige de dónde salen (`react-icons`, `react-country-flag`, SVG propio).
|
|
40
|
-
|
|
41
|
-
## Instalación
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm install kaizen-lib
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
`react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
|
|
48
|
-
alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
|
|
49
|
-
styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
|
|
50
|
-
|
|
51
|
-
En `next.config`:
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
const nextConfig = { transpilePackages: ['kaizen-lib'] };
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Tokens
|
|
58
|
-
|
|
59
|
-
El contrato es uno solo: **los nombres**. Los valores los define cada producto.
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
import styled from 'styled-components';
|
|
63
|
-
import { surface, fg, space, radius } from 'kaizen-lib/tokens';
|
|
64
|
-
|
|
65
|
-
const Card = styled.div`
|
|
66
|
-
background: ${surface[2]};
|
|
67
|
-
color: ${fg.DEFAULT};
|
|
68
|
-
padding: ${space[4]};
|
|
69
|
-
border-radius: ${radius.md};
|
|
70
|
-
`;
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
```css
|
|
74
|
-
/* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
|
|
75
|
-
@import 'kaizen-lib/tokens.css';
|
|
76
|
-
|
|
77
|
-
:root {
|
|
78
|
-
--kz-accent: #e8318f; /* la marca del producto */
|
|
79
|
-
}
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
**Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
|
|
83
|
-
valor por defecto. Si no importa `tokens.css` y no define un token que un componente
|
|
84
|
-
usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
|
|
85
|
-
todo token de la API esté definido en `defaults.css`.
|
|
86
|
-
|
|
87
|
-
**Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
|
|
88
|
-
mecanismo de tutipoker-school-front, el único consumidor con toggle real.
|
|
89
|
-
|
|
90
|
-
**Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
|
|
91
|
-
`sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
|
|
92
|
-
|
|
93
|
-
## Componentes
|
|
94
|
-
|
|
95
|
-
```tsx
|
|
96
|
-
import { IconButton } from 'kaizen-lib/ui';
|
|
97
|
-
|
|
98
|
-
<IconButton label="Cambiar tema" size="lg" onClick={alternar}>
|
|
99
|
-
<IconoLuna />
|
|
100
|
-
</IconButton>
|
|
101
|
-
|
|
102
|
-
<IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
|
|
103
|
-
<IconoCampana />
|
|
104
|
-
</IconButton>
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
**`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
|
|
108
|
-
(es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
|
|
109
|
-
cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
|
|
110
|
-
otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
|
|
111
|
-
área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
|
|
112
|
-
qué.
|
|
113
|
-
|
|
114
|
-
La librería no trae íconos: cada producto usa los suyos.
|
|
115
|
-
|
|
116
|
-
```tsx
|
|
117
|
-
import { ReactionButton } from 'kaizen-lib/ui';
|
|
118
|
-
|
|
119
|
-
<ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
|
|
120
|
-
<IconoCorazon />
|
|
121
|
-
</ReactionButton>
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
**`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
|
|
125
|
-
`accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
|
|
126
|
-
del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
|
|
127
|
-
una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
|
|
128
|
-
"esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
|
|
129
|
-
entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
|
|
130
|
-
ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
|
|
131
|
-
|
|
132
|
-
Un color que los seis tonos no cubren —la marca de un producto, no una categoría
|
|
133
|
-
semántica— se pisa con `style`, porque el color activo sale de la variable CSS
|
|
134
|
-
`--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
|
|
135
|
-
|
|
136
|
-
```tsx
|
|
137
|
-
<ReactionButton
|
|
138
|
-
active={guardado}
|
|
139
|
-
style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
|
|
140
|
-
label="Guardar"
|
|
141
|
-
>
|
|
142
|
-
<IconoMarcador />
|
|
143
|
-
</ReactionButton>
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
```tsx
|
|
147
|
-
import { Medidor } from 'kaizen-lib/ui';
|
|
148
|
-
|
|
149
|
-
<Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
**`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
|
|
153
|
-
un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
|
|
154
|
-
un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
|
|
155
|
-
número del centro son decorativos, y el color nunca es el único portador del significado.
|
|
156
|
-
|
|
157
|
-
Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
|
|
158
|
-
lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
|
|
159
|
-
`4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
|
|
160
|
-
|
|
161
|
-
`sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
|
|
162
|
-
número *significa* en el producto y no el componente:
|
|
163
|
-
|
|
164
|
-
| Escalón | `menos-es-mejor` | `mas-es-mejor` |
|
|
165
|
-
|---|---|---|
|
|
166
|
-
| bajo (< 40%) | verde | rojo |
|
|
167
|
-
| medio (40–79%) | azul | naranja |
|
|
168
|
-
| alto (80–99%) | naranja | azul |
|
|
169
|
-
| tope (100%) | rojo | verde |
|
|
170
|
-
|
|
171
|
-
`menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
|
|
172
|
-
cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
|
|
173
|
-
`warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
|
|
174
|
-
centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
|
|
175
|
-
|
|
176
|
-
```tsx
|
|
177
|
-
import { IconButton, Relieve } from 'kaizen-lib/ui';
|
|
178
|
-
import Link from 'next/link';
|
|
179
|
-
|
|
180
|
-
<Relieve>
|
|
181
|
-
<IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
|
|
182
|
-
</Relieve>
|
|
183
|
-
|
|
184
|
-
<Relieve $prendido={enPerfil}>
|
|
185
|
-
<IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
|
|
186
|
-
</Relieve>
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
**`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
|
|
190
|
-
borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
|
|
191
|
-
presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
|
|
192
|
-
activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
|
|
193
|
-
`styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
|
|
194
|
-
intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
|
|
195
|
-
saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
|
|
196
|
-
marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
|
|
197
|
-
|
|
198
|
-
```tsx
|
|
199
|
-
import { LangSelector } from 'kaizen-lib/ui';
|
|
200
|
-
|
|
201
|
-
<LangSelector
|
|
202
|
-
actual={i18n.language}
|
|
203
|
-
onChange={(codigo) => i18n.changeLanguage(codigo)}
|
|
204
|
-
etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
|
|
205
|
-
opciones={[
|
|
206
|
-
{ codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
|
|
207
|
-
{ codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
|
|
208
|
-
]}
|
|
209
|
-
/>
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
**`LangSelector`** es el botón de idioma de una topbar: cicla a la siguiente opción en
|
|
213
|
-
cada tap y muestra el ícono del idioma actual, con el relieve de `Relieve` ya incluido
|
|
214
|
-
(a diferencia de `IconButton`, acá no hace falta envolverlo aparte — todo consumidor lo
|
|
215
|
-
quería). No trae banderas — mismo criterio que `IconButton` no trae íconos —, así que
|
|
216
|
-
`icono` es cosa del producto; `react-country-flag` (ya usado en tuxon-front) es una
|
|
217
|
-
opción probada. `etiqueta` arma el nombre accesible a partir de la opción siguiente y la
|
|
218
|
-
actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya
|
|
219
|
-
difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma:
|
|
220
|
-
English. Cambiar idioma"). `onChange` recibe el código de la opción siguiente; qué hacer
|
|
221
|
-
con eso (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no
|
|
222
|
-
sabe qué mecanismo de traducción hay atrás.
|
|
223
|
-
|
|
224
|
-
```tsx
|
|
225
|
-
import { Modal } from 'kaizen-lib/ui';
|
|
226
|
-
|
|
227
|
-
<Modal
|
|
228
|
-
open={abierto}
|
|
229
|
-
onClose={cerrar}
|
|
230
|
-
title="Eliminar cepa"
|
|
231
|
-
description="Esta acción no se puede deshacer."
|
|
232
|
-
footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
|
|
233
|
-
>
|
|
234
|
-
<p>Se van a borrar también sus lotes asociados.</p>
|
|
235
|
-
</Modal>
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
**`Modal`** es el shell único de un panel flotante: portalea a `document.body`, atrapa el
|
|
239
|
-
foco y lo devuelve a quien lo abrió al cerrar, cierra con `Escape`/click afuera/la X, y
|
|
240
|
-
bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
|
|
241
|
-
confirmación abierto encima de un formulario): sólo el de más arriba atiende `Escape`, y el
|
|
242
|
-
scroll se libera recién cuando se cierra el último.
|
|
243
|
-
|
|
244
|
-
Sin `title` no hay nombre accesible propio — pasá `ariaLabel`. `size` (`sm`|`md`|`lg`|`xl`)
|
|
245
|
-
fija el ancho máximo del panel; `maxWidth` lo pisa con un valor explícito. `disableClose`
|
|
246
|
-
bloquea la X, el overlay y `Escape` a la vez — para una operación en curso, no para "no
|
|
247
|
-
quiero que cierre nunca". `dismissOnOverlay={false}` sólo desactiva el click afuera, para
|
|
248
|
-
un formulario a medio llenar que sigue queriendo cerrar con la X o `Escape`. `closeLabel`
|
|
249
|
-
(default `'Cerrar'`) es el nombre accesible de la X — mismo criterio que `etiqueta` de
|
|
250
|
-
`LangSelector`: un consumidor con más de un idioma lo pisa, el resto no lo toca.
|
|
251
|
-
|
|
252
|
-
Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cuerpo son
|
|
253
|
-
casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
|
|
254
|
-
ahí con un wrapper local hasta que un segundo consumidor los pida acá.
|
|
255
|
-
|
|
256
|
-
```tsx
|
|
257
|
-
import { Badge } from 'kaizen-lib/ui';
|
|
258
|
-
|
|
259
|
-
<Badge tone="success">Activo</Badge>
|
|
260
|
-
<Badge tone="danger" bordered>Vencido</Badge>
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
**`Badge`** es una píldora de estado: fondo lavado + texto del mismo tono (`neutral` |
|
|
264
|
-
`accent` | `success` | `danger` | `warning` | `info`, default `neutral`), mayúsculas
|
|
265
|
-
apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde,
|
|
266
|
-
kaizen-next) — el look es el de valle-verde. `bordered` agrega un borde de 1px del color
|
|
267
|
-
pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la
|
|
268
|
-
superficie de atrás; no es una segunda estética, es el mismo tono con más peso.
|
|
269
|
-
|
|
270
|
-
```tsx
|
|
271
|
-
import Link from 'next/link';
|
|
272
|
-
import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';
|
|
273
|
-
|
|
274
|
-
<BottomNav
|
|
275
|
-
items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
|
|
276
|
-
activeId={activo}
|
|
277
|
-
maxWidth={640}
|
|
278
|
-
ariaLabel="Secciones"
|
|
279
|
-
linkAs={Link}
|
|
280
|
-
/>
|
|
281
|
-
|
|
282
|
-
const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
**`BottomNav`** es la barra de navegación fija abajo del teléfono: ícono + rótulo por
|
|
286
|
-
destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de
|
|
287
|
-
cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta
|
|
288
|
-
`maxWidth` px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.
|
|
289
|
-
|
|
290
|
-
Qué decide el producto: `activeId` (si un destino matchea exacto o por prefijo es regla de
|
|
291
|
-
sus rutas), `linkAs` (su router; sin él, `<a>`), y `ariaLabel` (su idioma, sin default).
|
|
292
|
-
`onSelect(item, event)` se llama antes de navegar; con `event.preventDefault()` el
|
|
293
|
-
destino hace otra cosa sin dejar de ser un link.
|
|
294
|
-
|
|
295
|
-
Mientras se ve, publica `--kz-bottom-nav-offset` (su alto más el inset inferior del
|
|
296
|
-
dispositivo). La barra es fija, así que el contenido lleva `bottomNavOffset` de padding
|
|
297
|
-
inferior y lo que flota (FAB, avisos) lo suma a su `bottom`; donde la barra no está, vale
|
|
298
|
-
`0px`.
|
|
299
|
-
|
|
300
|
-
## Desarrollo
|
|
301
|
-
|
|
302
|
-
```bash
|
|
303
|
-
npm run check # typecheck + tests
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
Los tests verifican dos cosas:
|
|
307
|
-
|
|
308
|
-
- **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
|
|
309
|
-
token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
|
|
310
|
-
redefinir.
|
|
311
|
-
- **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
|
|
312
|
-
cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
|
|
313
|
-
|
|
314
|
-
Si cambiás un color, el número lo da la suite, no el ojo.
|
|
315
|
-
|
|
316
|
-
## Licencia
|
|
317
|
-
|
|
318
|
-
MIT
|
|
1
|
+
# kaizen-lib
|
|
2
|
+
|
|
3
|
+
Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con
|
|
4
|
+
subpath exports: cada parte se importa por separado y el resto no entra al bundle.
|
|
5
|
+
|
|
6
|
+
Se publica en **TypeScript crudo**, sin build. Los consumidores son Next + React 19 +
|
|
7
|
+
styled-components con `moduleResolution: "bundler"`, así que lo transpilan ellos.
|
|
8
|
+
|
|
9
|
+
## Estado
|
|
10
|
+
|
|
11
|
+
| Subpath | Qué es | Estado |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
|
|
14
|
+
| `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
|
|
15
|
+
| `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`, `Badge`, `BottomNav`, `DiscoHaxball`) | en curso |
|
|
16
|
+
|
|
17
|
+
## ¿Qué uso para qué?
|
|
18
|
+
|
|
19
|
+
Antes de escribir un `styled.button` a mano o un ciclador de idioma propio, revisá esta
|
|
20
|
+
tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no
|
|
21
|
+
alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del harness:
|
|
22
|
+
"un duplicado local de algo que ya existe compartido se reemplaza por el compartido").
|
|
23
|
+
|
|
24
|
+
| Necesito... | Uso | No es... |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | `IconButton` | — |
|
|
27
|
+
| Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | `IconButton` con `active` | `ReactionButton` (ver abajo, distinción a propósito) |
|
|
28
|
+
| Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — **sin** fondo teñido | `ReactionButton` | `IconButton` con `active` |
|
|
29
|
+
| El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un `IconButton` | `Relieve` envolviendo el `IconButton` | una prop de `IconButton` — ver por qué en su docblock |
|
|
30
|
+
| Un ciclador de idioma con banderas en la topbar | `LangSelector` | reconstruir el ciclado a mano — ya lo resuelve, con `Relieve` incluido |
|
|
31
|
+
| Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | `Modal` | un overlay propio — portal, foco atrapado, `Escape`, scroll bloqueado y anidamiento ya están resueltos ahí |
|
|
32
|
+
| Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
|
|
33
|
+
| Una píldora de estado (activo, vencido, un rol, una etiqueta) con fondo lavado y texto del mismo tono | `Badge` | un `styled.span` propio por producto — es exactamente lo que ya había triplicado |
|
|
34
|
+
| La navegación principal fija abajo en el teléfono (ícono + rótulo por destino) | `BottomNav`, y `bottomNavOffset` de padding inferior en lo que no debe quedar tapado | una barra propia — ya estaba cuadruplicada |
|
|
35
|
+
| Espaciado, radios, color, tipografía, sombra | `kaizen-lib/tokens` (`space`, `radius`, `fg`, `accent`, `font`, `shadow`, `relief`...) | un valor a mano — cada uno es una referencia `var(--kz-*)` |
|
|
36
|
+
| Mostrar la ficha/disco de Haxball de un equipo (para elegir un equipo representativo, o para lucir el catálogo de camisetas) | `DiscoHaxball` + `CAMISETAS_HAXBALL`; el avatar (`texto`) se valida al guardarlo con `recortarAvatarHaxball` | una imagen propia — es una aproximación visual generada por SVG, no requiere ningún asset |
|
|
37
|
+
|
|
38
|
+
Ningún componente trae íconos ni banderas: los recibe como `children`/`icono`, y el
|
|
39
|
+
producto elige de dónde salen (`react-icons`, `react-country-flag`, SVG propio).
|
|
40
|
+
|
|
41
|
+
## Instalación
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install kaizen-lib
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
|
|
48
|
+
alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
|
|
49
|
+
styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
|
|
50
|
+
|
|
51
|
+
En `next.config`:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const nextConfig = { transpilePackages: ['kaizen-lib'] };
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Tokens
|
|
58
|
+
|
|
59
|
+
El contrato es uno solo: **los nombres**. Los valores los define cada producto.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import styled from 'styled-components';
|
|
63
|
+
import { surface, fg, space, radius } from 'kaizen-lib/tokens';
|
|
64
|
+
|
|
65
|
+
const Card = styled.div`
|
|
66
|
+
background: ${surface[2]};
|
|
67
|
+
color: ${fg.DEFAULT};
|
|
68
|
+
padding: ${space[4]};
|
|
69
|
+
border-radius: ${radius.md};
|
|
70
|
+
`;
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
/* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
|
|
75
|
+
@import 'kaizen-lib/tokens.css';
|
|
76
|
+
|
|
77
|
+
:root {
|
|
78
|
+
--kz-accent: #e8318f; /* la marca del producto */
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
|
|
83
|
+
valor por defecto. Si no importa `tokens.css` y no define un token que un componente
|
|
84
|
+
usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
|
|
85
|
+
todo token de la API esté definido en `defaults.css`.
|
|
86
|
+
|
|
87
|
+
**Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
|
|
88
|
+
mecanismo de tutipoker-school-front, el único consumidor con toggle real.
|
|
89
|
+
|
|
90
|
+
**Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
|
|
91
|
+
`sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
|
|
92
|
+
|
|
93
|
+
## Componentes
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { IconButton } from 'kaizen-lib/ui';
|
|
97
|
+
|
|
98
|
+
<IconButton label="Cambiar tema" size="lg" onClick={alternar}>
|
|
99
|
+
<IconoLuna />
|
|
100
|
+
</IconButton>
|
|
101
|
+
|
|
102
|
+
<IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
|
|
103
|
+
<IconoCampana />
|
|
104
|
+
</IconButton>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
|
|
108
|
+
(es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
|
|
109
|
+
cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
|
|
110
|
+
otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
|
|
111
|
+
área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
|
|
112
|
+
qué.
|
|
113
|
+
|
|
114
|
+
La librería no trae íconos: cada producto usa los suyos.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { ReactionButton } from 'kaizen-lib/ui';
|
|
118
|
+
|
|
119
|
+
<ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
|
|
120
|
+
<IconoCorazon />
|
|
121
|
+
</ReactionButton>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
|
|
125
|
+
`accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
|
|
126
|
+
del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
|
|
127
|
+
una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
|
|
128
|
+
"esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
|
|
129
|
+
entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
|
|
130
|
+
ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
|
|
131
|
+
|
|
132
|
+
Un color que los seis tonos no cubren —la marca de un producto, no una categoría
|
|
133
|
+
semántica— se pisa con `style`, porque el color activo sale de la variable CSS
|
|
134
|
+
`--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
<ReactionButton
|
|
138
|
+
active={guardado}
|
|
139
|
+
style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
|
|
140
|
+
label="Guardar"
|
|
141
|
+
>
|
|
142
|
+
<IconoMarcador />
|
|
143
|
+
</ReactionButton>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { Medidor } from 'kaizen-lib/ui';
|
|
148
|
+
|
|
149
|
+
<Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
|
|
153
|
+
un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
|
|
154
|
+
un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
|
|
155
|
+
número del centro son decorativos, y el color nunca es el único portador del significado.
|
|
156
|
+
|
|
157
|
+
Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
|
|
158
|
+
lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
|
|
159
|
+
`4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
|
|
160
|
+
|
|
161
|
+
`sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
|
|
162
|
+
número *significa* en el producto y no el componente:
|
|
163
|
+
|
|
164
|
+
| Escalón | `menos-es-mejor` | `mas-es-mejor` |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| bajo (< 40%) | verde | rojo |
|
|
167
|
+
| medio (40–79%) | azul | naranja |
|
|
168
|
+
| alto (80–99%) | naranja | azul |
|
|
169
|
+
| tope (100%) | rojo | verde |
|
|
170
|
+
|
|
171
|
+
`menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
|
|
172
|
+
cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
|
|
173
|
+
`warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
|
|
174
|
+
centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { IconButton, Relieve } from 'kaizen-lib/ui';
|
|
178
|
+
import Link from 'next/link';
|
|
179
|
+
|
|
180
|
+
<Relieve>
|
|
181
|
+
<IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
|
|
182
|
+
</Relieve>
|
|
183
|
+
|
|
184
|
+
<Relieve $prendido={enPerfil}>
|
|
185
|
+
<IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
|
|
186
|
+
</Relieve>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
|
|
190
|
+
borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
|
|
191
|
+
presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
|
|
192
|
+
activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
|
|
193
|
+
`styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
|
|
194
|
+
intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
|
|
195
|
+
saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
|
|
196
|
+
marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { LangSelector } from 'kaizen-lib/ui';
|
|
200
|
+
|
|
201
|
+
<LangSelector
|
|
202
|
+
actual={i18n.language}
|
|
203
|
+
onChange={(codigo) => i18n.changeLanguage(codigo)}
|
|
204
|
+
etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
|
|
205
|
+
opciones={[
|
|
206
|
+
{ codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
|
|
207
|
+
{ codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
|
|
208
|
+
]}
|
|
209
|
+
/>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**`LangSelector`** es el botón de idioma de una topbar: cicla a la siguiente opción en
|
|
213
|
+
cada tap y muestra el ícono del idioma actual, con el relieve de `Relieve` ya incluido
|
|
214
|
+
(a diferencia de `IconButton`, acá no hace falta envolverlo aparte — todo consumidor lo
|
|
215
|
+
quería). No trae banderas — mismo criterio que `IconButton` no trae íconos —, así que
|
|
216
|
+
`icono` es cosa del producto; `react-country-flag` (ya usado en tuxon-front) es una
|
|
217
|
+
opción probada. `etiqueta` arma el nombre accesible a partir de la opción siguiente y la
|
|
218
|
+
actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya
|
|
219
|
+
difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma:
|
|
220
|
+
English. Cambiar idioma"). `onChange` recibe el código de la opción siguiente; qué hacer
|
|
221
|
+
con eso (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no
|
|
222
|
+
sabe qué mecanismo de traducción hay atrás.
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { Modal } from 'kaizen-lib/ui';
|
|
226
|
+
|
|
227
|
+
<Modal
|
|
228
|
+
open={abierto}
|
|
229
|
+
onClose={cerrar}
|
|
230
|
+
title="Eliminar cepa"
|
|
231
|
+
description="Esta acción no se puede deshacer."
|
|
232
|
+
footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
|
|
233
|
+
>
|
|
234
|
+
<p>Se van a borrar también sus lotes asociados.</p>
|
|
235
|
+
</Modal>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**`Modal`** es el shell único de un panel flotante: portalea a `document.body`, atrapa el
|
|
239
|
+
foco y lo devuelve a quien lo abrió al cerrar, cierra con `Escape`/click afuera/la X, y
|
|
240
|
+
bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
|
|
241
|
+
confirmación abierto encima de un formulario): sólo el de más arriba atiende `Escape`, y el
|
|
242
|
+
scroll se libera recién cuando se cierra el último.
|
|
243
|
+
|
|
244
|
+
Sin `title` no hay nombre accesible propio — pasá `ariaLabel`. `size` (`sm`|`md`|`lg`|`xl`)
|
|
245
|
+
fija el ancho máximo del panel; `maxWidth` lo pisa con un valor explícito. `disableClose`
|
|
246
|
+
bloquea la X, el overlay y `Escape` a la vez — para una operación en curso, no para "no
|
|
247
|
+
quiero que cierre nunca". `dismissOnOverlay={false}` sólo desactiva el click afuera, para
|
|
248
|
+
un formulario a medio llenar que sigue queriendo cerrar con la X o `Escape`. `closeLabel`
|
|
249
|
+
(default `'Cerrar'`) es el nombre accesible de la X — mismo criterio que `etiqueta` de
|
|
250
|
+
`LangSelector`: un consumidor con más de un idioma lo pisa, el resto no lo toca.
|
|
251
|
+
|
|
252
|
+
Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cuerpo son
|
|
253
|
+
casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
|
|
254
|
+
ahí con un wrapper local hasta que un segundo consumidor los pida acá.
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { Badge } from 'kaizen-lib/ui';
|
|
258
|
+
|
|
259
|
+
<Badge tone="success">Activo</Badge>
|
|
260
|
+
<Badge tone="danger" bordered>Vencido</Badge>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**`Badge`** es una píldora de estado: fondo lavado + texto del mismo tono (`neutral` |
|
|
264
|
+
`accent` | `success` | `danger` | `warning` | `info`, default `neutral`), mayúsculas
|
|
265
|
+
apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde,
|
|
266
|
+
kaizen-next) — el look es el de valle-verde. `bordered` agrega un borde de 1px del color
|
|
267
|
+
pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la
|
|
268
|
+
superficie de atrás; no es una segunda estética, es el mismo tono con más peso.
|
|
269
|
+
|
|
270
|
+
```tsx
|
|
271
|
+
import Link from 'next/link';
|
|
272
|
+
import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';
|
|
273
|
+
|
|
274
|
+
<BottomNav
|
|
275
|
+
items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
|
|
276
|
+
activeId={activo}
|
|
277
|
+
maxWidth={640}
|
|
278
|
+
ariaLabel="Secciones"
|
|
279
|
+
linkAs={Link}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**`BottomNav`** es la barra de navegación fija abajo del teléfono: ícono + rótulo por
|
|
286
|
+
destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de
|
|
287
|
+
cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta
|
|
288
|
+
`maxWidth` px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.
|
|
289
|
+
|
|
290
|
+
Qué decide el producto: `activeId` (si un destino matchea exacto o por prefijo es regla de
|
|
291
|
+
sus rutas), `linkAs` (su router; sin él, `<a>`), y `ariaLabel` (su idioma, sin default).
|
|
292
|
+
`onSelect(item, event)` se llama antes de navegar; con `event.preventDefault()` el
|
|
293
|
+
destino hace otra cosa sin dejar de ser un link.
|
|
294
|
+
|
|
295
|
+
Mientras se ve, publica `--kz-bottom-nav-offset` (su alto más el inset inferior del
|
|
296
|
+
dispositivo). La barra es fija, así que el contenido lleva `bottomNavOffset` de padding
|
|
297
|
+
inferior y lo que flota (FAB, avisos) lo suma a su `bottom`; donde la barra no está, vale
|
|
298
|
+
`0px`.
|
|
299
|
+
|
|
300
|
+
## Desarrollo
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
npm run check # typecheck + tests
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Los tests verifican dos cosas:
|
|
307
|
+
|
|
308
|
+
- **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
|
|
309
|
+
token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
|
|
310
|
+
redefinir.
|
|
311
|
+
- **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
|
|
312
|
+
cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
|
|
313
|
+
|
|
314
|
+
Si cambiás un color, el número lo da la suite, no el ojo.
|
|
315
|
+
|
|
316
|
+
## Licencia
|
|
317
|
+
|
|
318
|
+
MIT
|
package/package.json
CHANGED
package/src/ui/BottomNav.tsx
CHANGED
|
@@ -80,7 +80,9 @@ const Barra = styled.nav<{ $maxWidth: number }>`
|
|
|
80
80
|
background: color-mix(in srgb, ${surface[1]} 88%, transparent);
|
|
81
81
|
backdrop-filter: blur(12px);
|
|
82
82
|
-webkit-backdrop-filter: blur(12px);
|
|
83
|
-
border-top: 1px
|
|
83
|
+
/* Sombra interna y no border-top: el borde le sumaba 1px al alto y la
|
|
84
|
+
barra tapaba 1px de lo que el offset dejaba libre. */
|
|
85
|
+
box-shadow: inset 0 1px 0 ${hairline.DEFAULT};
|
|
84
86
|
padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
85
87
|
|
|
86
88
|
@media (max-width: ${({ $maxWidth }) => $maxWidth}px) {
|
package/src/ui/DiscoHaxball.tsx
CHANGED
|
@@ -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
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
* no
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,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
|
+
}
|
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catálogo de camisetas de Haxball, copiado de `global_shirts` en
|
|
3
|
-
* `kaizen-bot/structure/base.js` — mismo patrón que cualquier catálogo
|
|
4
|
-
* portado entre repos de Kaizen (ver `tagsValoracionCancha.ts` en
|
|
5
|
-
* mixbol-front): **tocar el original implica revisar esta copia**, no hay
|
|
6
|
-
* sincronización automática.
|
|
7
|
-
*
|
|
8
|
-
* Dos cosas del original que NO se trajeron:
|
|
9
|
-
* - El 4to valor numérico de cada entrada (`[angulo, colorTexto, colores, N,
|
|
10
|
-
* nombreNarrativo]`). El propósito es real —agrupar camisetas parecidas
|
|
11
|
-
* para que la rotación automática de kaizen-bot no le toque a un equipo el
|
|
12
|
-
* mismo color de familia que el rival (Brasil vs. Villarreal, amarillo
|
|
13
|
-
* contra amarillo)—, pero en el código de hoy ese propósito lo cumple otra
|
|
14
|
-
* cosa: `homeostatic_changeRedShirt` decide el choque con
|
|
15
|
-
* `host_shirtDistance`, una distancia de color calculada de verdad sobre
|
|
16
|
-
* `colorTexto` + `colores` (`base.js`), no con este número — no encontré
|
|
17
|
-
* ningún lugar del repo que lea el índice `[3]`. Como acá no hay rotación
|
|
18
|
-
* automática ni riesgo de choque (es un selector fijo, no equipos
|
|
19
|
-
* enfrentados en vivo), no hace falta portarlo.
|
|
20
|
-
* - El texto narrativo de chat (`" booocaaa"`, `"l equipo con camiseta
|
|
21
|
-
* oscura"`): está pensado para completar una frase ("cambiaste tu camiseta
|
|
22
|
-
* a la de..."), no para ser un nombre de exhibición. `nombre` es un rótulo
|
|
23
|
-
* prolijo nuevo, escrito para esta lista.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
export interface CamisetaHaxball {
|
|
27
|
-
/** Nombre para mostrar en un selector o como texto alternativo. */
|
|
28
|
-
nombre: string;
|
|
29
|
-
/** Ángulo de las franjas, en grados — el mismo valor que recibe `room.setTeamColors`. */
|
|
30
|
-
angulo: number;
|
|
31
|
-
/** Color del
|
|
32
|
-
colorTexto: string;
|
|
33
|
-
/** 1 a 3 colores que arman las franjas, en el orden en que se ciclan. */
|
|
34
|
-
colores: string[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const CAMISETAS_HAXBALL = {
|
|
38
|
-
// Equipos de fantasía
|
|
39
|
-
pataflaca: { nombre: 'Pataflaca', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#c26366', '#000000'] },
|
|
40
|
-
negro: { nombre: 'Negro', angulo: 0, colorTexto: '#ffffff', colores: ['#000000'] },
|
|
41
|
-
blanco: { nombre: 'Blanco', angulo: 0, colorTexto: '#000000', colores: ['#ffffff'] },
|
|
42
|
-
pink: { nombre: 'Rosa', angulo: 15, colorTexto: '#6b0068', colores: ['#d47b9f', '#e685ac', '#ff94bf'] },
|
|
43
|
-
hack: { nombre: 'Hacker', angulo: 60, colorTexto: '#00ff00', colores: ['#000000', '#002602', '#000000'] },
|
|
44
|
-
red: { nombre: 'Rojo', angulo: 132, colorTexto: '#ffffff', colores: ['#ba0202', '#540101', '#ba0202'] },
|
|
45
|
-
blue: { nombre: 'Azul', angulo: 60, colorTexto: '#ffffff', colores: ['#0080ff', '#004077', '#002033'] },
|
|
46
|
-
lila: { nombre: 'Lila', angulo: 60, colorTexto: '#efff12', colores: ['#ff03d5', '#991188', '#661155'] },
|
|
47
|
-
green: { nombre: 'Verde', angulo: 60, colorTexto: '#f7ff19', colores: ['#44bb1e', '#177711', '#0d3304'] },
|
|
48
|
-
violet: { nombre: 'Violeta', angulo: 13, colorTexto: '#ffffff', colores: ['#6e00ff', '#6e00ff', '#000000'] },
|
|
49
|
-
aqua: { nombre: 'Aguamarina', angulo: 60, colorTexto: '#00ffd5', colores: ['#003029', '#00211c', '#00120f'] },
|
|
50
|
-
salmon: { nombre: 'Salmón', angulo: 60, colorTexto: '#000000', colores: ['#ff5e5e', '#ff8c8c', '#ffbe99'] },
|
|
51
|
-
halloween1: { nombre: 'Vampiro', angulo: 120, colorTexto: '#a2ff00', colores: ['#463e70', '#513f70', '#5c3f70'] },
|
|
52
|
-
halloween2: { nombre: 'Zombie', angulo: 70, colorTexto: '#a80000', colores: ['#00ff11', '#99c211'] },
|
|
53
|
-
tornasolado: { nombre: 'Tornasolado', angulo: 60, colorTexto: '#ffffff', colores: ['#870fff', '#6e42ff', '#4d58ff'] },
|
|
54
|
-
verano: { nombre: 'Verano', angulo: 60, colorTexto: '#072940', colores: ['#ff7100', '#ff7142', '#ff7162'] },
|
|
55
|
-
|
|
56
|
-
// Clubes argentinos
|
|
57
|
-
boca: { nombre: 'Boca Juniors', angulo: 90, colorTexto: '#ffffff', colores: ['#0c004d', '#ffd500', '#0c004d'] },
|
|
58
|
-
bocaa: { nombre: 'Boca Juniors (alterna)', angulo: 90, colorTexto: '#ffffff', colores: ['#ffd500', '#0c004d', '#ffd500'] },
|
|
59
|
-
river: { nombre: 'River Plate', angulo: 40, colorTexto: '#000000', colores: ['#ffffff', '#e31212', '#ffffff'] },
|
|
60
|
-
independiente: { nombre: 'Independiente', angulo: 0, colorTexto: '#ffffff', colores: ['#ff0000', '#b50000', '#9e0500'] },
|
|
61
|
-
racing: { nombre: 'Racing Club', angulo: 0, colorTexto: '#000000', colores: ['#00a2ff', '#ffffff', '#00a2ff'] },
|
|
62
|
-
|
|
63
|
-
// Clubes sudamericanos
|
|
64
|
-
flamengo: { nombre: 'Flamengo', angulo: 90, colorTexto: '#ffffff', colores: ['#1f1f1f', '#ff1414', '#1f1f1f'] },
|
|
65
|
-
gremio: { nombre: 'Grêmio', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#2457ff', '#000000'] },
|
|
66
|
-
fluminense: { nombre: 'Fluminense', angulo: 0, colorTexto: '#ffffff', colores: ['#004537', '#75021a', '#004537'] },
|
|
67
|
-
palmeiras: { nombre: 'Palmeiras', angulo: 0, colorTexto: '#ffffff', colores: ['#004f00', '#003b00', '#004f00'] },
|
|
68
|
-
corinthians: { nombre: 'Corinthians', angulo: 0, colorTexto: '#d60000', colores: ['#ffffff', '#000000', '#ffffff'] },
|
|
69
|
-
penarol: { nombre: 'Peñarol', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#ffee00', '#000000'] },
|
|
70
|
-
nacional: { nombre: 'Nacional (Uruguay)', angulo: 40, colorTexto: '#c90000', colores: ['#0011ff', '#ffffff', '#0011ff'] },
|
|
71
|
-
boston: { nombre: 'Boston River', angulo: 40, colorTexto: '#d9ffe0', colores: ['#058717', '#de2f2f', '#058717'] },
|
|
72
|
-
danubio: { nombre: 'Danubio', angulo: 40, colorTexto: '#ff0000', colores: ['#ffffff', '#000000', '#ffffff'] },
|
|
73
|
-
fenix: { nombre: 'Fénix (Uruguay)', angulo: 0, colorTexto: '#a67014', colores: ['#ffffff', '#671f96'] },
|
|
74
|
-
atleticonacional: { nombre: 'Atlético Nacional', angulo: 37, colorTexto: '#dbdbdb', colores: ['#059c19', '#048a16', '#047d14'] },
|
|
75
|
-
millonarios: { nombre: 'Millonarios', angulo: 37, colorTexto: '#dbdbdb', colores: ['#0843a8', '#073b94', '#063380'] },
|
|
76
|
-
|
|
77
|
-
// Selecciones
|
|
78
|
-
argentina: { nombre: 'Argentina', angulo: 0, colorTexto: '#000000', colores: ['#96c7ff', '#ffffff', '#96c7ff'] },
|
|
79
|
-
españa: { nombre: 'España', angulo: 90, colorTexto: '#000000', colores: ['#e61700', '#ffe121', '#e61700'] },
|
|
80
|
-
inglaterra: { nombre: 'Inglaterra', angulo: 40, colorTexto: '#de0000', colores: ['#d7f0ff', '#ffffff', '#d7f0ff'] },
|
|
81
|
-
francia: { nombre: 'Francia', angulo: 90, colorTexto: '#ffffff', colores: ['#156485', '#26294f', '#1b1d38'] },
|
|
82
|
-
alemania: { nombre: 'Alemania', angulo: 270, colorTexto: '#ffffff', colores: ['#ffef5e', '#ff120a', '#002033'] },
|
|
83
|
-
italia: { nombre: 'Italia', angulo: 0, colorTexto: '#000000', colores: ['#00ff00', '#fee8ff', '#ff1c03'] },
|
|
84
|
-
brasil: { nombre: 'Brasil', angulo: 90, colorTexto: '#11520a', colores: ['#4ac900', '#fff700', '#fff700'] },
|
|
85
|
-
uruguay: { nombre: 'Uruguay', angulo: 90, colorTexto: '#000000', colores: ['#94b4eb', '#69aaff'] },
|
|
86
|
-
eeuu: { nombre: 'Estados Unidos', angulo: 0, colorTexto: '#00345c', colores: ['#a30000', '#e80000', '#a30000'] },
|
|
87
|
-
canada: { nombre: 'Canadá', angulo: 0, colorTexto: '#470700', colores: ['#cc0000', '#e8e8e8', '#cc0000'] },
|
|
88
|
-
mexico: { nombre: 'México', angulo: 0, colorTexto: '#030303', colores: ['#009919', '#ffffff', '#fc0000'] },
|
|
89
|
-
colombia: { nombre: 'Colombia', angulo: 90, colorTexto: '#ffffff', colores: ['#f7ff00', '#0022ff', '#fc0000'] },
|
|
90
|
-
bolivia: { nombre: 'Bolivia', angulo: -90, colorTexto: '#ded81f', colores: ['#10a11e', '#11870c', '#0b6911'] },
|
|
91
|
-
holanda: { nombre: 'Países Bajos', angulo: 0, colorTexto: '#0831ff', colores: ['#ed5a11', '#ff6112', '#ed5a11'] },
|
|
92
|
-
portugal: { nombre: 'Portugal', angulo: 0, colorTexto: '#ffff14', colores: ['#c90000', '#2d8501'] },
|
|
93
|
-
egipto: { nombre: 'Egipto', angulo: 90, colorTexto: '#ffff14', colores: ['#c90000', '#ffffff', '#000000'] },
|
|
94
|
-
ucrania: { nombre: 'Ucrania', angulo: 90, colorTexto: '#000000', colores: ['#004fd9', '#e6de00'] },
|
|
95
|
-
suecia: { nombre: 'Suecia', angulo: 0, colorTexto: '#030302', colores: ['#4059ff', '#ffff00', '#4059ff'] },
|
|
96
|
-
belgica: { nombre: 'Bélgica', angulo: 0, colorTexto: '#ffffff', colores: ['#050505', '#fff700', '#c91010'] },
|
|
97
|
-
venezuela: { nombre: 'Venezuela', angulo: 90, colorTexto: '#ffffff', colores: ['#fff700', '#090ead', '#d60000'] },
|
|
98
|
-
nigeria: { nombre: 'Nigeria', angulo: 0, colorTexto: '#030303', colores: ['#086302', '#ffffff', '#086302'] },
|
|
99
|
-
marruecos: { nombre: 'Marruecos', angulo: 0, colorTexto: '#133800', colores: ['#cc1b0e', '#cc1b0e', '#cc1b0e'] },
|
|
100
|
-
japon: { nombre: 'Japón', angulo: 0, colorTexto: '#ffffff', colores: ['#0033aa', '#0033aa', '#0033aa'] },
|
|
101
|
-
corea: { nombre: 'Corea del Sur', angulo: 90, colorTexto: '#000000', colores: ['#ffffff', '#ff0000', '#ffffff'] },
|
|
102
|
-
croacia: { nombre: 'Croacia', angulo: 0, colorTexto: '#000000', colores: ['#ffffff', '#d60000', '#ffffff'] },
|
|
103
|
-
suiza: { nombre: 'Suiza', angulo: 0, colorTexto: '#ffffff', colores: ['#d60000', '#d60000', '#d60000'] },
|
|
104
|
-
dinamarca: { nombre: 'Dinamarca', angulo: 0, colorTexto: '#ffffff', colores: ['#c00000', '#c00000', '#c00000'] },
|
|
105
|
-
serbia: { nombre: 'Serbia', angulo: 90, colorTexto: '#ffffff', colores: ['#c60000', '#0033aa', '#ffffff'] },
|
|
106
|
-
polonia: { nombre: 'Polonia', angulo: 90, colorTexto: '#d60000', colores: ['#ffffff', '#ffffff', '#d60000'] },
|
|
107
|
-
australia: { nombre: 'Australia', angulo: 0, colorTexto: '#ffd700', colores: ['#006b3f', '#006b3f', '#006b3f'] },
|
|
108
|
-
senegal: { nombre: 'Senegal', angulo: 0, colorTexto: '#000000', colores: ['#00853f', '#fdef42', '#e31b23'] },
|
|
109
|
-
camerun: { nombre: 'Camerún', angulo: 0, colorTexto: '#ffd700', colores: ['#007a33', '#c8102e', '#fcd116'] },
|
|
110
|
-
argelia: { nombre: 'Argelia', angulo: 90, colorTexto: '#d60000', colores: ['#ffffff', '#0b8a3e', '#ffffff'] },
|
|
111
|
-
peru: { nombre: 'Perú', angulo: 45, colorTexto: '#d60000', colores: ['#ffffff', '#ffffff', '#ffffff'] },
|
|
112
|
-
chile: { nombre: 'Chile', angulo: 90, colorTexto: '#ffffff', colores: ['#0039a6', '#ffffff', '#d52b1e'] },
|
|
113
|
-
paraguay: { nombre: 'Paraguay', angulo: 90, colorTexto: '#0039a6', colores: ['#d60000', '#ffffff', '#d60000'] },
|
|
114
|
-
ecuador: { nombre: 'Ecuador', angulo: 90, colorTexto: '#000000', colores: ['#ffd100', '#0033a0', '#d60000'] },
|
|
115
|
-
costarica: { nombre: 'Costa Rica', angulo: 90, colorTexto: '#ffffff', colores: ['#0033a0', '#d60000', '#0033a0'] },
|
|
116
|
-
canadaAlt: { nombre: 'Canadá (alterna)', angulo: 0, colorTexto: '#ffffff', colores: ['#d60000', '#d60000', '#d60000'] },
|
|
117
|
-
gales: { nombre: 'Gales', angulo: 0, colorTexto: '#ffffff', colores: ['#c8102e', '#c8102e', '#c8102e'] },
|
|
118
|
-
escocia: { nombre: 'Escocia', angulo: 0, colorTexto: '#ffffff', colores: ['#005eb8', '#005eb8', '#005eb8'] },
|
|
119
|
-
irlanda: { nombre: 'Irlanda', angulo: 90, colorTexto: '#ffffff', colores: ['#009a44', '#ffffff', '#ff8200'] },
|
|
120
|
-
noruega: { nombre: 'Noruega', angulo: 0, colorTexto: '#ffffff', colores: ['#ba0c2f', '#ba0c2f', '#ba0c2f'] },
|
|
121
|
-
turquia: { nombre: 'Turquía', angulo: 0, colorTexto: '#ffffff', colores: ['#e30a17', '#e30a17', '#e30a17'] },
|
|
122
|
-
arabia: { nombre: 'Arabia Saudita', angulo: 0, colorTexto: '#ffffff', colores: ['#006c35', '#006c35', '#006c35'] },
|
|
123
|
-
iran: { nombre: 'Irán', angulo: 90, colorTexto: '#ffffff', colores: ['#239f40', '#ffffff', '#da0000'] },
|
|
124
|
-
qatar: { nombre: 'Qatar', angulo: 0, colorTexto: '#ffffff', colores: ['#8a1538', '#8a1538', '#8a1538'] },
|
|
125
|
-
nuevazelanda: { nombre: 'Nueva Zelanda', angulo: 0, colorTexto: '#ffffff', colores: ['#111111', '#111111', '#111111'] },
|
|
126
|
-
} as const satisfies Record<string, CamisetaHaxball>;
|
|
127
|
-
|
|
128
|
-
export type NombreCamiseta = keyof typeof CAMISETAS_HAXBALL;
|
|
1
|
+
/**
|
|
2
|
+
* Catálogo de camisetas de Haxball, copiado de `global_shirts` en
|
|
3
|
+
* `kaizen-bot/structure/base.js` — mismo patrón que cualquier catálogo
|
|
4
|
+
* portado entre repos de Kaizen (ver `tagsValoracionCancha.ts` en
|
|
5
|
+
* mixbol-front): **tocar el original implica revisar esta copia**, no hay
|
|
6
|
+
* sincronización automática.
|
|
7
|
+
*
|
|
8
|
+
* Dos cosas del original que NO se trajeron:
|
|
9
|
+
* - El 4to valor numérico de cada entrada (`[angulo, colorTexto, colores, N,
|
|
10
|
+
* nombreNarrativo]`). El propósito es real —agrupar camisetas parecidas
|
|
11
|
+
* para que la rotación automática de kaizen-bot no le toque a un equipo el
|
|
12
|
+
* mismo color de familia que el rival (Brasil vs. Villarreal, amarillo
|
|
13
|
+
* contra amarillo)—, pero en el código de hoy ese propósito lo cumple otra
|
|
14
|
+
* cosa: `homeostatic_changeRedShirt` decide el choque con
|
|
15
|
+
* `host_shirtDistance`, una distancia de color calculada de verdad sobre
|
|
16
|
+
* `colorTexto` + `colores` (`base.js`), no con este número — no encontré
|
|
17
|
+
* ningún lugar del repo que lea el índice `[3]`. Como acá no hay rotación
|
|
18
|
+
* automática ni riesgo de choque (es un selector fijo, no equipos
|
|
19
|
+
* enfrentados en vivo), no hace falta portarlo.
|
|
20
|
+
* - El texto narrativo de chat (`" booocaaa"`, `"l equipo con camiseta
|
|
21
|
+
* oscura"`): está pensado para completar una frase ("cambiaste tu camiseta
|
|
22
|
+
* a la de..."), no para ser un nombre de exhibición. `nombre` es un rótulo
|
|
23
|
+
* prolijo nuevo, escrito para esta lista.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface CamisetaHaxball {
|
|
27
|
+
/** Nombre para mostrar en un selector o como texto alternativo. */
|
|
28
|
+
nombre: string;
|
|
29
|
+
/** Ángulo de las franjas, en grados — el mismo valor que recibe `room.setTeamColors`. */
|
|
30
|
+
angulo: number;
|
|
31
|
+
/** Color del avatar del jugador (hasta 2 caracteres) sobre el disco, en Haxball. `DiscoHaxball` lo usa para su `texto`. */
|
|
32
|
+
colorTexto: string;
|
|
33
|
+
/** 1 a 3 colores que arman las franjas, en el orden en que se ciclan. */
|
|
34
|
+
colores: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const CAMISETAS_HAXBALL = {
|
|
38
|
+
// Equipos de fantasía
|
|
39
|
+
pataflaca: { nombre: 'Pataflaca', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#c26366', '#000000'] },
|
|
40
|
+
negro: { nombre: 'Negro', angulo: 0, colorTexto: '#ffffff', colores: ['#000000'] },
|
|
41
|
+
blanco: { nombre: 'Blanco', angulo: 0, colorTexto: '#000000', colores: ['#ffffff'] },
|
|
42
|
+
pink: { nombre: 'Rosa', angulo: 15, colorTexto: '#6b0068', colores: ['#d47b9f', '#e685ac', '#ff94bf'] },
|
|
43
|
+
hack: { nombre: 'Hacker', angulo: 60, colorTexto: '#00ff00', colores: ['#000000', '#002602', '#000000'] },
|
|
44
|
+
red: { nombre: 'Rojo', angulo: 132, colorTexto: '#ffffff', colores: ['#ba0202', '#540101', '#ba0202'] },
|
|
45
|
+
blue: { nombre: 'Azul', angulo: 60, colorTexto: '#ffffff', colores: ['#0080ff', '#004077', '#002033'] },
|
|
46
|
+
lila: { nombre: 'Lila', angulo: 60, colorTexto: '#efff12', colores: ['#ff03d5', '#991188', '#661155'] },
|
|
47
|
+
green: { nombre: 'Verde', angulo: 60, colorTexto: '#f7ff19', colores: ['#44bb1e', '#177711', '#0d3304'] },
|
|
48
|
+
violet: { nombre: 'Violeta', angulo: 13, colorTexto: '#ffffff', colores: ['#6e00ff', '#6e00ff', '#000000'] },
|
|
49
|
+
aqua: { nombre: 'Aguamarina', angulo: 60, colorTexto: '#00ffd5', colores: ['#003029', '#00211c', '#00120f'] },
|
|
50
|
+
salmon: { nombre: 'Salmón', angulo: 60, colorTexto: '#000000', colores: ['#ff5e5e', '#ff8c8c', '#ffbe99'] },
|
|
51
|
+
halloween1: { nombre: 'Vampiro', angulo: 120, colorTexto: '#a2ff00', colores: ['#463e70', '#513f70', '#5c3f70'] },
|
|
52
|
+
halloween2: { nombre: 'Zombie', angulo: 70, colorTexto: '#a80000', colores: ['#00ff11', '#99c211'] },
|
|
53
|
+
tornasolado: { nombre: 'Tornasolado', angulo: 60, colorTexto: '#ffffff', colores: ['#870fff', '#6e42ff', '#4d58ff'] },
|
|
54
|
+
verano: { nombre: 'Verano', angulo: 60, colorTexto: '#072940', colores: ['#ff7100', '#ff7142', '#ff7162'] },
|
|
55
|
+
|
|
56
|
+
// Clubes argentinos
|
|
57
|
+
boca: { nombre: 'Boca Juniors', angulo: 90, colorTexto: '#ffffff', colores: ['#0c004d', '#ffd500', '#0c004d'] },
|
|
58
|
+
bocaa: { nombre: 'Boca Juniors (alterna)', angulo: 90, colorTexto: '#ffffff', colores: ['#ffd500', '#0c004d', '#ffd500'] },
|
|
59
|
+
river: { nombre: 'River Plate', angulo: 40, colorTexto: '#000000', colores: ['#ffffff', '#e31212', '#ffffff'] },
|
|
60
|
+
independiente: { nombre: 'Independiente', angulo: 0, colorTexto: '#ffffff', colores: ['#ff0000', '#b50000', '#9e0500'] },
|
|
61
|
+
racing: { nombre: 'Racing Club', angulo: 0, colorTexto: '#000000', colores: ['#00a2ff', '#ffffff', '#00a2ff'] },
|
|
62
|
+
|
|
63
|
+
// Clubes sudamericanos
|
|
64
|
+
flamengo: { nombre: 'Flamengo', angulo: 90, colorTexto: '#ffffff', colores: ['#1f1f1f', '#ff1414', '#1f1f1f'] },
|
|
65
|
+
gremio: { nombre: 'Grêmio', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#2457ff', '#000000'] },
|
|
66
|
+
fluminense: { nombre: 'Fluminense', angulo: 0, colorTexto: '#ffffff', colores: ['#004537', '#75021a', '#004537'] },
|
|
67
|
+
palmeiras: { nombre: 'Palmeiras', angulo: 0, colorTexto: '#ffffff', colores: ['#004f00', '#003b00', '#004f00'] },
|
|
68
|
+
corinthians: { nombre: 'Corinthians', angulo: 0, colorTexto: '#d60000', colores: ['#ffffff', '#000000', '#ffffff'] },
|
|
69
|
+
penarol: { nombre: 'Peñarol', angulo: 0, colorTexto: '#ffffff', colores: ['#000000', '#ffee00', '#000000'] },
|
|
70
|
+
nacional: { nombre: 'Nacional (Uruguay)', angulo: 40, colorTexto: '#c90000', colores: ['#0011ff', '#ffffff', '#0011ff'] },
|
|
71
|
+
boston: { nombre: 'Boston River', angulo: 40, colorTexto: '#d9ffe0', colores: ['#058717', '#de2f2f', '#058717'] },
|
|
72
|
+
danubio: { nombre: 'Danubio', angulo: 40, colorTexto: '#ff0000', colores: ['#ffffff', '#000000', '#ffffff'] },
|
|
73
|
+
fenix: { nombre: 'Fénix (Uruguay)', angulo: 0, colorTexto: '#a67014', colores: ['#ffffff', '#671f96'] },
|
|
74
|
+
atleticonacional: { nombre: 'Atlético Nacional', angulo: 37, colorTexto: '#dbdbdb', colores: ['#059c19', '#048a16', '#047d14'] },
|
|
75
|
+
millonarios: { nombre: 'Millonarios', angulo: 37, colorTexto: '#dbdbdb', colores: ['#0843a8', '#073b94', '#063380'] },
|
|
76
|
+
|
|
77
|
+
// Selecciones
|
|
78
|
+
argentina: { nombre: 'Argentina', angulo: 0, colorTexto: '#000000', colores: ['#96c7ff', '#ffffff', '#96c7ff'] },
|
|
79
|
+
españa: { nombre: 'España', angulo: 90, colorTexto: '#000000', colores: ['#e61700', '#ffe121', '#e61700'] },
|
|
80
|
+
inglaterra: { nombre: 'Inglaterra', angulo: 40, colorTexto: '#de0000', colores: ['#d7f0ff', '#ffffff', '#d7f0ff'] },
|
|
81
|
+
francia: { nombre: 'Francia', angulo: 90, colorTexto: '#ffffff', colores: ['#156485', '#26294f', '#1b1d38'] },
|
|
82
|
+
alemania: { nombre: 'Alemania', angulo: 270, colorTexto: '#ffffff', colores: ['#ffef5e', '#ff120a', '#002033'] },
|
|
83
|
+
italia: { nombre: 'Italia', angulo: 0, colorTexto: '#000000', colores: ['#00ff00', '#fee8ff', '#ff1c03'] },
|
|
84
|
+
brasil: { nombre: 'Brasil', angulo: 90, colorTexto: '#11520a', colores: ['#4ac900', '#fff700', '#fff700'] },
|
|
85
|
+
uruguay: { nombre: 'Uruguay', angulo: 90, colorTexto: '#000000', colores: ['#94b4eb', '#69aaff'] },
|
|
86
|
+
eeuu: { nombre: 'Estados Unidos', angulo: 0, colorTexto: '#00345c', colores: ['#a30000', '#e80000', '#a30000'] },
|
|
87
|
+
canada: { nombre: 'Canadá', angulo: 0, colorTexto: '#470700', colores: ['#cc0000', '#e8e8e8', '#cc0000'] },
|
|
88
|
+
mexico: { nombre: 'México', angulo: 0, colorTexto: '#030303', colores: ['#009919', '#ffffff', '#fc0000'] },
|
|
89
|
+
colombia: { nombre: 'Colombia', angulo: 90, colorTexto: '#ffffff', colores: ['#f7ff00', '#0022ff', '#fc0000'] },
|
|
90
|
+
bolivia: { nombre: 'Bolivia', angulo: -90, colorTexto: '#ded81f', colores: ['#10a11e', '#11870c', '#0b6911'] },
|
|
91
|
+
holanda: { nombre: 'Países Bajos', angulo: 0, colorTexto: '#0831ff', colores: ['#ed5a11', '#ff6112', '#ed5a11'] },
|
|
92
|
+
portugal: { nombre: 'Portugal', angulo: 0, colorTexto: '#ffff14', colores: ['#c90000', '#2d8501'] },
|
|
93
|
+
egipto: { nombre: 'Egipto', angulo: 90, colorTexto: '#ffff14', colores: ['#c90000', '#ffffff', '#000000'] },
|
|
94
|
+
ucrania: { nombre: 'Ucrania', angulo: 90, colorTexto: '#000000', colores: ['#004fd9', '#e6de00'] },
|
|
95
|
+
suecia: { nombre: 'Suecia', angulo: 0, colorTexto: '#030302', colores: ['#4059ff', '#ffff00', '#4059ff'] },
|
|
96
|
+
belgica: { nombre: 'Bélgica', angulo: 0, colorTexto: '#ffffff', colores: ['#050505', '#fff700', '#c91010'] },
|
|
97
|
+
venezuela: { nombre: 'Venezuela', angulo: 90, colorTexto: '#ffffff', colores: ['#fff700', '#090ead', '#d60000'] },
|
|
98
|
+
nigeria: { nombre: 'Nigeria', angulo: 0, colorTexto: '#030303', colores: ['#086302', '#ffffff', '#086302'] },
|
|
99
|
+
marruecos: { nombre: 'Marruecos', angulo: 0, colorTexto: '#133800', colores: ['#cc1b0e', '#cc1b0e', '#cc1b0e'] },
|
|
100
|
+
japon: { nombre: 'Japón', angulo: 0, colorTexto: '#ffffff', colores: ['#0033aa', '#0033aa', '#0033aa'] },
|
|
101
|
+
corea: { nombre: 'Corea del Sur', angulo: 90, colorTexto: '#000000', colores: ['#ffffff', '#ff0000', '#ffffff'] },
|
|
102
|
+
croacia: { nombre: 'Croacia', angulo: 0, colorTexto: '#000000', colores: ['#ffffff', '#d60000', '#ffffff'] },
|
|
103
|
+
suiza: { nombre: 'Suiza', angulo: 0, colorTexto: '#ffffff', colores: ['#d60000', '#d60000', '#d60000'] },
|
|
104
|
+
dinamarca: { nombre: 'Dinamarca', angulo: 0, colorTexto: '#ffffff', colores: ['#c00000', '#c00000', '#c00000'] },
|
|
105
|
+
serbia: { nombre: 'Serbia', angulo: 90, colorTexto: '#ffffff', colores: ['#c60000', '#0033aa', '#ffffff'] },
|
|
106
|
+
polonia: { nombre: 'Polonia', angulo: 90, colorTexto: '#d60000', colores: ['#ffffff', '#ffffff', '#d60000'] },
|
|
107
|
+
australia: { nombre: 'Australia', angulo: 0, colorTexto: '#ffd700', colores: ['#006b3f', '#006b3f', '#006b3f'] },
|
|
108
|
+
senegal: { nombre: 'Senegal', angulo: 0, colorTexto: '#000000', colores: ['#00853f', '#fdef42', '#e31b23'] },
|
|
109
|
+
camerun: { nombre: 'Camerún', angulo: 0, colorTexto: '#ffd700', colores: ['#007a33', '#c8102e', '#fcd116'] },
|
|
110
|
+
argelia: { nombre: 'Argelia', angulo: 90, colorTexto: '#d60000', colores: ['#ffffff', '#0b8a3e', '#ffffff'] },
|
|
111
|
+
peru: { nombre: 'Perú', angulo: 45, colorTexto: '#d60000', colores: ['#ffffff', '#ffffff', '#ffffff'] },
|
|
112
|
+
chile: { nombre: 'Chile', angulo: 90, colorTexto: '#ffffff', colores: ['#0039a6', '#ffffff', '#d52b1e'] },
|
|
113
|
+
paraguay: { nombre: 'Paraguay', angulo: 90, colorTexto: '#0039a6', colores: ['#d60000', '#ffffff', '#d60000'] },
|
|
114
|
+
ecuador: { nombre: 'Ecuador', angulo: 90, colorTexto: '#000000', colores: ['#ffd100', '#0033a0', '#d60000'] },
|
|
115
|
+
costarica: { nombre: 'Costa Rica', angulo: 90, colorTexto: '#ffffff', colores: ['#0033a0', '#d60000', '#0033a0'] },
|
|
116
|
+
canadaAlt: { nombre: 'Canadá (alterna)', angulo: 0, colorTexto: '#ffffff', colores: ['#d60000', '#d60000', '#d60000'] },
|
|
117
|
+
gales: { nombre: 'Gales', angulo: 0, colorTexto: '#ffffff', colores: ['#c8102e', '#c8102e', '#c8102e'] },
|
|
118
|
+
escocia: { nombre: 'Escocia', angulo: 0, colorTexto: '#ffffff', colores: ['#005eb8', '#005eb8', '#005eb8'] },
|
|
119
|
+
irlanda: { nombre: 'Irlanda', angulo: 90, colorTexto: '#ffffff', colores: ['#009a44', '#ffffff', '#ff8200'] },
|
|
120
|
+
noruega: { nombre: 'Noruega', angulo: 0, colorTexto: '#ffffff', colores: ['#ba0c2f', '#ba0c2f', '#ba0c2f'] },
|
|
121
|
+
turquia: { nombre: 'Turquía', angulo: 0, colorTexto: '#ffffff', colores: ['#e30a17', '#e30a17', '#e30a17'] },
|
|
122
|
+
arabia: { nombre: 'Arabia Saudita', angulo: 0, colorTexto: '#ffffff', colores: ['#006c35', '#006c35', '#006c35'] },
|
|
123
|
+
iran: { nombre: 'Irán', angulo: 90, colorTexto: '#ffffff', colores: ['#239f40', '#ffffff', '#da0000'] },
|
|
124
|
+
qatar: { nombre: 'Qatar', angulo: 0, colorTexto: '#ffffff', colores: ['#8a1538', '#8a1538', '#8a1538'] },
|
|
125
|
+
nuevazelanda: { nombre: 'Nueva Zelanda', angulo: 0, colorTexto: '#ffffff', colores: ['#111111', '#111111', '#111111'] },
|
|
126
|
+
} as const satisfies Record<string, CamisetaHaxball>;
|
|
127
|
+
|
|
128
|
+
export type NombreCamiseta = keyof typeof CAMISETAS_HAXBALL;
|
package/src/ui/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
export { IconButton } from './IconButton';
|
|
2
|
-
export type { IconButtonSize } from './IconButton';
|
|
3
|
-
export { Medidor } from './Medidor';
|
|
4
|
-
export type { MedidorSentido } from './Medidor';
|
|
5
|
-
export { Relieve } from './Relieve';
|
|
6
|
-
export { ReactionButton } from './ReactionButton';
|
|
7
|
-
export type { ReactionTone } from './ReactionButton';
|
|
8
|
-
export { LangSelector } from './LangSelector';
|
|
9
|
-
export type { OpcionDeIdioma } from './LangSelector';
|
|
10
|
-
export { Modal } from './Modal';
|
|
11
|
-
export type { ModalSize } from './Modal';
|
|
12
|
-
export { Badge } from './Badge';
|
|
13
|
-
export type { BadgeTone } from './Badge';
|
|
14
|
-
export { BottomNav, bottomNavOffset } from './BottomNav';
|
|
15
|
-
export type { BottomNavItem } from './BottomNav';
|
|
16
|
-
export { DiscoHaxball, CAMISETAS_HAXBALL } from './DiscoHaxball';
|
|
17
|
-
export type { CamisetaHaxball, NombreCamiseta } from './DiscoHaxball';
|
|
1
|
+
export { IconButton } from './IconButton';
|
|
2
|
+
export type { IconButtonSize } from './IconButton';
|
|
3
|
+
export { Medidor } from './Medidor';
|
|
4
|
+
export type { MedidorSentido } from './Medidor';
|
|
5
|
+
export { Relieve } from './Relieve';
|
|
6
|
+
export { ReactionButton } from './ReactionButton';
|
|
7
|
+
export type { ReactionTone } from './ReactionButton';
|
|
8
|
+
export { LangSelector } from './LangSelector';
|
|
9
|
+
export type { OpcionDeIdioma } from './LangSelector';
|
|
10
|
+
export { Modal } from './Modal';
|
|
11
|
+
export type { ModalSize } from './Modal';
|
|
12
|
+
export { Badge } from './Badge';
|
|
13
|
+
export type { BadgeTone } from './Badge';
|
|
14
|
+
export { BottomNav, bottomNavOffset } from './BottomNav';
|
|
15
|
+
export type { BottomNavItem } from './BottomNav';
|
|
16
|
+
export { DiscoHaxball, CAMISETAS_HAXBALL, recortarAvatarHaxball, LUGARES_AVATAR_HAXBALL } from './DiscoHaxball';
|
|
17
|
+
export type { CamisetaHaxball, NombreCamiseta } from './DiscoHaxball';
|