@netzi/tokens 0.1.0-beta.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 ADDED
@@ -0,0 +1,1008 @@
1
+ # @netzi/tokens
2
+
3
+ Design tokens de Netzi como **CSS custom properties**. Agnóstico de framework
4
+ (sirve para Angular, React, HTML plano, Tailwind, etc.). Es la base sobre la que
5
+ se construyen `@netzi/ui` y cualquier app de Netzi Tech.
6
+
7
+ - Tema **claro** (`:root`, tema claro) y **oscuro** (`[data-theme="dark"]`, Netzi navy).
8
+ - Acentos de marca constantes en ambos temas.
9
+ - Todos los tokens usan el prefijo `--netzi-*` para evitar colisiones.
10
+
11
+ ## Instalación
12
+
13
+ En el **npm público**, bajo el scope `@netzi` de Netzi Tech: sin registry especial ni
14
+ token. Mientras el kit esté en beta, cada versión nueva es una beta y es la que se
15
+ instala por defecto:
16
+
17
+ ```bash
18
+ pnpm add @netzi/tokens
19
+ ```
20
+
21
+ Licencia de uso libre con código privado: ver `LICENSE.md` dentro del paquete. Las
22
+ tipografías incluidas van bajo la SIL Open Font License 1.1.
23
+
24
+ ## Uso
25
+
26
+ Importa la entrada por defecto (tokens + reset base):
27
+
28
+ ```css
29
+ @import '@netzi/tokens';
30
+ ```
31
+
32
+ O por partes:
33
+
34
+ ```css
35
+ @import '@netzi/tokens/tokens.css'; /* solo las variables (sin reset) */
36
+ @import '@netzi/tokens/base.css'; /* reset + tipografía base + scrollbar */
37
+ @import '@netzi/tokens/fonts.css'; /* opcional: fuentes de marca, autoalojadas */
38
+ ```
39
+
40
+ En tu CSS, consume los tokens:
41
+
42
+ ```css
43
+ .boton {
44
+ background: var(--netzi-primary);
45
+ color: var(--netzi-on-primary);
46
+ border-radius: var(--netzi-radius-control);
47
+ box-shadow: var(--netzi-elevation-floating);
48
+ }
49
+ ```
50
+
51
+ Usa los tokens por **papel** (superficie, texto, espaciado de grupo, radio de control,
52
+ estilo de título), no los pasos de una escala: el papel dice para qué es y la escala de
53
+ debajo puede cambiar sin tocar tu app. Las escalas con nombre de paso (`--netzi-space-4`,
54
+ `--netzi-radius-sm`, `--netzi-font-size-md`…) están en desuso para las apps (ver
55
+ «Escalas que pasan a internas»).
56
+
57
+ ## Espaciado (`--netzi-space-*`)
58
+
59
+ Espaciado por **papel**, de lo más cercano a lo más lejano: cuanto más relacionadas están
60
+ dos cosas, menos aire hay entre ellas (proximidad). Úsalo en vez de píxeles o `rem` sueltos
61
+ para mantener el ritmo del sistema; detrás hay una escala de base 4px que es de la fuente.
62
+
63
+ | Token | Valor | Compacto | Para qué |
64
+ | ------------------------ | ----- | -------- | ----------------------------------------------------------------------------- |
65
+ | `--netzi-space-related` | 4px | 4px | piezas de un mismo elemento: icono y texto, chips de una lista, campo y ayuda |
66
+ | `--netzi-space-group` | 8px | 8px | elementos hermanos de un grupo: botones de una barra, cabecera y contenido |
67
+ | `--netzi-space-stack` | 12px | 10px | grupos apilados en un panel o un formulario: campos, filas, columnas |
68
+ | `--netzi-space-inset-sm` | 12px | 10px | relleno de un contenedor compacto: panel flotante, aviso |
69
+ | `--netzi-space-inset` | 16px | 12px | relleno de una tarjeta o un panel |
70
+ | `--netzi-space-inset-lg` | 24px | 20px | relleno de un diálogo, un panel lateral o una tarjeta grande |
71
+ | `--netzi-space-section` | 32px | 28px | entre secciones de una página |
72
+ | `--netzi-space-page` | 48px | 40px | entre regiones grandes de una página y su margen en pantallas anchas |
73
+
74
+ Bajo `[data-density='compact']` se reducen desde `stack` hacia arriba (los finos no
75
+ cambian), coherente con la densidad de los controles, también en una sección con su propia
76
+ densidad.
77
+
78
+ ```css
79
+ .tarjeta {
80
+ padding: var(--netzi-space-inset);
81
+ display: flex;
82
+ gap: var(--netzi-space-group);
83
+ }
84
+ ```
85
+
86
+ ## Fuentes de marca (`fonts.css`)
87
+
88
+ `fonts.css` trae **Plus Jakarta Sans** (texto) y **Fira Code** (monoespaciada) como
89
+ fuentes variables woff2 **dentro del paquete** (~95 KB en total, en dos subconjuntos
90
+ `latin` y `latin-ext` que el navegador descarga según haga falta). No se pide nada
91
+ a Google: política 14 (autoalojar) y 08b (sin terceros antes del consentimiento).
92
+ Va aparte de `index.css`; impórtalo si tu app usa las fuentes de marca:
93
+
94
+ ```css
95
+ @import '@netzi/tokens/fonts.css';
96
+ ```
97
+
98
+ Las rutas son relativas al paquete y las reescriben Vite y el CLI de Angular, que
99
+ copian los woff2 al build.
100
+
101
+ Mientras llegan, el texto se pinta con un **respaldo del sistema con las métricas
102
+ ajustadas** (Arial o Liberation Sans, y Courier New o Liberation Mono, con `size-adjust`,
103
+ `ascent-override`, `descent-override` y `line-gap-override`): ocupa casi lo mismo que la
104
+ fuente de marca y la página no salta al cambiar. Los tokens `--netzi-font-sans` y
105
+ `--netzi-font-mono` ya lo nombran detrás de la de marca; si cambias la familia en línea,
106
+ deja `'Plus Jakarta Sans Fallback'` (o `'Fira Code Fallback'`) justo detrás. De dónde
107
+ salen los números está en el propio `fonts.css`. Licencia OFL-1.1, adjunta en `styles/fonts/`. Ninguna hoja
108
+ publicada carga nada desde fuera: todo viaja dentro del paquete.
109
+
110
+ ## Tipografía (`--netzi-typography-*`)
111
+
112
+ Un **estilo de texto** por papel (título, cuerpo, metadato), con familia, tamaño, peso,
113
+ interlineado y espaciado entre letras juntos: es lo que usa una app. Todos escalan con
114
+ `--netzi-font-scale` (el ajuste de accesibilidad del panel), igual que los controles; `body-ui`
115
+ (14px) es el 100 %. El texto corrido (`body`) mide 16px desde la #219 (ADR 0015). Las familias son `--netzi-font-sans` (texto) y `--netzi-font-mono`
116
+ (código y cifras en columna).
117
+
118
+ **Medida legible** (`--netzi-measure`, `33em`, #213): el ancho máximo del texto corrido, para
119
+ que cada línea se quede en unos 40 a 75 caracteres (64 de mediana) en cualquier ventana (criterio
120
+ C.9). Va en el párrafo (`max-inline-size`), nunca en la página ni en la sección: el ancho que
121
+ sobra lo ocupan el contenido auxiliar, las acciones, otro panel, la rejilla o la prosa en columnas entre párrafos; un párrafo no se parte en columnas, y una descripción corta puede dejar hueco. Va en `em` y
122
+ no en `ch`: el `ch` es el ancho del cero de la fuente que pinta en ese momento, y al llegar la
123
+ fuente de marca cambiaría el ancho de cada párrafo y lo haría saltar (criterio C.12).
124
+
125
+ ```css
126
+ .descripcion {
127
+ max-inline-size: var(--netzi-measure);
128
+ }
129
+ ```
130
+
131
+ Los tamaños de los **controles** (`--netzi-control-fs-sm/md/lg`) son otra familia,
132
+ ligada a la altura del control; no salen de la escala de texto. La densidad compacta no
133
+ cambia el texto (encoge el aire, no la letra).
134
+
135
+ ### Estilos de texto
136
+
137
+ En CSS cada estilo es el atajo `font` (tokens `typography` de DTCG) y su espaciado entre letras
138
+ va aparte, porque `font` no lo incluye. El tamaño solo sale también aparte
139
+ (`--netzi-typography-body-font-size`, #155), para la hoja que pone el peso y el interlineado
140
+ por su cuenta y no quiere que el atajo se los pise:
141
+
142
+ | Estilo | Tamaño | Peso | Interlineado | Espaciado | Para qué |
143
+ | ----------------------------- | ------ | ---- | ------------ | --------- | ----------------------------------------- |
144
+ | `--netzi-typography-display` | 28px | 800 | 1.25 | -0.5px | cifras grandes, portadas |
145
+ | `--netzi-typography-headline` | 22px | 700 | 1.25 | -0.2px | títulos de página |
146
+ | `--netzi-typography-title` | 18px | 700 | 1.25 | 0 | títulos de sección |
147
+ | `--netzi-typography-title-sm` | 16px | 600 | 1.25 | 0 | texto destacado, títulos de tarjeta |
148
+ | `--netzi-typography-body` | 16px | 400 | 1.5 | 0 | texto corrido: párrafos, descripciones |
149
+ | `--netzi-typography-body-ui` | 14px | 400 | 1.5 | 0 | interfaz: opciones, pestañas, celdas |
150
+ | `--netzi-typography-body-sm` | 12.5px | 400 | 1.5 | 0 | líneas secundarias, pistas, pies |
151
+ | `--netzi-typography-caption` | 11px | 600 | 1.25 | 0.5px | metadatos, etiquetas cortas en mayúsculas |
152
+
153
+ ```css
154
+ .titulo-de-seccion {
155
+ font: var(--netzi-typography-title);
156
+ letter-spacing: var(--netzi-typography-title-letter-spacing);
157
+ }
158
+ .meta {
159
+ font: var(--netzi-typography-caption);
160
+ letter-spacing: var(--netzi-typography-caption-letter-spacing);
161
+ text-transform: uppercase;
162
+ }
163
+ .celda {
164
+ font-size: var(--netzi-typography-body-ui-font-size);
165
+ }
166
+ ```
167
+
168
+ Siguen a `--netzi-font-sans` si la app la cambia. El atajo `font` reinicia también el
169
+ interlineado y el peso: ponlo primero y cambia después lo que quieras (una familia mono para
170
+ un código, por ejemplo). Las mayúsculas de `caption` las pone quien lo usa (`text-transform`),
171
+ no el estilo. Detrás hay una escala de tamaños, pesos e interlineados que es de la fuente: sus
172
+ tokens (`--netzi-font-size-*`, `--netzi-font-weight-*`, `--netzi-line-height-*`) están en
173
+ desuso para las apps.
174
+
175
+ ## Movimiento (`--netzi-duration-*`, `--netzi-ease-*`)
176
+
177
+ Duraciones y curvas con **nombre de intención**, no de valor: el componente dice qué hace
178
+ (un hover, un panel que entra) y el token decide cuánto dura. Así el ritmo del kit se
179
+ cambia en un sitio y el movimiento reducido también.
180
+
181
+ | Duración | Valor | Para qué |
182
+ | -------------------------- | ----- | ----------------------------------------------------- |
183
+ | `--netzi-duration-instant` | 100ms | la respuesta a una pulsación |
184
+ | `--netzi-duration-fast` | 150ms | hover, foco, cambios de color; lo pequeño que aparece |
185
+ | `--netzi-duration-base` | 200ms | lo que se despliega o gira (flechas, acordeón) |
186
+ | `--netzi-duration-slow` | 280ms | los paneles y capas que entran (modal, drawer) |
187
+
188
+ | Curva | Valor | Para qué |
189
+ | ----------------------- | ---------------------------------- | ------------------------------------------ |
190
+ | `--netzi-ease-standard` | `cubic-bezier(0.25, 0.1, 0.25, 1)` | lo que cambia sin entrar ni salir (`ease`) |
191
+ | `--netzi-ease-enter` | `cubic-bezier(0.16, 1, 0.3, 1)` | lo que aparece: frena al llegar |
192
+ | `--netzi-ease-exit` | `cubic-bezier(0.4, 0, 1, 1)` | lo que se va: acelera al irse |
193
+ | `--netzi-ease-settle` | `cubic-bezier(0.34, 1.45, 0.5, 1)` | lo que llega, se pasa un poco y se asienta |
194
+
195
+ ### Bucles y destello
196
+
197
+ Lo que se repite sin fin no es un cambio de estado: su token da la duración de **una vuelta**
198
+ y queda fuera de la escala de arriba (que no pasa de 300 ms). El destello es un aviso que se
199
+ apaga solo, largo a propósito para que dé tiempo a verlo.
200
+
201
+ | Duración | Valor | Para qué |
202
+ | ----------------------------- | ------ | ----------------------------------------------------------------- |
203
+ | `--netzi-duration-loop-spin` | 700ms | el giro de un indicador de carga |
204
+ | `--netzi-duration-loop-orbit` | 1100ms | el anillo que recorre el borde de un ítem o una pestaña |
205
+ | `--netzi-duration-loop-pulse` | 1200ms | el latido de opacidad que sustituye al giro (movimiento reducido) |
206
+ | `--netzi-duration-loop-sweep` | 1200ms | la barra de progreso indeterminada |
207
+ | `--netzi-duration-loop-shine` | 1300ms | el brillo del skeleton |
208
+ | `--netzi-duration-flash` | 1400ms | un aviso que se enciende, se deja ver y se apaga solo |
209
+
210
+ | Curva | Valor | Para qué |
211
+ | -------------------- | -------------------------------- | -------------------------------------------------------------- |
212
+ | `--netzi-ease-swing` | `cubic-bezier(0.42, 0, 0.58, 1)` | lo que va y vuelve: latidos, barridos, brillos (`ease-in-out`) |
213
+ | `--netzi-ease-spin` | `cubic-bezier(0, 0, 1, 1)` | lo que gira sin fin a velocidad constante (`linear`) |
214
+
215
+ Los bucles **no cambian** con `prefers-reduced-motion`: una duración de bucle no para una
216
+ animación infinita. Cada componente quita su bucle o lo cambia por un latido de opacidad en
217
+ su propia regla reducida:
218
+
219
+ ```css
220
+ .cargando {
221
+ animation: giro var(--netzi-duration-loop-spin) var(--netzi-ease-spin) infinite;
222
+ }
223
+ @media (prefers-reduced-motion: reduce) {
224
+ .cargando {
225
+ animation: latido var(--netzi-duration-loop-pulse) var(--netzi-ease-swing) infinite;
226
+ }
227
+ }
228
+ ```
229
+
230
+ ### Desplazamiento y fundido: dos familias, un solo sitio para el movimiento reducido
231
+
232
+ Con `prefers-reduced-motion: reduce` lo que **se desplaza, gira, escala o cambia de
233
+ geometría** no debe moverse (WCAG 2.3.3), pero un cambio de color o de opacidad no es
234
+ movimiento y puede seguir suavizándose. Por eso hay dos familias:
235
+
236
+ - `--netzi-duration-{instant,fast,base,slow}` son las de los **fundidos** (color, fondo,
237
+ borde, sombra, opacidad). No cambian con la preferencia.
238
+ - `--netzi-duration-move-{fast,base,slow}` son las del **desplazamiento** (`transform`,
239
+ `translate`, `rotate`, `scale`, `top`/`left`, alturas, filas de rejilla, márgenes). Valen
240
+ lo mismo que su fundido, y bajo `@media (prefers-reduced-motion: reduce)` `tokens.css` las
241
+ pone a `0.01ms` **en un solo bloque**: todo lo que las usa salta a su estado final.
242
+
243
+ Van a `0.01ms` y no a `0` para que `transitionend` y `animationend` sigan llegando a quien
244
+ los espera (con duración cero no hay transición y el evento no se dispara nunca).
245
+
246
+ ```css
247
+ .panel {
248
+ transition:
249
+ transform var(--netzi-duration-move-slow) var(--netzi-ease-enter),
250
+ opacity var(--netzi-duration-slow) var(--netzi-ease-enter);
251
+ }
252
+ /* Con reduce, el panel ya no se desliza (salta) pero sigue apareciendo con un fundido:
253
+ no hace falta escribir ninguna regla @media. */
254
+ ```
255
+
256
+ Para cambiar el ritmo en tu app, redefine `--netzi-duration-{fast,base,slow}`: las `move`
257
+ apuntan a ellas y las siguen, y el bloque de movimiento reducido las sigue anulando. Si
258
+ redefines un token `move` directamente, tu regla de `:root` gana a la de `tokens.css` también
259
+ con la preferencia, así que repite la anulación en tu propio `@media`:
260
+
261
+ ```css
262
+ :root {
263
+ --netzi-duration-move-slow: 320ms;
264
+ }
265
+ @media (prefers-reduced-motion: reduce) {
266
+ :root {
267
+ --netzi-duration-move-slow: 0.01ms;
268
+ }
269
+ }
270
+ ```
271
+
272
+ Una transición que mueve con una duración de **fundido** sí sigue moviéndose con la
273
+ preferencia: elige la familia por lo que anima, no por cuánto dura. Si una capa mezcla
274
+ las dos (`transition: 0.15s`, que es `all`), necesita su propia regla reducida. Las
275
+ **animaciones** con `@keyframes` también: la duración de una animación infinita no la para.
276
+ En `@netzi/ui` lo vigilan dos guardianes: el de movimiento reducido entiende estos tokens
277
+ (una transición que solo mueve con `move` ya está reducida) y otro falla con cualquier
278
+ duración o curva literal en las hojas del kit, también en los bucles (solo pasan `0s` y el
279
+ `0.01ms` de la regla reducida).
280
+
281
+ La salida DTCG resuelta publica la escala sin la preferencia (tipos `duration` y
282
+ `cubicBezier`): cada plataforma nativa lee el ajuste de movimiento reducido de su sistema.
283
+
284
+ ## Tamaños de ventana (`--netzi-window-*`)
285
+
286
+ El diseño cambia en cinco **tamaños de ventana con nombre** (ADR 0016 del repo), siempre en
287
+ los mismos cortes. Cada token es el ancho en el que **empieza** ese tamaño:
288
+
289
+ | Token | Valor | Tamaño | Ancho | Paneles |
290
+ | ---------------------------- | ------ | ----------- | ------------ | ------- |
291
+ | (ninguno) | 0 | compacto | < 600 px | 1 |
292
+ | `--netzi-window-medium` | 600px | medio | 600–839 px | 1 o 2 |
293
+ | `--netzi-window-expanded` | 840px | expandido | 840–1199 px | 2 |
294
+ | `--netzi-window-large` | 1200px | grande | 1200–1599 px | 2 |
295
+ | `--netzi-window-extra-large` | 1600px | extragrande | ≥ 1600 px | hasta 3 |
296
+
297
+ `--netzi-window-reflow` (375px) no es un tamaño: es el corte de **reflujo** dentro de
298
+ compacto. Por debajo (teléfonos de 320 y 360 y el zoom al 400 %, WCAG 1.4.10) lo que va en
299
+ fila dentro de un panel flotante se apila.
300
+
301
+ Una `var()` no vale dentro de `@media`, así que en una hoja se escribe el número del token,
302
+ siempre en forma de rango y con el mismo sentido:
303
+
304
+ ```css
305
+ /* Compacto: por debajo del medio. */
306
+ @media (width < 600px) {
307
+ }
308
+ /* Del expandido hacia arriba. */
309
+ @media (width >= 840px) {
310
+ }
311
+ ```
312
+
313
+ En `@netzi/ui` un guardián falla con cualquier otro corte de ventana en las hojas del kit.
314
+ Lo que depende de su **propia caja** y no de la ventana (una rejilla que cabe o no en su
315
+ columna) usa una consulta de contenedor con nombre, `@container <nombre> (…)`. En el código,
316
+ `@netzi/ui` exporta los mismos cortes (`NETZI_WINDOW_BREAKPOINTS`) y una señal con el tamaño
317
+ actual (`NetziWindowSizeService`). La salida DTCG resuelta los publica como `dimension` en px.
318
+
319
+ ## Capas (`--netzi-layer-*`)
320
+
321
+ El orden de apilamiento (`z-index`) sale de una escala con **nombre de intención**: quien
322
+ pinta algo encima dice qué es (un desplegable, un aviso) y la escala decide qué tapa a qué.
323
+ De abajo arriba:
324
+
325
+ | Token | Valor | Para qué |
326
+ | ------------------------ | ----- | --------------------------------------------------------------------------- |
327
+ | `--netzi-layer-content` | 0 | el contenido de la página: el punto de partida |
328
+ | `--netzi-layer-sticky` | 2 | encabezados y columnas fijas (la tabla de datos) |
329
+ | `--netzi-layer-bar` | 20 | lo del marco que pasa sobre el contenido: plegar el menú, la barra de carga |
330
+ | `--netzi-layer-flyout` | 50 | submenús del menú plegado y menús contextuales anclados al contenido |
331
+ | `--netzi-layer-scrim` | 80 | el velo de un panel lateral que se abre encima (el menú en el móvil) |
332
+ | `--netzi-layer-modal` | 100 | diálogos y paneles laterales, con su velo dentro |
333
+ | `--netzi-layer-dropdown` | 120 | paneles anclados a un control y la paleta de comandos |
334
+ | `--netzi-layer-toast` | 200 | el aviso, que se ve aunque haya un modal o un desplegable abiertos |
335
+ | `--netzi-layer-tooltip` | 300 | la etiqueta de lo que tiene el puntero o el foco |
336
+ | `--netzi-layer-blocking` | 1000 | la pantalla que bloquea la app hasta resolver algo (sin conexión) |
337
+ | `--netzi-layer-top` | 10000 | lo que nunca puede quedar tapado: el enlace «Ir al contenido» con foco |
338
+
339
+ Reglas, las mismas que sigue `@netzi/ui` (un guardián falla con cualquier número suelto en
340
+ sus hojas):
341
+
342
+ - Dentro de la capa de un componente, el orden local va con `-1`, `0` o `1` (la capa de
343
+ cristal detrás de su panel, una celda fijada sobre las demás).
344
+ - Lo que tiene que quedar justo encima o debajo de otra cosa de su misma capa va un escalón
345
+ más allá: `calc(var(--netzi-layer-dropdown) + 1)`. Nunca más de uno: si hace falta más,
346
+ falta una capa en la escala.
347
+ - En `@netzi/ui` cada uso lleva el valor del token como respaldo
348
+ (`var(--netzi-layer-dropdown, 120)`): sin `tokens.css` el kit apila igual, y el guardián
349
+ comprueba que el número coincide con el token.
350
+ - Un desplegable dentro de un modal no necesita nada: el modal abre su propio contexto de
351
+ apilamiento y lo que lleva dentro va en su capa.
352
+
353
+ ```css
354
+ .mi-barra-fija {
355
+ position: sticky;
356
+ top: 0;
357
+ z-index: var(--netzi-layer-bar); /* por encima de los encabezados fijos de la tabla */
358
+ }
359
+ ```
360
+
361
+ Los huecos entre valores dejan sitio a la app: una capa propia entre dos del kit se escribe
362
+ con `calc()` sobre la de debajo. La salida DTCG publica la escala como `number`; en móvil el
363
+ orden lo da la composición, y la escala sirve de referencia si una pieza tiene que ordenar.
364
+
365
+ ## Forma y elevación (`--netzi-radius-*`, `--netzi-elevation-*`)
366
+
367
+ Radios por **papel**:
368
+
369
+ | Token | Valor | Compacto | Para qué |
370
+ | --------------------------- | ------ | -------- | --------------------------------------------------------------------------- |
371
+ | `--netzi-radius-control` | 6px | 5px | controles y piezas pequeñas: botones, campos, ítems de menú, iconos en caja |
372
+ | `--netzi-radius-surface-sm` | 8px | 6px | superficies pequeñas o pegadas a un control: el panel de un select, avisos |
373
+ | `--netzi-radius-surface` | 10px | 8px | superficies: tarjetas, diálogos, popovers, paneles |
374
+ | `--netzi-radius-pill` | 9999px | 9999px | lo del todo redondeado: píldoras, puntos, avatares, interruptores |
375
+
376
+ En una pieza cuadrada, `pill` es un círculo; en una alargada, una píldora. Sustituye a los
377
+ `999px` y `50%` sueltos, y la densidad no lo toca.
378
+
379
+ La elevación dice **cuánto se despega** una pieza de lo que tiene debajo, con nombre de
380
+ intención y de menos a más:
381
+
382
+ | Token | Para qué |
383
+ | ---------------------------- | -------------------------------------------------------------------------------- |
384
+ | `--netzi-elevation-base` | pegado a su superficie, sin sombra: para devolver a cero lo que la hereda |
385
+ | `--netzi-elevation-raised` | levantado sobre su superficie: el pulgar de un interruptor, una tecla |
386
+ | `--netzi-elevation-floating` | pequeño y suelto sobre el contenido: el tooltip, la etiqueta de una gráfica |
387
+ | `--netzi-elevation-modal` | lo que se abre encima y lo aparta: desplegables, menús, aviso, diálogos, paneles |
388
+
389
+ ```css
390
+ .mi-tarjeta-flotante {
391
+ border: 1px solid var(--netzi-border);
392
+ border-radius: var(--netzi-radius-surface);
393
+ box-shadow: var(--netzi-elevation-floating);
394
+ }
395
+ .mi-punto {
396
+ border-radius: var(--netzi-radius-pill);
397
+ }
398
+ ```
399
+
400
+ Una sombra **nunca es lo único** que separa una capa: lleva su borde. En oscuro las sombras son
401
+ más densas, en contraste alto `raised` se marca más y en papel no hay ninguna. La salida DTCG
402
+ publica los mismos niveles (tipo `shadow`): en móvil cada plataforma pinta el nivel a su
403
+ manera.
404
+
405
+ ## Tema claro / oscuro
406
+
407
+ El tema se controla con el atributo `data-theme` en un ancestro (normalmente `<html>`):
408
+
409
+ ```html
410
+ <html data-theme="dark">
411
+ ...
412
+ </html>
413
+ ```
414
+
415
+ Sin el atributo, aplica el tema claro (`:root`). Con `data-theme="light"`, una sección vuelve
416
+ al claro dentro de una página oscura (#237): pinta lo mismo que una página clara con el preset,
417
+ el contraste y el cristal de la página. Si tu app redefine tokens en `:root` para el claro,
418
+ ponlos también en `[data-theme='light']`, como haces en `[data-theme='dark']` para el oscuro.
419
+
420
+ ## Impresión
421
+
422
+ Al imprimir, el papel sale claro, opaco y quieto sin que la app haga nada:
423
+
424
+ - Los bloques que fijan `data-theme`, `data-glass` o `data-ambient` van en
425
+ `@media not print`: en papel no aplican y sale el tema claro y opaco, con el preset, el
426
+ contraste alto y la densidad intactos.
427
+ - En `@media print`, la raíz pierde las sombras y el desenfoque del velo, y cada duración
428
+ baja a `0.01ms`.
429
+ - El bloque de papel se declara en la raíz y en `[data-contrast='high']`, que redefine la
430
+ sombra pequeña: una sección con su propio contraste alto tampoco imprime sombras.
431
+ - El contrato de contraste se comprueba también en papel: un color que el contexto de
432
+ impresión redefina y no llegue a su mínimo rompe el build.
433
+ - `base.css` pone el papel en blanco, subraya los enlaces (en blanco y negro el color no los
434
+ distingue) y quita animaciones y transiciones a medio camino.
435
+ - Con el atributo `data-print-urls` en un enlace o en un contenedor, cada enlace externo
436
+ imprime su dirección detrás. Es opcional: en toda la página llenaría de direcciones el menú.
437
+
438
+ En `@netzi/ui`, el marco de `netzi-layout` y las capas flotantes no se imprimen. Un modal o
439
+ un drawer abierto sí: una vez, sin el velo y en el flujo de la página, donde está el
440
+ componente, sin tapar nada. Fijo, el navegador lo repetiría encima de cada hoja, y puede ser
441
+ justo lo que se quiere en papel (un recibo).
442
+
443
+ Lo propio de la app que no va en papel se oculta con su regla `@media print`. Si redefines
444
+ un token del tema oscuro en tu hoja, envuélvelo también en `@media not print`.
445
+
446
+ ## Overrides (white-label por app)
447
+
448
+ El mecanismo de personalización **sobrevive a las actualizaciones**: redefine los
449
+ tokens que quieras en tu propio scope. Las nuevas versiones de `@netzi/tokens`
450
+ cambian solo los _defaults_; nunca pisan lo que tu app sobreescribe.
451
+
452
+ ```css
453
+ /* tema de la app "Acme": radios propios */
454
+ :root {
455
+ --netzi-radius: 8px;
456
+ --netzi-radius-lg: 14px;
457
+ }
458
+ ```
459
+
460
+ El movimiento se personaliza por las duraciones de fundido (`--netzi-duration-{fast,base,slow}`),
461
+ que arrastran a las de desplazamiento. Redefinir un `--netzi-duration-move-*` obliga a repetir
462
+ su `0.01ms` dentro de tu `@media (prefers-reduced-motion: reduce)`: ver «Movimiento».
463
+
464
+ Los colores no se cambian de uno en uno: cada token de color se pinta encima o
465
+ debajo de otro, y cambiar solo uno rompe el par. Para poner el color de tu marca,
466
+ sigue el contrato de la sección siguiente.
467
+
468
+ ## Estados de interacción
469
+
470
+ Hover, foco, presionado, arrastrado, seleccionado y deshabilitado son un solo sistema (#156).
471
+ Cada estado es una **capa** translúcida del color del texto que se pinta encima del fondo del
472
+ control, sin cambiarlo; la opacidad va aparte para quien pinte la capa con su propio color:
473
+
474
+ | Token | Opacidad | Para qué |
475
+ | -------------------------------- | -------- | ---------------------------------------------------- |
476
+ | `--netzi-state-hover` | 0.08 | El puntero encima |
477
+ | `--netzi-state-focus` | 0.12 | Lo enfocado dentro de un compuesto (más su anillo) |
478
+ | `--netzi-state-pressed` | 0.12 | Mientras se pulsa, con el ratón o con el dedo |
479
+ | `--netzi-state-dragged` | 0.16 | La pieza que se lleva con el puntero |
480
+ | `--netzi-state-selected` | — | Lo seleccionado: el tinte del primario |
481
+ | `--netzi-state-disabled-opacity` | 0.6 | Lo deshabilitado, sobre el control entero |
482
+ | `--netzi-primary-pressed` | — | El relleno de marca presionado (cada preset el suyo) |
483
+
484
+ ```css
485
+ .mi-control:active:not(:disabled) {
486
+ background-image: linear-gradient(var(--netzi-state-pressed), var(--netzi-state-pressed));
487
+ }
488
+ ```
489
+
490
+ Como imagen de fondo la capa no se interpola: aparece al instante, también con movimiento
491
+ reducido. El build comprueba que `--netzi-text` llega a 4.5:1 sobre cada superficie con
492
+ cualquier capa encima y que el texto deshabilitado se queda a 3:1. Un relleno de marca no lleva
493
+ la capa (acercaría el fondo a su tinta): usa `--netzi-primary-pressed`.
494
+
495
+ ## Marca propia (`@netzi/tokens/brand`)
496
+
497
+ La marca va en el **primario**, no en `--netzi-blue`. `--netzi-blue` es un acento de
498
+ Netzi y además el color de estado `--netzi-info`, y `--netzi-on-accent` (la tinta de
499
+ los contadores y de los rellenos de acento) se mide sobre él: cambiarlo a un morado
500
+ deja esa tinta en 3.35:1. Si aun así lo cambias, redefine con él `--netzi-blue-text`,
501
+ `--netzi-blue-dark` y `--netzi-on-accent`, y comprueba el contraste de los tres.
502
+
503
+ ### El contrato: ocho tokens que se redefinen juntos
504
+
505
+ | Token | Papel | Se mide sobre | Mínimo |
506
+ | ------------------------- | --------------------------------------------- | ----------------------------------------------- | ------ |
507
+ | `--netzi-primary` | Relleno de marca: botón, interruptor, barra | (fondo de `on-primary`) | — |
508
+ | `--netzi-primary-hover` | El relleno al pasar el puntero | (fondo de `on-primary`) | — |
509
+ | `--netzi-primary-pressed` | El relleno presionado (#156) | (fondo de `on-primary`) | — |
510
+ | `--netzi-on-primary` | Tinta sobre el relleno, su hover y presionado | `primary`, `primary-hover`, `primary-pressed` | 4.5:1 |
511
+ | `--netzi-primary-dark` | Relleno de lo marcado (checkbox, radio) | cada superficie | 3:1 |
512
+ | `--netzi-on-primary-dark` | Tinta sobre el primario oscuro (blanco) | `primary-dark` | 3:1 |
513
+ | `--netzi-primary-text` | El primario que se lee (enlaces, ítem activo) | cada superficie y `primary-tint` sobre cada una | 4.5:1 |
514
+ | `--netzi-primary-graphic` | Lo no textual que comunica (progreso, anillo) | cada superficie y `primary-tint` sobre cada una | 3:1 |
515
+
516
+ Lo demás sale solo del primario: los tintes (`--netzi-primary-tint`, 10 % en claro y
517
+ 15 % en oscuro; `--netzi-primary-tint-strong`, 22 % y 28 %), el anillo de foco
518
+ (`--netzi-focus-ring` es `--netzi-primary-text`) y el fondo ambiental. Pero el
519
+ relleno también se mide por ellos: `--netzi-text` tiene que llegar a 4.5:1 sobre el
520
+ tinte marcado y el pulgar de las barras de desplazamiento (`--netzi-text-2`) a 3:1
521
+ sobre el suave. Todo en claro y en oscuro, sobre `bg`, `surface`, `surface-2` y
522
+ `surface-3`; el alto contraste no cambia el primario y solo sube los textos.
523
+
524
+ ### Derivarlo de un color
525
+
526
+ `deriveBrand` devuelve los ocho tokens de cada modo ya calibrados. El relleno es tu
527
+ color tal cual mientras alguna de las dos tintas del kit (`#04121f` o blanco) llegue a
528
+ 4.5:1 encima; en los tonos medios no llega ninguna, y entonces se mueve al tono más
529
+ cercano que sí (`adjusted: true`). El texto, el gráfico y el primario oscuro son el
530
+ tono más cercano al relleno que llega a su mínimo en cada modo; el hover oscurece lo
531
+ justo para notarse sin perder la tinta, y el presionado es el hover con la capa de
532
+ presionado (12 %) del extremo contrario a la tinta, que la aleja en vez de acercarla.
533
+
534
+ ```js
535
+ import { brandVariables, deriveBrand, validateBrand } from '@netzi/tokens/brand';
536
+
537
+ const marca = deriveBrand('#6c3cff'); // { color, adjusted, light, dark }
538
+ validateBrand(marca).ok; // true
539
+ brandVariables(marca.dark); // { '--netzi-primary': '#6c3cff', '--netzi-primary-text': '#9f80ff', … }
540
+ ```
541
+
542
+ En CSS, el resultado para el morado de «Acme». Carga esta hoja **después** de
543
+ `@netzi/tokens` y no pongas `data-preset`, que redefine el primario:
544
+
545
+ <!-- ejemplo-marca -->
546
+
547
+ ```css
548
+ /* Marca "Acme": deriveBrand('#6c3cff') de @netzi/tokens/brand */
549
+ [data-theme='light'],
550
+ :root {
551
+ --netzi-primary: #6c3cff;
552
+ --netzi-primary-hover: #5f35e0;
553
+ --netzi-primary-pressed: #542fc5;
554
+ --netzi-primary-dark: #6c3cff;
555
+ --netzi-primary-text: #693af7;
556
+ --netzi-primary-graphic: #6c3cff;
557
+ --netzi-on-primary: #ffffff;
558
+ --netzi-on-primary-dark: #ffffff;
559
+ }
560
+
561
+ [data-theme='dark'] {
562
+ --netzi-primary: #6c3cff;
563
+ --netzi-primary-hover: #5f35e0;
564
+ --netzi-primary-pressed: #542fc5;
565
+ --netzi-primary-dark: #764aff;
566
+ --netzi-primary-text: #9f80ff;
567
+ --netzi-primary-graphic: #7f55ff;
568
+ --netzi-on-primary: #ffffff;
569
+ --netzi-on-primary-dark: #ffffff;
570
+ }
571
+ ```
572
+
573
+ La regla clara va también en `[data-theme='light']`: el paquete vuelve a declarar ahí los
574
+ valores claros de las capas para una sección clara dentro de una página oscura (#237), y sin
575
+ ella la sección perdería la marca. Un test del paquete lee este bloque: si deja de cumplir, o
576
+ de ser lo que da `deriveBrand('#6c3cff')`, la suite se pone roja.
577
+
578
+ ### Validar una marca hecha a mano
579
+
580
+ `validateBrand({ light, dark })` mide cada par del contrato y devuelve
581
+ `{ ok, checks, failures }`; cada fallo dice el modo, la tinta, el fondo, el ratio y el
582
+ mínimo. Lanza `TypeError` si falta un modo o un token no es `#rgb` o `#rrggbb`. Mide contra las
583
+ superficies del tema por defecto: si tu app redefine también las superficies, ya no
584
+ es este contrato, y conviene medir el resultado en la página. Sobre las capas de
585
+ **cristal** el texto se lee contra lo que haya detrás; el kit lo comprueba para sus
586
+ presets, no para una marca propia.
587
+
588
+ En Angular, `@netzi/ui` lo hace por ti: `provideNetziBrand('#6c3cff')` (o
589
+ `NetziThemeService.setBrand`) deriva la marca, la escribe en una hoja propia por encima
590
+ del preset (en claro, en cada sección clara y en cada sección oscura) y, en desarrollo, avisa en la consola si el color no cumple tal cual.
591
+
592
+ El contrato no es una copia: un test del paquete mete lo que deriva `deriveBrand` en la
593
+ fuente DTCG y le pasa el mismo contrato de contraste que el build, en claro, oscuro y
594
+ alto contraste.
595
+
596
+ ## Catálogo de tokens
597
+
598
+ | Grupo | Tokens |
599
+ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
600
+ | Estado | `--netzi-info` `--netzi-success` `--netzi-warning` `--netzi-danger` (+ su `-text`) |
601
+ | Superficies | `--netzi-bg` `--netzi-surface` `--netzi-surface-2` `--netzi-surface-3` |
602
+ | Bordes | `--netzi-border` `--netzi-border-2` |
603
+ | Flotantes | `--netzi-float-bg` `--netzi-float-border` `--netzi-float-border-2` `--netzi-float-surface` (`-2`, `-3`) `--netzi-float-filter` `--netzi-float-glow` |
604
+ | Velo modal | `--netzi-scrim` `--netzi-scrim-filter` |
605
+ | Lente | `--netzi-float-lens` `--netzi-float-lens-size` (opt-in, ver abajo) |
606
+ | Texto | `--netzi-text` `--netzi-text-2` `--netzi-text-3` `--netzi-on-accent` |
607
+ | Elevación | `--netzi-elevation-{base,raised,floating,modal}` |
608
+ | Acento | `--netzi-tint` `--netzi-primary-tint` `--netzi-primary-tint-strong` |
609
+ | Forma | `--netzi-radius-control` `--netzi-radius-surface` `--netzi-radius-surface-sm` `--netzi-radius-pill` |
610
+ | Controles | `--netzi-control-h-{sm,md,lg}` `--netzi-control-px-*` `--netzi-control-fs-*` `--netzi-touch-target` `--netzi-touch-gap` |
611
+ | Tipografía | `--netzi-font-sans` `--netzi-font-mono` `--netzi-font-scale` `--netzi-typography-*` `--netzi-measure` |
612
+ | Espaciado | `--netzi-space-{related,group,stack,section,page}` `--netzi-space-inset` (`-sm`, `-lg`) |
613
+ | Movimiento | `--netzi-duration-{instant,fast,base,slow}` `--netzi-duration-move-{fast,base,slow}` `--netzi-ease-{standard,enter,exit}` |
614
+ | Capas | `--netzi-layer-{content,sticky,bar,flyout,scrim,modal,dropdown,toast,tooltip,blocking,top}` |
615
+
616
+ El catálogo son los tokens **públicos**: los que puedes usar y redefinir. La lista exacta va
617
+ en el paquete (`public-tokens.json`, ver «Públicos e internos»).
618
+
619
+ Un token que **no** existe no da error: la declaración entera se descarta y el elemento se
620
+ queda sin fondo, sin color o sin radio, sin aviso en build ni en consola. Por eso cada papel
621
+ tiene **un solo nombre**: los sinónimos que había están en desuso (ver abajo).
622
+
623
+ ### Un panel de cristal entra por transición, nunca por animación
624
+
625
+ Una animación CSS terminada se queda retenida en el elemento y, si animó
626
+ `opacity`, Chromium deja el panel compuesto como su propia raíz de fondo: el
627
+ `backdrop-filter` de su capa de cristal solo ve el fondo del propio panel y la
628
+ página se lee nítida a través de él. Por eso todos los paneles del kit entran con
629
+ `transition` + `@starting-style`, y un guardián de la suite lo vigila. Si añades
630
+ una capa de cristal nueva, no le pongas `@keyframes` de entrada.
631
+
632
+ ### La lente del canto, y por qué es opt-in
633
+
634
+ Con `data-glass-lens="on"` en la raíz, el borde de las capas modales (modal,
635
+ drawer, paleta ⌘K) lleva **su propio desenfoque**, más fuerte que el del interior:
636
+ es lo que da sensación de espesor.
637
+
638
+ No es refracción de verdad —el fondo no se curva— y **hoy no puede serlo**:
639
+ Chromium acepta `backdrop-filter: url(#filtro-svg)` y luego lo ignora (el mismo
640
+ filtro como `filter` normal sí deforma, así que el problema es el
641
+ `backdrop-filter`, no el filtro), y Safari ni lo acepta.
642
+
643
+ Es opt-in porque **cuesta**: es una segunda capa de `backdrop-filter`. Medido con
644
+ el fondo en movimiento:
645
+
646
+ | escenario | fps |
647
+ | ------------------------------------ | ----- |
648
+ | una capa con cristal | 60 |
649
+ | una capa con cristal **+ lente** | 60 |
650
+ | tres capas apiladas | 30-38 |
651
+ | tres capas apiladas **+ lente** | 26 |
652
+ | capa con lente, con el shell apagado | 51 |
653
+
654
+ Con una sola capa en pantalla sale gratis; apilada, se nota. La última fila es
655
+ un experimento **descartado**: apagar el filtro del topbar y del sidebar bajo una
656
+ capa modal daba esos frames, pero el velo es translúcido y el salto de tono del
657
+ shell se veía al abrir. `NetziOverlayService` y su marca `data-netzi-overlay="open"`
658
+ se quedan para que la app reaccione a «hay algo encima» (pausar una animación
659
+ cara, un vídeo de fondo).
660
+
661
+ ### Estados y tintes: relleno o texto, nunca los dos
662
+
663
+ `--netzi-success` y compañía son **relleno** (un punto de estado, un fondo). Para texto va su
664
+ `-text`, que es la versión que llega a 4.5:1. Con los tintes del primario pasa lo mismo por otro
665
+ lado: `--netzi-primary-tint` (10 %) es para superficies grandes y aguanta `--netzi-primary-text`
666
+ justo; en una pieza pequeña se pierde contra el fondo, y para eso está
667
+ `--netzi-primary-tint-strong` (22 %), cuya tinta es `--netzi-text`.
668
+
669
+ Lo mismo vale para el primario y los acentos a secas: `--netzi-primary`,
670
+ `--netzi-blue`, `--netzi-green`… son **relleno** y no llegan a 4.5:1 como texto
671
+ sobre fondo claro (`--netzi-blue` da 2.54:1 sobre el fondo y 2.68:1 sobre blanco;
672
+ `--netzi-primary-dark` baja a 4.23:1 en `surface-3`). Si se lee, va
673
+ `--netzi-primary-text` o el `-text` del color, que están calibrados por tema y por
674
+ preset. Es lo que le pasó al botón `soft` (#56).
675
+
676
+ Y un relleno del primario tiene **dos fondos oscuros** según lo que lleve encima.
677
+ `--netzi-primary-hover` es el del estado hover de un botón relleno: su tinta es la
678
+ misma `--netzi-on-primary` del reposo, y cada preset lo calibra para que no baje de
679
+ 4.5:1 (5.4:1 en azul). `--netzi-primary-dark` es el fondo de lo que lleva **blanco**
680
+ (`--netzi-on-primary-dark`): la pista de un interruptor encendido, la marca de un
681
+ checkbox. Si se cruzan, la tinta oscura sobre el azul oscuro se queda en 3.9:1 (#68).
682
+
683
+ ### Lo no textual que comunica: `--netzi-primary-graphic`
684
+
685
+ El relleno de una barra de progreso, un anillo de carga, una barra de navegación: no son
686
+ texto, pero dicen algo, y WCAG 1.4.11 les pide **3:1** frente a lo que tienen al lado. El
687
+ primario crudo no llega en claro con azul (2.15:1 en el peor fondo), verde (1.36), naranja
688
+ (1.78), rojo (2.51) ni cian (1.92). `--netzi-primary-graphic` es el tono más cercano al
689
+ primario que llega a 3:1 sobre cada superficie y sobre `--netzi-primary-tint` (el fondo de un
690
+ ítem activo), calibrado por preset y por tema; su contrato `non-text` lo comprueba el build en
691
+ las 24 combinaciones (#140). `--netzi-primary-text` también cumple, pero se aleja más del
692
+ color de marca.
693
+
694
+ Dos casos en que no basta:
695
+
696
+ - **Lo que late en opacidad.** En el valle del latido el color se mezcla con el fondo y
697
+ baja; con el valle de `netzi-progress` (0,75) `--netzi-primary-graphic` se queda por debajo de
698
+ 3:1. Lo que late va con `--netzi-primary-text`, que deja margen.
699
+ - **Los colores de estado.** Una barra de éxito, aviso o error va con su `-text`
700
+ (`--netzi-success-text`…) o con su `-graphic` (abajo): el crudo da 1.43:1 (verde) o
701
+ 1.50:1 (amarillo) en claro.
702
+
703
+ Un degradado cuenta en cada punto que se ve: si se desvanece hasta transparente, su cola
704
+ baja de cualquier mínimo. El del kit va de un primario que cumple a otro que cumple.
705
+
706
+ ### Lo no textual de un estado: `--netzi-*-graphic` y `--netzi-*-veil`
707
+
708
+ La estrella marcada, el punto de presencia de un avatar, el borde de un campo inválido o el
709
+ icono de un resultado también son no textuales que comunican, y el crudo del estado no llega
710
+ en claro: azul 2.08:1, verde 1.33, amarillo 1.41 y rojo 2.15 en el peor fondo (#189). Cada
711
+ estado tiene su gráfico, el tono más cercano al crudo que llega a **3:1** sobre cada superficie
712
+ y sobre su propio velo. En oscuro el crudo ya cumple y es su valor.
713
+
714
+ | Token | Claro | Oscuro | Velo (14 %) |
715
+ | ------------------------- | --------- | --------- | ---------------------- |
716
+ | `--netzi-info-graphic` | `#0e84cd` | `#0ba5ff` | `--netzi-info-veil` |
717
+ | `--netzi-success-graphic` | `#069047` | `#0ae972` | `--netzi-success-veil` |
718
+ | `--netzi-warning-graphic` | `#9d7a0c` | `#f0c040` | `--netzi-warning-veil` |
719
+ | `--netzi-danger-graphic` | `#df464b` | `#ff6b6b` | `--netzi-danger-veil` |
720
+
721
+ Los alias de estado apuntan a los de marca (`--netzi-blue-graphic`, `--netzi-green-graphic`,
722
+ `--netzi-yellow-graphic`, `--netzi-red-graphic`), como los `-text`. El velo es el fondo redondo
723
+ de un icono de estado: el contrato `non-text` de cada gráfico lo mide compuesto sobre cada
724
+ superficie, en las 24 combinaciones de modo, contraste y preset. Si pones un icono sobre una
725
+ mezcla más opaca, ya no está medido.
726
+
727
+ ### Con el dedo, objetivos de 44 px (`--netzi-touch-target`)
728
+
729
+ Con puntero táctil (`@media (pointer: coarse)`) nada de lo que se toca en un campo baja de
730
+ `--netzi-touch-target` (44 px, WCAG 2.5.5). Los tokens suben `--netzi-control-h-sm` y
731
+ `--netzi-control-h-md` a 46 px también con la densidad compacta (el borde de 1 px del
732
+ contenedor no le quita al input), y cada componente agranda lo suyo: filas de casillas y
733
+ radios, opciones, días del calendario, chips, estrellas, la franja de los sliders… Los
734
+ iconos pequeños (las × de los chips, la flecha de un desplegable) amplían solo su zona de toque, sin mover
735
+ el dibujo. Con ratón no cambia nada. Una app que ponga controles propios puede usar el
736
+ mismo token en su `@media (pointer: coarse)`.
737
+
738
+ Entre dos objetivos vecinos queda además `--netzi-touch-gap` (8 px, criterio A.5 de la
739
+ política 17), medido entre zonas de toque y no entre dibujos: botones de la paginación,
740
+ estrellas, pestañas, chips, celdas, días del calendario, ítems de un menú, la barra del
741
+ editor… Las piezas de un control segmentado se tocan a propósito y quedan fuera, como pide
742
+ la política. También quedan fuera, mientras la política no las recoja (#226), las piezas de
743
+ un campo de una línea (el input y su botón: −/+, el ojo, la flecha del desplegable) y las
744
+ opciones de una lista. El token es para usarlo en un `@media (pointer: coarse)` propio; con
745
+ ratón, cada componente conserva su hueco.
746
+
747
+ ### El foco va con `--netzi-focus-ring`, no con el primario
748
+
749
+ Un indicador de foco, contorno o borde, tiene que llegar a **3:1** sobre el fondo (WCAG
750
+ 1.4.11). El primario no llega en claro con azul (2.7:1), verde (1.6), naranja (2.2) ni cian
751
+ (2.4). `--netzi-focus-ring` se apoya en `--netzi-primary-text`, que cada preset y cada tema
752
+ calibran a 4.5:1 sobre las superficies, también en alto contraste. Se declara en la raíz y
753
+ en cada elemento con `data-theme` o `data-preset`, así que una sección oscura dentro de una
754
+ página clara tiene su propio anillo.
755
+
756
+ Todo `:focus`, `:focus-visible` y `:focus-within` del kit lo usa como contorno o borde (los
757
+ halos del tinte no cuentan: dan 1.1:1). Dos guardianes de la suite del kit impiden volver
758
+ al primario y quitar el contorno sin poner otro indicador (#76).
759
+
760
+ Sobre una capa de **cristal** el anillo se ve contra lo que haya detrás: sobre el fondo del
761
+ kit cumple, pero con una imagen muy oscura (en claro) o muy clara (en oscuro) debajo puede
762
+ bajar de 3:1. Si la app pone cristal sobre fondos así, conviene darle a esa capa un tinte
763
+ más opaco.
764
+
765
+ ## Los tres niveles de texto
766
+
767
+ Tienen **papeles distintos**, no son tres grados de gris intercambiables. El ratio
768
+ es el peor de las cuatro superficies del tema (`bg`, `surface`, `surface-2`,
769
+ `surface-3`); `surface-3` es siempre el caso malo.
770
+
771
+ | Token | Claro | Oscuro | Para qué |
772
+ | ---------------- | ------ | ------ | ---------------------------------------------------------------------------------- |
773
+ | `--netzi-text` | 13.7:1 | 15.2:1 | Títulos, valores y cuerpo. |
774
+ | `--netzi-text-2` | 4.8:1 | 5.0:1 | **Cualquier** texto secundario: rótulos, ayudas, pies, metadatos, placeholders. |
775
+ | `--netzi-text-3` | 3.2:1 | 3.3:1 | **No textual**: iconos decorativos, chevrons, separadores, líneas, deshabilitados. |
776
+
777
+ WCAG 2.1 pide **4.5:1** para texto normal (SC 1.4.3) y **3:1** para lo no textual
778
+ con significado (SC 1.4.11). Por eso `--netzi-text-3` **no vale para texto**: si
779
+ lo que pintas se lee, va `--netzi-text-2`.
780
+
781
+ Con `data-contrast='high'` cada nivel toma el valor del de encima: `text-2` sube a
782
+ 11.3:1 en claro y 7.2:1 en oscuro, y `text-3` a 4.8:1 y 5.0:1, así que el modo cumple
783
+ incluso donde alguien haya usado `text-3` para un rótulo.
784
+
785
+ Los ratios no son una promesa del README: cada token que se pinta sobre algo declara en
786
+ la fuente su **contrato de contraste** (texto, 4.5:1; no textual, 3:1; y sobre qué fondos),
787
+ y el build lo comprueba en las 24 combinaciones de modo, contraste y preset antes de
788
+ generar `tokens.css`. Si un valor deja de cumplir, el paquete no se construye. Ver
789
+ «Cómo se genera» al final.
790
+
791
+ ## Capas flotantes y superficies de cristal
792
+
793
+ Las capas que se pintan **encima** del contenido —topbar, sidebar, paneles
794
+ anclados, modal, drawer, menús y toasts— no usan `--netzi-surface` directamente,
795
+ sino su propio juego de tokens:
796
+
797
+ | Token | Para qué |
798
+ | ------------------------- | ----------------------------------------------- |
799
+ | `--netzi-float-bg` | Fondo de la capa |
800
+ | `--netzi-float-border` | Su borde |
801
+ | `--netzi-float-border-2` | Su borde marcado |
802
+ | `--netzi-float-surface` | Lo ANIDADO dentro de la capa: la lámina misma |
803
+ | `--netzi-float-surface-2` | … a un paso de la lámina (tarjetas, hovers) |
804
+ | `--netzi-float-surface-3` | … a dos pasos |
805
+ | `--netzi-float-filter` | Valor de `backdrop-filter` (por defecto `none`) |
806
+
807
+ Cada capa re-escopa `--netzi-surface`, `-2` y `-3` a los tres `float-surface`, así
808
+ que **todo lo que va dentro** (una tarjeta, la burbuja de un comentario, el hover
809
+ de un botón) hereda el vidrio sin saber que está en una capa. Con cristal son
810
+ tintes translúcidos **oscuros** que se alejan de la lámina un paso, dos y tres
811
+ (una tarjeta es más gris que la lámina, como en opaco). En claro eso resta
812
+ contraste, y por eso `--netzi-text-2` con cristal baja dos niveles (el mismo
813
+ valor que en alto contraste, `#2a333c`); el test de contraste del paquete exige que
814
+ sobre el tinte más fuerte siga por encima de su suelo incluso con negro puro debajo.
815
+
816
+ Con el cristal apagado valen exactamente lo mismo que los tokens de siempre, así
817
+ que no cambia nada. Con `data-glass="on"` en un ancestro (lo pone
818
+ `NetziThemeService` en `<html>`, y también vale en un scope acotado) esas capas
819
+ pasan a ser translúcidas con el fondo desenfocado y saturado:
820
+
821
+ ```html
822
+ <html data-theme="dark" data-glass="on">
823
+ ...
824
+ </html>
825
+ ```
826
+
827
+ Las superficies de **contenido** (tarjetas, secciones, tablas) siguen opacas a
828
+ propósito: ahí el cristal solo resta legibilidad.
829
+
830
+ ### Fondo ambiental: darle al cristal algo que desenfocar
831
+
832
+ Un cristal sobre un fondo liso se ve como una placa gris: no hay nada detrás
833
+ que atravesar. Con `data-ambient="on"` en la raíz (`NetziThemeService.setAmbient`,
834
+ interruptor «Fondo con color» en `netzi-settings`) el layout pinta bajo
835
+ `--netzi-bg` unas manchas de color suaves —el primario del preset, morado y
836
+ verde— que se ven a través del topbar, del sidebar y de cualquier capa que pase
837
+ por encima. Token: `--netzi-ambient` (`none` sin el eje). Los porcentajes son
838
+ bajos y el texto se sigue leyendo encima (`--netzi-text-3` sobre la mancha más
839
+ fuerte: 4.4:1 en claro, 4.6:1 en oscuro). En alto contraste se apaga solo.
840
+
841
+ ```html
842
+ <html data-theme="dark" data-glass="on" data-ambient="on"></html>
843
+ ```
844
+
845
+ **El suelo de contraste no lo pone solo el alfa.** Sobre cristal el texto se lee
846
+ contra lo que haya debajo, que cambia al scrollear, así que el caso a cumplir es
847
+ el fondo más contrario posible. Taparlo con opacidad exige un alfa de 0.82, y a
848
+ 0.82 esto ya no parece cristal; por eso el filtro **acota el fondo** antes de
849
+ componerlo:
850
+
851
+ | función | qué hace |
852
+ | ---------------- | -------------------------------------------------------------- |
853
+ | `saturate(180%)` | aviva el color de debajo al difuminarse (lo vistoso) |
854
+ | `contrast(0.6)` | comprime el rango del fondo hacia el gris medio |
855
+ | `brightness()` | lo empuja al tono de la capa: `1.5` en claro, `0.66` en oscuro |
856
+ | `blur(28px)` | lo difumina; cuanto más ancho, más vidrio esmerilado |
857
+
858
+ Bastan **0.44** de alfa y brillo 1.25 en claro,
859
+ **0.12** y brillo 0.56 en oscuro, 10 px de desenfoque en los dos. El precio es
860
+ el plano contrario puro debajo (negro en claro, blanco en oscuro): ahí el texto
861
+ secundario se queda en **4.1-4.2:1**, y el primario no baja de 4.5:1
862
+ nunca; contra el fondo del kit y contra un gris medio, ambos pasan de 6:1. El test de
863
+ contraste del paquete modela la cadena entera, no solo el alfa: tocar cualquiera de sus
864
+ funciones mueve el veredicto.
865
+
866
+ Degradación automática, sin JS y sin polyfill: con
867
+ `prefers-reduced-transparency: reduce` y donde no haya `backdrop-filter`, la
868
+ superficie opaca de siempre.
869
+
870
+ ## Públicos e internos
871
+
872
+ `tokens.css` declara todo lo que el kit necesita, pero no todo es para las apps. Cada token de
873
+ la fuente dice si es **público** (el contrato: lo puedes usar y redefinir, y cambiarlo de
874
+ forma incompatible sigue el procedimiento de desuso) o **interno** (materia prima de la fuente
875
+ o pieza del kit: puede cambiar o desaparecer en cualquier versión).
876
+
877
+ - La capa **semántica** es pública entera: superficies, texto, bordes, primario, estados,
878
+ elevación, capas, espaciado y radios por papel, estilos de texto, familias, duraciones y
879
+ curvas.
880
+ - Las **primitivas** son internas: son la materia prima de la fuente (política 17). Los colores
881
+ de marca crudos (`blue`, `green`, `yellow`, `red`, `purple` y `blue-dark`) se usan por el
882
+ primario y los estados, que siguen al preset y al contraste alto, y `radius-full` por
883
+ `radius-pill`. Las escalas con nombre de paso (espacio, radios, tamaños, pesos e
884
+ interlineados) siguen públicas **en desuso** mientras convive su retirada (ver abajo).
885
+ - Las de **componente** son internas salvo las que el kit ofrece: capas flotantes, velo,
886
+ controles, objetivo táctil, anillo de foco, paleta de gráficas y fondo ambiental. Quedan
887
+ internos los pulgares de los interruptores y de las barras de desplazamiento.
888
+
889
+ La lista va en el paquete, generada por el build en el orden de la fuente:
890
+
891
+ ```js
892
+ import publicos from '@netzi/tokens/public-tokens.json' with { type: 'json' };
893
+
894
+ // [{ name: '--netzi-primary-text', path: 'primary.text', layer: 'semantic' }, …]
895
+ const nombres = new Set(publicos.tokens.map((t) => t.name));
896
+ ```
897
+
898
+ Cada entrada lleva su custom property, su ruta en la fuente (el nombre canónico), su capa
899
+ (`theme` si solo existe en un tema, como la lente del cristal) y, si está en desuso, el
900
+ aviso. La salida DTCG resuelta marca lo mismo en `$extensions["tech.netzi"].visibility`. Un
901
+ test del paquete falla si este README, el contrato de marca o el sustituto de un token en
902
+ desuso dependen de un nombre que no sea público.
903
+
904
+ ## En desuso
905
+
906
+ Un token en desuso sigue saliendo en `tokens.css` con el mismo valor que su sustituto
907
+ (apunta a él), así que nada se rompe al actualizar; convive al menos una beta publicada y se
908
+ retira en un cambio incompatible propio, con su guía de migración (ADR 0012). Mientras
909
+ tanto lo marcan:
910
+
911
+ - `$deprecated` en la fuente DTCG y en la salida resuelta, con el sustituto y la versión;
912
+ - un comentario `/* deprecated: … */` encima de su declaración en `tokens.css`;
913
+ - el build, que los lista cada vez que corre;
914
+ - y esta tabla.
915
+
916
+ Cuando el paquete llega a la versión de retirada (o la pasa), el build **avisa sin fallar**:
917
+ «el token X debía retirarse en @netzi/tokens 0.1.0-beta.11; retíralo o mueve la versión». Es
918
+ el recordatorio de que el sinónimo ya ha convivido lo que tocaba; retirarlo es un cambio
919
+ incompatible propio, y el momento lo decide quien corta la release.
920
+
921
+ | En desuso | Usa | Deprecado en | Se retira, como pronto |
922
+ | ------------------- | ---------------------------- | ----------------------------- | ----------------------------- |
923
+ | `--netzi-surface-1` | `--netzi-surface` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
924
+ | `--netzi-text-1` | `--netzi-text` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
925
+ | `--netzi-radius-md` | `--netzi-radius-surface-sm` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
926
+ | `--netzi-shadow-sm` | `--netzi-elevation-raised` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
927
+ | `--netzi-shadow` | `--netzi-elevation-floating` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
928
+ | `--netzi-shadow-lg` | `--netzi-elevation-modal` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
929
+
930
+ El nombre canónico de cada papel es la raíz de su grupo (`surface`, `text`), que es el que usa
931
+ la política de diseño como nombre de ruta; el radio medio es el de una superficie pequeña. Para migrar basta con buscar y
932
+ sustituir: `var(--netzi-surface-1)` por `var(--netzi-surface)`, y lo mismo con los demás.
933
+ `@netzi/ui` y el playground ya solo usan los canónicos, y un guardián lo vigila.
934
+
935
+ Las sombras de antes (`shadow-sm`, `shadow`, `shadow-lg`) pasan a la escala de elevación
936
+ (#220): mismo valor en cada tema, con nombre de intención. Como el anillo de foco, se vuelven a
937
+ declarar en cada sección con su propio tema o contraste, así que siguen al tema igual que
938
+ antes. Lo mismo hacen los otros tres sinónimos.
939
+
940
+ ### Escalas que pasan a internas
941
+
942
+ La política de diseño reserva las primitivas a la fuente: una app usa papeles. Las escalas con
943
+ nombre de paso eran públicas porque este README las enseñaba; desde `@netzi/tokens`
944
+ `0.1.0-beta.10` cada una tiene su papel y la escala está en desuso para las apps (#228). No
945
+ desaparecen: los papeles se apoyan en ellas, así que siguen en `tokens.css` con su valor y, al
946
+ retirarse, **pasan a internas** (salen de `public-tokens.json` y pueden cambiar sin aviso). Se
947
+ marcan igual que un sinónimo (`$deprecated`, el comentario en `tokens.css`, el build, esta
948
+ tabla), y el build avisa cuando el paquete llega a la versión:
949
+
950
+ | En desuso | Usa | Deprecado en | Pasa a interna, como pronto |
951
+ | ---------------------------- | ---------------------------------------------------------------------- | ----------------------------- | ----------------------------- |
952
+ | `--netzi-space-0` | `0` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
953
+ | `--netzi-space-1` | `--netzi-space-related` (cambia el valor: 2px no es un paso del ritmo) | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
954
+ | `--netzi-space-2` | `--netzi-space-related` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
955
+ | `--netzi-space-3` | `--netzi-space-group` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
956
+ | `--netzi-space-4` | `--netzi-space-stack` o `--netzi-space-inset-sm` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
957
+ | `--netzi-space-5` | `--netzi-space-inset` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
958
+ | `--netzi-space-6` | `--netzi-space-inset-lg` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
959
+ | `--netzi-space-7` | `--netzi-space-section` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
960
+ | `--netzi-space-8` | `--netzi-space-page` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
961
+ | `--netzi-radius-sm` | `--netzi-radius-control` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
962
+ | `--netzi-radius` | `--netzi-radius-surface-sm` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
963
+ | `--netzi-radius-lg` | `--netzi-radius-surface` | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
964
+ | `--netzi-font-size-{xs…3xl}` | el estilo de su papel (`caption` … `display`, arriba) | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
965
+ | `--netzi-font-weight-*` | el estilo de su papel (`medium` no tiene: `body` cambia el valor) | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
966
+ | `--netzi-line-height-*` | el estilo de su papel (`none` y `relaxed` no tienen: cambia el valor) | `@netzi/tokens 0.1.0-beta.10` | `@netzi/tokens 0.1.0-beta.11` |
967
+
968
+ Para migrar el espaciado y los radios basta con buscar y sustituir por el papel de cada uso
969
+ (`var(--netzi-space-3)` por `var(--netzi-space-group)`; el `4` tiene dos papeles, y el que
970
+ toca lo dice el sitio: aire entre grupos o relleno). Para el texto, cambia el trío
971
+ `font-size` + `font-weight` + `line-height` por el atajo del estilo:
972
+
973
+ ```css
974
+ /* Antes */
975
+ .meta {
976
+ font-size: var(--netzi-font-size-xs);
977
+ font-weight: var(--netzi-font-weight-semibold);
978
+ line-height: var(--netzi-line-height-tight);
979
+ }
980
+ /* Después */
981
+ .meta {
982
+ font: var(--netzi-typography-caption);
983
+ letter-spacing: var(--netzi-typography-caption-letter-spacing);
984
+ }
985
+ ```
986
+
987
+ Las duraciones, las curvas y las familias no cambian: ya tenían nombre de intención y pasan a
988
+ la capa semántica con el mismo nombre. `@netzi/ui` y el playground ya solo usan los papeles, y
989
+ el mismo guardián de los sinónimos lo vigila.
990
+
991
+ ## Cómo se genera
992
+
993
+ `tokens.css` **no se escribe a mano**: sale de una fuente única en JSON con el formato del
994
+ W3C Design Tokens Community Group (DTCG 2025.10), con Style Dictionary. La fuente tiene
995
+ tres capas —primitivos (marca y escalas), semánticos (papeles: superficie, texto, borde,
996
+ primario, estado) y de componente (controles, capas flotantes, foco)— y un archivo por eje
997
+ de tema (modo, preset, contraste, cristal, fondo ambiental, densidad, puntero táctil y
998
+ movimiento reducido).
999
+
1000
+ La misma fuente sirve a quien no lee CSS: una salida DTCG ya resuelta, un archivo por
1001
+ combinación de modo, contraste y preset, pensada para librerías nativas (Compose,
1002
+ SwiftUI). En CSS, las referencias entre tokens se mantienen como `var()`, así que un
1003
+ override en tu scope sigue arrastrando a los tokens que se apoyan en el tuyo.
1004
+
1005
+ ## Versionado
1006
+
1007
+ SemVer. Cambiar el **valor** de un token es `minor`/`patch`; **renombrar o quitar**
1008
+ un token es `major` (rompe la API pública).