kaizen-lib 0.2.0 → 0.2.2

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 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 (por ahora `IconButton`) | en curso |
15
+ | `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`) | en curso |
16
16
 
17
17
  ## Instalación
18
18
 
@@ -89,6 +89,110 @@ qué.
89
89
 
90
90
  La librería no trae íconos: cada producto usa los suyos.
91
91
 
92
+ ```tsx
93
+ import { ReactionButton } from 'kaizen-lib/ui';
94
+
95
+ <ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
96
+ <IconoCorazon />
97
+ </ReactionButton>
98
+ ```
99
+
100
+ **`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
101
+ `accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
102
+ del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
103
+ una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
104
+ "esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
105
+ entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
106
+ ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
107
+
108
+ Un color que los seis tonos no cubren —la marca de un producto, no una categoría
109
+ semántica— se pisa con `style`, porque el color activo sale de la variable CSS
110
+ `--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
111
+
112
+ ```tsx
113
+ <ReactionButton
114
+ active={guardado}
115
+ style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
116
+ label="Guardar"
117
+ >
118
+ <IconoMarcador />
119
+ </ReactionButton>
120
+ ```
121
+
122
+ ```tsx
123
+ import { Medidor } from 'kaizen-lib/ui';
124
+
125
+ <Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
126
+ ```
127
+
128
+ **`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
129
+ un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
130
+ un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
131
+ número del centro son decorativos, y el color nunca es el único portador del significado.
132
+
133
+ Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
134
+ lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
135
+ `4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
136
+
137
+ `sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
138
+ número *significa* en el producto y no el componente:
139
+
140
+ | Escalón | `menos-es-mejor` | `mas-es-mejor` |
141
+ |---|---|---|
142
+ | bajo (< 40%) | verde | rojo |
143
+ | medio (40–79%) | azul | naranja |
144
+ | alto (80–99%) | naranja | azul |
145
+ | tope (100%) | rojo | verde |
146
+
147
+ `menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
148
+ cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
149
+ `warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
150
+ centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
151
+
152
+ ```tsx
153
+ import { IconButton, Relieve } from 'kaizen-lib/ui';
154
+ import Link from 'next/link';
155
+
156
+ <Relieve>
157
+ <IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
158
+ </Relieve>
159
+
160
+ <Relieve $prendido={enPerfil}>
161
+ <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
162
+ </Relieve>
163
+ ```
164
+
165
+ **`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
166
+ borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
167
+ presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
168
+ activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
169
+ `styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
170
+ intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
171
+ saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
172
+ marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
173
+
174
+ ```tsx
175
+ import { LangSelector } from 'kaizen-lib/ui';
176
+
177
+ <LangSelector
178
+ actual={i18n.language}
179
+ onChange={(codigo) => i18n.changeLanguage(codigo)}
180
+ opciones={[
181
+ { codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
182
+ { codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
183
+ ]}
184
+ />
185
+ ```
186
+
187
+ **`LangSelector`** es el botón de idioma de una topbar: cicla a la siguiente opción en
188
+ cada tap y muestra el ícono del idioma actual, con el relieve de `Relieve` ya incluido
189
+ (a diferencia de `IconButton`, acá no hace falta envolverlo aparte — todo consumidor lo
190
+ quería). No trae banderas — mismo criterio que `IconButton` no trae íconos —, así que
191
+ `icono` es cosa del producto; `react-country-flag` (ya usado en tuxon-front) es una
192
+ opción probada. `onChange` recibe el código de la opción siguiente; qué hacer con eso
193
+ (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no sabe
194
+ qué mecanismo de traducción hay atrás.
195
+
92
196
  ## Desarrollo
93
197
 
94
198
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-lib",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
5
5
  "license": "MIT",
6
6
  "author": "Nicolás Sandobal",
@@ -87,6 +87,17 @@
87
87
  --kz-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
88
88
  --kz-shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.6);
89
89
 
