kaizen-lib 0.2.8 → 0.2.10
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 +48 -1
- package/package.json +1 -1
- package/src/ui/Badge.tsx +77 -0
- package/src/ui/BottomNav.tsx +185 -0
- package/src/ui/DiscoHaxball.tsx +81 -0
- package/src/ui/camisetasHaxball.ts +128 -0
- package/src/ui/index.ts +6 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ styled-components con `moduleResolution: "bundler"`, así que lo transpilan ello
|
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
|
|
14
14
|
| `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
|
|
15
|
-
| `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`) | en curso |
|
|
15
|
+
| `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`, `Badge`, `BottomNav`, `DiscoHaxball`) | en curso |
|
|
16
16
|
|
|
17
17
|
## ¿Qué uso para qué?
|
|
18
18
|
|
|
@@ -30,7 +30,10 @@ alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del ha
|
|
|
30
30
|
| Un ciclador de idioma con banderas en la topbar | `LangSelector` | reconstruir el ciclado a mano — ya lo resuelve, con `Relieve` incluido |
|
|
31
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
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 |
|
|
33
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 |
|
|
34
37
|
|
|
35
38
|
Ningún componente trae íconos ni banderas: los recibe como `children`/`icono`, y el
|
|
36
39
|
producto elige de dónde salen (`react-icons`, `react-country-flag`, SVG propio).
|
|
@@ -250,6 +253,50 @@ Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cue
|
|
|
250
253
|
casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
|
|
251
254
|
ahí con un wrapper local hasta que un segundo consumidor los pida acá.
|
|
252
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
|
+
|
|
253
300
|
## Desarrollo
|
|
254
301
|
|
|
255
302
|
```bash
|
package/package.json
CHANGED
package/src/ui/Badge.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { accent, danger, fg, font, info, radius, space, success, surface, warning } from '../tokens';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* El tono resuelve un TRÍO por tabla: fondo lavado, texto y el color pleno
|
|
9
|
+
* (para `bordered`) — así no puede salir una píldora con el texto invisible
|
|
10
|
+
* sobre su propio fondo, ni un borde que no combina con ninguno de los dos.
|
|
11
|
+
*/
|
|
12
|
+
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'danger' | 'warning' | 'info';
|
|
13
|
+
|
|
14
|
+
const TONOS: Record<BadgeTone, { fondo: string; texto: string; pleno: string }> = {
|
|
15
|
+
neutral: { fondo: surface.hover, texto: fg.muted, pleno: fg.muted },
|
|
16
|
+
accent: { fondo: accent.soft, texto: accent.fg, pleno: accent.DEFAULT },
|
|
17
|
+
success: { fondo: success.soft, texto: success.fg, pleno: success.DEFAULT },
|
|
18
|
+
danger: { fondo: danger.soft, texto: danger.fg, pleno: danger.DEFAULT },
|
|
19
|
+
warning: { fondo: warning.soft, texto: warning.fg, pleno: warning.DEFAULT },
|
|
20
|
+
info: { fondo: info.soft, texto: info.fg, pleno: info.DEFAULT },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Props = {
|
|
24
|
+
tone?: BadgeTone;
|
|
25
|
+
/** Borde de 1px del color pleno del tono. Para una píldora que necesita más peso que el fondo lavado solo. */
|
|
26
|
+
bordered?: boolean;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const Raiz = styled.span<{ $tone: BadgeTone; $bordered: boolean }>`
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: ${space[1]};
|
|
35
|
+
padding: 3px ${space[2]};
|
|
36
|
+
border-radius: ${radius.sm};
|
|
37
|
+
border: 1px solid ${({ $tone, $bordered }) => ($bordered ? TONOS[$tone].pleno : 'transparent')};
|
|
38
|
+
font-size: ${font.xs};
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
line-height: 1.35;
|
|
41
|
+
letter-spacing: 0.04em;
|
|
42
|
+
text-transform: uppercase;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
background: ${({ $tone }) => TONOS[$tone].fondo};
|
|
45
|
+
color: ${({ $tone }) => TONOS[$tone].texto};
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Píldora de estado: fondo lavado + texto del mismo tono, mayúsculas
|
|
50
|
+
* apretadas. Elicitado de las implementaciones ya duplicadas en tres
|
|
51
|
+
* productos (taboo-next, valle-verde, kaizen-next) — el look es el de
|
|
52
|
+
* valle-verde, que era el más resuelto de los tres.
|
|
53
|
+
*
|
|
54
|
+
* ```tsx
|
|
55
|
+
* <Badge tone="success">Activo</Badge>
|
|
56
|
+
* <Badge tone="danger" bordered>Vencido</Badge>
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* `bordered` es opcional: el fondo lavado solo ya es suficiente contraste
|
|
60
|
+
* para la mayoría de los casos (ver `bordered={false}`, el default). Se usa
|
|
61
|
+
* cuando la píldora flota sobre una superficie parecida a su propio fondo y
|
|
62
|
+
* necesita un borde para no perderse — no es una segunda variante estética,
|
|
63
|
+
* es el mismo tono con más peso.
|
|
64
|
+
*/
|
|
65
|
+
export function Badge({
|
|
66
|
+
tone = 'neutral',
|
|
67
|
+
bordered = false,
|
|
68
|
+
children,
|
|
69
|
+
className,
|
|
70
|
+
...rest
|
|
71
|
+
}: Props & Omit<HTMLAttributes<HTMLSpanElement>, keyof Props>) {
|
|
72
|
+
return (
|
|
73
|
+
<Raiz $tone={tone} $bordered={bordered} className={className} {...rest}>
|
|
74
|
+
{children}
|
|
75
|
+
</Raiz>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ElementType, MouseEvent, ReactNode } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { accent, fg, font, hairline, motion, ring, surface } from '../tokens';
|
|
6
|
+
|
|
7
|
+
export type BottomNavItem = {
|
|
8
|
+
/** Identidad del destino: la key de React y lo que compara `activeId`. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Rótulo visible, siempre presente: en una barra de pulgar el ícono solo no alcanza. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** El ícono. La librería no trae íconos: cada producto usa los suyos. */
|
|
13
|
+
icon: ReactNode;
|
|
14
|
+
href: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
items: readonly BottomNavItem[];
|
|
19
|
+
/**
|
|
20
|
+
* El ítem de la pantalla actual, o `null` si ninguno. Lo resuelve el
|
|
21
|
+
* producto: si `/` matchea exacto o por prefijo es una regla de sus rutas,
|
|
22
|
+
* no de esta barra.
|
|
23
|
+
*/
|
|
24
|
+
activeId: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Ancho máximo, en px, en el que la barra existe. Arriba se apaga por CSS
|
|
27
|
+
* —no se desmonta— para que no parpadee en la primera pintura: medir la
|
|
28
|
+
* ventana desde JS arranca en "desktop" hasta que hidrata.
|
|
29
|
+
*/
|
|
30
|
+
maxWidth: number;
|
|
31
|
+
/** Nombre accesible del `<nav>`. Sin default: el idioma es del producto. */
|
|
32
|
+
ariaLabel: string;
|
|
33
|
+
/**
|
|
34
|
+
* Componente de link (el `<Link>` de Next). Sin esto, `<a>` pelado: la
|
|
35
|
+
* librería no sabe con qué router navega cada producto.
|
|
36
|
+
*/
|
|
37
|
+
linkAs?: ElementType;
|
|
38
|
+
/**
|
|
39
|
+
* Se llama al tocar un ítem, antes de navegar. `event.preventDefault()`
|
|
40
|
+
* cancela la navegación: es como un producto le cambia la acción a un
|
|
41
|
+
* destino sin dejar de ser un link (valle-verde abre el escáner desde el
|
|
42
|
+
* ítem del medio cuando ya está en ese eje).
|
|
43
|
+
*/
|
|
44
|
+
onSelect?: (item: BottomNavItem, event: MouseEvent<HTMLElement>) => void;
|
|
45
|
+
className?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Alto de contenido de la barra. El inset del dispositivo se suma aparte. */
|
|
49
|
+
const ALTO = '56px';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lo que la barra le quita al viewport por abajo: su alto más el inset del
|
|
53
|
+
* dispositivo. Existe sólo mientras la barra está montada y visible; fuera de
|
|
54
|
+
* eso cae a `0px`. El producto lo usa de padding inferior del contenido y lo
|
|
55
|
+
* suma al `bottom` de lo que flota (FAB, avisos), así nada queda debajo.
|
|
56
|
+
*/
|
|
57
|
+
export const bottomNavOffset = 'var(--kz-bottom-nav-offset, 0px)';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Un `<style>` de React y no `createGlobalStyle`: va en el HTML del servidor
|
|
61
|
+
* igual, y vive y muere con la barra sin depender de cómo cada entorno
|
|
62
|
+
* inyecta estilos globales.
|
|
63
|
+
*/
|
|
64
|
+
function reglaDeOffset(maxWidth: number): string {
|
|
65
|
+
return `@media (max-width: ${maxWidth}px) { :root { --kz-bottom-nav-offset: calc(${ALTO} + env(safe-area-inset-bottom, 0px)); } }`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const Barra = styled.nav<{ $maxWidth: number }>`
|
|
69
|
+
display: none;
|
|
70
|
+
position: fixed;
|
|
71
|
+
left: 0;
|
|
72
|
+
right: 0;
|
|
73
|
+
bottom: 0;
|
|
74
|
+
z-index: 100;
|
|
75
|
+
/* Translúcida y desenfocada: lo que scrollea por detrás se ve pasar, así la
|
|
76
|
+
barra se lee como una capa encima y no como el final de la página. El
|
|
77
|
+
relleno y el desenfoque van juntos: sin desenfoque, lo de atrás compite
|
|
78
|
+
con los rótulos. El prefijo -webkit- lo sigue pidiendo Safari en iOS, que
|
|
79
|
+
es justo el ancho donde esta barra vive. */
|
|
80
|
+
background: color-mix(in srgb, ${surface[1]} 88%, transparent);
|
|
81
|
+
backdrop-filter: blur(12px);
|
|
82
|
+
-webkit-backdrop-filter: blur(12px);
|
|
83
|
+
border-top: 1px solid ${hairline.DEFAULT};
|
|
84
|
+
padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
85
|
+
|
|
86
|
+
@media (max-width: ${({ $maxWidth }) => $maxWidth}px) {
|
|
87
|
+
display: flex;
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const Item = styled.a<{ $active: boolean }>`
|
|
92
|
+
position: relative;
|
|
93
|
+
flex: 1 1 0;
|
|
94
|
+
min-width: 0;
|
|
95
|
+
height: ${ALTO};
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
gap: 2px;
|
|
101
|
+
color: ${({ $active }) => ($active ? accent.DEFAULT : fg.muted)};
|
|
102
|
+
font-family: inherit;
|
|
103
|
+
text-decoration: none;
|
|
104
|
+
transition: color ${motion.fast} ${motion.ease};
|
|
105
|
+
|
|
106
|
+
&:focus-visible {
|
|
107
|
+
outline: 2px solid ${ring};
|
|
108
|
+
outline-offset: -2px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* "Acá estoy" no se dice sólo con color: una franja arriba del activo. */
|
|
112
|
+
&::before {
|
|
113
|
+
content: '';
|
|
114
|
+
position: absolute;
|
|
115
|
+
top: 0;
|
|
116
|
+
left: 25%;
|
|
117
|
+
right: 25%;
|
|
118
|
+
height: 2px;
|
|
119
|
+
border-radius: 0 0 2px 2px;
|
|
120
|
+
background: ${({ $active }) => ($active ? accent.DEFAULT : 'transparent')};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
svg {
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
width: 22px;
|
|
126
|
+
height: 22px;
|
|
127
|
+
font-size: 22px;
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const Rotulo = styled.span<{ $active: boolean }>`
|
|
132
|
+
max-width: 100%;
|
|
133
|
+
font-size: ${font['2xs']};
|
|
134
|
+
font-weight: ${({ $active }) => ($active ? 600 : 500)};
|
|
135
|
+
line-height: 1.1;
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
text-overflow: ellipsis;
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Navegación inferior fija para el teléfono: ícono + rótulo por destino, el
|
|
143
|
+
* activo con color y franja. Elicitada de cuatro implementaciones ya
|
|
144
|
+
* duplicadas (mixbol, valle-verde, tuxon, platenzen).
|
|
145
|
+
*
|
|
146
|
+
* Mientras se ve, publica `--kz-bottom-nav-offset` (ver `bottomNavOffset`):
|
|
147
|
+
* es fija, así que sin ese padding el final de la página queda debajo.
|
|
148
|
+
*
|
|
149
|
+
* ```tsx
|
|
150
|
+
* <BottomNav
|
|
151
|
+
* items={ITEMS}
|
|
152
|
+
* activeId={ITEMS.find((i) => i.href === pathname)?.id ?? null}
|
|
153
|
+
* maxWidth={640}
|
|
154
|
+
* ariaLabel="Secciones"
|
|
155
|
+
* linkAs={Link}
|
|
156
|
+
* />
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function BottomNav({ items, activeId, maxWidth, ariaLabel, linkAs, onSelect, className }: Props) {
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<style>{reglaDeOffset(maxWidth)}</style>
|
|
163
|
+
<Barra $maxWidth={maxWidth} aria-label={ariaLabel} className={className}>
|
|
164
|
+
{items.map((item) => {
|
|
165
|
+
const active = item.id === activeId;
|
|
166
|
+
return (
|
|
167
|
+
<Item
|
|
168
|
+
key={item.id}
|
|
169
|
+
as={linkAs}
|
|
170
|
+
href={item.href}
|
|
171
|
+
$active={active}
|
|
172
|
+
aria-current={active ? 'page' : undefined}
|
|
173
|
+
onClick={onSelect ? (evento: MouseEvent<HTMLElement>) => onSelect(item, evento) : undefined}
|
|
174
|
+
>
|
|
175
|
+
<span aria-hidden="true" style={{ display: 'contents' }}>
|
|
176
|
+
{item.icon}
|
|
177
|
+
</span>
|
|
178
|
+
<Rotulo $active={active}>{item.label}</Rotulo>
|
|
179
|
+
</Item>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</Barra>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +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 número de jugador sobre el disco, en Haxball. `DiscoHaxball` no lo dibuja hoy (no hay número que mostrar fuera de una partida). */
|
|
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
|
@@ -9,3 +9,9 @@ export { LangSelector } from './LangSelector';
|
|
|
9
9
|
export type { OpcionDeIdioma } from './LangSelector';
|
|
10
10
|
export { Modal } from './Modal';
|
|
11
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';
|