kaizen-lib 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolás Sandobal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
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 (por ahora `IconButton`) | en curso |
16
+
17
+ ## Instalación
18
+
19
+ ```bash
20
+ npm install kaizen-lib
21
+ ```
22
+
23
+ `react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
24
+ alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
25
+ styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
26
+
27
+ En `next.config`:
28
+
29
+ ```ts
30
+ const nextConfig = { transpilePackages: ['kaizen-lib'] };
31
+ ```
32
+
33
+ ## Tokens
34
+
35
+ El contrato es uno solo: **los nombres**. Los valores los define cada producto.
36
+
37
+ ```ts
38
+ import styled from 'styled-components';
39
+ import { surface, fg, space, radius } from 'kaizen-lib/tokens';
40
+
41
+ const Card = styled.div`
42
+ background: ${surface[2]};
43
+ color: ${fg.DEFAULT};
44
+ padding: ${space[4]};
45
+ border-radius: ${radius.md};
46
+ `;
47
+ ```
48
+
49
+ ```css
50
+ /* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
51
+ @import 'kaizen-lib/tokens.css';
52
+
53
+ :root {
54
+ --kz-accent: #e8318f; /* la marca del producto */
55
+ }
56
+ ```
57
+
58
+ **Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
59
+ valor por defecto. Si no importa `tokens.css` y no define un token que un componente
60
+ usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
61
+ todo token de la API esté definido en `defaults.css`.
62
+
63
+ **Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
64
+ mecanismo de tutipoker-school-front, el único consumidor con toggle real.
65
+
66
+ **Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
67
+ `sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
68
+
69
+ ## Componentes
70
+
71
+ ```tsx
72
+ import { IconButton } from 'kaizen-lib/ui';
73
+
74
+ <IconButton label="Cambiar tema" size="lg" onClick={alternar}>
75
+ <IconoLuna />
76
+ </IconButton>
77
+
78
+ <IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
79
+ <IconoCampana />
80
+ </IconButton>
81
+ ```
82
+
83
+ **`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
84
+ (es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
85
+ cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
86
+ otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
87
+ área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
88
+ qué.
89
+
90
+ La librería no trae íconos: cada producto usa los suyos.
91
+
92
+ ## Desarrollo
93
+
94
+ ```bash
95
+ npm run check # typecheck + tests
96
+ ```
97
+
98
+ Los tests verifican dos cosas:
99
+
100
+ - **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
101
+ token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
102
+ redefinir.
103
+ - **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
104
+ cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
105
+
106
+ Si cambiás un color, el número lo da la suite, no el ojo.
107
+
108
+ ## Licencia
109
+
110
+ MIT
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "kaizen-lib",
3
+ "version": "0.2.0",
4
+ "description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
5
+ "license": "MIT",
6
+ "author": "Nicolás Sandobal",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nikosanarg/kaizen-lib.git"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": [
13
+ "*.css"
14
+ ],
15
+ "files": [
16
+ "src",
17
+ "!src/**/*.test.*",
18
+ "!src/**/testing"
19
+ ],
20
+ "exports": {
21
+ "./tokens": "./src/tokens/index.ts",
22
+ "./tokens.css": "./src/tokens/defaults.css",
23
+ "./ui": "./src/ui/index.ts",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "scripts": {
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "typecheck": "tsc --noEmit",
30
+ "check": "npm run typecheck && npm run test"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=19",
34
+ "styled-components": ">=6"
35
+ },
36
+ "devDependencies": {
37
+ "@testing-library/react": "16.3.2",
38
+ "@testing-library/user-event": "14.6.1",
39
+ "@types/node": "24.10.1",
40
+ "@types/react": "19.2.7",
41
+ "@types/react-dom": "19.2.3",
42
+ "jsdom": "29.1.1",
43
+ "react": "19.2.4",
44
+ "react-dom": "19.2.4",
45
+ "styled-components": "6.4.2",
46
+ "typescript": "5.9.3",
47
+ "vitest": "5.0.1"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ }
52
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ Valores por defecto de los tokens de kaizen-lib.
3
+
4
+ Importar este archivo es OPCIONAL y sirve para dos cosas: arrancar un
5
+ proyecto sin definir nada, y tener un valor de respaldo para cada token
6
+ mientras la adopción es parcial. Un producto con identidad propia redefine
7
+ los que le importan en su propio `globals.css`, después de este import.
8
+
9
+ La paleta es deliberadamente neutra: gris azulado y un azul de acento. No es
10
+ la marca de ningún producto y no debería serlo — si se pareciera a alguno, el
11
+ resto heredaría esa identidad sin haberla elegido.
12
+
13
+ Tema: oscuro en `:root`, claro cuando `<html>` lleva la clase `light`. El
14
+ mecanismo sale de tutipoker-school-front, el único consumidor con un toggle
15
+ real (`context/useDarkMode.tsx`), para no inventar un tercero.
16
+
17
+ Los pares de contraste de estos valores los verifica `defaults.test.ts`
18
+ contra los mínimos de WCAG 2.1. Si cambiás un color, corré los tests: el
19
+ número lo da la suite, no el ojo.
20
+ */
21
+
22
+ :root {
23
+ /* ---- Superficies -------------------------------------------------------
24
+ Cuatro niveles de elevación con uso fijo; no se mezclan libremente. El
25
+ modelo viene de kaizen-next, que es el que lo dejó explícito.
26
+ 0 fondo de página
27
+ 1 panel o sección
28
+ 2 card dentro de un panel
29
+ 3 overlay: modal, popover, dropdown
30
+ `sunken` es el pozo: va más oscuro que su contenedor (inputs, tracks,
31
+ wells), así que no es un quinto nivel de la escala sino su opuesto. */
32
+ --kz-surface-0: #0b0d10;
33
+ --kz-surface-1: #14171c;
34
+ --kz-surface-2: #1b1f26;
35
+ --kz-surface-3: #232830;
36
+ --kz-surface-sunken: #07080a;
37
+ --kz-surface-hover: #262c35;
38
+ --kz-surface-active: #2e3540;
39
+
40
+ /* ---- Texto -------------------------------------------------------------
41
+ Tres niveles. `subtle` es el piso: por debajo de eso no hay token porque
42
+ no habría contraste suficiente, y el que lo necesite está pidiendo texto
43
+ ilegible. El texto que va ENCIMA de un color de acento o semántico no es
44
+ un nivel de `fg`: cada tono trae el suyo (`--kz-accent-fg`, `--kz-danger-fg`). */
45
+ --kz-fg: #e8ecf1;
46
+ --kz-fg-muted: #a3adba;
47
+ --kz-fg-subtle: #8792a1;
48
+
49
+ /* ---- Bordes y foco ----------------------------------------------------- */
50
+ --kz-hairline: #262b33;
51
+ --kz-hairline-strong: #3a424e;
52
+ /* Anillo de foco. Es un elemento no textual: el mínimo que le aplica es
53
+ 3:1, no 4.5:1. */
54
+ --kz-ring: #5b9dff;
55
+
56
+ /* ---- Acento ------------------------------------------------------------
57
+ `soft` es el fondo lavado para chips y estados seleccionados; el texto que
58
+ va encima de `soft` es el acento mismo, no `accent-fg`. */
59
+ --kz-accent: #5a9df8;
60
+ --kz-accent-hover: #78b0fa;
61
+ --kz-accent-active: #3b82f6;
62
+ --kz-accent-soft: rgba(90, 157, 248, 0.16);
63
+ --kz-accent-fg: #06111f;
64
+
65
+ /* ---- Tonos semánticos --------------------------------------------------
66
+ Cuatro tonos × tres ranuras. `danger` y `warning` ganan sobre `error` y
67
+ `warn` por ser los nombres que más consumidores ya usaban. */
68
+ --kz-success: #3ad07d;
69
+ --kz-success-soft: rgba(58, 208, 125, 0.16);
70
+ --kz-success-fg: #04180d;
71
+
72
+ --kz-danger: #f87171;
73
+ --kz-danger-soft: rgba(248, 113, 113, 0.16);
74
+ --kz-danger-fg: #1d0505;
75
+
76
+ --kz-warning: #f0ab3e;
77
+ --kz-warning-soft: rgba(240, 171, 62, 0.16);
78
+ --kz-warning-fg: #1c1002;
79
+
80
+ --kz-info: #56c4f5;
81
+ --kz-info-soft: rgba(86, 196, 245, 0.16);
82
+ --kz-info-fg: #04161f;
83
+
84
+ /* ---- Sombras ----------------------------------------------------------- */
85
+ --kz-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
86
+ --kz-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
87
+ --kz-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
88
+ --kz-shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.6);
89
+
90
+ /* ---- Espaciado ---------------------------------------------------------
91
+ Escala en pasos de 4px. Los saltos (no hay 7, 9, 11) son a propósito: una
92
+ escala continua no es una escala, es un número libre con prefijo. */
93
+ --kz-space-1: 4px;
94
+ --kz-space-2: 8px;
95
+ --kz-space-3: 12px;
96
+ --kz-space-4: 16px;
97
+ --kz-space-5: 20px;
98
+ --kz-space-6: 24px;
99
+ --kz-space-8: 32px;
100
+ --kz-space-10: 40px;
101
+ --kz-space-12: 48px;
102
+ --kz-space-16: 64px;
103
+
104
+ /* ---- Radios ------------------------------------------------------------
105
+ No hay token para 0: eso es `0`, y darle nombre sólo agrega una
106
+ indirección que hay que resolver al leer. */
107
+ --kz-radius-sm: 4px;
108
+ --kz-radius-md: 8px;
109
+ --kz-radius-lg: 12px;
110
+ --kz-radius-xl: 20px;
111
+ --kz-radius-pill: 999px;
112
+ --kz-radius-circle: 50%;
113
+
114
+ /* ---- Tipografía -------------------------------------------------------- */
115
+ --kz-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
116
+ --kz-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
117
+
118
+ --kz-font-xs: 12px;
119
+ --kz-font-sm: 14px;
120
+ --kz-font-md: 16px;
121
+ --kz-font-lg: 20px;
122
+ --kz-font-xl: 26px;
123
+ --kz-font-2xl: 34px;
124
+
125
+ /* ---- Movimiento --------------------------------------------------------
126
+ Una sola duración para los cambios de estado de un control (hover, foco,
127
+ encendido). Lo que necesite otra es una animación, no una transición. */
128
+ --kz-duration-fast: 120ms;
129
+ --kz-ease: cubic-bezier(0.2, 0, 0, 1);
130
+ }
131
+
132
+ /*
133
+ Tema claro. Sólo se redefine lo que cambia —color y sombra—: espaciado,
134
+ radios y tipografía valen igual en los dos temas y repetirlos acá sería una
135
+ segunda fuente que algún día diverge.
136
+
137
+ Las superficies no se "aclaran" una a una: se invierte la relación. En
138
+ oscuro la página es lo más negro y las cards suben; en claro la página es
139
+ gris y las cards son blancas. Por eso 1, 2 y 3 son todas blancas y lo que
140
+ las separa del fondo es el hairline.
141
+ */
142
+ :root.light {
143
+ --kz-surface-0: #f4f6f9;
144
+ --kz-surface-1: #ffffff;
145
+ --kz-surface-2: #ffffff;
146
+ --kz-surface-3: #ffffff;
147
+ --kz-surface-sunken: #e8ecf1;
148
+ --kz-surface-hover: #eef1f5;
149
+ --kz-surface-active: #e2e7ee;
150
+
151
+ --kz-fg: #12161c;
152
+ --kz-fg-muted: #4d5762;
153
+ --kz-fg-subtle: #656f7c;
154
+
155
+ --kz-hairline: #e1e6ec;
156
+ --kz-hairline-strong: #c4ccd6;
157
+ --kz-ring: #1d4ed8;
158
+
159
+ --kz-accent: #1d62d8;
160
+ --kz-accent-hover: #1a56bf;
161
+ --kz-accent-active: #17499f;
162
+ --kz-accent-soft: rgba(29, 98, 216, 0.12);
163
+ --kz-accent-fg: #ffffff;
164
+
165
+ --kz-success: #11763d;
166
+ --kz-success-soft: rgba(17, 118, 61, 0.12);
167
+ --kz-success-fg: #ffffff;
168
+
169
+ --kz-danger: #c02626;
170
+ --kz-danger-soft: rgba(192, 38, 38, 0.12);
171
+ --kz-danger-fg: #ffffff;
172
+
173
+ --kz-warning: #96500a;
174
+ --kz-warning-soft: rgba(150, 80, 10, 0.12);
175
+ --kz-warning-fg: #ffffff;
176
+
177
+ --kz-info: #0b5f8a;
178
+ --kz-info-soft: rgba(11, 95, 138, 0.12);
179
+ --kz-info-fg: #ffffff;
180
+
181
+ --kz-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.08);
182
+ --kz-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.1);
183
+ --kz-shadow-lg: 0 10px 30px rgba(16, 24, 40, 0.12);
184
+ --kz-shadow-xl: 0 24px 60px rgba(16, 24, 40, 0.16);
185
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Nombres de los tokens de diseño de kaizen-lib (`--kz-*`).
3
+ *
4
+ * Esto NO declara valores: son referencias `var(...)` para escribir estilos sin
5
+ * literales mágicos y con autocompletado. Los valores viven en CSS —
6
+ * `defaults.css` acá, o el `globals.css` de cada producto.
7
+ *
8
+ * La regla es de taboo-next, que la aprendió al revés: su `tokens.ts` original
9
+ * declaraba los valores en TypeScript en paralelo a los de `globals.css`, y no
10
+ * lo importaba ni un componente. Dos fuentes de verdad, las dos ignoradas. Por
11
+ * eso acá sólo hay referencias, y `tokens.test.ts` falla si alguna se escapa.
12
+ *
13
+ * El contrato en una línea: si un token está acá, tiene que estar definido en
14
+ * `defaults.css`, y al revés. Los tests lo verifican en las dos direcciones.
15
+ */
16
+
17
+ /**
18
+ * Superficies por nivel de elevación. El uso de cada nivel es fijo — elegir
19
+ * por "cuánto quiero que resalte" es lo que termina en seis grises distintos
20
+ * que significan lo mismo.
21
+ *
22
+ * - `0` fondo de página
23
+ * - `1` panel o sección
24
+ * - `2` card dentro de un panel
25
+ * - `3` overlay: modal, popover, dropdown
26
+ *
27
+ * `sunken` no es un quinto nivel: va más oscuro que su contenedor (inputs,
28
+ * tracks, wells). `hover` y `active` son estados de una superficie
29
+ * clickeable, no elevaciones.
30
+ */
31
+ export const surface = {
32
+ 0: 'var(--kz-surface-0)',
33
+ 1: 'var(--kz-surface-1)',
34
+ 2: 'var(--kz-surface-2)',
35
+ 3: 'var(--kz-surface-3)',
36
+ sunken: 'var(--kz-surface-sunken)',
37
+ hover: 'var(--kz-surface-hover)',
38
+ active: 'var(--kz-surface-active)',
39
+ } as const;
40
+
41
+ /**
42
+ * Color de texto sobre una superficie. El texto que va encima de un color de
43
+ * acento o semántico es la ranura `fg` de ese tono (`accent.fg`, `danger.fg`).
44
+ */
45
+ export const fg = {
46
+ DEFAULT: 'var(--kz-fg)',
47
+ muted: 'var(--kz-fg-muted)',
48
+ subtle: 'var(--kz-fg-subtle)',
49
+ } as const;
50
+
51
+ /** Bordes de 1px. `strong` es para separar, no para decorar. */
52
+ export const hairline = {
53
+ DEFAULT: 'var(--kz-hairline)',
54
+ strong: 'var(--kz-hairline-strong)',
55
+ } as const;
56
+
57
+ /** Anillo de foco. Elemento no textual: el mínimo de contraste es 3:1. */
58
+ export const ring = 'var(--kz-ring)';
59
+
60
+ /**
61
+ * Acento de marca. `soft` es el fondo lavado de chips y estados
62
+ * seleccionados; encima de `soft` va el acento mismo, no `fg`.
63
+ */
64
+ export const accent = {
65
+ DEFAULT: 'var(--kz-accent)',
66
+ hover: 'var(--kz-accent-hover)',
67
+ active: 'var(--kz-accent-active)',
68
+ soft: 'var(--kz-accent-soft)',
69
+ fg: 'var(--kz-accent-fg)',
70
+ } as const;
71
+
72
+ /**
73
+ * Tonos semánticos. Tres ranuras cada uno: el color pleno, el fondo lavado y
74
+ * el texto que va encima del pleno.
75
+ */
76
+ export const success = {
77
+ DEFAULT: 'var(--kz-success)',
78
+ soft: 'var(--kz-success-soft)',
79
+ fg: 'var(--kz-success-fg)',
80
+ } as const;
81
+
82
+ export const danger = {
83
+ DEFAULT: 'var(--kz-danger)',
84
+ soft: 'var(--kz-danger-soft)',
85
+ fg: 'var(--kz-danger-fg)',
86
+ } as const;
87
+
88
+ export const warning = {
89
+ DEFAULT: 'var(--kz-warning)',
90
+ soft: 'var(--kz-warning-soft)',
91
+ fg: 'var(--kz-warning-fg)',
92
+ } as const;
93
+
94
+ export const info = {
95
+ DEFAULT: 'var(--kz-info)',
96
+ soft: 'var(--kz-info-soft)',
97
+ fg: 'var(--kz-info-fg)',
98
+ } as const;
99
+
100
+ export const shadow = {
101
+ sm: 'var(--kz-shadow-sm)',
102
+ md: 'var(--kz-shadow-md)',
103
+ lg: 'var(--kz-shadow-lg)',
104
+ xl: 'var(--kz-shadow-xl)',
105
+ } as const;
106
+
107
+ /** Escala de espaciado, en pasos de 4px. Las claves salteadas son a propósito. */
108
+ export const space = {
109
+ 1: 'var(--kz-space-1)',
110
+ 2: 'var(--kz-space-2)',
111
+ 3: 'var(--kz-space-3)',
112
+ 4: 'var(--kz-space-4)',
113
+ 5: 'var(--kz-space-5)',
114
+ 6: 'var(--kz-space-6)',
115
+ 8: 'var(--kz-space-8)',
116
+ 10: 'var(--kz-space-10)',
117
+ 12: 'var(--kz-space-12)',
118
+ 16: 'var(--kz-space-16)',
119
+ } as const;
120
+
121
+ /** Radios. Para 0 no hay token: se escribe `0`. */
122
+ export const radius = {
123
+ sm: 'var(--kz-radius-sm)',
124
+ md: 'var(--kz-radius-md)',
125
+ lg: 'var(--kz-radius-lg)',
126
+ xl: 'var(--kz-radius-xl)',
127
+ pill: 'var(--kz-radius-pill)',
128
+ circle: 'var(--kz-radius-circle)',
129
+ } as const;
130
+
131
+ export const font = {
132
+ family: 'var(--kz-font-family)',
133
+ mono: 'var(--kz-font-mono)',
134
+ xs: 'var(--kz-font-xs)',
135
+ sm: 'var(--kz-font-sm)',
136
+ md: 'var(--kz-font-md)',
137
+ lg: 'var(--kz-font-lg)',
138
+ xl: 'var(--kz-font-xl)',
139
+ '2xl': 'var(--kz-font-2xl)',
140
+ } as const;
141
+
142
+ /** Transición de un cambio de estado de control: hover, foco, encendido. */
143
+ export const motion = {
144
+ fast: 'var(--kz-duration-fast)',
145
+ ease: 'var(--kz-ease)',
146
+ } as const;
147
+
148
+ export type Surface = keyof typeof surface;
149
+ export type Space = keyof typeof space;
150
+ export type Radius = keyof typeof radius;
151
+ export type Shadow = keyof typeof shadow;
152
+ export type FontSize = Exclude<keyof typeof font, 'family' | 'mono'>;
153
+
154
+ /** Los cuatro tonos semánticos, para tipar la prop `tone` de un componente. */
155
+ export type Tone = 'success' | 'danger' | 'warning' | 'info';
156
+
157
+ export const tone = { success, danger, warning, info } as const;
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ import type { ComponentPropsWithoutRef, ElementType, MouseEvent } from 'react';
4
+ import styled, { css } from 'styled-components';
5
+ import { accent, danger, fg, font, motion, radius, ring, surface } from '../tokens';
6
+
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 };
16
+
17
+ /** Tope del badge: "+9" ocupa lo mismo que un dígito y dice lo mismo. */
18
+ const BADGE_MAXIMO = 9;
19
+
20
+ type Propios = {
21
+ /**
22
+ * Nombre accesible. Obligatorio: un botón de sólo ícono sin rótulo no existe
23
+ * para un lector de pantalla. También es el tooltip nativo, salvo que haya
24
+ * `motivo`.
25
+ *
26
+ * Si hay `badge`, la cuenta va **dentro** del label ("Notificaciones, 3 sin
27
+ * leer"): el número visual es decorativo (`aria-hidden`) y el idioma del
28
+ * texto no es asunto de esta librería.
29
+ */
30
+ label: string;
31
+ /** El ícono. La librería no trae íconos: cada producto usa los suyos. */
32
+ children: React.ReactNode;
33
+ size?: IconButtonSize;
34
+ /** Encendido: filtro aplicado, panel abierto, sección actual. */
35
+ active?: boolean;
36
+ /**
37
+ * Contador. `0`, `undefined` y negativos no dibujan nada: "0 avisos" no es
38
+ * un aviso. Pasado el tope se muestra "+9".
39
+ */
40
+ badge?: number;
41
+ /**
42
+ * Motivo por el que está bloqueado. Marca `aria-disabled` en vez del
43
+ * `disabled` nativo: un botón realmente deshabilitado no recibe mouse ni
44
+ * foco, así que nunca podría mostrar el motivo — que es lo único que hay
45
+ * para comunicar.
46
+ */
47
+ motivo?: string;
48
+ };
49
+
50
+ type Props<T extends ElementType> = Propios & {
51
+ /** Renderiza otro elemento o componente (un `<Link>` de Next, un `<a>`). */
52
+ as?: T;
53
+ } & Omit<ComponentPropsWithoutRef<T>, keyof Propios | 'as'>;
54
+
55
+ 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};
66
+ background: ${({ $active }) => ($active ? accent.soft : 'transparent')};
67
+ 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
+
86
+ ${({ $active }) =>
87
+ !$active &&
88
+ css`
89
+ &:hover:not([aria-disabled='true']) {
90
+ background: ${surface.hover};
91
+ color: ${fg.DEFAULT};
92
+ }
93
+
94
+ &:active:not([aria-disabled='true']) {
95
+ background: ${surface.active};
96
+ }
97
+ `}
98
+
99
+ &[aria-disabled='true'] {
100
+ opacity: 0.45;
101
+ cursor: not-allowed;
102
+ }
103
+ `;
104
+
105
+ const Contador = styled.span`
106
+ position: absolute;
107
+ top: 2px;
108
+ right: 2px;
109
+ min-width: 18px;
110
+ height: 18px;
111
+ padding: 0 4px;
112
+ border-radius: ${radius.pill};
113
+ background: ${danger.DEFAULT};
114
+ color: ${danger.fg};
115
+ font-size: ${font.xs};
116
+ font-weight: 700;
117
+ line-height: 18px;
118
+ text-align: center;
119
+ pointer-events: none;
120
+ `;
121
+
122
+ function textoDelBadge(badge: number | undefined): string | null {
123
+ if (badge === undefined || !Number.isFinite(badge) || badge < 1) return null;
124
+ return badge > BADGE_MAXIMO ? `+${BADGE_MAXIMO}` : String(Math.floor(badge));
125
+ }
126
+
127
+ /**
128
+ * Botón redondo de sólo ícono: el de la derecha de una topbar (notificaciones,
129
+ * usuario, idioma, tema).
130
+ *
131
+ * Sin `as` es un `<button type="button">`. Con `as` toma otro elemento y no le
132
+ * pone `type`, que en un `<a>` sería un atributo inválido.
133
+ */
134
+ export function IconButton<T extends ElementType = 'button'>({
135
+ label,
136
+ children,
137
+ size = 'md',
138
+ active = false,
139
+ badge,
140
+ motivo,
141
+ as,
142
+ onClick,
143
+ ...rest
144
+ }: Props<T>) {
145
+ const bloqueado = Boolean(motivo);
146
+ const textoBadge = textoDelBadge(badge);
147
+ const esBoton = as === undefined || as === 'button';
148
+
149
+ return (
150
+ <Raiz
151
+ {...rest}
152
+ as={as}
153
+ type={esBoton ? 'button' : undefined}
154
+ $size={size}
155
+ $active={active}
156
+ aria-label={label}
157
+ aria-pressed={esBoton && active ? true : undefined}
158
+ aria-current={!esBoton && active ? 'page' : undefined}
159
+ aria-disabled={bloqueado || undefined}
160
+ title={motivo ?? label}
161
+ onClick={(evento: MouseEvent<HTMLElement>) => {
162
+ if (bloqueado) {
163
+ evento.preventDefault();
164
+ return;
165
+ }
166
+ (onClick as ((e: MouseEvent<HTMLElement>) => void) | undefined)?.(evento);
167
+ }}
168
+ >
169
+ {children}
170
+ {textoBadge ? <Contador aria-hidden="true">{textoBadge}</Contador> : null}
171
+ </Raiz>
172
+ );
173
+ }
@@ -0,0 +1,2 @@
1
+ export { IconButton } from './IconButton';
2
+ export type { IconButtonSize } from './IconButton';