90
+ /* ---- Relieve neumórfico -------------------------------------------------
91
+ Par de sombras opuestas para un control sin relleno ni borde: el círculo
92
+ lo dibuja la luz, no un fondo. `out` es el reposo, `out-strong` el hover
93
+ y `in` (invertida) el presionado o el "esto está activo". Neutras —negro
94
+ y blanco— porque la paleta por defecto ya lo es; un producto con marca
95
+ las tiñe (ver Relieve en ui/, y el ejemplo de mixbol-front en el README:
96
+ ahí la sombra oscura sale del azul de marca, no de un negro genérico). */
97
+ --kz-relief-out: 3px 3px 7px rgba(0, 0, 0, 0.5), -3px -3px 7px rgba(255, 255, 255, 0.05);
98
+ --kz-relief-out-strong: 4px 4px 10px rgba(0, 0, 0, 0.6), -4px -4px 10px rgba(255, 255, 255, 0.08);
99
+ --kz-relief-in: inset 3px 3px 6px rgba(0, 0, 0, 0.55), inset -3px -3px 6px rgba(255, 255, 255, 0.06);
100
+
90
101
  /* ---- Espaciado ---------------------------------------------------------
91
102
  Escala en pasos de 4px. Los saltos (no hay 7, 9, 11) son a propósito: una
92
103
  escala continua no es una escala, es un número libre con prefijo. */
@@ -115,6 +126,7 @@
115
126
  --kz-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
116
127
  --kz-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
117
128
 
129
+ --kz-font-2xs: 11px;
118
130
  --kz-font-xs: 12px;
119
131
  --kz-font-sm: 14px;
120
132
  --kz-font-md: 16px;
@@ -182,4 +194,8 @@
182
194
  --kz-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.1);
183
195
  --kz-shadow-lg: 0 10px 30px rgba(16, 24, 40, 0.12);
184
196
  --kz-shadow-xl: 0 24px 60px rgba(16, 24, 40, 0.16);
197
+
198
+ --kz-relief-out: 3px 3px 6px rgba(16, 24, 40, 0.14), -3px -3px 6px rgba(255, 255, 255, 0.9);
199
+ --kz-relief-out-strong: 4px 4px 9px rgba(16, 24, 40, 0.18), -4px -4px 9px rgba(255, 255, 255, 0.95);
200
+ --kz-relief-in: inset 3px 3px 6px rgba(16, 24, 40, 0.16), inset -3px -3px 6px rgba(255, 255, 255, 0.85);
185
201
  }
@@ -104,6 +104,17 @@ export const shadow = {
104
104
  xl: 'var(--kz-shadow-xl)',
105
105
  } as const;
106
106
 
107
+ /**
108
+ * Relieve neumórfico: el par de sombras opuestas que usa `Relieve` (`ui/`).
109
+ * `out` es el reposo y el hover fuerte, `in` es el presionado o "esto está
110
+ * prendido" — ver el docblock de `Relieve` para cuándo va cada uno.
111
+ */
112
+ export const relief = {
113
+ out: 'var(--kz-relief-out)',
114
+ outStrong: 'var(--kz-relief-out-strong)',
115
+ in: 'var(--kz-relief-in)',
116
+ } as const;
117
+
107
118
  /** Escala de espaciado, en pasos de 4px. Las claves salteadas son a propósito. */
