@eduardoalvarez/arrecife 0.5.1 → 0.7.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +868 -467
  3. package/dist/brand/index.cjs +112 -95
  4. package/dist/brand/index.d.cts +40 -39
  5. package/dist/brand/index.d.ts +40 -39
  6. package/dist/brand/index.js +5 -4
  7. package/dist/catalog-D13txprv.d.cts +78 -0
  8. package/dist/catalog-D13txprv.d.ts +78 -0
  9. package/dist/chart/index.cjs +100 -83
  10. package/dist/chart/index.d.cts +66 -66
  11. package/dist/chart/index.d.ts +66 -66
  12. package/dist/chart/index.js +14 -12
  13. package/dist/chunk-2WPWEIMD.js +27 -0
  14. package/dist/chunk-45HVCTB7.js +70 -0
  15. package/dist/{chunk-ZEOQKRQ7.js → chunk-727HCBD4.js} +1 -1
  16. package/dist/chunk-CKRSQPTX.js +36 -0
  17. package/dist/chunk-E6KFUSKB.js +144 -0
  18. package/dist/chunk-GCRII2KQ.js +86 -0
  19. package/dist/{chunk-YZ2SDOVZ.js → chunk-JN3IS5OS.js} +30 -30
  20. package/dist/chunk-ODBFN44D.js +45 -0
  21. package/dist/chunk-OMKSESQB.js +300 -0
  22. package/dist/{chunk-VPT32GPG.js → chunk-TA7TLWW4.js} +2 -2
  23. package/dist/chunk-WGNIRIN7.js +42 -0
  24. package/dist/doctor.mjs +166 -0
  25. package/dist/form/index.cjs +109 -92
  26. package/dist/form/index.d.cts +43 -42
  27. package/dist/form/index.d.ts +43 -42
  28. package/dist/form/index.js +25 -23
  29. package/dist/icons/index.cjs +149 -0
  30. package/dist/icons/index.d.cts +94 -0
  31. package/dist/icons/index.d.ts +94 -0
  32. package/dist/icons/index.js +28 -0
  33. package/dist/index-DlAO2JZs.d.cts +47 -0
  34. package/dist/index-DlAO2JZs.d.ts +47 -0
  35. package/dist/index.cjs +1292 -983
  36. package/dist/index.d.cts +927 -806
  37. package/dist/index.d.ts +927 -806
  38. package/dist/index.js +809 -778
  39. package/dist/{label-DuTvJGxD.d.ts → label-MgHFKnFy.d.cts} +3 -3
  40. package/dist/{label-DuTvJGxD.d.cts → label-MgHFKnFy.d.ts} +3 -3
  41. package/dist/og/index.cjs +133 -132
  42. package/dist/og/index.d.cts +93 -89
  43. package/dist/og/index.d.ts +93 -89
  44. package/dist/og/index.js +106 -106
  45. package/dist/shiki/index.cjs +28 -30
  46. package/dist/shiki/index.d.cts +4 -4
  47. package/dist/shiki/index.d.ts +4 -4
  48. package/dist/shiki/index.js +12 -12
  49. package/dist/social/index.cjs +67 -0
  50. package/dist/social/index.d.cts +2 -0
  51. package/dist/social/index.d.ts +2 -0
  52. package/dist/social/index.js +2 -0
  53. package/dist/theme/index.cjs +97 -0
  54. package/dist/theme/index.d.cts +144 -0
  55. package/dist/theme/index.d.ts +144 -0
  56. package/dist/theme/index.js +2 -0
  57. package/dist/tokens/index.cjs +159 -88
  58. package/dist/tokens/index.d.cts +277 -165
  59. package/dist/tokens/index.d.ts +277 -165
  60. package/dist/tokens/index.js +2 -2
  61. package/dist/tokens/theme.css +165 -100
  62. package/dist/variants/index.cjs +195 -0
  63. package/dist/variants/index.d.cts +195 -0
  64. package/dist/variants/index.d.ts +195 -0
  65. package/dist/variants/index.js +3 -0
  66. package/llms.txt +1145 -746
  67. package/package.json +42 -11
  68. package/dist/catalogo-Du5ID-Hi.d.cts +0 -77
  69. package/dist/catalogo-Du5ID-Hi.d.ts +0 -77
  70. package/dist/chunk-E3OMP2DL.js +0 -36
  71. package/dist/chunk-KPZNNMV5.js +0 -83
  72. package/dist/chunk-NHS7ETKJ.js +0 -27
  73. package/dist/chunk-TSPJOM6K.js +0 -229
  74. package/dist/chunk-UOWIDFCB.js +0 -81
  75. package/dist/tema/index.cjs +0 -94
  76. package/dist/tema/index.d.cts +0 -110
  77. package/dist/tema/index.d.ts +0 -110
  78. package/dist/tema/index.js +0 -2
package/README.md CHANGED
@@ -1,151 +1,224 @@
1
1
  # Arrecife
2
2
 
3
- Librería de componentes de la identidad visual de Eduardo Álvarez.
3
+ The component library of Eduardo Álvarez's visual identity.
4
4
  `@eduardoalvarez/arrecife` · React 19 · TypeScript · shadcn/ui · Storybook · tsup.
5
5
 
6
- ## Los documentos de identidad
6
+ Published Storybook: [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev).
7
7
 
8
- `docs/design-system.md` y `docs/manual-de-marca.md` son la extracción de los dos
9
- canvas de Claude Design, en el repo para poder hacer `grep` y para versionarlos.
10
- El canvas sigue siendo la fuente; esto es la copia consultable.
8
+ ## The identity documents
11
9
 
12
- Está aquí por un motivo concreto: la paleta de resaltado vivía escrita a mano en
13
- un proyecto con un `#E05252` que este README declara incorrecto desde hace
14
- meses, y nadie lo vio porque el documento no era `grep`-able desde el código.
10
+ `docs/design-system.md` and `docs/brand-manual.md` are the extraction of the two
11
+ Claude Design canvases, kept in the repo so they can be grepped and versioned.
12
+ The canvas is still the source; this is the consultable copy.
15
13
 
16
- `docs/decisiones.md` es la otra mitad: los quince puntos donde el código y el
17
- documento no dicen lo mismo, con la resolución y el motivo de cada uno.
14
+ They are here for a concrete reason: the highlighting palette lived hand-written
15
+ in a project with a `#E05252` that this README has declared wrong for months, and
16
+ nobody saw it because the document was not greppable from the code.
18
17
 
19
- ## Los dos documentos para agentes
18
+ `docs/decisions.md` is the other half: the points where the code and the document
19
+ do not say the same thing, each with its resolution and its reason.
20
20
 
21
- `AGENTS.md` es para un agente que trabaja **en este repo**: cómo se crea un
22
- componente, qué reglas no puede romper y dónde se verifica cada una. `CLAUDE.md`
23
- es un symlink a ese archivo, así que Claude, Codex y Cursor leen el mismo texto.
21
+ ## The two documents for agents
24
22
 
25
- `llms.txt` es para un agente que trabaja en uno de los **cinco proyectos que
26
- consumen** la librería. Ese agente nunca ve este repo: ve
27
- `node_modules/@eduardoalvarez/arrecife/`, y por eso `llms.txt` viaja en el
28
- tarball y se declara en `exports`.
23
+ `AGENTS.md` is for an agent working **in this repo**: how a component is created,
24
+ which rules it cannot break and where each one is verified. `CLAUDE.md` is a
25
+ symlink to that file, so Claude, Codex and Cursor all read the same text.
29
26
 
30
- El inventario de componentes y props de `llms.txt` no está escrito a mano: lo
31
- extrae `scripts/build-llms.mjs` del compilador de TypeScript, y `pnpm check:llms`
32
- falla en CI si alguien cambia un prop y no lo regenera. La prosa vive en
33
- `docs/llms.plantilla.md`. Es la misma decisión que los tokens: una fuente, una
34
- salida generada, y un check que impide que discrepen.
27
+ `llms.txt` is for an agent working in one of the **five projects that consume**
28
+ the library. That agent never sees this repo: it sees
29
+ `node_modules/@eduardoalvarez/arrecife/`, which is why `llms.txt` travels in the
30
+ tarball and is declared in `exports`.
35
31
 
36
- ## La restricción que manda sobre todo lo demás
32
+ The component and prop inventory in `llms.txt` is not written by hand:
33
+ `scripts/build-llms.mjs` extracts it from the TypeScript compiler, and
34
+ `pnpm check:llms` fails in CI if somebody changes a prop and does not regenerate
35
+ it. The prose lives in `docs/llms.template.md`. It is the same decision as the
36
+ tokens: one source, a generated output, and a check that stops them disagreeing.
37
37
 
38
- `src/tokens/` no importa nada: ni React, ni componentes, ni CSS de terceros. Es el
39
- único subpaquete que pueden consumir los cinco proyectos, incluido un generador
40
- de OG con Satori y un sitio Astro que no monta React. Si un token termina
41
- dependiendo de un componente, la librería dejó de ser portable.
38
+ ## The constraint that outranks everything else
42
39
 
43
- No es documentación: `pnpm check:tokens` lo verifica en cada build y ESLint lo
44
- dice en el editor.
40
+ `src/tokens/` imports nothing: not React, not components, not third-party CSS. It
41
+ is the only subpackage the five projects can all consume, including an OG
42
+ generator running on Satori and an Astro site that mounts no React. The moment a
43
+ token depends on a component, the library has stopped being portable.
45
44
 
46
- ## Salida de Tailwind
45
+ It is not documentation: `pnpm check:tokens` verifies it on every build and
46
+ ESLint says so in the editor.
47
47
 
48
- Una sola fuente, `src/tokens/tokens.ts`. Una salida generada,
49
- `dist/tokens/theme.css`, con `@theme` para Tailwind v4. La genera
50
- `scripts/build-tokens.mjs`; no se edita a mano y se regenera en cada build.
48
+ ## Tailwind output
51
49
 
52
- Decisión de la Fase 0: **solo v4**. El portfolio (`eduardoalvarez.dev`) migra de
53
- Tailwind v3 a v4 antes de consumir Arrecife. Si esa migración se atrasa, volver a
54
- publicar el preset de v3 es añadir un emisor más a `build-tokens.mjs` que lea el
55
- mismo objeto `tokens`: la fuente no cambia.
50
+ One source, `src/tokens/tokens.ts`. One generated output,
51
+ `dist/tokens/theme.css`, with `@theme` for Tailwind v4. It is generated by
52
+ `scripts/build-tokens.mjs`; it is not edited by hand and it is regenerated on
53
+ every build.
56
54
 
57
- ### Consumo
55
+ Phase 0 decision: **v4 only**. The portfolio (`eduardoalvarez.dev`) migrates from
56
+ Tailwind v3 to v4 before consuming Arrecife. If that migration slips, publishing
57
+ the v3 preset again is one more emitter in `build-tokens.mjs` reading the same
58
+ `tokens` object: the source does not change.
59
+
60
+ ### Consuming it
58
61
 
59
62
  ```css
60
63
  @import "tailwindcss";
61
64
  @import "@eduardoalvarez/arrecife/tokens/theme.css";
62
65
  ```
63
66
 
64
- El modo oscuro es el primario y es el default. Un proyecto en modo claro declara
65
- `data-theme="light"` en `<html>`; uno oscuro no necesita declarar nada.
67
+ Dark mode is primary and it is the default. A project in light mode declares
68
+ `data-theme="light"` on `<html>`; a dark one declares nothing.
66
69
 
67
- **Si además vas a usar componentes, falta una línea, y sin ella no falla nada.**
68
- Tailwind no escanea `node_modules`, así que purga todas las clases que emiten los
69
- componentes: `border-hairline`, `rounded-pill` y `p-step-lg` resuelven a la nada.
70
- No hay error en consola, no hay aviso en el build, no hay clase sin definir — la
71
- tarjeta simplemente sale con borde `currentColor` y la píldora, cuadrada.
70
+ **If you are also going to use components, one line is missing, and without it
71
+ nothing fails.** Tailwind does not scan `node_modules`, so it purges every class
72
+ the components emit: `border-hairline`, `rounded-pill` and `p-step-lg` resolve to
73
+ nothing. There is no console error, no build warning, no undefined class — the
74
+ card simply comes out with a `currentColor` border and the pill comes out square.
72
75
 
73
76
  ```css
74
77
  @import "tailwindcss";
75
78
  @import "@eduardoalvarez/arrecife/tokens/theme.css";
76
79
 
77
- /* Sin esto, los componentes se montan sin ningún estilo del sistema. */
80
+ /* Without this, the components mount with none of the system's styles. */
78
81
  @source "../node_modules/@eduardoalvarez/arrecife/dist";
79
82
  ```
80
83
 