108
119
  export const space = {
109
120
  1: 'var(--kz-space-1)',
@@ -131,6 +142,7 @@ export const radius = {
131
142
  export const font = {
132
143
  family: 'var(--kz-font-family)',
133
144
  mono: 'var(--kz-font-mono)',
145
+ '2xs': 'var(--kz-font-2xs)',
134
146
  xs: 'var(--kz-font-xs)',
135
147
  sm: 'var(--kz-font-sm)',
136
148
  md: 'var(--kz-font-md)',
@@ -2,17 +2,10 @@
2
2
 
3
3
  import type { ComponentPropsWithoutRef, ElementType, MouseEvent } from 'react';
4
4
  import styled, { css } from 'styled-components';
5
- import { accent, danger, fg, font, motion, radius, ring, surface } from '../tokens';
5
+ import { accent, danger, fg, font, radius, surface } from '../tokens';
6
+ import { formaBase, type IconButtonSize } from './shell';
6
7
 
7
- export type IconButtonSize = 'sm' | 'md' | 'lg';
8
-
9
- /**
10
- * Lado del círculo, en px. `lg` es el área táctil mínima de una topbar móvil
11
- * (44px): si el control va en una barra que se toca con el pulgar, es el que
12
- * corresponde. No son tokens porque son medidas de este componente, no una
13
- * escala que otros usen.
14
- */
15
- const LADO: Record<IconButtonSize, number> = { sm: 32, md: 40, lg: 44 };
8
+ export type { IconButtonSize };
16
9
 
17
10
  /** Tope del badge: "+9" ocupa lo mismo que un dígito y dice lo mismo. */
18
11
  const BADGE_MAXIMO = 9;
@@ -53,35 +46,9 @@ type Props<T extends ElementType> = Propios & {
53
46
  } & Omit<ComponentPropsWithoutRef<T>, keyof Propios | 'as'>;
54
47
 
55
48
  const Raiz = styled.button<{ $size: IconButtonSize; $active: boolean }>`
56
- position: relative;
57
- display: inline-flex;
58
- align-items: center;
59
- justify-content: center;
60
- flex-shrink: 0;
61
- width: ${({ $size }) => LADO[$size]}px;
62
- height: ${({ $size }) => LADO[$size]}px;
63
- padding: 0;
64
- border: none;
65
- border-radius: ${radius.circle};
49
+ ${({ $size }) => formaBase($size)}
66
50
  background: ${({ $active }) => ($active ? accent.soft : 'transparent')};
67
51
  color: ${({ $active }) => ($active ? accent.DEFAULT : fg.muted)};
68
- font-family: inherit;
69
- text-decoration: none;
70
- cursor: pointer;
71
- transition:
72
- background ${motion.fast} ${motion.ease},
73
- color ${motion.fast} ${motion.ease};
74
-
75
- svg {
76
- display: block;
77
- width: 22px;
78
- height: 22px;
79
- }
80
-
81
- &:focus-visible {
82
- outline: 2px solid ${ring};
83
- outline-offset: 2px;
84
- }
85
52
 
86
53
  ${({ $active }) =>
87
54
  !$active &&
@@ -95,11 +62,6 @@ const Raiz = styled.button<{ $size: IconButtonSize; $active: boolean }>`
95
62
  background: ${surface.active};
96
63
  }
97
64
  `}
98
-
99
- &[aria-disabled='true'] {
100
- opacity: 0.45;
101
- cursor: not-allowed;
102
- }
103
65
  `;
104
66
 
105
67
  const Contador = styled.span`
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { IconButton } from './IconButton';
5
+ import { Relieve } from './Relieve';
6
+ import type { IconButtonSize } from './shell';
7
+
8
+ /**
9
+ * Una opción del selector: el código de idioma, su nombre (para el nombre
10
+ * accesible) y el ícono que lo representa. La librería no trae banderas —
11
+ * mismo criterio que `IconButton` no trae íconos — porque una bandera bien
12
+ * hecha es un SVG por país, no algo que valga la pena embeber en un paquete
13
+ * sin build. `react-country-flag` (ya usado en tuxon-front) es una opción
14
+ * probada; cualquier ícono sirve.
15
+ */
16
+ export type OpcionDeIdioma<T extends string> = {
17
+ codigo: T;
18
+ /** Nombre del idioma, en ese idioma ("English", no "Inglés"). */
19
+ nombre: string;
20
+ icono: ReactNode;
21
+ };
22
+
23
+ type Props<T extends string> = {
24
+ opciones: OpcionDeIdioma<T>[];
25
+ actual: T;
26
+ onChange: (codigo: T) => void;
27
+ size?: IconButtonSize;
28
+ };
29
+
30
+ /**
31
+ * El botón de idioma de una topbar: cicla a la siguiente opción en cada tap
32
+ * y muestra el ícono del idioma actual. No es un `<select>` ni un menú — es
33
+ * el mismo patrón que ya tenían, cada uno por su cuenta, mixbol-front,
34
+ * nsandev-front, tutipoker-school-front y valle-verde antes de esto.
35
+ *
36
+ * Trae el relieve neumórfico (`Relieve`) incluido: a diferencia de
37
+ * `IconButton`, acá no hace falta envolverlo aparte, porque el 100% de los
38
+ * consumidores lo quería.
39
+ *
40
+ * `onChange` recibe el CÓDIGO de la opción siguiente — qué hacer con eso
41
+ * (`i18n.changeLanguage`, guardarlo en `localStorage`, lo que sea) es del
42
+ * producto. La librería no sabe qué mecanismo de traducción hay atrás.
43
+ *
44
+ * ```tsx
45
+ * <LangSelector
46
+ * actual={idiomaActual}
47
+ * onChange={(codigo) => i18n.changeLanguage(codigo)}
48
+ * opciones={[
49
+ * { codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
50
+ * { codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
51
+ * ]}
52
+ * />
53
+ * ```
54
+ */
55
+ export function LangSelector<T extends string>({ opciones, actual, onChange, size = 'lg' }: Props<T>) {
56
+ // Sin opciones no hay nada que ciclar. Defensivo: un selector de idioma
57
+ // vacío es un error de uso, no algo que la librería deba explicar.
58
+ if (opciones.length === 0) return null;
59
+
60
+ const indiceActual = opciones.findIndex((opcion) => opcion.codigo === actual);
61
+ const opcionActual = opciones[indiceActual < 0 ? 0 : indiceActual] as OpcionDeIdioma<T>;
62
+ const siguiente = opciones[(indiceActual + 1 + opciones.length) % opciones.length] as OpcionDeIdioma<T>;
63
+
64
+ return (
65
+ <Relieve>
66
+ <IconButton size={size} label={`Cambiar idioma a ${siguiente.nombre}`} onClick={() => onChange(siguiente.codigo)}>
67
+ {opcionActual.icono}
68
+ </IconButton>
69
+ </Relieve>
70
+ );
71
+ }
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import styled from 'styled-components';
5
+ import { danger, fg, font, info, success, warning } from '../tokens';
6
+
7
+ /**
8
+ * Hacia dónde apunta lo bueno en la escala.
9
+ *
10
+ * - `menos-es-mejor`: vacío es verde y lleno es rojo. Es la lectura de "cuánto
11
+ * lugar queda": un partido con dos anotados es una oportunidad, uno lleno es
12
+ * una puerta cerrada.
13
+ * - `mas-es-mejor`: la inversa. Lleno es verde y vacío es rojo. Es la lectura
14
+ * de un progreso, una meta o un puntaje.
15
+ *
16
+ * No hay un default universal a propósito: cuál corresponde depende de lo que
17
+ * el número **significa** en el producto, no del componente. Por eso es
18
+ * obligatoria — un default sería elegir en silencio el sentido de todos los
19
+ * medidores que no la pasen.
20
+ */
21
+ export type MedidorSentido = 'menos-es-mejor' | 'mas-es-mejor';
22
+
23
+ /**
24
+ * Los cuatro escalones de la escala, del más vacío al más lleno.
25
+ *
26
+ * Cortes sobre la **proporción** `valor / maximo` y no sobre el valor: sobre
27
+ * una escala de 0 a 10 son 10 → tope, 8 a 9,99 → alto, 4 a 7,99 → medio y
28
+ * menos de 4 → bajo, pero sobre un cupo de 22 el mismo `8` no significa lo
29
+ * mismo. Cada corte es inclusivo abajo: exactamente 8 de 10 ya es `alto`.
30
+ */
31
+ type Escalon = 'bajo' | 'medio' | 'alto' | 'tope';
32
+
33
+ const CORTE_MEDIO = 0.4;
34
+ const CORTE_ALTO = 0.8;
35
+
36
+ function escalonDe(proporcion: number): Escalon {
37
+ if (proporcion >= 1) return 'tope';
38
+ if (proporcion >= CORTE_ALTO) return 'alto';
39
+ if (proporcion >= CORTE_MEDIO) return 'medio';
40
+ return 'bajo';
41
+ }
42
+
43
+ /**
44
+ * Color de cada escalón según el sentido. Los cuatro son los tonos semánticos
45
+ * de la librería —ninguno inventado acá— y las dos rampas usan los mismos
46
+ * cuatro: el `info` (azul) es el escalón templado en las dos direcciones, y es
47
+ * azul y no un cuarto verde/ámbar a propósito, porque los otros tres van por
48
+ * temperatura y meter un cuarto dentro de esa franja los vuelve
49
+ * indistinguibles de un vistazo.
50
+ */
51
+ const COLOR: Record<MedidorSentido, Record<Escalon, string>> = {
52
+ 'menos-es-mejor': {
53
+ bajo: success.DEFAULT,
54
+ medio: info.DEFAULT,
55
+ alto: warning.DEFAULT,
56
+ tope: danger.DEFAULT,
57
+ },
58
+ 'mas-es-mejor': {
59
+ bajo: danger.DEFAULT,
60
+ medio: warning.DEFAULT,
61
+ alto: info.DEFAULT,
62
+ tope: success.DEFAULT,
63
+ },
64
+ };
65
+
66
+ type Props = {
67
+ /** Cuánto hay. Fuera de `0..maximo` se recorta: el arco nunca se sale de su pista. */
68
+ valor: number;
69
+ /** Cuánto es el 100%. Debe ser mayor que 0; si no, el arco se dibuja vacío. */
70
+ maximo: number;
71
+ /**
72
+ * Nombre accesible, obligatorio y en palabras ("Anotados: 8 de 10"). El
73
+ * arco y el número son decorativos (`aria-hidden`): sin esto un lector de
74
+ * pantalla leería el mismo dato dos veces, o ninguna.
75
+ *
76
+ * El color nunca es el único portador del significado: el número va en
77
+ * texto en el centro y esto lo dice en palabras.
78
+ */
79
+ label: string;
80
+ sentido: MedidorSentido;
81
+ /** Lo que va en el centro. Por defecto `valor/maximo`. */
82
+ children?: ReactNode;
83
+ };
84
+
85
+ /**
86
+ * Un semicírculo de 100 unidades de perímetro: `pathLength={100}` hace que
87
+ * `stroke-dashoffset` sea directamente el porcentaje, sin calcular la longitud
88
+ * real del arco.
89
+ */
90
+ const ARCO_D = 'M12 52 A38 38 0 0 1 88 52';
91
+
92
+ const Raiz = styled.div`
93
+ position: relative;
94
+ flex-shrink: 0;
95
+ width: 56px;
96
+ height: 34px;
97
+ `;
98
+
99
+ const Svg = styled.svg`
100
+ display: block;
101
+ width: 100%;
102
+ height: 100%;
103
+ `;
104
+
105
+ const Centro = styled.span`
106
+ position: absolute;
107
+ bottom: 2px;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ font-size: ${font['2xs']};
111
+ font-weight: 800;
112
+ line-height: 1;
113
+ color: ${fg.DEFAULT};
114
+ font-variant-numeric: tabular-nums;
115
+ white-space: nowrap;
116
+ `;
117
+
118
+ /** Proporción en `0..1`. Un `maximo` inválido o un valor no finito dan 0, no NaN. */
119
+ function proporcionDe(valor: number, maximo: number): number {
120
+ if (!Number.isFinite(valor) || !Number.isFinite(maximo) || maximo <= 0) return 0;
121
+ return Math.min(1, Math.max(0, valor / maximo));
122
+ }
123
+
124
+ /**
125
+ * Medidor de arco: cuánto de un total está ocupado, con el color por escalón.
126
+ *
127
+ * Es un `role="img"`, no un control: no se enfoca ni se opera. Si el dato
128
+ * tiene que llevar a algún lado, lo envuelve un link o un botón del producto.
129
+ */
130
+ export function Medidor({ valor, maximo, label, sentido, children }: Props) {
131
+ const proporcion = proporcionDe(valor, maximo);
132
+ const color = COLOR[sentido][escalonDe(proporcion)];
133
+
134
+ return (
135
+ <Raiz role="img" aria-label={label}>
136
+ <Svg viewBox="0 0 100 60" aria-hidden="true">
137
+ <path
138
+ d={ARCO_D}
139
+ fill="none"
140
+ stroke={`color-mix(in srgb, ${fg.muted} 25%, transparent)`}
141
+ strokeWidth={10}
142
+ strokeLinecap="round"
143
+ pathLength={100}
144
+ />
145
+ <path
146
+ d={ARCO_D}
147
+ fill="none"
148
+ stroke={color}
149
+ strokeWidth={10}
150
+ strokeLinecap="round"
151
+ pathLength={100}
152
+ strokeDasharray={100}
153
+ strokeDashoffset={100 - proporcion * 100}
154
+ />
155
+ </Svg>
156
+ <Centro aria-hidden="true">{children ?? `${valor}/${maximo}`}</Centro>
157
+ </Raiz>
158
+ );
159
+ }
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+
3
+ import type { ButtonHTMLAttributes, MouseEvent, ReactNode } from 'react';
4
+ import styled from 'styled-components';
5
+ import { accent, danger, fg, font, info, motion, success, warning } from '../tokens';
6
+ import { formaBase, LADO_ICONO, type IconButtonSize } from './shell';
7
+
8
+ /**
9
+ * El color que toma el ícono al reaccionar. Reusa los tonos semánticos que
10
+ * la librería ya tiene — no hay `heart` ni `bookmark`: eso es marca de
11
+ * producto, no una categoría que otro consumidor vaya a repetir. Un color
12
+ * que ninguno de estos seis cubre se pisa con `style` (ver `label`, abajo
13
+ * del componente, para el ejemplo).
14
+ */
15
+ export type ReactionTone = 'neutral' | 'accent' | 'success' | 'danger' | 'warning' | 'info';
16
+
17
+ const TONO_ENCENDIDO: Record<ReactionTone, string> = {
18
+ neutral: fg.DEFAULT,
19
+ accent: accent.DEFAULT,
20
+ success: success.DEFAULT,
21
+ danger: danger.DEFAULT,
22
+ warning: warning.DEFAULT,
23
+ info: info.DEFAULT,
24
+ };
25
+
26
+ type Props = {
27
+ /**
28
+ * Nombre accesible. Si hay `count`, la cifra va DENTRO del label ("Me
29
+ * gusta, 24"): el número visible es decorativo (`aria-hidden`), mismo
30
+ * criterio que el `badge` de `IconButton`.
31
+ */
32
+ label: string;
33
+ children: ReactNode;
34
+ tone?: ReactionTone;
35
+ size?: IconButtonSize;
36
+ /** Reaccionado o no. A diferencia de `IconButton`, no tiñe el fondo: sólo el ícono cambia de color. */
37
+ active?: boolean;
38
+ /**
39
+ * Resplandor alrededor del ícono cuando está activo. Del color del tono
40
+ * (`currentColor`), no uno propio — así no hace falta un token de glow por
41
+ * tono, y un color pisado con `style` resplandece del mismo color.
42
+ */
43
+ glow?: boolean;
44
+ /** Cuenta visible junto al ícono. `0` se muestra: "0 me gusta" es un dato, no una ausencia. */
45
+ count?: number;
46
+ motivo?: string;
47
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
48
+ };
49
+
50
+ const Raiz = styled.button<{ $size: IconButtonSize; $tone: ReactionTone; $active: boolean; $glow: boolean }>`
51
+ ${({ $size }) => formaBase($size)}
52
+ gap: 4px;
53
+ width: auto;
54
+ min-width: ${({ $size }) => LADO_ICONO[$size]}px;
55
+ padding: 0 8px;
56
+ background: transparent;
57
+ --kz-icon-on: ${({ $tone }) => TONO_ENCENDIDO[$tone]};
58
+ color: ${({ $active }) => ($active ? 'var(--kz-icon-on)' : fg.muted)};
59
+
60
+ svg {
61
+ filter: ${({ $active, $glow }) =>
62
+ $active && $glow ? 'drop-shadow(0 0 8px color-mix(in srgb, currentColor 65%, transparent))' : 'none'};
63
+ transition: filter ${motion.fast} ${motion.ease};
64
+ }
65
+
66
+ &:hover:not([aria-disabled='true']) {
67
+ background: transparent;
68
+ color: ${({ $active }) => ($active ? 'var(--kz-icon-on)' : fg.DEFAULT)};
69
+ }
70
+ `;
71
+
72
+ const Cuenta = styled.span`
73
+ font-size: ${font.xs};
74
+ font-weight: 600;
75
+ line-height: 1;
76
+ pointer-events: none;
77
+ `;
78
+
79
+ /**
80
+ * Botón de reacción: ícono que cambia de color (y opcionalmente resplandece)
81
+ * al activarse, con una cuenta al lado. Generalizado del `IconButton` de
82
+ * taboo-next.
83
+ *
84
+ * No es `IconButton` con más props: `IconButton` tiñe el FONDO cuando está
85
+ * `active` (es "esto está seleccionado" — la página actual, un filtro
86
+ * aplicado). `ReactionButton` nunca tiñe el fondo — sólo el ícono cambia
87
+ * (es "reaccioné con esto"): un corazón marcado no necesita, además, un
88
+ * círculo rosa detrás. Por eso es un componente aparte y no una prop más.
89
+ *
90
+ * ```tsx
91
+ * <ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
92
+ * <IconoCorazon />
93
+ * </ReactionButton>
94
+ * ```
95
+ *
96
+ * Un color que los seis tonos no cubren (la marca de un producto, no una
97
+ * categoría semántica) se pisa con `style`, ya que `--kz-icon-on` es una
98
+ * variable CSS común y un estilo en línea le gana a la de acá:
99
+ *
100
+ * ```tsx
101
+ * <ReactionButton
102
+ * active={guardado}
103
+ * glow
104
+ * style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
105
+ * label="Guardar"
106
+ * >
107
+ * <IconoMarcador />
108
+ * </ReactionButton>
109
+ * ```
110
+ */
111
+ export function ReactionButton({
112
+ label,
113
+ children,
114
+ tone = 'neutral',
115
+ size = 'md',
116
+ active = false,
117
+ glow = false,
118
+ count,
119
+ motivo,
120
+ onClick,
121
+ ...rest
122
+ }: Props & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof Props>) {
123
+ const bloqueado = Boolean(motivo);
124
+
125
+ return (
126
+ <Raiz
127
+ {...rest}
128
+ type="button"
129
+ $size={size}
130
+ $tone={tone}
131
+ $active={active}
132
+ $glow={glow}
133
+ aria-label={label}
134
+ aria-pressed={active}
135
+ aria-disabled={bloqueado || undefined}
136
+ title={motivo ?? label}
137
+ onClick={(event) => {
138
+ if (bloqueado) {
139
+ event.preventDefault();
140
+ return;
141
+ }
142
+ onClick?.(event);
143
+ }}
144
+ >
145
+ {children}
146
+ {typeof count === 'number' ? <Cuenta aria-hidden="true">{count}</Cuenta> : null}
147
+ </Raiz>
148
+ );
149
+ }
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+
3
+ import styled from 'styled-components';
4
+ import { motion, relief } from '../tokens';
5
+
6
+ /**
7
+ * Relieve neumórfico: envuelve a `IconButton` (o cualquier control) y le
8
+ * agrega el par de sombras opuestas que dibuja un círculo sin relleno ni
9
+ * borde. El botón se lee "hundido" al presionar, y "prendido" con `$prendido`
10
+ * — el mismo tratamiento en reposo mientras el estado se sostiene, para "vos
11
+ * estás acá" o "esto está activo".
12
+ *
13
+ * Es un `<span>` alrededor del control, no un `styled(IconButton)`, porque
14
+ * `IconButton` es polimórfico (`as={Link}`): styled-components intercepta su
15
+ * propio `as` en cualquier componente que envuelve, así que envolverlo así
16
+ * haría que renderice el `Link` directo y se saltee toda la lógica de
17
+ * `IconButton` (aria, badge, bloqueo por motivo). El span sólo pinta la
18
+ * sombra; el click, el foco y el aria son enteramente del control de adentro.
19
+ *
20
+ * ```tsx
21
+ * <Relieve>
22
+ * <IconButton label="Notificaciones" badge={3}><IconoCampana /></IconButton>
23
+ * </Relieve>
24
+ *
25
+ * <Relieve $prendido={enPerfil}>
26
+ * <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil">
27
+ * <IconoPerfil />
28
+ * </IconButton>
29
+ * </Relieve>
30
+ * ```
31
+ *
32
+ * Nace en mixbol-front y valle-verde, cada uno con su propia paleta —el
33
+ * relieve es neutro (`--kz-relief-*`, negro/blanco) y cada producto lo tiñe
34
+ * redefiniendo esos tres tokens con el color de su marca, igual que hace
35
+ * cualquier otro token de la librería.
36
+ */
37
+ export const Relieve = styled.span<{ $prendido?: boolean }>`
38
+ display: inline-flex;
39
+ border-radius: 50%;
40
+ box-shadow: ${({ $prendido }) => ($prendido ? relief.in : relief.out)};
41
+ transition: box-shadow ${motion.fast} ${motion.ease};
42
+
43
+ /* $prendido ya vale el relieve hundido: hover no le suma nada. */
44
+ &:hover {
45
+ box-shadow: ${({ $prendido }) => ($prendido ? relief.in : relief.outStrong)};
46
+ }
47
+
48
+ &:active {
49
+ box-shadow: ${relief.in};
50
+ transform: translateY(1px);
51
+ }
52
+ `;
package/src/ui/index.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  export { IconButton } from './IconButton';
2
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';
@@ -0,0 +1,58 @@
1
+ import { css } from 'styled-components';
2
+ import { motion, radius, ring } from '../tokens';
3
+
4
+ export type IconButtonSize = 'sm' | 'md' | 'lg';
5
+
6
+ /**
7
+ * Lado del círculo, en px. `lg` es el área táctil mínima de una topbar móvil
8
+ * (44px): si el control va en una barra que se toca con el pulgar, es el que
9
+ * corresponde. No son tokens porque son medidas de este componente, no una
10
+ * escala que otros usen.
11
+ */
12
+ export const LADO_ICONO: Record<'sm' | 'md' | 'lg', number> = { sm: 32, md: 40, lg: 44 };
13
+
14
+ /**
15
+ * Forma compartida entre `IconButton` y `ReactionButton`: el círculo, el
16
+ * foco y el bloqueo. Lo que NO comparten —de dónde sale el color, si hay
17
+ * fondo teñido— es justamente lo que los distingue, y vive en cada uno.
18
+ *
19
+ * `IconButton` es "esto está seleccionado" (fondo teñido, un solo color de
20
+ * encendido). `ReactionButton` es "reaccioné con esto" (sin fondo, color +
21
+ * resplandor por tono, con contador). Forzar los dos casos por una sola
22
+ * prop de color es lo que hacía que una reacción con `tone="heart"` te
23
+ * dejara, de yapa, un círculo rosa detrás del ícono que nadie pidió.
24
+ */
25
+ export const formaBase = (size: 'sm' | 'md' | 'lg') => css`
26
+ position: relative;
27
+ display: inline-flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ flex-shrink: 0;
31
+ width: ${LADO_ICONO[size]}px;
32
+ height: ${LADO_ICONO[size]}px;
33
+ padding: 0;
34
+ border: none;
35
+ border-radius: ${radius.circle};
36
+ font-family: inherit;
37
+ text-decoration: none;
38
+ cursor: pointer;
39
+ transition:
40
+ background ${motion.fast} ${motion.ease},
41
+ color ${motion.fast} ${motion.ease};
42
+
43
+ svg {
44
+ display: block;
45
+ width: 22px;
46
+ height: 22px;
47
+ }
48
+
49
+ &:focus-visible {
50
+ outline: 2px solid ${ring};
51
+ outline-offset: 2px;
52
+ }
53
+
54
+ &[aria-disabled='true'] {
55
+ opacity: 0.45;
56
+ cursor: not-allowed;
57
+ }
58
+ `;