81
- La ruta es relativa al archivo CSS donde va la directiva, así que en un proyecto
82
- con la hoja en `src/styles/` sube dos niveles y no uno. Lo detectaron los tests
83
- E2E del blog, no el build, y hasta la 0.3.0 esto solo estaba escrito en
84
- `llms.txt` — el archivo que lee un agente y no una persona.
84
+ The path is relative to the CSS file the directive lives in, so in a project with
85
+ the sheet in `src/styles/` it goes up two levels and not one. The blog's E2E
86
+ tests caught it, not the build, and until 0.3.0 this was only written in
87
+ `llms.txt` — the file an agent reads and a person does not.
88
+
89
+ ### `npx arrecife` — the two things that fail without saying so
90
+
91
+ ```
92
+ npx arrecife
93
+ ```
94
+
95
+ It reads your stylesheets and checks the two failures that produce no error, both
96
+ of which cost real hours in the migration:
97
+
98
+ **The missing `@source`**, above. It also works out the path for you, counted
99
+ from the sheet and not from the project root, which is the part that gets written
100
+ wrong.
101
+
102
+ **A token of yours redefining one of ours.** A project coming from shadcn brings
103
+ `@theme inline { --color-accent: var(--accent); }`, and the two are not the same
104
+ colour: shadcn's `--accent` is the hover **surface**, `#17303E`, and this
105
+ library's is the brand turquoise, `#35D6C0`. The result was **88 classes inside
106
+ the library's own components** painting grey — 28 `text-accent`, 26 focus rings,
107
+ 15 `bg-accent`, 12 `border-accent`. Buttons, focus rings and badges came out the
108
+ colour of a surface and it looked as though the migration had done nothing. (The
109
+ twenty-six are one `focus-ring` utility now, which changes the count and not the
110
+ failure: it reads `var(--color-accent)` like everything else here.)
85
111
 
86
- > **Vienes de la 0.4.0 o anterior.** `Toast`, `ToastProvider`, `ToastViewport`,
87
- > `ToastTitle` y `ToastDescription` dejaron de ser API pública en la 0.5.0: se
88
- > usa `Toaster` y `toast()`. `ToastAction` se queda. La migración, con el porqué
89
- > y los ejemplos, está en [`docs/migracion-0.5.md`](docs/migracion-0.5.md).
112
+ ```
113
+ arrecife · 2 thing(s) that fail without saying so:
90
114
 
91
- > **Vienes de la 0.2.0 o anterior.** Los cinco escalones de espaciado se
92
- > renombraron: `p-md` es ahora `p-step-md`, `gap-sm` es `gap-step-sm`. Es un
93
- > cambio incompatible, y si tu proyecto usa `max-w-sm`, `max-w-md` o `max-w-lg`,
94
- > además te estaban valiendo 12, 16 y 26px sin que nada lo dijera. El porqué, el
95
- > patrón de migración y qué revisar después están en
96
- > [`docs/migracion-0.3.md`](docs/migracion-0.3.md).
115
+ src/styles/globals.css
116
+ imports @eduardoalvarez/arrecife/tokens/theme.css and has no @source.
117
+ Every class the components emit is being purged — silently. Add:
97
118
 
98
- Las familias tipográficas se declaran por nombre. Cada proyecto carga Bricolage
99
- Grotesque, Geist y JetBrains Mono como prefiera: la librería no impone cómo.
119
+ @source "../../node_modules/@eduardoalvarez/arrecife/dist";
100
120
 
101
- **Los nombres tienen que coincidir EXACTAMENTE**, y esto ya ha mordido dos veces.
102
- Los tokens declaran las familias así:
121
+ src/styles/globals.css
122
+ redefines --color-accent, which @eduardoalvarez/arrecife owns.
123
+ yours: var(--accent) ← points at another property, so it wins silently
124
+ arrecife: #35D6C0
125
+ ```
103
126
 
104
- | Token | `font-family` que declara | Utilidad |
127
+ Five names collide with shadcn's — `background`, `border`, `warm`, `warm-hover`
128
+ and `accent`. Four are harmless because both sides happen to agree on the value,
129
+ so the command reports the value on each side and only fails on the ones that
130
+ differ. A collision that agrees is worth knowing about and is not worth failing
131
+ over.
132
+
133
+ > **Coming from 0.6.0.** One break, and it is a find and replace the type checker
134
+ > points at: `Stat`'s `tone="alerta"` is `tone="alert"`.
135
+ >
136
+ > Everything else is additive, and most of it lets a project delete something it
137
+ > was maintaining by hand: `./social` and `./icons` are two new subpaths,
138
+ > `EmptyState` has a shape with no face, `Stat` covers the KPI cards, `SidebarNav`
139
+ > groups and collapses, and `npx arrecife` catches two failures that produce no
140
+ > error at all.
141
+ >
142
+ > Run `npx arrecife` first, then read
143
+ > [`docs/migration-0.7.md`](docs/migration-0.7.md).
144
+
145
+ > **Coming from 0.5.x.** Two unrelated things landed in 0.6.0, and they ship
146
+ > together because in `0.x` a breaking change bumps the minor.
147
+ >
148
+ > The whole public API moved to English: `./tema` is now `./theme`, `scriptTema`
149
+ > is `themeScript`, `Red` is `SocialLink`, the `degradado-hero` utility is
150
+ > `gradient-hero`, and `Hero`'s `variant="cabecera"` is `variant="header"`.
151
+ >
152
+ > And three things break on the API side: `themeScript` is a function, the root
153
+ > ships `"use client"`, and `TalkCardProps` is a union. All three break loudly —
154
+ > the type checker catches every one at the call site.
155
+ >
156
+ > Both halves, in order, with the full rename table and what each project can now
157
+ > **delete**: [`docs/migration-0.6.md`](docs/migration-0.6.md).
158
+
159
+ > **Coming from 0.4.0 or earlier.** `Toast`, `ToastProvider`, `ToastViewport`,
160
+ > `ToastTitle` and `ToastDescription` stopped being public API in 0.5.0: you use
161
+ > `Toaster` and `toast()`. `ToastAction` stays. The migration, with the reasoning
162
+ > and the examples, is in [`docs/migration-0.5.md`](docs/migration-0.5.md).
163
+
164
+ > **Coming from 0.2.0 or earlier.** The five spacing steps were renamed: `p-md`
165
+ > is now `p-step-md`, `gap-sm` is `gap-step-sm`. It is a breaking change, and if
166
+ > your project uses `max-w-sm`, `max-w-md` or `max-w-lg`, those were also worth
167
+ > 12, 16 and 26px with nothing saying so. The reasoning, the migration pattern
168
+ > and what to check afterwards are in
169
+ > [`docs/migration-0.3.md`](docs/migration-0.3.md).
170
+
171
+ The font families are declared by name. Each project loads Bricolage Grotesque,
172
+ Geist and JetBrains Mono however it prefers: the library does not dictate how.
173
+
174
+ **The names have to match EXACTLY**, and this has already bitten twice. The
175
+ tokens declare the families like this:
176
+
177
+ | Token | `font-family` it declares | Utility |
105
178
  | --- | --- | --- |
106
179
  | `fonts.display` | `"Bricolage Grotesque"` | `font-display` |
107
180
  | `fonts.sans` | `"Geist"` | `font-sans` |
108
181
  | `fonts.mono` | `"JetBrains Mono"` | `font-mono` |
109
182
 
110
- Un proyecto que registre su `@font-face` como `"Bricolage Grotesque Variable"` o
111
- `"Geist Variable"` —el nombre con el que las publican varios paquetes de fuentes—
112
- **no** está cargando lo que los tokens piden: la display y la mono caen a la
113
- fuente del sistema, en silencio y sin un aviso en consola. Es exactamente lo que
114
- pasó en dos de los cinco proyectos.
183
+ A project registering its `@font-face` as `"Bricolage Grotesque Variable"` or
184
+ `"Geist Variable"` — the name several font packages publish them under — is
185
+ **not** loading what the tokens ask for: the display and the mono fall back to
186
+ the system font, silently and with no console warning. It is exactly what
187
+ happened in two of the five projects.
115
188
 
116
- El `family` del `@font-face` es un alias que elige el proyecto, así que la
117
- solución es declararlo con el nombre que pide el token:
189
+ The `@font-face`'s `family` is an alias the project chooses, so the fix is to
190
+ declare it with the name the token asks for:
118
191
 
119
192
  ```css
120
193
  @font-face {
121
- font-family: "Bricolage Grotesque"; /* NO "Bricolage Grotesque Variable" */
122
- src: url("/fuentes/bricolage-grotesque.woff2") format("woff2-variations");
194
+ font-family: "Bricolage Grotesque"; /* NOT "Bricolage Grotesque Variable" */
195
+ src: url("/fonts/bricolage-grotesque.woff2") format("woff2-variations");
123
196
  font-weight: 200 800;
124
197
  font-display: swap;
125
198
  }
126
199
  ```
127
200
 
128
- #### En Next, con `next/font`
201
+ #### In Next, with `next/font`
129
202
 
130
- Es el mismo fallo por otra puerta, y muerde a los dos proyectos Next. `next/font`
131
- registra cada familia bajo un nombre GENERADO —`__Geist_a1b2c3`— y la expone como
132
- una custom property; el nombre literal `"Geist"` que declaran los tokens no
133
- existe en ningún `@font-face` de la página.
203
+ It is the same failure through another door, and it bites both Next projects.
204
+ `next/font` registers each family under a GENERATED name — `__Geist_a1b2c3` — and
205
+ exposes it as a custom property; the literal `"Geist"` the tokens declare exists
206
+ in no `@font-face` on the page.
134
207
 
135
- Importar `theme.css` sobrescribe `--font-sans` con ese literal, y las tres
136
- familias caen a la fuente del sistema. En silencio: no hay 404, porque la fuente
137
- sí se cargó — con otro nombre.
208
+ Importing `theme.css` overwrites `--font-sans` with that literal, and all three
209
+ families fall back to the system font. Silently: there is no 404, because the
210
+ font did load — under another name.
138
211
 
139
- La solución es reafirmar las tres DESPUÉS del import, apuntando a las variables
140
- que genera `next/font`:
212
+ The fix is to reassert all three AFTER the import, pointing at the variables
213
+ `next/font` generates:
141
214
 
142
215
  ```ts
143
- // app/fuentes.ts
216
+ // app/fonts.ts
144
217
  import { Geist, Bricolage_Grotesque, JetBrains_Mono } from 'next/font/google';
145
218
 
146
- export const sans = Geist({ subsets: ['latin'], variable: '--fuente-sans' });
147
- export const display = Bricolage_Grotesque({ subsets: ['latin'], variable: '--fuente-display' });
148
- export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--fuente-mono' });
219
+ export const sans = Geist({ subsets: ['latin'], variable: '--project-sans' });
220
+ export const display = Bricolage_Grotesque({ subsets: ['latin'], variable: '--project-display' });
221
+ export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--project-mono' });
149
222
  ```
150
223
 
151
224
  ```css
@@ -153,52 +226,52 @@ export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--fuente-mon
153
226
  @import "@eduardoalvarez/arrecife/tokens/theme.css";
154
227
  @source "../node_modules/@eduardoalvarez/arrecife/dist";
155
228
 
156
- /* Después del import, o gana el literal que no está cargado. */
229
+ /* After the import, or the literal that is not loaded wins. */
157
230
  @theme {
158
- --font-sans: var(--fuente-sans), ui-sans-serif, system-ui, sans-serif;
159
- --font-display: var(--fuente-display), ui-sans-serif, system-ui, sans-serif;
160
- --font-mono: var(--fuente-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
231
+ --font-sans: var(--project-sans), ui-sans-serif, system-ui, sans-serif;
232
+ --font-display: var(--project-display), ui-sans-serif, system-ui, sans-serif;
233
+ --font-mono: var(--project-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
161
234
  }
162
235
  ```
163
236
 
164
- Las variables se llaman `--fuente-*` y no `--font-*` a propósito: `--font-sans`
165
- es el nombre que Tailwind usa para SU token, y dárselo a `next/font` deja las dos
166
- capas peleando por la misma propiedad.
237
+ The variables are called `--project-*` and not `--font-*` on purpose:
238
+ `--font-sans` is the name Tailwind uses for ITS token, and handing it to
239
+ `next/font` leaves the two layers fighting over the same property.
167
240
 
168
- El `variable` de cada familia va en la clase del `<html>`, como pide Next:
241
+ Each family's `variable` goes on the `<html>` class, as Next asks:
169
242
  `className={`${sans.variable} ${display.variable} ${mono.variable}`}`.
170
243
 
171
- ### Mapa de tokens a utilidades
244
+ ### Token-to-utility map
172
245
 
173
- | Token | Custom property | Utilidad |
246
+ | Token | Custom property | Utility |
174
247
  | --- | --- | --- |
175
- | `colors[modo].surfaceRaised` | `--color-surface-raised` | `bg-surface-raised` |
248
+ | `colors[mode].surfaceRaised` | `--color-surface-raised` | `bg-surface-raised` |
176
249
  | `brand.hull` | `--color-brand-hull` | `bg-brand-hull` |
177
- | `typeScale.h1` | `--text-h1` | `text-h1` (arrastra line-height, weight y tracking) |
250
+ | `typeScale.h1` | `--text-h1` | `text-h1` (brings line-height, weight and tracking) |
178
251
  | `fonts.display` | `--font-display` | `font-display` |
179
252
  | `radius.card` | `--radius-card` | `rounded-card` |
180
253
  | `spacing.stepLg` | `--spacing-step-lg` | `p-step-lg`, `gap-step-lg`, `mb-step-lg` |
181
254
  | `spacing.section` | `--spacing-section` | `py-section`, `mb-section` |
182
- | `control.md` | `--spacing-control-md` | `px-control-md` (padding de botón) |
183
- | `control.icon` | `--spacing-control-icon` | `size-control-icon` (botón de icono 42×42) |
184
- | `gradient[modo].hero` | `--gradient-hero` | `degradado-hero` (utilidad, sigue el modo) |
255
+ | `control.md` | `--spacing-control-md` | `px-control-md` (button padding) |
256
+ | `control.icon` | `--spacing-control-icon` | `size-control-icon` (42×42 icon button) |
257
+ | `gradient[mode].hero` | `--gradient-hero` | `gradient-hero` (a utility, follows the mode) |
185
258
  | `size.nav` | `--spacing-nav` | `h-nav` |
186
259
  | `size.content` / `size.wide` | `--container-content` / `--container-wide` | `max-w-content` / `max-w-wide` |
187
260
  | `limits.measure` | `--container-measure` | `max-w-measure` |
188
261
  | `shadow.standard` | `--shadow-standard` | `shadow-standard` |
189
262
  | `motion` | `--duration-standard`, `--ease-standard` | `duration-standard`, `ease-standard` |
190
263
 
191
- El variante `light:` está disponible para los casos del modo claro invertido.
264
+ The `light:` variant is available for the inverted light-mode cases.
192
265
 
193
- También se puede consumir el objeto en JS, sin CSS y sin React — es lo que usan
194
- las plantillas de OG:
266
+ The object can also be consumed in JS, with no CSS and no React — it is what the
267
+ OG templates use:
195
268
 
196
269
  ```ts
197
270
  import { tokens } from '@eduardoalvarez/arrecife/tokens';
198
271
  ```
199
272
 
200
- El tema de resaltado va en otra subruta por la misma razón — se consume desde
201
- `astro.config.mjs`, no desde un componente:
273
+ The highlighting theme lives in another subpath for the same reason — it is
274
+ consumed from `astro.config.mjs`, not from a component:
202
275
 
203
276
  ```ts
204
277
  import { arrecife } from '@eduardoalvarez/arrecife/shiki';
@@ -208,30 +281,30 @@ export default defineConfig({
208
281
  });
209
282
  ```
210
283
 
211
- **La librería no trae Shiki.** Los proyectos ya resaltan en build con su propia
212
- herramienta; lo que les faltaba no era un resaltador, era el tema. `CodeBlock`
213
- sigue recibiendo el código ya resaltado, que es para lo que está escrito.
284
+ **The library does not ship Shiki.** The projects already highlight at build time
285
+ with their own tooling; what they were missing was not a highlighter, it was the
286
+ theme. `CodeBlock` still receives the code already highlighted, which is what it
287
+ is written for.
214
288
 
215
- Las plantillas de OG se publican en su propia subruta por la misma razón: un
216
- generador corre en un worker o en un script de build y no debe arrastrar React ni
217
- un solo componente.
289
+ The OG templates are published in their own subpath for the same reason: a
290
+ generator runs in a worker or in a build script and must not drag in React or a
291
+ single component.
218
292
 
219
293
  ```ts
220
294
  import satori from 'satori';
221
- import { plantillaArticulo, OG } from '@eduardoalvarez/arrecife/og';
295
+ import { articleTemplate, OG } from '@eduardoalvarez/arrecife/og';
222
296
 
223
- const svg = await satori(plantillaArticulo({ title, date, readingMinutes }), {
297
+ const svg = await satori(articleTemplate({ title, date, readingMinutes }), {
224
298
  width: OG.width, // 1200
225
299
  height: OG.height, // 630
226
300
  fonts: [...],
227
301
  });
228
302
  ```
229
303
 
230
- Son funciones puras que devuelven el árbol que Satori pinta, construido solo con
231
- tokens. `dist/og/index.js` no menciona React en ninguna línea, y eso es
232
- comprobable con un `grep`.
304
+ They are pure functions returning the tree Satori paints, built only from tokens.
305
+ `dist/og/index.js` mentions React on no line, and that is checkable with a grep.
233
306
 
234
- ## Cómo se usa desde un proyecto
307
+ ## How it is used from a project
235
308
 
236
309
  ```tsx
237
310
  import { Text, Button, Badge, cn } from '@eduardoalvarez/arrecife';
@@ -239,137 +312,307 @@ import type { TextProps } from '@eduardoalvarez/arrecife';
239
312
 
240
313
  <Text variant="eyebrow" tone="muted">charlas</Text>
241
314
  <Text as="h2" variant="h1">Escalar con criterio</Text>
242
- <Text variant="body">Se corta solo a 68ch.</Text>
243
- <Text variant="ui" measure={false}>Sin corte, para una celda estrecha.</Text>
315
+ <Text variant="body">Clamps itself to 68ch.</Text>
316
+ <Text variant="ui" measure={false}>No clamp, for a narrow cell.</Text>
244
317
  ```
245
318
 
246
- Cada componente publica su página de documentación en Storybook con la tabla de
247
- props generada desde los tipos. Las de `Text`:
319
+ Every component publishes its documentation page in Storybook with the props
320
+ table generated from the types. `Text`'s:
248
321
 
249
- | prop | tipo | por defecto |
322
+ | prop | type | default |
250
323
  | --- | --- | --- |
251
324
  | `variant` | `display · stat · h1 · h2 · h3 · body · lead · ui · label · tag · meta · chip · eyebrow` | `body` |
252
325
  | `tone` | `primary · secondary · muted · accent · warm · success · warning · error` | `primary` |
253
- | `as` | `h1 · h2 · h3 · h4 · p · span · strong · em · figcaption · caption · legend · dt · dd · li` | según `variant` |
254
- | `measure` | `boolean` — corta a 68ch | `true` en `body` |
255
- | `asChild` | `boolean` — renderiza el hijo, para envolver un enlace | `false` |
326
+ | `as` | `h1 · h2 · h3 · h4 · p · span · strong · em · figcaption · caption · legend · dt · dd · li` | per `variant` |
327
+ | `measure` | `boolean` — clamps to 68ch | `true` on `body` |
328
+ | `asChild` | `boolean` — renders the child, to wrap a link | `false` |
256
329
 
257
- Comprobado empaquetando la librería con `pnpm pack` e instalándola en un proyecto
258
- aparte: los tipos resuelven desde `dist/`, `./tokens` carga sin arrastrar React y
259
- `./tokens/theme.css` se resuelve por subruta.
330
+ Verified by packing the library with `pnpm pack` and installing it in a separate
331
+ project: the types resolve from `dist/`, `./tokens` loads without dragging React
332
+ in and `./tokens/theme.css` resolves by subpath.
260
333
 
261
- ### Los iconos de redes van agrupados
262
-
263
- Es lo primero con lo que tropieza quien consume la librería, porque la forma
264
- natural no funciona:
334
+ ### The social icons come from `./social`
265
335
 
266
336
  ```tsx
267
- // ❌ no existe
337
+ // ❌ does not exist: the root publishes them grouped, not loose
268
338
  import { GitHub, LinkedIn } from '@eduardoalvarez/arrecife';
269
339
 
270
- // ✅
271
- import { social } from '@eduardoalvarez/arrecife';
340
+ // ✅ the normal form
341
+ import { GitHub, LinkedIn } from '@eduardoalvarez/arrecife/social';
272
342
 
343
+ // ✅ for iterating the catalogue
344
+ import { social } from '@eduardoalvarez/arrecife';
273
345
  <social.GitHub />
274
- <social.LinkedIn />
275
346
  ```
276
347
 
277
- Los ocho son `GitHub`, `LinkedIn`, `X`, `Instagram`, `Discord`, `YouTube`, `Rss`
278
- y `Correo`. Van bajo un namespace por un motivo concreto: **uno se llama `X`**.
279
- Un `export const X` en la raíz de una librería de componentes colisiona con
280
- cualquier cosa —una variable de un genérico, un `import { X }` de otro sitio— y
281
- el fallo aparece lejos de aquí.
348
+ All nine are `GitHub`, `LinkedIn`, `X`, `Instagram`, `Discord`, `YouTube`, `Rss`,
349
+ `Email` and `Newsletter`.
350
+
351
+ **The two forms are not taste, and in Next they are not interchangeable.** The
352
+ root carries `"use client"`, and what crosses into a Server Component is a client
353
+ reference **per export** — the properties of a plain object are not exports. So
354
+ from a Server Component `social.LinkedIn` is `undefined`, and `undefined` as an
355
+ element type kills the build at prerender. `./social` carries no directive: the
356
+ icon renders on the server, ships no client JS, and pulls 5.6 KB instead of the
357
+ root's 116 KB. Reach for the subpath by default; reach for `social` when you are
358
+ mapping a list of link names onto icons.
359
+
360
+ The namespace stays because **one of them is called `X`**. An `export const X` at
361
+ the root of a component library collides with anything — a generic's type
362
+ variable, an `import { X }` from somewhere else — and the failure shows up far
363
+ from here. In the subpath you asked for icons, so the collision is yours to
364
+ resolve and it takes one word: `import { X as XIcon }`.
365
+
366
+ `Newsletter` is the bell, and it is named for what it means and not for what it
367
+ draws — same as everything else in the system. It plays `Rss`'s role: a way to
368
+ follow, not a social network. That is what keeps it inside this catalogue and
369
+ keeps the catalogue from turning into an icon library.
370
+
371
+ **The internal glyphs are NOT exported.** `Close`, `ChevronDown`, `Copy`, `Sun`
372
+ and company are the minimum set the primitives need and they stay inside.
373
+ Publishing them would turn `lib/glyphs.tsx` into the icon library the system
374
+ decided not to have, and from there it grows on its own. A project that needs an
375
+ icon passes its own: `Stat` receives `icon`, `Footer` receives each social link's
376
+ `icon`.
377
+
378
+ ### The icons are yours, the way they are drawn is not
379
+
380
+ That last sentence used to end there, and «its own what, drawn how» had no
381
+ answer. The admin panel imports 89 distinct icons in 229 places — 77 of them
382
+ domain icons for a course admin, which no design system was going to ship — and
383
+ drew them at `size-4` twenty-six times, plus `size-3.5`, `size-3`, `size-6` and
384
+ `size-7`, with no rule behind any of them.
282
385
 
283
- **Los glifos internos NO se exportan.** `Close`, `ChevronDown`, `Copy`, `Sol` y
284
- compañía son el juego mínimo que necesitan los primitivos y se quedan dentro.
285
- Publicarlos convertiría `lib/glyphs.tsx` en la librería de iconos que el sistema
286
- decidió no tener, y a partir de ahí crece sola. Un proyecto que necesite un icono
287
- pasa el suyo: `Stat` recibe `icon`, `Footer` recibe el `icon` de cada red.
386
+ ```tsx
387
+ import { GraduationCap } from '@phosphor-icons/react';
388
+ import { Icon } from '@eduardoalvarez/arrecife/icons';
288
389
 
289
- ### Las dos subrutas que piden una dependencia
390
+ <Icon as={GraduationCap} />
391
+ ```
290
392
 
291
- `./form` y `./chart` no cuelgan de la raíz, y es a propósito. Cada una pide una
292
- dependencia de pares **opcional** —`react-hook-form` y `recharts`—, y colgarlas
293
- del índice principal obligaría a los cinco proyectos a instalarlas para que su
294
- bundler resolviera un import que cuatro de ellos nunca ejecutan.
393
+ 1em, so the icon takes the size of the text it sits in and nobody picks a number.
394
+ Weight `regular` by default, and **that is the whole reason the set is
395
+ Phosphor**: it bakes the weight into the path instead of exposing a
396
+ `strokeWidth`, and its regular lands on the one stroke the identity document
397
+ names. Measured on the `Minus` path itself, whose regular form is a bar of radius
398
+ 8 on a 256 grid:
295
399
 
296
- Es la misma decisión que `./og` y `./shiki`, mirada desde el otro lado: allí se
297
- saca React del camino de quien no lo monta; aquí se saca Recharts del camino de
298
- quien no dibuja.
400
+ | | Line | As a fraction of the rendered size |
401
+ | --- | --- | --- |
402
+ | phosphor `regular` | 16 on a 256 grid | **0.0625em** |
403
+ | the document | 1.6 on a 24 grid | **0.0667em** |
404
+ | `lib/glyphs.tsx` | 1.75 on a 16 grid | 0.109em |
405
+
406
+ Six per cent apart, which is no pixel on any screen. Nothing had to be derived and
407
+ no number had to be invented. The `Icons/Icon` → `regular IS the document's
408
+ stroke` story alternates the bars so the claim can be checked instead of believed
409
+ — and it also shows the third row, because **`glyphs.tsx` is the outlier**: at
410
+ 0.109em it is three quarters heavier than both, it was never argued anywhere, and
411
+ aligning it would restyle every primitive in the library. That is a separate
412
+ change and `docs/decisions.md` § 29 says so.
413
+
414
+ **The weight is an axis with three values, and `tone` is how you name them.**
415
+ `weight` is not a prop: Phosphor ships six and this system reads three, because
416
+ `thin`, `bold` and `duotone` have no role behind them here.
417
+
418
+ | `tone` | Weight | What it is |
419
+ | --- | --- | --- |
420
+ | `action` · the default | `regular` | An icon that is a control or names one |
421
+ | `current` | `fill` | The one of a set you are on — the item carrying `aria-current` |
422
+ | `quiet` | `light` | Furniture: a marker in a metadata row, not a control and not a state |
423
+
424
+ `current` is the value that earns the axis, and the argument is not taste.
425
+ An active sidebar item already says so in biolume, and **colour on its own is the
426
+ one channel WCAG 1.4.1 says may not carry meaning** — the fill is the second
427
+ channel, and it is the one that survives a forced-colours mode where the biolume
428
+ does not. `quiet` is the opposite problem: in a metadata row the icon is not the
429
+ point of the line, and at `regular` it draws as heavy as the date beside it.
430
+ `docs/decisions.md` § 35 has the rest.
431
+
432
+ `@phosphor-icons/react` is an **optional** peer dependency on its own subpath, by
433
+ the same rule as `./form` and `./chart`: two of the five projects use no icons and
434
+ install nothing.
435
+
436
+ **An icon is not illustration.** Tiburoncín — the faces, the poses, the fin — is
437
+ the mascot, it comes from `./brand`, and the manual doses it by surface: a face
438
+ only in an empty state, a confirmation, an error, course progress or a
439
+ celebration. An icon is functional vocabulary and goes wherever a control needs a
440
+ label it cannot spell. Adopting a set changed nothing about the first, and
441
+ neither stands in for the other in either direction.
442
+
443
+ **In Next, import from `@phosphor-icons/react/ssr` inside a Server Component.**
444
+ Phosphor's default build reads `IconContext` through `useContext`, and a hook in a
445
+ Server Component throws — and it ships no `"use client"` to stop you, so the
446
+ failure arrives at render. The `/ssr` entry is the same icons without the context
447
+ read, and `Icon` works with either.
448
+
449
+ ### `"use client"` is in the published `dist`
450
+
451
+ The root, `./brand`, `./form` and `./chart` carry the directive. They render
452
+ React, and their Radix primitives call `createContext` at module scope: without
453
+ it, a Next project with the App Router cannot import the library at all — it
454
+ fails at build time with `TypeError: (0 , r.createContext) is not a function`.
455
+ It blocked `cursos` for a whole version, and the workaround there was a
456
+ `"use client"` in every one of that project's own adapters, including a `Badge`
457
+ that is a `<span>` with no interaction. It cost 272 KB of client chunk.
458
+
459
+ The five portable subpaths do NOT carry it — `./tokens`, `./theme`,
460
+ `./variants`, `./og` and `./shiki` — and that is the half that matters more.
461
+ Marking them client would be a lie with a cost: a Server Component importing
462
+ `buttonVariants`, a function that returns a string, would pull a client boundary
463
+ in with it.
464
+
465
+ `./social` is the third case, and it is why the check stopped looking only at the
466
+ portable ones. It renders React — it is nine `<svg>` — so it can never be
467
+ portable, and it holds no state, so it must not be a client entry either. Listed
468
+ in neither set, nothing would have noticed it being marked client by mistake, and
469
+ that mistake undoes the only reason the subpath exists. See `docs/decisions.md`
470
+ § 26.
471
+
472
+ It is stamped by `scripts/add-use-client.mjs` after tsup, and not by tsup's
473
+ `banner`. That was tried first: esbuild writes the directive and the bundling
474
+ pass strips it back out with a `Module level directives cause errors when
475
+ bundled` warning. The build stayed green and the published package was broken for
476
+ Next — the worst way to fail, because the failure surfaces in somebody else's
477
+ project. `check:exports` now verifies it in both directions: present on the four
478
+ client entries, absent from every other subpath.
479
+
480
+ It is inert outside Next. In Astro and in plain Vite it is a string literal at
481
+ the top of a module; Rollup may warn and nothing else happens. One `dist` serves
482
+ the Next projects and the Astro ones, which is the constraint that decided the
483
+ shape.
484
+
485
+ ### `./variants` — the class vocabulary without React
486
+
487
+ ```ts
488
+ import { buttonVariants, badgeVariants, CARD_SURFACE }
489
+ from '@eduardoalvarez/arrecife/variants';
490
+ ```
491
+
492
+ `buttonVariants`, `badgeVariants` and `categoryBadgeVariants` are not
493
+ components: they are functions that return a string of classes. They touch
494
+ neither React nor the DOM. They used to live inside the components, so importing
495
+ one dragged the whole library along, and that had a cost measured in two of the
496
+ five projects.
497
+
498
+ In `cursos` it forced a `"use client"` on an adapter whose entire content was one
499
+ call to CVA. In `links`, which depends on no React at all, it was not even an
500
+ option: that project copied the class vocabulary by hand into `LinkRow.astro` and
501
+ `Footer.astro`, and the copy had already drifted once: the hero gradient sat at
502
+ `55%` and `#e9eeea` against the token's `60%` and, at the time, `#EFE9DE`, and
503
+ nothing compared them. That light stop is `#FFFFFF` now — § 9 measured it — which
504
+ is the same lesson seen from the other end. A copied value goes stale the moment
505
+ the original moves, and only the original is ever right.
506
+
507
+ The rule for what belongs in the subpath: if it returns classes, it goes there;
508
+ if it returns markup, it stays in the component. `Button` renders a `<button>`,
509
+ so it stays at the root; `buttonVariants` returns a string, so it is in
510
+ `./variants`. The root re-exports all of it, so an existing
511
+ `import { buttonVariants } from '@eduardoalvarez/arrecife'` keeps working — what
512
+ the subpath buys is not the name, it is not paying for React to get it.
513
+
514
+ ### The two subpaths that ask for a dependency
515
+
516
+ `./form` and `./chart` do not hang off the root, and that is deliberate. Each
517
+ asks for an **optional** peer dependency — `react-hook-form` and `recharts` — and
518
+ hanging them off the main index would force all five projects to install them so
519
+ their bundler could resolve an import four of them never execute.
520
+
521
+ It is the same decision as `./og` and `./shiki`, seen from the other side: there
522
+ React is kept out of the way of whoever does not mount it; here Recharts is kept
523
+ out of the way of whoever does not draw.
299
524
 
300
525
  ```tsx
301
526
  import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage }
302
527
  from '@eduardoalvarez/arrecife/form';
303
528
 
304
- import { ChartContainer, ChartTooltip, ChartTooltipContent, colorDeSerie }
529
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, seriesColor }
305
530
  from '@eduardoalvarez/arrecife/chart';
306
531
  ```
307
532
 
308
- `check:exports` verifica que las cuatro portables —`./tokens`, `./tema`, `./og` y
309
- `./shiki`— no traen React en el `dist/` publicado, **siguiendo los imports
310
- relativos**. Sin eso el check era papel mojado: con `treeshake` activo, cada
311
- entrada portable queda en dos líneas que reexportan de un `chunk-XXXX.js`, y un
312
- grep sobre esas dos líneas no encuentra React ni aunque el chunk lo importe.
533
+ `check:exports` verifies that the five portable ones — `./tokens`, `./theme`,
534
+ `./variants`, `./og` and `./shiki` — bring no React into the published `dist/`,
535
+ **by following the relative imports**. Without that the check was worthless: with `treeshake`
536
+ on, each portable entry ends up as two lines re-exporting from a
537
+ `chunk-XXXX.js`, and a grep over those two lines finds no React even when the
538
+ chunk imports it.
313
539
 
314
540
  ## Scripts
315
541
 
316
542
  | | |
317
543
  | --- | --- |
318
- | `pnpm build` | verifica la pureza de tokens, compila con tsup y genera `theme.css` |
544
+ | `pnpm build` | verifies token purity, compiles with tsup and generates `theme.css` |
319
545
  | `pnpm typecheck` | `tsc --noEmit` |
320
- | `pnpm lint` | ESLint, incluido el veto a hex literales fuera de `tokens.ts` |
321
- | `pnpm check:tokens` | falla si `src/tokens/` importa algo de fuera |
322
- | `pnpm test` | compila Tailwind y corre axe sobre las 206 stories, en los dos modos |
323
- | `pnpm check:exports` | verifica que `dist/` tiene lo que `exports` promete |
324
- | `pnpm check:release` | valida `release-please-config.json` contra el esquema oficial |
325
- | `pnpm storybook` | genera los tokens y levanta Storybook en el 6006 |
546
+ | `pnpm lint` | ESLint, including the ban on literal hexes outside `tokens.ts` |
547
+ | `pnpm check:tokens` | fails if `src/tokens/` imports anything from outside |
548
+ | `pnpm test` | compiles Tailwind and runs axe over the 208 stories, in both modes |
549
+ | `pnpm check:exports` | verifies that `dist/` holds what `exports` promises |
550
+ | `pnpm check:release` | validates `release-please-config.json` against the official schema |
551
+ | `pnpm storybook` | generates the tokens and serves Storybook on 6006 |
326
552
 
327
- ## El contraste como test, no como panel
553
+ ## Contrast as a test, not as a panel
328
554
 
329
- `pnpm test` monta cada story en un Chromium real y le pasa axe con
330
- `a11y: { test: 'error' }`. Corre dos veces, una por modo: un color solo falla en
331
- uno de los dos, así que pasar en oscuro no prueba nada sobre el claro.
555
+ `pnpm test` mounts every story in a real Chromium and runs axe over it with
556
+ `a11y: { test: 'error' }`. It runs twice, once per mode: a color only fails in
557
+ one of the two, so passing in dark proves nothing about light.
332
558
 
333
- Está comprobado que no es decorativo: devolver `textMuted` claro a su valor
334
- anterior tira ocho stories con «insufficient color contrast of 4.24».
559
+ It is demonstrably not decorative: putting light `textMuted` back to its previous
560
+ value takes down eight stories with «insufficient color contrast of 4.24».
335
561
 
336
- Hay una sola regla desactivada, en dos stories concretas y con el motivo escrito
337
- al lado: `aria-hidden-focus` en `Select`/`DropdownMenu` abiertos. Radix marca
338
- `aria-hidden` todo lo que queda fuera del portal y deja el disparador dentro
339
- siendo focusable; el foco está atrapado por su `FocusScope`, así que no se puede
340
- tabular hasta él. Es un desacuerdo conocido entre axe y Radix.
562
+ There is exactly one disabled rule, in two specific stories and with the reason
563
+ written beside it: `aria-hidden-focus` on open `Select`/`DropdownMenu`. Radix
564
+ marks everything outside the portal `aria-hidden` and leaves the trigger inside
565
+ it still focusable; focus is trapped by its `FocusScope`, so it cannot be tabbed
566
+ to. It is a known disagreement between axe and Radix.
341
567
 
342
- ## Correcciones de contraste
568
+ ## Contrast corrections
343
569
 
344
- El documento de identidad medía todo contra `background`. Pero `surfaceRaised`
345
- es el peor caso en los **dos** modos: en claro es más oscuro que el fondo de
346
- página, en oscuro es más claro. Es donde viven menús y tabs activos.
570
+ The identity document measured everything against `background`. But
571
+ `surfaceRaised` is the worst case in **both** modes: in light it is darker than
572
+ the page background, in dark it is lighter. It is where menus and active tabs
573
+ live.
347
574
 
348
- | token | antes | ahora | motivo |
575
+ | token | before | now | reason |
349
576
  | --- | --- | --- | --- |
350
- | `light.textMuted` | `#6B7480` | `#626A75` | 4.24 no llegaba a AA sobre papel |
351
- | `light.warning` | `#9A6A12` | `#8D6111` | 4.23 no llegaba a AA sobre papel |
352
- | `dark.error` | `#E05252` | `#E15757` | 4.35 sobre `surface`, que es donde va un error de formulario |
577
+ | `light.textMuted` | `#6B7480` | `#626A75` | 4.24 did not reach AA over paper |
578
+ | `light.warning` | `#9A6A12` | `#8D6111` | 4.23 did not reach AA over paper |
579
+ | `dark.error` | `#E05252` | `#E15757` | 4.35 over `surface`, which is where a form error goes |
580
+
581
+ All three keep their exact hue and saturation: only lightness moves, by one to
582
+ four points. Light `accent` and `warm` are untouched.
353
583
 
354
- Los tres conservan tono y saturación exactos: solo cambia la luminosidad entre
355
- uno y cuatro puntos. `accent` y `warm` claros no se tocan.
584
+ New token: `hairlineHover` — `#2C4D5D` in dark (rule 6's value) and `#D3C8B2` in
585
+ light, derived by matching the perceptual step (ΔL\* 10.5) rather than the
586
+ contrast ratio, which overshoots near white.
356
587
 
357
- Token nuevo: `hairlineHover` — `#2C4D5D` en oscuro (el valor de la regla 6) y
358
- `#D3C8B2` en claro, derivado de igualar el salto perceptual (ΔL\* 10.5) en vez
359
- de la razón de contraste, que cerca del blanco se pasa de frenada.
588
+ `textMuted` never goes over `surfaceRaised`: in dark it gives 4.07. Menus use
589
+ `textSecondary`, which gives 6.96.
360
590
 
361
- `textMuted` no va nunca sobre `surfaceRaised`: en oscuro da 4.07. Los menús usan
362
- `textSecondary`, que da 6.96.
591
+ **And no gradient ends there either**, which is the same rule applied to a
592
+ surface that moves. The light `hero` and `section` blocks used to sweep from the
593
+ page down onto `surfaceRaised`, where light `accent` reads **4.21** and `warm`
594
+ **4.19** — both under 4.5, and both of them fine at 4.55 and 4.53 on the page
595
+ they started from. That makes a token's contrast a function of **where in the
596
+ panel the text happens to sit**, which no token can guarantee and the suite
597
+ cannot see: axe does not evaluate text over a gradient, so both modes passed it.
598
+ `Hero` puts an `accent` eyebrow directly on that gradient.
363
599
 
364
- ### La tercera corrección: el color semántico no es color de texto sobre su tinte
600
+ The light blocks now sweep between `background` and `surface` and never touch
601
+ `surfaceRaised`, so the darkest point of either one is the page itself — a token
602
+ that passes on the page passes at every point of the sweep. `docs/decisions.md`
603
+ § 9 has the measurements, including the two other things the first composition
604
+ got wrong.
365
605
 
366
- Salió al implementar la receta de avisos del documento — fondo al 8 % del color
367
- semántico — y la tiró la suite en modo claro, en cinco stories.
606
+ ### The third correction: a semantic color is not a text color over its own tint
368
607
 
369
- Los semánticos claros están calibrados para pasar **justo** sobre papel. Teñir el
370
- fondo con ellos los hunde por debajo de AA:
608
+ It came up while implementing the document's alert recipe — background at 8 % of
609
+ the semantic color — and the suite took it down in light mode, across five
610
+ stories.
371
611
 
372
- | tono | sobre papel | sobre su propio tinte 8 % | `textPrimary` sobre el tinte |
612
+ The light semantics are calibrated to pass **just** over paper. Tinting the
613
+ background with them sinks them below AA:
614
+
615
+ | tone | over paper | over its own 8 % tint | `textPrimary` over the tint |
373
616
  | --- | --- | --- | --- |
374
617
  | `accent` | 4.55 | **4.12** | 14.82 |
375
618
  | `warm` | 4.54 | **4.11** | 14.85 |
@@ -377,79 +620,83 @@ fondo con ellos los hunde por debajo de AA:
377
620
  | `warning` | 4.88 | **4.40** | 14.78 |
378
621
  | `error` | 4.87 | **4.35** | 14.64 |
379
622
 
380
- No hay alfa que lo arregle: el problema es poner el color encima de sí mismo. La
381
- resolución no toca la paleta — el tinte es una **superficie**, así que el texto
382
- que lleva encima es un token de texto. El color semántico se queda donde no es
383
- texto: el borde y el glifo.
623
+ No alpha fixes it: the problem is putting the color on top of itself. The
624
+ resolution does not touch the palette — the tint is a **surface**, so the text on
625
+ top of it is a text token. The semantic color stays where it is not text: the
626
+ border and the glyph.
384
627
 
385
- El 8 %, por cierto, aguanta igual o mejor sobre papel que sobre abismo (1.106 vs.
386
- 1.149 en acento). La sospecha de que el modo claro necesitaba una segunda tabla
387
- iba al revés: el punto flojo del sistema es `error` sobre abismo, 1.067.
628
+ The 8 %, incidentally, holds up as well as or better over paper than over abyss
629
+ (1.106 vs. 1.149 in accent). The suspicion that light mode needed a second table
630
+ ran the other way round: the system's weak point is `error` over abyss, 1.067.
388
631
 
389
- ## Publicar una versión
632
+ ## Publishing a version
390
633
 
391
- **No hay pasos manuales.** El tag, el CHANGELOG y el bump de versión los hace
392
- release-please a partir de los commits convencionales que ya se escriben —y que
393
- `lint-pr-title` ya obliga a escribir bien.
634
+ **There are no manual steps.** The tag, the CHANGELOG and the version bump are
635
+ done by release-please from the conventional commits that already get written —
636
+ and that `lint-pr-title` already forces to be written correctly.
394
637
 
395
- El ciclo, entero, está en `.github/workflows/release.yml`:
638
+ The whole cycle lives in `.github/workflows/release.yml`:
396
639
 
397
- 1. Mergeas un PR a `main` con un título tipo `feat(badge): …`.
398
- 2. release-please abre —o actualiza— un PR llamado `chore: versión X.Y.Z` con el
399
- bump en `package.json` y la entrada nueva del `CHANGELOG.md`. Ese PR se queda
400
- abierto y se va acumulando con cada merge, así que puedes juntar varios
401
- cambios en una versión.
402
- 3. Cuando lo mergeas, corta el tag, crea el release y dispara la publicación.
403
- 4. Antes de subir nada, el workflow comprueba que el tag y `package.json`
404
- coinciden, y corre lint, tipos, build, la verificación de `exports` y la
405
- suite completa en los dos modos.
640
+ 1. You merge a PR to `main` with a title like `feat(badge): …`.
641
+ 2. release-please opens — or updates — a PR called `chore: release X.Y.Z` with
642
+ the bump in `package.json` and the new `CHANGELOG.md` entry. That PR stays
643
+ open and accumulates with every merge, so you can group several changes into
644
+ one version.
645
+ 3. When you merge it, it cuts the tag, creates the release and triggers the
646
+ publish.
647
+ 4. Before uploading anything, the workflow checks that the tag and `package.json`
648
+ match, and runs lint, types, build, the `exports` verification and the full
649
+ suite in both modes.
650
+ 5. With the package already on npm, it builds Storybook and deploys it to
651
+ [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev).
406
652
 
407
- `feat:` sube la minor y `fix:` la patch. Mientras la versión sea `0.x`, un
408
- cambio que rompe sube la **minor** y no la major: eso es lo que significa el
409
- `0.` — que la API todavía se puede mover sin gastar la 1.0. Está en
410
- `release-please-config.json`, y ahí también está `initial-version` con el `0.1.0`
411
- de la primera versión.
653
+ `feat:` bumps the minor and `fix:` the patch. While the version is `0.x`, a
654
+ breaking change bumps the **minor** and not the major: that is what the `0.`
655
+ means — that the API can still move without spending 1.0. It is in
656
+ `release-please-config.json`, and `initial-version` with the first release's
657
+ `0.1.0` is there too.
412
658
 
413
- Cuando la API se estabilice, se sube a `1.0.0` a mano una vez y a partir de ahí
414
- un `BREAKING CHANGE:` sube la major como en cualquier paquete.
659
+ When the API stabilises, it goes up to `1.0.0` by hand once, and from then on a
660
+ `BREAKING CHANGE:` bumps the major like in any other package.
415
661
 
416
- ### Cómo release-please decide qué cortar
662
+ ### How release-please decides what to cut
417
663
 
418
- Dos datos, y salen de sitios distintos. Saberlo evita el único fallo que deja el
419
- release atascado:
664
+ Two pieces of information, and they come from different places. Knowing this
665
+ avoids the one failure that leaves the release stuck:
420
666
 
421
- | Dato | De dónde sale |
667
+ | Datum | Where it comes from |
422
668
  | --- | --- |
423
- | La **versión** | Del **título** del PR — `chore(main): release 0.2.0` |
424
- | El **componente** | Del **nombre de la rama** — `release-please--branches--main--components--arrecife` |
669
+ | The **version** | From the PR **title** — `chore(main): release 0.2.0` |
670
+ | The **component** | From the **branch name** — `release-please--branches--main--components--arrecife` |
425
671
 
426
- El componente lo deriva de `package.json` y **no se puede fijar por
427
- configuración**: no existe una clave `component` en el
428
- [esquema oficial](https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json).
429
- `include-component-in-tag: false` es lo que hace que el tag sea `v0.2.0` y no
672
+ The component is derived from `package.json` and **cannot be pinned by
673
+ configuration**: there is no `component` key in the
674
+ [official schema](https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json).
675
+ `include-component-in-tag: false` is what makes the tag `v0.2.0` and not
430
676
  `arrecife-v0.2.0`.
431
677
 
432
- Si el título o la rama se editan a mano y dejan de casar, release-please no crea
433
- el release y cada run termina en `There are untagged, merged release PRs
434
- outstanding - aborting`. De ahí no se sale con configuración: hay que cortar el
435
- tag y el release a mano y reetiquetar el PR a `autorelease: tagged`.
678
+ If the title or the branch are edited by hand and stop matching, release-please
679
+ does not create the release and every run ends in `There are untagged, merged
680
+ release PRs outstanding - aborting`. There is no way out of that through
681
+ configuration: the tag and the release have to be cut by hand and the PR
682
+ relabelled `autorelease: tagged`.
436
683
 
437
- `pnpm check:release` valida la configuración contra ese esquema en cada CI.
438
- Existe porque release-please **ignora en silencio** las claves que no conoce: una
439
- opción inventada no da error, no sale en el log y no hace nada.
684
+ `pnpm check:release` validates the configuration against that schema on every CI
685
+ run. It exists because release-please **silently ignores** keys it does not know:
686
+ an invented option raises no error, shows in no log and does nothing.
440
687
 
441
- ### La publicación de confianza
688
+ ### Trusted publishing
442
689
 
443
- El workflow publica con **OIDC**: GitHub emite un token que prueba «este build
444
- salió de este repo y de este workflow», y npm lo cambia por permiso de
445
- publicación. No hay ningún secreto de larga vida que robar, ni que rotar. De
446
- paso genera la **procedencia**, que firma el paquete con un enlace verificable a
447
- ese commit exacto.
690
+ The workflow publishes with **OIDC**: GitHub issues a token proving «this build
691
+ came out of this repo and this workflow», and npm exchanges it for permission to
692
+ publish. There is no long-lived secret to steal, or to rotate. It also generates
693
+ **provenance**, which signs the package with a verifiable link to that exact
694
+ commit.
448
695
 
449
- Se configura una vez, en npmjs.com → el paquete → *Settings* → *Trusted
696
+ It is configured once, on npmjs.com → the package → *Settings* → *Trusted
450
697
  publisher*:
451
698
 
452
- | Campo | Valor |
699
+ | Field | Value |
453
700
  | --- | --- |
454
701
  | Publisher | GitHub Actions |
455
702
  | Organization or user | `Proskynete` |
@@ -457,262 +704,416 @@ publisher*:
457
704
  | Workflow filename | `release.yml` |
458
705
  | Environment | `npm` |
459
706
 
460
- Ese *Workflow filename* es la razón de que el release y la publicación vivan en
461
- un solo archivo en vez de en un workflow reutilizable: npm casa el token contra
462
- un nombre, y con `workflow_call` hay dos candidatos.
463
-
464
- **El huevo y la gallina.** No se puede configurar un publicador de confianza en
465
- un paquete que todavía no existe, así que la primera versión necesita token:
466
-
467
- 1. Crea el entorno `npm` en la configuración del repo con el secreto
468
- `NPM_TOKEN` (un token de tipo *automation*).
469
- 2. Sube la versión y publica la primera vez. El workflow avisa en el log de que
470
- está usando token.
471
- 3. Configura la publicación de confianza con la tabla de arriba.
472
- 4. **Borra el secreto `NPM_TOKEN`.** El paso que lo usa se salta solo cuando no
473
- está, y OIDC toma el relevo sin tocar ni una línea del workflow.
474
-
475
- Para probar sin gastar una versión: *Actions → Release y publicación → Run
476
- workflow* con el ensayo activado. Hace todo menos publicar, no necesita token, y
477
- el resumen del run lista qué archivos viajarían y cuánto pesa el tarball.
478
-
479
- ## Estado
480
-
481
- - **Fase 1** · andamiaje, tokens y Storybook con el switch de tema. Completa.
482
- - **Fase 2** · `brand/`. Completa con los PNG que ya existían.
483
- - **Fase 3** · los 18 primitivos sobre shadcn/Radix, más `Text` y ocho añadidos
484
- después de medir el uso real en los cinco proyectos. Completa.
485
- - **Fase 4** · `AudioPlayer`, migrado. Completa.
486
- - **Fase 5** · completa. `ArticleCard`, `AuthorCard`, `TalkCard`, `CourseCard`,
707
+ That *Workflow filename* is the reason the release and the publish live in a
708
+ single file rather than in a reusable workflow: npm matches the token against a
709
+ name, and with `workflow_call` there are two candidates.
710
+
711
+ **The chicken and the egg.** You cannot configure a trusted publisher on a
712
+ package that does not exist yet, so the first version needs a token:
713
+
714
+ 1. Create the `npm` environment in the repo settings with the `NPM_TOKEN` secret
715
+ (an *automation* token).
716
+ 2. Bump the version and publish for the first time. The workflow says in the log
717
+ that it is using a token.
718
+ 3. Configure trusted publishing with the table above.
719
+ 4. **Delete the `NPM_TOKEN` secret.** The step that uses it skips itself when it
720
+ is absent, and OIDC takes over without touching a line of the workflow.
721
+
722
+ To test without spending a version: *Actions → Release y publicación → Run
723
+ workflow* with the dry run enabled. It does everything but publish, needs no
724
+ token, and the run summary lists which files would travel and how big the tarball
725
+ is.
726
+
727
+ The dry run **does deploy Storybook**, to a Vercel preview and not to the public
728
+ domain. That is deliberate: a dry run that skips a job cannot tell you whether
729
+ that job works, and the deploy was the only step in the workflow that could not
730
+ be tested without spending a version.
731
+
732
+ One detail of the dry run that is confusing the first time: `npm publish
733
+ --dry-run` queries the registry, and `package.json` points at an **already
734
+ published** version except in the window between release-please bumping the
735
+ number and the workflow publishing. So the dry run runs into `cannot publish over
736
+ the previously published versions` over the one thing that cannot be right in a
737
+ dry run. That specific message is forgiven with a notice; any other failure still
738
+ fails.
739
+
740
+ ### Storybook is deployed with the version
741
+
742
+ The published Storybook is the library's documentation: every story is both the
743
+ example and the test that verifies it. It lives at
744
+ [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev) and it is
745
+ uploaded by `release.yml`'s `deploy` job, **after** npm has published.
746
+
747
+ That order is not an implementation detail. The site and the package have to tell
748
+ the same version: a Storybook ahead of npm shows components nobody can install
749
+ yet, and that is exactly the failure this repo exists because of — one source of
750
+ truth drifting from another.
751
+
752
+ Hence the decision that surprises people: **the Vercel project is not connected
753
+ to GitHub.** With the Git integration, Vercel builds on its own on every push to
754
+ `main` and there is no way to ask it to wait for the tag. The only thing that
755
+ deploys is the workflow, and it uploads Storybook **already built**: with the
756
+ Build Output API and `--prebuilt`, Vercel executes nothing, it serves what is in
757
+ `.vercel/output/static`. That way it is built by the same Node and the same
758
+ lockfile that just verified the library.
759
+
760
+ It is configured once, with `vercel link` in a local clone to create the project
761
+ and get the two ids out of `.vercel/project.json`. **Careful with one extra
762
+ step:** `vercel link` connects the GitHub repository to the project on its own
763
+ and without asking, which is exactly what you do not want. It is undone with
764
+ `vercel git disconnect`, and it is worth checking before calling the project
765
+ configured.
766
+
767
+ | Where | Name | What it is |
768
+ | --- | --- | --- |
769
+ | *Secrets* | `VERCEL_TOKEN` | A Vercel account token |
770
+ | *Variables* | `VERCEL_ORG_ID` | The `orgId` from `.vercel/project.json` |
771
+ | *Variables* | `VERCEL_PROJECT_ID` | The `projectId` from `.vercel/project.json` |
772
+
773
+ The two ids go in as **variables** and not as secrets on purpose: they are not
774
+ secret — they come out of any clone that runs `vercel link` — and as variables
775
+ they are readable in the log when something does not add up.
776
+
777
+ The team's deployment protection is `all_except_custom_domains`: each deploy's
778
+ unique URL asks for a team session and returns a 302 to the login, and the public
779
+ one is the custom domain. It is not a misconfiguration, it is Vercel's default
780
+ and it is the one we want.
781
+
782
+ **If they are missing, the job warns and does not break.** By the time it runs,
783
+ npm has published and the tag has been cut: a red there would read as «the
784
+ release failed», which is the opposite of what happened. The warning stays in the
785
+ run summary.
786
+
787
+ ## Status
788
+
789
+ - **Phase 1** · scaffolding, tokens and Storybook with the theme switch. Done.
790
+ - **Phase 2** · `brand/`. Done, with the PNGs that already existed.
791
+ - **Phase 3** · the 18 primitives on shadcn/Radix, plus `Text` and eight more
792
+ added after measuring real usage across the five projects. Done.
793
+ - **Phase 4** · `AudioPlayer`, migrated. Done.
794
+ - **Phase 5** · done. `ArticleCard`, `AuthorCard`, `TalkCard`, `CourseCard`,
487
795
  `LinkRow`, `CodeBlock`, `Blockquote`, `PageHeader`, `EmptyState`, `Breadcrumb`,
488
796
  `Nav`, `SidebarNav`, `TableOfContents`, `Stat`, `Footer`, `Hero`,
489
- `NewsletterForm`, `og/` y `shiki/`.
490
-
491
- El criterio para decidir qué entra sigue siendo el mismo: **codifica una regla
492
- de identidad, tiene dos o más consumidores, y no arrastra infraestructura del
493
- proyecto.**
494
-
495
- ### `Hero` y `NewsletterForm` volvieron a entrar
496
-
497
- Estaban fuera de la lista con un argumento escrito, y el argumento se revisó.
498
-
499
- **`Hero`.** Se había descartado porque «el hero del portafolio y el de cursos son
500
- el mismo esqueleto que una cabecera de sección, y `PageHeader` los cubre con una
501
- prop de escala». Eso vale para el texto y solo para el texto. El hero del
502
- documento tiene además degradado, radio de panel, texto acotado al 62 % del ancho
503
- y la pose sangrando por la esquina inferior derecha — nada de lo cual cabe en una
504
- prop de escala de `PageHeader`, y todo lo cual son reglas de identidad que si no
505
- viven aquí se reimplementan cinco veces. Son dos piezas distintas: `PageHeader`
506
- sigue siendo la cabecera de sección y va dentro de `<main>`; `Hero` es la portada
507
- y va uno por sitio.
508
-
509
- **`NewsletterForm`.** Se había descartado porque «la mitad de su código es un
510
- `POST` a un endpoint que solo vive ahí: eso es infraestructura». Correcto, y por
511
- eso el `POST` no está aquí. El componente es presentacional: recibe `state` y
512
- emite `onSubmitEmail`, y el proyecto hace la llamada con su proveedor. Lo que sí
513
- es identidad son los cuatro estados y, sobre todo, que el aviso vaya **debajo**
514
- del formulario en vez de reemplazarlo — reemplazarlo es lo que rompe el caso real
515
- de quien se suscribe con el correo equivocado.
516
-
517
- `Nav`, `Footer`, `Breadcrumb` y `Hero` son composición de página y se pueden
518
- discutir como piezas de librería. Entran igual: la estética CLI —el `./sección`
519
- de la barra, el `~ / artículos / slug` de la ruta, la firma `$ cd ~/…` del pie—
520
- es lo primero que se desincroniza cuando cinco proyectos la escriben cada uno por
521
- su cuenta.
522
-
523
- ### Decisiones de la Fase 3
524
-
525
- - **Sin `lucide-react`.** Los ocho glifos que los primitivos necesitan están
526
- inline en `src/lib/glyphs.tsx`, heredan `currentColor` y miden 1em. Una
527
- librería de iconos como dependencia se la come cada uno de los cinco proyectos.
528
- - **Sin animaciones de entrada.** Modales, menús, tooltips y toasts aparecen
529
- donde van a quedarse. La perilla del `Switch` cambia de posición sin deslizarse.
530
- La única transición del sistema es `transition-standard`, que solo puede animar
531
- color y borde porque así está escrita la utilidad.
532
- - **Una excepción, documentada:** el spinner de `Button loading` gira. Un botón
533
- cargando sin movimiento es indistinguible de uno deshabilitado; es
534
- realimentación de progreso, no de estado, y va envuelto en `motion-safe`.
535
- - **`Progress` exige `label`.** Una barra sin nombre accesible no dice de qué es
536
- el progreso, y ninguna otra parte del componente puede deducirlo.
537
- - **Cero hex literales**, incluido `Button`. La regla 2 sale con
538
- `light:bg-brand-hull`, porque el casco ya era un token.
539
- - **`cursor-pointer` explícito** en todo lo que se pulsa. Tailwind v4 quitó del
540
- preflight el `cursor: pointer` de `button`, así que un botón sin la clase se
541
- queda con la flecha del sistema. Lo llevan `Button`, `Checkbox`,
542
- `RadioGroupItem`, `Switch`, `TabsTrigger`, el disparador de `Select`, los
543
- cerrar de `Dialog`/`Sheet`/`Toast`, `PaginationLink`, el casco de las tarjetas
544
- pulsables y los enlaces de `Nav`, `Footer` y `Breadcrumb` — que renderizan
545
- `<a>` sin `href` cuando se les enchufa el `Link` de un enrutador.
546
-
547
- Dos excepciones deliberadas. `Label` apunta a un control pero no es el control.
548
- Y los **ítems de menú** de `Select` y `DropdownMenu` se quedan en
549
- `cursor-default`: un menú nativo no muestra la manito, y el highlight de la
550
- fila ya dice que la fila responde.
551
-
552
- ### La paleta de sintaxis
553
-
554
- Del documento, literal: «keywords arena, strings bioluz, comments plancton,
555
- identifiers espuma», sobre casco. Cuatro colores a propósito — funciones,
556
- variables y tipos caen los tres en espuma, porque el sistema se comunica con
557
- color y borde y no con ruido cromático. Los números y booleanos van con las
558
- cadenas: el documento no los asigna, y agruparlos por «son literales» es más
559
- coherente que estrenar un quinto color.
560
-
561
- Medido sobre `brand.hull` #0B1524, todo AA:
562
-
563
- | rol | token | contraste |
797
+ `NewsletterForm`, `og/` and `shiki/`.
798
+
799
+ The criterion for deciding what gets in is still the same: **it encodes an
800
+ identity rule, it has two or more consumers, and it drags in no project
801
+ infrastructure.**
802
+
803
+ ### `Hero` and `NewsletterForm` came back in
804
+
805
+ They were off the list with a written argument, and the argument was revisited.
806
+
807
+ **`Hero`.** It had been ruled out because «the portfolio's hero and the courses
808
+ one are the same skeleton as a section header, and `PageHeader` covers them with
809
+ a scale prop». That holds for the text and only for the text. The document's hero
810
+ also has a gradient, a panel radius, text clamped to 62 % of the width and the
811
+ pose bleeding off the bottom-right corner — none of which fits in a `PageHeader`
812
+ scale prop, and all of which are identity rules that get reimplemented five times
813
+ if they do not live here. They are two different pieces: `PageHeader` is still
814
+ the section header and goes inside `<main>`; `Hero` is the cover and there is one
815
+ per site.
816
+
817
+ **`NewsletterForm`.** It had been ruled out because «half its code is a `POST` to
818
+ an endpoint that only lives there: that is infrastructure». Correct, and that is
819
+ why the `POST` is not here. The component is presentational: it takes `state` and
820
+ emits `onSubmitEmail`, and the project makes the call with its own provider. What
821
+ IS identity are the four states and, above all, that the notice goes **below**
822
+ the form instead of replacing it — replacing it is what breaks the real case of
823
+ somebody who subscribes with the wrong email.
824
+
825
+ `Nav`, `Footer`, `Breadcrumb` and `Hero` are page composition and can be argued
826
+ about as library pieces. They get in anyway: the CLI aesthetic — the bar's
827
+ `./section`, the path's `~ / artículos / slug`, the footer's `$ cd ~/…` signature
828
+ — is the first thing that drifts when five projects each write it on their own.
829
+
830
+ ### Phase 3 decisions
831
+
832
+ - **It ships no icon set**, and that has not changed. The eight glyphs the
833
+ primitives need are inline in `src/lib/glyphs.tsx`, inherit `currentColor` and
834
+ measure 1em, and they are not exported. What DID change is that
835
+ `@phosphor-icons/react` is now an optional peer on `./icons`, so the set a
836
+ project chooses is drawn at the system's weight — see «The icons are yours»
837
+ above. Optional and on a subpath is the point: the two projects that use no
838
+ icons install nothing.
839
+ - **No entrance animations.** Modals, menus, tooltips and toasts appear where
840
+ they will stay. The `Switch` knob changes position without sliding. The
841
+ system's only transition is `transition-standard`, which can only animate color
842
+ and border because that is how the utility is written.
843
+ - **One exception, documented:** the `Button loading` spinner spins. A loading
844
+ button with no movement is indistinguishable from a disabled one; it is
845
+ feedback about progress, not about state, and it is wrapped in `motion-safe`.
846
+ - **`Progress` requires `label`.** A bar with no accessible name does not say
847
+ what the progress is about, and no other part of the component can deduce it.
848
+ - **Zero literal hexes**, `Button` included. Rule 2 comes out as
849
+ `light:bg-brand-hull`, because the hull was already a token.
850
+ - **Explicit `cursor-pointer`** on everything you press. Tailwind v4 removed
851
+ `cursor: pointer` for `button` from the preflight, so a button without the
852
+ class keeps the system arrow. It is carried by `Button`, `Checkbox`,
853
+ `RadioGroupItem`, `Switch`, `TabsTrigger`, `Select`'s trigger, the close
854
+ buttons of `Dialog`/`Sheet`/`Toast`, `PaginationLink`, the shell of the
855
+ clickable cards and the links in `Nav`, `Footer` and `Breadcrumb` — which
856
+ render an `<a>` with no `href` when a router's `Link` is plugged into them.
857
+
858
+ Two deliberate exceptions. `Label` points at a control but is not the control.
859
+ And the **menu items** of `Select` and `DropdownMenu` stay on `cursor-default`:
860
+ a native menu does not show the pointing hand, and the row highlight already
861
+ says the row responds.
862
+
863
+ ### The syntax palette
864
+
865
+ Straight from the document: «keywords sand, strings biolume, comments plankton,
866
+ identifiers foam», over hull. Four colors on purpose — functions, variables and
867
+ types all land on foam, because the system communicates with color and border and
868
+ not with chromatic noise. Numbers and booleans ride with strings: the document
869
+ does not assign them, and grouping them under «they are literals» is more
870
+ coherent than introducing a fifth color.
871
+
872
+ Measured over `brand.hull` #0B1524, all AA:
873
+
874
+ | role | token | contrast |
564
875
  | --- | --- | --- |
565
- | identificador | `textPrimary` | 16.42:1 |
876
+ | identifier | `textPrimary` | 16.42:1 |
566
877
  | literal | `accent` | 10.05:1 |
567
- | palabra clave | `warm` | 9.05:1 |
568
- | comentario | `textMuted` | 5.43:1 |
569
- | invalidez | `error` | 4.97:1 |
878
+ | keyword | `warm` | 9.05:1 |
879
+ | comment | `textMuted` | 5.43:1 |
880
+ | invalid | `error` | 4.97:1 |
570
881
 
571
- `brand.body` (#3E7CB1) no entra: el sistema lo restringe a relleno y aquí mide
572
- 4.2:1.
882
+ `brand.body` (#3E7CB1) is not in it: the system restricts it to fill and here it
883
+ measures 4.2:1.
573
884
 
574
- Vivía escrita a mano en `eduardoalvarez.dev/src/settings/shiki-reef.ts`, y ahí
575
- dentro se había quedado un `#E05252` — justo el hex que este README dice que está
576
- mal. Es el caso de libro de por qué la paleta no puede vivir dentro de un
577
- proyecto: el tema se genera desde `tokens.sintaxis` y el rojo sale corregido solo.
885
+ It used to live hand-written in
886
+ `eduardoalvarez.dev/src/settings/shiki-reef.ts`, and a `#E05252` had been left
887
+ inside it — precisely the hex this README says is wrong. It is the textbook case
888
+ for why the palette cannot live inside a project: the theme is generated from
889
+ `tokens.syntax` and the red comes out corrected on its own.
578
890
 
579
- ### Temas anidables
891
+ ### Nestable themes
580
892
 
581
- `theme.css` emite un bloque por modo, no solo el claro. Así un subárbol puede
582
- declarar el modo contrario al de la página y todo lo de dentro lo respeta.
893
+ `theme.css` emits one block per mode, not just the light one. That way a subtree
894
+ can declare the opposite mode to the page and everything inside honours it.
583
895
 
584
- Lo usa `CodeBlock`: `brand.hull` es «fondo de bloques de código», así que un
585
- bloque es oscuro también en modo claro — y ahí `textPrimary` es casi negro. La
586
- raíz del bloque declara `data-theme="dark"` y la tinta se resuelve sola. Es la
587
- única isla de tema invertido del sistema, y es deliberada.
896
+ `CodeBlock` uses it: `brand.hull` is «the background of code blocks», so a block
897
+ is dark in light mode too — and there `textPrimary` is nearly black. The block's
898
+ root declares `data-theme="dark"` and the ink resolves itself. It is the system's
899
+ only island of inverted theme, and it is deliberate.
588
900
 
589
- ### Las tarjetas y la regla 6
901
+ ### The cards and rule 6
590
902
 
591
- `ArticleCard`, `TalkCard`, `CourseCard` y `LinkRow` comparten un casco interno
592
- que no se publica, para que la regla 6 viva en un solo sitio: el hover cambia el
593
- borde de `hairline` a `hairlineHover` y tiñe el título de acento. Nada más.
903
+ `ArticleCard`, `TalkCard`, `CourseCard` and `LinkRow` share an internal shell
904
+ that is not published, so rule 6 lives in exactly one place: the hover changes
905
+ the border from `hairline` to `hairlineHover` and tints the title with accent.
906
+ Nothing else.
594
907
 
595
- `LinkRow` viene de `links/src/components/Card.astro`, que escalaba la tarjeta al
596
- 102 %, subía el título un píxel y giraba y agrandaba el icono — cuatro
597
- movimientos que el sistema no permite.
908
+ `LinkRow` comes from `links/src/components/Card.astro`, which scaled the card to
909
+ 102 %, lifted the title by a pixel and rotated and enlarged the icon — four
910
+ movements the system does not allow.
598
911
 
599
- Ninguna tarjeta depende de un enrutador: por defecto renderizan un `<a href>`, y
600
- `asChild` deja enchufar el `Link` de Next o de Astro.
912
+ No card depends on a router: by default they render an `<a href>`, and `asChild`
913
+ lets Next's or Astro's `Link` be plugged in.
601
914
 
602
- ### `AudioPlayer` — qué cambió al migrarlo
915
+ ### `AudioPlayer` — what changed on migration
603
916
 
604
- La lógica no se reescribió. Los tres modos, el reproductor flotante, los saltos
605
- de ±15s, el ciclo de velocidad 1 → 1.25 → 1.5 → 1.75 → 2 y el volumen con mute
606
- son los del portafolio. Lo que cambió:
917
+ The logic was not rewritten. The three modes, the floating player, the ±15s
918
+ skips, the 1 → 1.25 → 1.5 → 1.75 → 2 speed cycle and the volume with mute are the
919
+ portfolio's. What changed:
607
920
 
608
- **Dos dependencias que un paquete no puede tener.** `Icon` del portafolio pasó a
609
- `src/lib/glyphs.tsx` con los trazados idénticos; `trackEvent` pasó a la prop
610
- `onFirstPlay`, que sigue disparándose una sola vez por carga.
921
+ **Two dependencies a package cannot have.** The portfolio's `Icon` became
922
+ `src/lib/glyphs.tsx` with identical paths; `trackEvent` became the `onFirstPlay`
923
+ prop, which still fires exactly once per load.
611
924
 
612
- **Un cambio de API.** `compact`/`banner` como dos booleanos pasaron a
613
- `mode="full" | "compact" | "banner"`, que es el vocabulario con el que ya se
614
- describían los tres modos. Hay que tocar las llamadas del portafolio en la Fase 6.
925
+ **One API change.** `compact`/`banner` as two booleans became
926
+ `mode="full" | "compact" | "banner"`, which is the vocabulary the three modes were
927
+ already described with. The portfolio's call sites need touching in Phase 6.
615
928
 
616
- **Tres animaciones que el sistema no permite.** La onda ya no anima `scaleY` — las
617
- barras siguen distinguiendo reproducción de pausa por opacidad. El flotante
618
- aparece y desaparece en vez de deslizarse. La barra de progreso ya no interpola
619
- el ancho, que además la hacía ir por detrás del audio. El giro del spinner de
620
- carga se queda, con la misma justificación que en `Button`.
929
+ **Three animations the system does not allow.** The waveform no longer animates
930
+ `scaleY` — the bars still tell playback from pause by opacity. The floating
931
+ player appears and disappears instead of sliding. The progress bar no longer
932
+ interpolates its width, which also made it lag behind the audio. The loading
933
+ spinner's spin stays, with the same justification as in `Button`.
621
934
 
622
- **Un fallo de contraste heredado.** El botón de velocidad ponía `textMuted` sobre
623
- `surfaceRaised`: 4.07:1 en oscuro. Pasó a `textSecondary`. El original arrastra
624
- ese fallo.
935
+ **An inherited contrast failure.** The speed button put `textMuted` over
936
+ `surfaceRaised`: 4.07:1 in dark. It moved to `textSecondary`. The original still
937
+ carries that failure.
625
938
 
626
- **Un `bug` latente.** Las piezas del reproductor viven a nivel de módulo, no
627
- dentro del componente. Declaradas dentro, cambian de identidad en cada render y
628
- React las remonta: con `timeupdate` disparando cuatro veces por segundo, el
629
- arrastre de la barra perdía el pointer capture.
939
+ **A latent bug.** The player's pieces live at module level, not inside the
940
+ component. Declared inside, they change identity on every render and React
941
+ remounts them: with `timeupdate` firing four times a second, dragging the bar
942
+ lost pointer capture.
630
943
 
631
- ### La marca
944
+ ### The brand
632
945
 
633
- Las trece piezas de Tiburoncín estaban repartidas por los cinco proyectos, byte
634
- a byte idénticas. Se consolidaron en `assets/brand/` y se publican en el
635
- paquete; se sirven en `/brand`, que es la misma ruta que todos usan ya desde su
636
- `public/`, así que el valor por defecto de `basePath` funciona sin configurar
637
- nada.
946
+ Tiburoncín's thirteen pieces were scattered across the five projects,
947
+ byte-for-byte identical. They were consolidated into `assets/brand/` and are
948
+ published in the package; they are served at `/brand`, the same path everyone
949
+ already uses from their `public/`, so `basePath`'s default works with nothing to
950
+ configure.
638
951
 
639
952
  ```tsx
640
- import { Logo, Mascota, CaraDeMascota, listaCaras } from '@eduardoalvarez/arrecife/brand';
953
+ import { Logo, Mascot, MascotFace, faceList } from '@eduardoalvarez/arrecife/brand';
641
954
  ```
642
955
 
643
956
  | | |
644
957
  | --- | --- |
645
- | aletas | `fin.png` (dos azules) y `fin-foam.png` (silueta espuma) |
646
- | caras | annoyed · confused · hearts · laughing · shades · waiting · wink |
958
+ | fins | `fin.png` (two blues) and `fin-foam.png` (foam silhouette) |
959
+ | faces | annoyed · confused · hearts · laughing · shades · waiting · wink |
647
960
  | poses | desk · laptop-coffee · peek · surf |
648
961
 
649
- Los nombres son un tipo: una cara que no existe no compila, y el autocompletado
650
- ofrece las que hay. Añadir una es soltar el PNG y añadir una línea al catálogo.
962
+ The names are a type: a face that does not exist will not compile, and
963
+ autocomplete offers the ones that do. Adding one means dropping the PNG in and
964
+ adding a line to the catalog.
651
965
 
652
- **Regla 1 como API.** `sobre="oscuro"` usa la silueta a una tinta y
653
- `sobre="claro"` la de dos azules. No es una nota en una guía: es una prop. El
654
- análisis de píxeles lo confirma — el 94 % de `fin-foam.png` es `#EDF4F3`, o sea
655
- el token espuma.
966
+ **Rule 1 as API.** `background="dark"` uses the single-ink silhouette and
967
+ `background="light"` the two-blue one. It is not a note in a guide: it is a prop.
968
+ Pixel analysis confirms it — 94 % of `fin-foam.png` is `#EDF4F3`, which is the
969
+ foam token.
656
970
 
657
- **Regla 5 como API.** El wordmark sale de `naming.wordmark` y siempre dice
658
- «Eduardo Álvarez». No hay ninguna prop que permita cambiar ese texto, y
659
- Tiburoncín no aparece escrito dentro del logo.
971
+ **Rule 5 as API.** The wordmark comes from `naming.wordmark` and always reads
972
+ «Eduardo Álvarez». There is no prop that changes that text, and Tiburoncín never
973
+ appears written inside the logo.
660
974
 
661
- **Regla 4 como API.** Las caras van solo en estados vacíos, confirmaciones,
662
- errores, progreso de curso y celebración. La regla vive en qué componentes
663
- aceptan una cara, no en la documentación.
975
+ **Rule 4 as API.** The faces only go in empty states, confirmations, errors,
976
+ course progress and celebration. The rule lives in which components accept a
977
+ face, not in the documentation.
664
978
 
665
- El formato es un detalle de implementación: cuando lleguen los SVG, se
666
- reemplazan los archivos y no cambia una línea de código.
979
+ The format is an implementation detail: when the SVGs arrive, the files get
980
+ replaced and not a line of code changes.
667
981
 
668
- ### Los ocho que se añadieron después
982
+ ### The eight added afterwards
669
983
 
670
- No estaban en la lista original. Entraron midiendo en cuántos de los cinco
671
- proyectos se usa cada uno, con el mismo criterio que sacó a `Hero` y
672
- `NewsletterSection`.
984
+ They were not on the original list. They got in by measuring how many of the five
985
+ projects use each one, with the same criterion that took `Hero` and
986
+ `NewsletterSection` out.
673
987
 
674
- | | archivos que lo usan | por qué |
988
+ | | files using it | why |
675
989
  | --- | --- | --- |
676
- | `Card` | 34, en 4 proyectos | Es la única definición de qué es una superficie de tarjeta. Las cuatro tarjetas con dominio reutilizan sus clases. |
677
- | `Label` | 21, en 2 | Había siete controles de formulario y ninguna etiqueta. |
678
- | `Avatar` | 19, en 3 | Uno solo para todo: no hay un `brand/Avatar` aparte, porque una foto de perfil es esto con otro `src`. |
679
- | `Sheet` | 6, en 3 | Es `Dialog` con variante de lado. |
680
- | `Separator` | 8, en 2 | `hairline` era un token sin componente. |
681
- | `Popover` | 5, en 3 | Base de cualquier selector desplegable. |
682
- | `DateField` | — | El control nativo, sin dependencias, para elegir fecha en un formulario. |
683
- | `Calendar` | 6, en 3 | Calendario mensual navegable, para el planificador de contenido. `fullWidth` lo estira al ancho del contenedor. |
990
+ | `Card` | 34, across 4 projects | It is the only definition of what a card surface is. All four cards with a domain reuse its classes. |
991
+ | `Label` | 21, across 2 | There were seven form controls and no label. |
992
+ | `Avatar` | 19, across 3 | One for everything: there is no separate `brand/Avatar`, because a profile photo is this with a different `src`. |
993
+ | `Sheet` | 6, across 3 | It is `Dialog` with a side variant. |
994
+ | `Separator` | 8, across 2 | `hairline` was a token with no component. |
995
+ | `Popover` | 5, across 3 | The base of any dropdown selector. |
996
+ | `DateField` | — | The native control, dependency-free, for picking a date in a form. |
997
+ | `Calendar` | 6, across 3 | A navigable month calendar, for the content planner. `fullWidth` stretches it to the container's width. |
998
+
999
+ There is no `DatePicker`: it is `Popover` plus `Calendar` and it is five lines. A
1000
+ third component that only glues together two that already exist is API surface to
1001
+ maintain for nothing.
1002
+
1003
+ `Popover` requires `aria-label` or `aria-labelledby` in the type. Radix puts
1004
+ `role="dialog"` on the content, and a dialog with no accessible name says nothing
1005
+ to a screen reader: now it cannot be forgotten because it does not compile.
1006
+
1007
+ ### The fifth motion exception: the footer's caret
1008
+
1009
+ The CLI signature ends in a block caret that blinks, behind `motion-safe`. It is
1010
+ the first exception that is not feedback about progress, so it needed a different
1011
+ argument.
1012
+
1013
+ The signature is a **prompt** — that is why it is mono, why the `$` is in accent
1014
+ and why it sits in a footer instead of a `<p>` saying «© 2026». A prompt whose
1015
+ caret does not blink is a terminal that has hung, and a still block at the end of
1016
+ a line reads as a stray character.
1017
+
1018
+ So the criterion splits in two. The first four exceptions are feedback about
1019
+ progress or spatial continuity; this one is legibility: it is not decoration, it
1020
+ is what makes the piece readable as what it is. `step-end` and not a fade,
1021
+ because a real caret is on or off and easing it turns a terminal into a pulsing
1022
+ dot. See `docs/decisions.md` § 23.
1023
+
1024
+ ### The second motion exception
1025
+
1026
+ `Sheet` slides. It is the second and last exception to «no displacement»,
1027
+ approved knowingly: a panel entering from an edge, held still, would be an
1028
+ off-centre modal. It lasts `--duration-standard` with `--ease-standard` — the
1029
+ same time and the same curve as any color change — so it introduces no new
1030
+ timing, and it sits behind `motion-safe`.
1031
+
1032
+ `Calendar` does **not** animate the month change: react-day-picker's `animate`
1033
+ stays on its default, which is off.
1034
+
1035
+ ### The danger variant, and where it does not go
1036
+
1037
+ `Button` has `destructive` and `destructiveOutline` since 0.6.0. For four
1038
+ versions it had neither, on an argument that is still half right: inside an
1039
+ `AlertDialog` the confirm button is **not** red, because a title explains what is
1040
+ about to happen, focus starts on cancel and clicking outside does not close it.
1041
+ The context does the work and a red button on top of it is shouting.
1042
+
1043
+ What broke the argument is the table row. `cursos` has eight destructive buttons
1044
+ in row actions and toolbars, next to «Editar» and «Duplicar», with nothing around
1045
+ them doing that work — and rendered as `secondary`, «Eliminar curso» looked
1046
+ exactly like «Cancelar».
1047
+
1048
+ The palette is not `error`, and the reason is the role. `error` is a text color:
1049
+ it reads against a dark surface, so it sits mid-red. `danger` is a fill: what
1050
+ reads is the ink on top of it, so it goes lighter. Same split as `accent` and
1051
+ `accentOn`. In light mode both land on `#C0392B`, because over paper a red dark
1052
+ enough to carry white ink is also the red that reads as text.
1053
+
1054
+ | | dark | light |
1055
+ | --- | --- | --- |
1056
+ | ink over fill | 6.53 | 5.11 |
1057
+ | ink over hover | 7.92 | 6.61 |
1058
+ | fill over background | 6.71 | 4.87 |
1059
+ | fill over surfaceRaised | 4.91 | **4.50** |
1060
+
1061
+ That last cell is exactly on the AA line, which is where every light semantic in
1062
+ this palette sits. It matters because it is the outline variant's border and
1063
+ text, and `surfaceRaised` is where a toolbar lives.
1064
+
1065
+ `destructiveOutline` fills on hover, and that is a declared exception to
1066
+ «secondary is never filled» — a destructive that looks identical to a secondary
1067
+ until you read it is the problem the variant exists to fix. See
1068
+ `docs/decisions.md` § 21.
1069
+
1070
+ ### `icon-sm`, for the one admin app
684
1071
 
685
- No hay `DatePicker`: son `Popover` más `Calendar` y son cinco líneas. Un tercer
686
- componente que solo pega dos que ya existen es superficie de API que mantener sin
687
- ganar nada.
1072
+ 42×42 is the right measure for a control you hit with a thumb, and four of the
1073
+ five projects are reading sites where that fits. `cursos` is the odd one out:
1074
+ three actions per table row, and at 42 the row grows with them.
688
1075
 
689
- `Popover` exige `aria-label` o `aria-labelledby` en el tipo. Radix le pone
690
- `role="dialog"` al contenido, y un diálogo sin nombre accesible no le dice nada a
691
- un lector de pantalla: ahora no se puede olvidar porque no compila.
1076
+ `size="icon-sm"` is 32×32, and it is 32 and not the 28 that project actually had:
1077
+ 32 is `sm`'s height, so a dense icon button lines up with a small text button and
1078
+ a toolbar mixing the two stays on one baseline. It does not replace `icon` — a
1079
+ page's primary action stays at 42. See `docs/decisions.md` § 22.
1080
+
1081
+ ### The theme script, and the mode a site already decided
1082
+
1083
+ ```astro
1084
+ <script is:inline set:html={themeScript({ base: 'dark' })} />
1085
+ ```
692
1086
 
693
- ### La segunda excepción de movimiento
1087
+ Until 0.6.0 `themeScript` was a fixed string and resolved stored choice →
1088
+ `prefers-color-scheme` → dark. That is the right default for a library, and it
1089
+ was wrong for all five of these projects: they are dark BY DECISION, and
1090
+ `eduardoalvarez.dev`'s own script said so out loud — «dark is the brand's PRIMARY
1091
+ mode, so it's the default and doesn't follow the OS setting». With the OS in
1092
+ charge, a reader whose machine is in light mode saw the blog in light.
694
1093
 
695
- `Sheet` se desliza. Es la segunda y última excepción a «nada de desplazamiento»,
696
- aprobada a sabiendas: un panel que entra desde un borde quieto sería un modal
697
- descentrado. Dura `--duration-standard` con `--ease-standard` —lo mismo y con la
698
- misma curva que cualquier cambio de color— así que no introduce un tiempo nuevo,
699
- y va detrás de `motion-safe`.
1094
+ There was no way to say otherwise, so those projects kept their own
1095
+ `public/theme.js` and the library published the hard part for nobody. Migrating
1096
+ just the button did not help either: `ThemeToggle` persists under
1097
+ `arrecife-theme` and their script read `theme`, so the two would have gone out of
1098
+ step.
700
1099
 
701
- `Calendar` **no** anima el cambio de mes: `animate` de react-day-picker se queda
702
- en su valor por defecto, que es apagado.
1100
+ `base` stops the OS from being consulted at all. A stored choice still wins over
1101
+ it — it sets what happens when nobody has chosen yet, not what happens instead of
1102
+ choosing, so the toggle keeps working.
703
1103
 
704
- ### `Text` — la escala como API
1104
+ ### `Text` — the scale as API
705
1105
 
706
- `Text` no estaba en la lista original y se añadió después, porque sin él la
707
- escala solo existía como clases sueltas y nada impedía poner `text-display` en un
708
- párrafo. Tres reglas del sistema viven dentro del componente:
1106
+ `Text` was not on the original list and was added later, because without it the
1107
+ scale only existed as loose classes and nothing stopped anyone putting
1108
+ `text-display` on a paragraph. Three of the system's rules live inside the
1109
+ component:
709
1110
 
710
- | regla | cómo se aplica |
1111
+ | rule | how it is applied |
711
1112
  | --- | --- |
712
- | display solo para titulares, nunca cuerpo | la familia va atada a la escala; no existe una prop `font` |
713
- | el peso y el tracking son de la escala | vienen del token `--text-*` y no se exponen |
714
- | medida máxima de cuerpo 68ch | `body` la aplica solo; `measure={false}` la quita |
1113
+ | display for headlines only, never body | the family is bound to the scale; no `font` prop exists |
1114
+ | weight and tracking belong to the scale | they come from the `--text-*` token and are not exposed |
1115
+ | maximum body measure 68ch | `body` applies it on its own; `measure={false}` removes it |
715
1116
 
716
- `as` y `variant` son independientes a propósito: un encabezado de segundo nivel
717
- que tiene que verse más pequeño es `<Text as="h2" variant="h3">`, no un `h3` que
718
- miente sobre la jerarquía de la página.
1117
+ `as` and `variant` are independent on purpose: a second-level heading that has to
1118
+ look smaller is `<Text as="h2" variant="h3">`, not an `h3` that lies about the
1119
+ page hierarchy.