@eduardoalvarez/arrecife 0.5.0 → 0.6.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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +706 -470
  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-25YNFCIF.js +141 -0
  14. package/dist/{chunk-YZ2SDOVZ.js → chunk-6O3KWB6P.js} +30 -30
  15. package/dist/chunk-CKRSQPTX.js +36 -0
  16. package/dist/{chunk-ZEOQKRQ7.js → chunk-DKCN7BAL.js} +1 -1
  17. package/dist/chunk-GCRII2KQ.js +86 -0
  18. package/dist/chunk-JMOOFZ3B.js +42 -0
  19. package/dist/chunk-O4TAH7YJ.js +276 -0
  20. package/dist/chunk-ODBFN44D.js +45 -0
  21. package/dist/{chunk-VPT32GPG.js → chunk-PMN7NR3G.js} +2 -2
  22. package/dist/chunk-XKYHTOUJ.js +27 -0
  23. package/dist/form/index.cjs +109 -92
  24. package/dist/form/index.d.cts +43 -42
  25. package/dist/form/index.d.ts +43 -42
  26. package/dist/form/index.js +25 -23
  27. package/dist/index.cjs +1068 -929
  28. package/dist/index.d.cts +770 -773
  29. package/dist/index.d.ts +770 -773
  30. package/dist/index.js +629 -675
  31. package/dist/{label-DuTvJGxD.d.ts → label-MgHFKnFy.d.cts} +3 -3
  32. package/dist/{label-DuTvJGxD.d.cts → label-MgHFKnFy.d.ts} +3 -3
  33. package/dist/og/index.cjs +130 -130
  34. package/dist/og/index.d.cts +93 -89
  35. package/dist/og/index.d.ts +93 -89
  36. package/dist/og/index.js +106 -106
  37. package/dist/shiki/index.cjs +28 -30
  38. package/dist/shiki/index.d.cts +4 -4
  39. package/dist/shiki/index.d.ts +4 -4
  40. package/dist/shiki/index.js +12 -12
  41. package/dist/theme/index.cjs +97 -0
  42. package/dist/theme/index.d.cts +144 -0
  43. package/dist/theme/index.d.ts +144 -0
  44. package/dist/theme/index.js +2 -0
  45. package/dist/tokens/index.cjs +133 -86
  46. package/dist/tokens/index.d.cts +246 -161
  47. package/dist/tokens/index.d.ts +246 -161
  48. package/dist/tokens/index.js +2 -2
  49. package/dist/tokens/theme.css +133 -98
  50. package/dist/variants/index.cjs +192 -0
  51. package/dist/variants/index.d.cts +192 -0
  52. package/dist/variants/index.d.ts +192 -0
  53. package/dist/variants/index.js +3 -0
  54. package/llms.txt +810 -744
  55. package/package.json +20 -11
  56. package/dist/catalogo-Du5ID-Hi.d.cts +0 -77
  57. package/dist/catalogo-Du5ID-Hi.d.ts +0 -77
  58. package/dist/chunk-E3OMP2DL.js +0 -36
  59. package/dist/chunk-KPZNNMV5.js +0 -83
  60. package/dist/chunk-NHS7ETKJ.js +0 -27
  61. package/dist/chunk-TSPJOM6K.js +0 -229
  62. package/dist/chunk-UOWIDFCB.js +0 -81
  63. package/dist/tema/index.cjs +0 -94
  64. package/dist/tema/index.d.cts +0 -110
  65. package/dist/tema/index.d.ts +0 -110
  66. package/dist/tema/index.js +0 -2
package/README.md CHANGED
@@ -1,151 +1,168 @@
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.
85
-
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).
90
-
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).
97
-
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.
100
-
101
- **Los nombres tienen que coincidir EXACTAMENTE**, y esto ya ha mordido dos veces.
102
- Los tokens declaran las familias así:
103
-
104
- | Token | `font-family` que declara | Utilidad |
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
+ > **Coming from 0.5.x.** Two unrelated things landed in 0.6.0, and they ship
90
+ > together because in `0.x` a breaking change bumps the minor.
91
+ >
92
+ > The whole public API moved to English: `./tema` is now `./theme`, `scriptTema`
93
+ > is `themeScript`, `Red` is `SocialLink`, the `degradado-hero` utility is
94
+ > `gradient-hero`, and `Hero`'s `variant="cabecera"` is `variant="header"`.
95
+ >
96
+ > And three things break on the API side: `themeScript` is a function, the root
97
+ > ships `"use client"`, and `TalkCardProps` is a union. All three break loudly —
98
+ > the type checker catches every one at the call site.
99
+ >
100
+ > Both halves, in order, with the full rename table and what each project can now
101
+ > **delete**: [`docs/migration-0.6.md`](docs/migration-0.6.md).
102
+
103
+ > **Coming from 0.4.0 or earlier.** `Toast`, `ToastProvider`, `ToastViewport`,
104
+ > `ToastTitle` and `ToastDescription` stopped being public API in 0.5.0: you use
105
+ > `Toaster` and `toast()`. `ToastAction` stays. The migration, with the reasoning
106
+ > and the examples, is in [`docs/migration-0.5.md`](docs/migration-0.5.md).
107
+
108
+ > **Coming from 0.2.0 or earlier.** The five spacing steps were renamed: `p-md`
109
+ > is now `p-step-md`, `gap-sm` is `gap-step-sm`. It is a breaking change, and if
110
+ > your project uses `max-w-sm`, `max-w-md` or `max-w-lg`, those were also worth
111
+ > 12, 16 and 26px with nothing saying so. The reasoning, the migration pattern
112
+ > and what to check afterwards are in
113
+ > [`docs/migration-0.3.md`](docs/migration-0.3.md).
114
+
115
+ The font families are declared by name. Each project loads Bricolage Grotesque,
116
+ Geist and JetBrains Mono however it prefers: the library does not dictate how.
117
+
118
+ **The names have to match EXACTLY**, and this has already bitten twice. The
119
+ tokens declare the families like this:
120
+
121
+ | Token | `font-family` it declares | Utility |
105
122
  | --- | --- | --- |
106
123
  | `fonts.display` | `"Bricolage Grotesque"` | `font-display` |
107
124
  | `fonts.sans` | `"Geist"` | `font-sans` |
108
125
  | `fonts.mono` | `"JetBrains Mono"` | `font-mono` |
109
126
 
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.
127
+ A project registering its `@font-face` as `"Bricolage Grotesque Variable"` or
128
+ `"Geist Variable"` — the name several font packages publish them under — is
129
+ **not** loading what the tokens ask for: the display and the mono fall back to
130
+ the system font, silently and with no console warning. It is exactly what
131
+ happened in two of the five projects.
115
132
 
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:
133
+ The `@font-face`'s `family` is an alias the project chooses, so the fix is to
134
+ declare it with the name the token asks for:
118
135
 
119
136
  ```css
120
137
  @font-face {
121
- font-family: "Bricolage Grotesque"; /* NO "Bricolage Grotesque Variable" */
122
- src: url("/fuentes/bricolage-grotesque.woff2") format("woff2-variations");
138
+ font-family: "Bricolage Grotesque"; /* NOT "Bricolage Grotesque Variable" */
139
+ src: url("/fonts/bricolage-grotesque.woff2") format("woff2-variations");
123
140
  font-weight: 200 800;
124
141
  font-display: swap;
125
142
  }
126
143
  ```
127
144
 
128
- #### En Next, con `next/font`
145
+ #### In Next, with `next/font`
129
146
 
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.
147
+ It is the same failure through another door, and it bites both Next projects.
148
+ `next/font` registers each family under a GENERATED name — `__Geist_a1b2c3` — and
149
+ exposes it as a custom property; the literal `"Geist"` the tokens declare exists
150
+ in no `@font-face` on the page.
134
151
 
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.
152
+ Importing `theme.css` overwrites `--font-sans` with that literal, and all three
153
+ families fall back to the system font. Silently: there is no 404, because the
154
+ font did load — under another name.
138
155
 
139
- La solución es reafirmar las tres DESPUÉS del import, apuntando a las variables
140
- que genera `next/font`:
156
+ The fix is to reassert all three AFTER the import, pointing at the variables
157
+ `next/font` generates:
141
158
 
142
159
  ```ts
143
- // app/fuentes.ts
160
+ // app/fonts.ts
144
161
  import { Geist, Bricolage_Grotesque, JetBrains_Mono } from 'next/font/google';
145
162
 
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' });
163
+ export const sans = Geist({ subsets: ['latin'], variable: '--project-sans' });
164
+ export const display = Bricolage_Grotesque({ subsets: ['latin'], variable: '--project-display' });
165
+ export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--project-mono' });
149
166
  ```
150
167
 
151
168
  ```css
@@ -153,52 +170,52 @@ export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--fuente-mon
153
170
  @import "@eduardoalvarez/arrecife/tokens/theme.css";
154
171
  @source "../node_modules/@eduardoalvarez/arrecife/dist";
155
172
 
156
- /* Después del import, o gana el literal que no está cargado. */
173
+ /* After the import, or the literal that is not loaded wins. */
157
174
  @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;
175
+ --font-sans: var(--project-sans), ui-sans-serif, system-ui, sans-serif;
176
+ --font-display: var(--project-display), ui-sans-serif, system-ui, sans-serif;
177
+ --font-mono: var(--project-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
161
178
  }
162
179
  ```
163
180
 
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.
181
+ The variables are called `--project-*` and not `--font-*` on purpose:
182
+ `--font-sans` is the name Tailwind uses for ITS token, and handing it to
183
+ `next/font` leaves the two layers fighting over the same property.
167
184
 
168
- El `variable` de cada familia va en la clase del `<html>`, como pide Next:
185
+ Each family's `variable` goes on the `<html>` class, as Next asks:
169
186
  `className={`${sans.variable} ${display.variable} ${mono.variable}`}`.
170
187
 
171
- ### Mapa de tokens a utilidades
188
+ ### Token-to-utility map
172
189
 
173
- | Token | Custom property | Utilidad |
190
+ | Token | Custom property | Utility |
174
191
  | --- | --- | --- |
175
- | `colors[modo].surfaceRaised` | `--color-surface-raised` | `bg-surface-raised` |
192
+ | `colors[mode].surfaceRaised` | `--color-surface-raised` | `bg-surface-raised` |
176
193
  | `brand.hull` | `--color-brand-hull` | `bg-brand-hull` |
177
- | `typeScale.h1` | `--text-h1` | `text-h1` (arrastra line-height, weight y tracking) |
194
+ | `typeScale.h1` | `--text-h1` | `text-h1` (brings line-height, weight and tracking) |
178
195
  | `fonts.display` | `--font-display` | `font-display` |
179
196
  | `radius.card` | `--radius-card` | `rounded-card` |
180
197
  | `spacing.stepLg` | `--spacing-step-lg` | `p-step-lg`, `gap-step-lg`, `mb-step-lg` |
181
198
  | `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) |
199
+ | `control.md` | `--spacing-control-md` | `px-control-md` (button padding) |
200
+ | `control.icon` | `--spacing-control-icon` | `size-control-icon` (42×42 icon button) |
201
+ | `gradient[mode].hero` | `--gradient-hero` | `gradient-hero` (a utility, follows the mode) |
185
202
  | `size.nav` | `--spacing-nav` | `h-nav` |
186
203
  | `size.content` / `size.wide` | `--container-content` / `--container-wide` | `max-w-content` / `max-w-wide` |
187
204
  | `limits.measure` | `--container-measure` | `max-w-measure` |
188
205
  | `shadow.standard` | `--shadow-standard` | `shadow-standard` |
189
206
  | `motion` | `--duration-standard`, `--ease-standard` | `duration-standard`, `ease-standard` |
190
207
 
191
- El variante `light:` está disponible para los casos del modo claro invertido.
208
+ The `light:` variant is available for the inverted light-mode cases.
192
209
 
193
- También se puede consumir el objeto en JS, sin CSS y sin React — es lo que usan
194
- las plantillas de OG:
210
+ The object can also be consumed in JS, with no CSS and no React — it is what the
211
+ OG templates use:
195
212
 
196
213
  ```ts
197
214
  import { tokens } from '@eduardoalvarez/arrecife/tokens';
198
215
  ```
199
216
 
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:
217
+ The highlighting theme lives in another subpath for the same reason — it is
218
+ consumed from `astro.config.mjs`, not from a component:
202
219
 
203
220
  ```ts
204
221
  import { arrecife } from '@eduardoalvarez/arrecife/shiki';
@@ -208,30 +225,30 @@ export default defineConfig({
208
225
  });
209
226
  ```
210
227
 
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.
228
+ **The library does not ship Shiki.** The projects already highlight at build time
229
+ with their own tooling; what they were missing was not a highlighter, it was the
230
+ theme. `CodeBlock` still receives the code already highlighted, which is what it
231
+ is written for.
214
232
 
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.
233
+ The OG templates are published in their own subpath for the same reason: a
234
+ generator runs in a worker or in a build script and must not drag in React or a
235
+ single component.
218
236
 
219
237
  ```ts
220
238
  import satori from 'satori';
221
- import { plantillaArticulo, OG } from '@eduardoalvarez/arrecife/og';
239
+ import { articleTemplate, OG } from '@eduardoalvarez/arrecife/og';
222
240
 
223
- const svg = await satori(plantillaArticulo({ title, date, readingMinutes }), {
241
+ const svg = await satori(articleTemplate({ title, date, readingMinutes }), {
224
242
  width: OG.width, // 1200
225
243
  height: OG.height, // 630
226
244
  fonts: [...],
227
245
  });
228
246
  ```
229
247
 
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`.
248
+ They are pure functions returning the tree Satori paints, built only from tokens.
249
+ `dist/og/index.js` mentions React on no line, and that is checkable with a grep.
233
250
 
234
- ## Cómo se usa desde un proyecto
251
+ ## How it is used from a project
235
252
 
236
253
  ```tsx
237
254
  import { Text, Button, Badge, cn } from '@eduardoalvarez/arrecife';
@@ -239,32 +256,32 @@ import type { TextProps } from '@eduardoalvarez/arrecife';
239
256
 
240
257
  <Text variant="eyebrow" tone="muted">charlas</Text>
241
258
  <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>
259
+ <Text variant="body">Clamps itself to 68ch.</Text>
260
+ <Text variant="ui" measure={false}>No clamp, for a narrow cell.</Text>
244
261
  ```
245
262
 
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`:
263
+ Every component publishes its documentation page in Storybook with the props
264
+ table generated from the types. `Text`'s:
248
265
 
249
- | prop | tipo | por defecto |
266
+ | prop | type | default |
250
267
  | --- | --- | --- |
251
268
  | `variant` | `display · stat · h1 · h2 · h3 · body · lead · ui · label · tag · meta · chip · eyebrow` | `body` |
252
269
  | `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` |
270
+ | `as` | `h1 · h2 · h3 · h4 · p · span · strong · em · figcaption · caption · legend · dt · dd · li` | per `variant` |
271
+ | `measure` | `boolean` — clamps to 68ch | `true` on `body` |
272
+ | `asChild` | `boolean` — renders the child, to wrap a link | `false` |
256
273
 
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.
274
+ Verified by packing the library with `pnpm pack` and installing it in a separate
275
+ project: the types resolve from `dist/`, `./tokens` loads without dragging React
276
+ in and `./tokens/theme.css` resolves by subpath.
260
277
 
261
- ### Los iconos de redes van agrupados
278
+ ### The social icons are namespaced
262
279
 
263
- Es lo primero con lo que tropieza quien consume la librería, porque la forma
264
- natural no funciona:
280
+ It is the first thing anyone consuming the library trips over, because the
281
+ natural form does not work:
265
282
 
266
283
  ```tsx
267
- // ❌ no existe
284
+ // ❌ does not exist
268
285
  import { GitHub, LinkedIn } from '@eduardoalvarez/arrecife';
269
286
 
270
287
  // ✅
@@ -274,102 +291,167 @@ import { social } from '@eduardoalvarez/arrecife';
274
291
  <social.LinkedIn />
275
292
  ```
276
293
 
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í.
282
-
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.
294
+ All nine are `GitHub`, `LinkedIn`, `X`, `Instagram`, `Discord`, `YouTube`, `Rss`,
295
+ `Email` and `Newsletter`. They live under a namespace for a concrete reason:
296
+ **one of them is called `X`**.
297
+
298
+ `Newsletter` is the bell, and it is named for what it means and not for what it
299
+ draws — same as everything else in the system. It plays `Rss`'s role: a way to
300
+ follow, not a social network. That is what keeps it inside this catalogue and
301
+ keeps the catalogue from turning into an icon library. An `export const X` at the root of a component library collides
302
+ with anything — a generic's type variable, an `import { X }` from somewhere else
303
+ — and the failure shows up far from here.
304
+
305
+ **The internal glyphs are NOT exported.** `Close`, `ChevronDown`, `Copy`, `Sun`
306
+ and company are the minimum set the primitives need and they stay inside.
307
+ Publishing them would turn `lib/glyphs.tsx` into the icon library the system
308
+ decided not to have, and from there it grows on its own. A project that needs an
309
+ icon passes its own: `Stat` receives `icon`, `Footer` receives each social link's
310
+ `icon`.
311
+
312
+ ### `"use client"` is in the published `dist`
313
+
314
+ The root, `./brand`, `./form` and `./chart` carry the directive. They render
315
+ React, and their Radix primitives call `createContext` at module scope: without
316
+ it, a Next project with the App Router cannot import the library at all — it
317
+ fails at build time with `TypeError: (0 , r.createContext) is not a function`.
318
+ It blocked `cursos` for a whole version, and the workaround there was a
319
+ `"use client"` in every one of that project's own adapters, including a `Badge`
320
+ that is a `<span>` with no interaction. It cost 272 KB of client chunk.
321
+
322
+ The five portable subpaths do NOT carry it — `./tokens`, `./theme`,
323
+ `./variants`, `./og` and `./shiki` — and that is the half that matters more.
324
+ Marking them client would be a lie with a cost: a Server Component importing
325
+ `buttonVariants`, a function that returns a string, would pull a client boundary
326
+ in with it.
327
+
328
+ It is stamped by `scripts/add-use-client.mjs` after tsup, and not by tsup's
329
+ `banner`. That was tried first: esbuild writes the directive and the bundling
330
+ pass strips it back out with a `Module level directives cause errors when
331
+ bundled` warning. The build stayed green and the published package was broken for
332
+ Next — the worst way to fail, because the failure surfaces in somebody else's
333
+ project. `check:exports` now verifies it in both directions: present on the four
334
+ client entries, absent from the portable ones.
335
+
336
+ It is inert outside Next. In Astro and in plain Vite it is a string literal at
337
+ the top of a module; Rollup may warn and nothing else happens. One `dist` serves
338
+ the Next projects and the Astro ones, which is the constraint that decided the
339
+ shape.
340
+
341
+ ### `./variants` — the class vocabulary without React
288
342
 
289
- ### Las dos subrutas que piden una dependencia
290
-
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.
343
+ ```ts
344
+ import { buttonVariants, badgeVariants, CARD_SURFACE }
345
+ from '@eduardoalvarez/arrecife/variants';
346
+ ```
295
347
 
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.
348
+ `buttonVariants`, `badgeVariants` and `categoryBadgeVariants` are not
349
+ components: they are functions that return a string of classes. They touch
350
+ neither React nor the DOM. They used to live inside the components, so importing
351
+ one dragged the whole library along, and that had a cost measured in two of the
352
+ five projects.
353
+
354
+ In `cursos` it forced a `"use client"` on an adapter whose entire content was one
355
+ call to CVA. In `links`, which depends on no React at all, it was not even an
356
+ option: that project copied the class vocabulary by hand into `LinkRow.astro` and
357
+ `Footer.astro`, and the copy had already drifted once — the hero gradient sat at
358
+ `55%` and `#e9eeea` against the token's `60%` and `#EFE9DE`, and nothing compared
359
+ them.
360
+
361
+ The rule for what belongs in the subpath: if it returns classes, it goes there;
362
+ if it returns markup, it stays in the component. `Button` renders a `<button>`,
363
+ so it stays at the root; `buttonVariants` returns a string, so it is in
364
+ `./variants`. The root re-exports all of it, so an existing
365
+ `import { buttonVariants } from '@eduardoalvarez/arrecife'` keeps working — what
366
+ the subpath buys is not the name, it is not paying for React to get it.
367
+
368
+ ### The two subpaths that ask for a dependency
369
+
370
+ `./form` and `./chart` do not hang off the root, and that is deliberate. Each
371
+ asks for an **optional** peer dependency — `react-hook-form` and `recharts` — and
372
+ hanging them off the main index would force all five projects to install them so
373
+ their bundler could resolve an import four of them never execute.
374
+
375
+ It is the same decision as `./og` and `./shiki`, seen from the other side: there
376
+ React is kept out of the way of whoever does not mount it; here Recharts is kept
377
+ out of the way of whoever does not draw.
299
378
 
300
379
  ```tsx
301
380
  import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage }
302
381
  from '@eduardoalvarez/arrecife/form';
303
382
 
304
- import { ChartContainer, ChartTooltip, ChartTooltipContent, colorDeSerie }
383
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, seriesColor }
305
384
  from '@eduardoalvarez/arrecife/chart';
306
385
  ```
307
386
 
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.
387
+ `check:exports` verifies that the five portable ones — `./tokens`, `./theme`,
388
+ `./variants`, `./og` and `./shiki` — bring no React into the published `dist/`,
389
+ **by following the relative imports**. Without that the check was worthless: with `treeshake`
390
+ on, each portable entry ends up as two lines re-exporting from a
391
+ `chunk-XXXX.js`, and a grep over those two lines finds no React even when the
392
+ chunk imports it.
313
393
 
314
394
  ## Scripts
315
395
 
316
396
  | | |
317
397
  | --- | --- |
318
- | `pnpm build` | verifica la pureza de tokens, compila con tsup y genera `theme.css` |
398
+ | `pnpm build` | verifies token purity, compiles with tsup and generates `theme.css` |
319
399
  | `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 |
400
+ | `pnpm lint` | ESLint, including the ban on literal hexes outside `tokens.ts` |
401
+ | `pnpm check:tokens` | fails if `src/tokens/` imports anything from outside |
402
+ | `pnpm test` | compiles Tailwind and runs axe over the 208 stories, in both modes |
403
+ | `pnpm check:exports` | verifies that `dist/` holds what `exports` promises |
404
+ | `pnpm check:release` | validates `release-please-config.json` against the official schema |
405
+ | `pnpm storybook` | generates the tokens and serves Storybook on 6006 |
326
406
 
327
- ## El contraste como test, no como panel
407
+ ## Contrast as a test, not as a panel
328
408
 
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.
409
+ `pnpm test` mounts every story in a real Chromium and runs axe over it with
410
+ `a11y: { test: 'error' }`. It runs twice, once per mode: a color only fails in
411
+ one of the two, so passing in dark proves nothing about light.
332
412
 
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».
413
+ It is demonstrably not decorative: putting light `textMuted` back to its previous
414
+ value takes down eight stories with «insufficient color contrast of 4.24».
335
415
 
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.
416
+ There is exactly one disabled rule, in two specific stories and with the reason
417
+ written beside it: `aria-hidden-focus` on open `Select`/`DropdownMenu`. Radix
418
+ marks everything outside the portal `aria-hidden` and leaves the trigger inside
419
+ it still focusable; focus is trapped by its `FocusScope`, so it cannot be tabbed
420
+ to. It is a known disagreement between axe and Radix.
341
421
 
342
- ## Correcciones de contraste
422
+ ## Contrast corrections
343
423
 
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.
424
+ The identity document measured everything against `background`. But
425
+ `surfaceRaised` is the worst case in **both** modes: in light it is darker than
426
+ the page background, in dark it is lighter. It is where menus and active tabs
427
+ live.
347
428
 
348
- | token | antes | ahora | motivo |
429
+ | token | before | now | reason |
349
430
  | --- | --- | --- | --- |
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 |
431
+ | `light.textMuted` | `#6B7480` | `#626A75` | 4.24 did not reach AA over paper |
432
+ | `light.warning` | `#9A6A12` | `#8D6111` | 4.23 did not reach AA over paper |
433
+ | `dark.error` | `#E05252` | `#E15757` | 4.35 over `surface`, which is where a form error goes |
353
434
 
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.
435
+ All three keep their exact hue and saturation: only lightness moves, by one to
436
+ four points. Light `accent` and `warm` are untouched.
356
437
 
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.
438
+ New token: `hairlineHover` — `#2C4D5D` in dark (rule 6's value) and `#D3C8B2` in
439
+ light, derived by matching the perceptual step (ΔL\* 10.5) rather than the
440
+ contrast ratio, which overshoots near white.
360
441
 
361
- `textMuted` no va nunca sobre `surfaceRaised`: en oscuro da 4.07. Los menús usan
362
- `textSecondary`, que da 6.96.
442
+ `textMuted` never goes over `surfaceRaised`: in dark it gives 4.07. Menus use
443
+ `textSecondary`, which gives 6.96.
363
444
 
364
- ### La tercera corrección: el color semántico no es color de texto sobre su tinte
445
+ ### The third correction: a semantic color is not a text color over its own tint
365
446
 
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.
447
+ It came up while implementing the document's alert recipe — background at 8 % of
448
+ the semantic color — and the suite took it down in light mode, across five
449
+ stories.
368
450
 
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:
451
+ The light semantics are calibrated to pass **just** over paper. Tinting the
452
+ background with them sinks them below AA:
371
453
 
372
- | tono | sobre papel | sobre su propio tinte 8 % | `textPrimary` sobre el tinte |
454
+ | tone | over paper | over its own 8 % tint | `textPrimary` over the tint |
373
455
  | --- | --- | --- | --- |
374
456
  | `accent` | 4.55 | **4.12** | 14.82 |
375
457
  | `warm` | 4.54 | **4.11** | 14.85 |
@@ -377,79 +459,83 @@ fondo con ellos los hunde por debajo de AA:
377
459
  | `warning` | 4.88 | **4.40** | 14.78 |
378
460
  | `error` | 4.87 | **4.35** | 14.64 |
379
461
 
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.
462
+ No alpha fixes it: the problem is putting the color on top of itself. The
463
+ resolution does not touch the palette — the tint is a **surface**, so the text on
464
+ top of it is a text token. The semantic color stays where it is not text: the
465
+ border and the glyph.
384
466
 
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.
467
+ The 8 %, incidentally, holds up as well as or better over paper than over abyss
468
+ (1.106 vs. 1.149 in accent). The suspicion that light mode needed a second table
469
+ ran the other way round: the system's weak point is `error` over abyss, 1.067.
388
470
 
389
- ## Publicar una versión
471
+ ## Publishing a version
390
472
 
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.
473
+ **There are no manual steps.** The tag, the CHANGELOG and the version bump are
474
+ done by release-please from the conventional commits that already get written —
475
+ and that `lint-pr-title` already forces to be written correctly.
394
476
 
395
- El ciclo, entero, está en `.github/workflows/release.yml`:
477
+ The whole cycle lives in `.github/workflows/release.yml`:
396
478
 
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.
479
+ 1. You merge a PR to `main` with a title like `feat(badge): …`.
480
+ 2. release-please opens — or updates — a PR called `chore: release X.Y.Z` with
481
+ the bump in `package.json` and the new `CHANGELOG.md` entry. That PR stays
482
+ open and accumulates with every merge, so you can group several changes into
483
+ one version.
484
+ 3. When you merge it, it cuts the tag, creates the release and triggers the
485
+ publish.
486
+ 4. Before uploading anything, the workflow checks that the tag and `package.json`
487
+ match, and runs lint, types, build, the `exports` verification and the full
488
+ suite in both modes.
489
+ 5. With the package already on npm, it builds Storybook and deploys it to
490
+ [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev).
406
491
 
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.
492
+ `feat:` bumps the minor and `fix:` the patch. While the version is `0.x`, a
493
+ breaking change bumps the **minor** and not the major: that is what the `0.`
494
+ means — that the API can still move without spending 1.0. It is in
495
+ `release-please-config.json`, and `initial-version` with the first release's
496
+ `0.1.0` is there too.
412
497
 
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.
498
+ When the API stabilises, it goes up to `1.0.0` by hand once, and from then on a
499
+ `BREAKING CHANGE:` bumps the major like in any other package.
415
500
 
416
- ### Cómo release-please decide qué cortar
501
+ ### How release-please decides what to cut
417
502
 
418
- Dos datos, y salen de sitios distintos. Saberlo evita el único fallo que deja el
419
- release atascado:
503
+ Two pieces of information, and they come from different places. Knowing this
504
+ avoids the one failure that leaves the release stuck:
420
505
 
421
- | Dato | De dónde sale |
506
+ | Datum | Where it comes from |
422
507
  | --- | --- |
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` |
508
+ | The **version** | From the PR **title** — `chore(main): release 0.2.0` |
509
+ | The **component** | From the **branch name** — `release-please--branches--main--components--arrecife` |
425
510
 
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
511
+ The component is derived from `package.json` and **cannot be pinned by
512
+ configuration**: there is no `component` key in the
513
+ [official schema](https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json).
514
+ `include-component-in-tag: false` is what makes the tag `v0.2.0` and not
430
515
  `arrecife-v0.2.0`.
431
516
 
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`.
517
+ If the title or the branch are edited by hand and stop matching, release-please
518
+ does not create the release and every run ends in `There are untagged, merged
519
+ release PRs outstanding - aborting`. There is no way out of that through
520
+ configuration: the tag and the release have to be cut by hand and the PR
521
+ relabelled `autorelease: tagged`.
436
522
 
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.
523
+ `pnpm check:release` validates the configuration against that schema on every CI
524
+ run. It exists because release-please **silently ignores** keys it does not know:
525
+ an invented option raises no error, shows in no log and does nothing.
440
526
 
441
- ### La publicación de confianza
527
+ ### Trusted publishing
442
528
 
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.
529
+ The workflow publishes with **OIDC**: GitHub issues a token proving «this build
530
+ came out of this repo and this workflow», and npm exchanges it for permission to
531
+ publish. There is no long-lived secret to steal, or to rotate. It also generates
532
+ **provenance**, which signs the package with a verifiable link to that exact
533
+ commit.
448
534
 
449
- Se configura una vez, en npmjs.com → el paquete → *Settings* → *Trusted
535
+ It is configured once, on npmjs.com → the package → *Settings* → *Trusted
450
536
  publisher*:
451
537
 
452
- | Campo | Valor |
538
+ | Field | Value |
453
539
  | --- | --- |
454
540
  | Publisher | GitHub Actions |
455
541
  | Organization or user | `Proskynete` |
@@ -457,262 +543,412 @@ publisher*:
457
543
  | Workflow filename | `release.yml` |
458
544
  | Environment | `npm` |
459
545
 
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`,
546
+ That *Workflow filename* is the reason the release and the publish live in a
547
+ single file rather than in a reusable workflow: npm matches the token against a
548
+ name, and with `workflow_call` there are two candidates.
549
+
550
+ **The chicken and the egg.** You cannot configure a trusted publisher on a
551
+ package that does not exist yet, so the first version needs a token:
552
+
553
+ 1. Create the `npm` environment in the repo settings with the `NPM_TOKEN` secret
554
+ (an *automation* token).
555
+ 2. Bump the version and publish for the first time. The workflow says in the log
556
+ that it is using a token.
557
+ 3. Configure trusted publishing with the table above.
558
+ 4. **Delete the `NPM_TOKEN` secret.** The step that uses it skips itself when it
559
+ is absent, and OIDC takes over without touching a line of the workflow.
560
+
561
+ To test without spending a version: *Actions → Release y publicación → Run
562
+ workflow* with the dry run enabled. It does everything but publish, needs no
563
+ token, and the run summary lists which files would travel and how big the tarball
564
+ is.
565
+
566
+ The dry run **does deploy Storybook**, to a Vercel preview and not to the public
567
+ domain. That is deliberate: a dry run that skips a job cannot tell you whether
568
+ that job works, and the deploy was the only step in the workflow that could not
569
+ be tested without spending a version.
570
+
571
+ One detail of the dry run that is confusing the first time: `npm publish
572
+ --dry-run` queries the registry, and `package.json` points at an **already
573
+ published** version except in the window between release-please bumping the
574
+ number and the workflow publishing. So the dry run runs into `cannot publish over
575
+ the previously published versions` over the one thing that cannot be right in a
576
+ dry run. That specific message is forgiven with a notice; any other failure still
577
+ fails.
578
+
579
+ ### Storybook is deployed with the version
580
+
581
+ The published Storybook is the library's documentation: every story is both the
582
+ example and the test that verifies it. It lives at
583
+ [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev) and it is
584
+ uploaded by `release.yml`'s `deploy` job, **after** npm has published.
585
+
586
+ That order is not an implementation detail. The site and the package have to tell
587
+ the same version: a Storybook ahead of npm shows components nobody can install
588
+ yet, and that is exactly the failure this repo exists because of — one source of
589
+ truth drifting from another.
590
+
591
+ Hence the decision that surprises people: **the Vercel project is not connected
592
+ to GitHub.** With the Git integration, Vercel builds on its own on every push to
593
+ `main` and there is no way to ask it to wait for the tag. The only thing that
594
+ deploys is the workflow, and it uploads Storybook **already built**: with the
595
+ Build Output API and `--prebuilt`, Vercel executes nothing, it serves what is in
596
+ `.vercel/output/static`. That way it is built by the same Node and the same
597
+ lockfile that just verified the library.
598
+
599
+ It is configured once, with `vercel link` in a local clone to create the project
600
+ and get the two ids out of `.vercel/project.json`. **Careful with one extra
601
+ step:** `vercel link` connects the GitHub repository to the project on its own
602
+ and without asking, which is exactly what you do not want. It is undone with
603
+ `vercel git disconnect`, and it is worth checking before calling the project
604
+ configured.
605
+
606
+ | Where | Name | What it is |
607
+ | --- | --- | --- |
608
+ | *Secrets* | `VERCEL_TOKEN` | A Vercel account token |
609
+ | *Variables* | `VERCEL_ORG_ID` | The `orgId` from `.vercel/project.json` |
610
+ | *Variables* | `VERCEL_PROJECT_ID` | The `projectId` from `.vercel/project.json` |
611
+
612
+ The two ids go in as **variables** and not as secrets on purpose: they are not
613
+ secret — they come out of any clone that runs `vercel link` — and as variables
614
+ they are readable in the log when something does not add up.
615
+
616
+ The team's deployment protection is `all_except_custom_domains`: each deploy's
617
+ unique URL asks for a team session and returns a 302 to the login, and the public
618
+ one is the custom domain. It is not a misconfiguration, it is Vercel's default
619
+ and it is the one we want.
620
+
621
+ **If they are missing, the job warns and does not break.** By the time it runs,
622
+ npm has published and the tag has been cut: a red there would read as «the
623
+ release failed», which is the opposite of what happened. The warning stays in the
624
+ run summary.
625
+
626
+ ## Status
627
+
628
+ - **Phase 1** · scaffolding, tokens and Storybook with the theme switch. Done.
629
+ - **Phase 2** · `brand/`. Done, with the PNGs that already existed.
630
+ - **Phase 3** · the 18 primitives on shadcn/Radix, plus `Text` and eight more
631
+ added after measuring real usage across the five projects. Done.
632
+ - **Phase 4** · `AudioPlayer`, migrated. Done.
633
+ - **Phase 5** · done. `ArticleCard`, `AuthorCard`, `TalkCard`, `CourseCard`,
487
634
  `LinkRow`, `CodeBlock`, `Blockquote`, `PageHeader`, `EmptyState`, `Breadcrumb`,
488
635
  `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 |
636
+ `NewsletterForm`, `og/` and `shiki/`.
637
+
638
+ The criterion for deciding what gets in is still the same: **it encodes an
639
+ identity rule, it has two or more consumers, and it drags in no project
640
+ infrastructure.**
641
+
642
+ ### `Hero` and `NewsletterForm` came back in
643
+
644
+ They were off the list with a written argument, and the argument was revisited.
645
+
646
+ **`Hero`.** It had been ruled out because «the portfolio's hero and the courses
647
+ one are the same skeleton as a section header, and `PageHeader` covers them with
648
+ a scale prop». That holds for the text and only for the text. The document's hero
649
+ also has a gradient, a panel radius, text clamped to 62 % of the width and the
650
+ pose bleeding off the bottom-right corner — none of which fits in a `PageHeader`
651
+ scale prop, and all of which are identity rules that get reimplemented five times
652
+ if they do not live here. They are two different pieces: `PageHeader` is still
653
+ the section header and goes inside `<main>`; `Hero` is the cover and there is one
654
+ per site.
655
+
656
+ **`NewsletterForm`.** It had been ruled out because «half its code is a `POST` to
657
+ an endpoint that only lives there: that is infrastructure». Correct, and that is
658
+ why the `POST` is not here. The component is presentational: it takes `state` and
659
+ emits `onSubmitEmail`, and the project makes the call with its own provider. What
660
+ IS identity are the four states and, above all, that the notice goes **below**
661
+ the form instead of replacing it — replacing it is what breaks the real case of
662
+ somebody who subscribes with the wrong email.
663
+
664
+ `Nav`, `Footer`, `Breadcrumb` and `Hero` are page composition and can be argued
665
+ about as library pieces. They get in anyway: the CLI aesthetic — the bar's
666
+ `./section`, the path's `~ / artículos / slug`, the footer's `$ cd ~/…` signature
667
+ — is the first thing that drifts when five projects each write it on their own.
668
+
669
+ ### Phase 3 decisions
670
+
671
+ - **No `lucide-react`.** The eight glyphs the primitives need are inline in
672
+ `src/lib/glyphs.tsx`, inherit `currentColor` and measure 1em. An icon library
673
+ as a dependency is something each of the five projects pays for.
674
+ - **No entrance animations.** Modals, menus, tooltips and toasts appear where
675
+ they will stay. The `Switch` knob changes position without sliding. The
676
+ system's only transition is `transition-standard`, which can only animate color
677
+ and border because that is how the utility is written.
678
+ - **One exception, documented:** the `Button loading` spinner spins. A loading
679
+ button with no movement is indistinguishable from a disabled one; it is
680
+ feedback about progress, not about state, and it is wrapped in `motion-safe`.
681
+ - **`Progress` requires `label`.** A bar with no accessible name does not say
682
+ what the progress is about, and no other part of the component can deduce it.
683
+ - **Zero literal hexes**, `Button` included. Rule 2 comes out as
684
+ `light:bg-brand-hull`, because the hull was already a token.
685
+ - **Explicit `cursor-pointer`** on everything you press. Tailwind v4 removed
686
+ `cursor: pointer` for `button` from the preflight, so a button without the
687
+ class keeps the system arrow. It is carried by `Button`, `Checkbox`,
688
+ `RadioGroupItem`, `Switch`, `TabsTrigger`, `Select`'s trigger, the close
689
+ buttons of `Dialog`/`Sheet`/`Toast`, `PaginationLink`, the shell of the
690
+ clickable cards and the links in `Nav`, `Footer` and `Breadcrumb` — which
691
+ render an `<a>` with no `href` when a router's `Link` is plugged into them.
692
+
693
+ Two deliberate exceptions. `Label` points at a control but is not the control.
694
+ And the **menu items** of `Select` and `DropdownMenu` stay on `cursor-default`:
695
+ a native menu does not show the pointing hand, and the row highlight already
696
+ says the row responds.
697
+
698
+ ### The syntax palette
699
+
700
+ Straight from the document: «keywords sand, strings biolume, comments plankton,
701
+ identifiers foam», over hull. Four colors on purpose — functions, variables and
702
+ types all land on foam, because the system communicates with color and border and
703
+ not with chromatic noise. Numbers and booleans ride with strings: the document
704
+ does not assign them, and grouping them under «they are literals» is more
705
+ coherent than introducing a fifth color.
706
+
707
+ Measured over `brand.hull` #0B1524, all AA:
708
+
709
+ | role | token | contrast |
564
710
  | --- | --- | --- |
565
- | identificador | `textPrimary` | 16.42:1 |
711
+ | identifier | `textPrimary` | 16.42:1 |
566
712
  | literal | `accent` | 10.05:1 |
567
- | palabra clave | `warm` | 9.05:1 |
568
- | comentario | `textMuted` | 5.43:1 |
569
- | invalidez | `error` | 4.97:1 |
713
+ | keyword | `warm` | 9.05:1 |
714
+ | comment | `textMuted` | 5.43:1 |
715
+ | invalid | `error` | 4.97:1 |
570
716
 
571
- `brand.body` (#3E7CB1) no entra: el sistema lo restringe a relleno y aquí mide
572
- 4.2:1.
717
+ `brand.body` (#3E7CB1) is not in it: the system restricts it to fill and here it
718
+ measures 4.2:1.
573
719
 
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.
720
+ It used to live hand-written in
721
+ `eduardoalvarez.dev/src/settings/shiki-reef.ts`, and a `#E05252` had been left
722
+ inside it — precisely the hex this README says is wrong. It is the textbook case
723
+ for why the palette cannot live inside a project: the theme is generated from
724
+ `tokens.syntax` and the red comes out corrected on its own.
578
725
 
579
- ### Temas anidables
726
+ ### Nestable themes
580
727
 
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.
728
+ `theme.css` emits one block per mode, not just the light one. That way a subtree
729
+ can declare the opposite mode to the page and everything inside honours it.
583
730
 
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.
731
+ `CodeBlock` uses it: `brand.hull` is «the background of code blocks», so a block
732
+ is dark in light mode too — and there `textPrimary` is nearly black. The block's
733
+ root declares `data-theme="dark"` and the ink resolves itself. It is the system's
734
+ only island of inverted theme, and it is deliberate.
588
735
 
589
- ### Las tarjetas y la regla 6
736
+ ### The cards and rule 6
590
737
 
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.
738
+ `ArticleCard`, `TalkCard`, `CourseCard` and `LinkRow` share an internal shell
739
+ that is not published, so rule 6 lives in exactly one place: the hover changes
740
+ the border from `hairline` to `hairlineHover` and tints the title with accent.
741
+ Nothing else.
594
742
 
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.
743
+ `LinkRow` comes from `links/src/components/Card.astro`, which scaled the card to
744
+ 102 %, lifted the title by a pixel and rotated and enlarged the icon — four
745
+ movements the system does not allow.
598
746
 
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.
747
+ No card depends on a router: by default they render an `<a href>`, and `asChild`
748
+ lets Next's or Astro's `Link` be plugged in.
601
749
 
602
- ### `AudioPlayer` — qué cambió al migrarlo
750
+ ### `AudioPlayer` — what changed on migration
603
751
 
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ó:
752
+ The logic was not rewritten. The three modes, the floating player, the ±15s
753
+ skips, the 1 → 1.25 → 1.5 → 1.75 → 2 speed cycle and the volume with mute are the
754
+ portfolio's. What changed:
607
755
 
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.
756
+ **Two dependencies a package cannot have.** The portfolio's `Icon` became
757
+ `src/lib/glyphs.tsx` with identical paths; `trackEvent` became the `onFirstPlay`
758
+ prop, which still fires exactly once per load.
611
759
 
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.
760
+ **One API change.** `compact`/`banner` as two booleans became
761
+ `mode="full" | "compact" | "banner"`, which is the vocabulary the three modes were
762
+ already described with. The portfolio's call sites need touching in Phase 6.
615
763
 
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`.
764
+ **Three animations the system does not allow.** The waveform no longer animates
765
+ `scaleY` — the bars still tell playback from pause by opacity. The floating
766
+ player appears and disappears instead of sliding. The progress bar no longer
767
+ interpolates its width, which also made it lag behind the audio. The loading
768
+ spinner's spin stays, with the same justification as in `Button`.
621
769
 
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.
770
+ **An inherited contrast failure.** The speed button put `textMuted` over
771
+ `surfaceRaised`: 4.07:1 in dark. It moved to `textSecondary`. The original still
772
+ carries that failure.
625
773
 
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.
774
+ **A latent bug.** The player's pieces live at module level, not inside the
775
+ component. Declared inside, they change identity on every render and React
776
+ remounts them: with `timeupdate` firing four times a second, dragging the bar
777
+ lost pointer capture.
630
778
 
631
- ### La marca
779
+ ### The brand
632
780
 
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.
781
+ Tiburoncín's thirteen pieces were scattered across the five projects,
782
+ byte-for-byte identical. They were consolidated into `assets/brand/` and are
783
+ published in the package; they are served at `/brand`, the same path everyone
784
+ already uses from their `public/`, so `basePath`'s default works with nothing to
785
+ configure.
638
786
 
639
787
  ```tsx
640
- import { Logo, Mascota, CaraDeMascota, listaCaras } from '@eduardoalvarez/arrecife/brand';
788
+ import { Logo, Mascot, MascotFace, faceList } from '@eduardoalvarez/arrecife/brand';
641
789
  ```
642
790
 
643
791
  | | |
644
792
  | --- | --- |
645
- | aletas | `fin.png` (dos azules) y `fin-foam.png` (silueta espuma) |
646
- | caras | annoyed · confused · hearts · laughing · shades · waiting · wink |
793
+ | fins | `fin.png` (two blues) and `fin-foam.png` (foam silhouette) |
794
+ | faces | annoyed · confused · hearts · laughing · shades · waiting · wink |
647
795
  | poses | desk · laptop-coffee · peek · surf |
648
796
 
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.
797
+ The names are a type: a face that does not exist will not compile, and
798
+ autocomplete offers the ones that do. Adding one means dropping the PNG in and
799
+ adding a line to the catalog.
651
800
 
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.
801
+ **Rule 1 as API.** `background="dark"` uses the single-ink silhouette and
802
+ `background="light"` the two-blue one. It is not a note in a guide: it is a prop.
803
+ Pixel analysis confirms it — 94 % of `fin-foam.png` is `#EDF4F3`, which is the
804
+ foam token.
656
805
 
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.
806
+ **Rule 5 as API.** The wordmark comes from `naming.wordmark` and always reads
807
+ «Eduardo Álvarez». There is no prop that changes that text, and Tiburoncín never
808
+ appears written inside the logo.
660
809
 
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.
810
+ **Rule 4 as API.** The faces only go in empty states, confirmations, errors,
811
+ course progress and celebration. The rule lives in which components accept a
812
+ face, not in the documentation.
664
813
 
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.
814
+ The format is an implementation detail: when the SVGs arrive, the files get
815
+ replaced and not a line of code changes.
667
816
 
668
- ### Los ocho que se añadieron después
817
+ ### The eight added afterwards
669
818
 
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`.
819
+ They were not on the original list. They got in by measuring how many of the five
820
+ projects use each one, with the same criterion that took `Hero` and
821
+ `NewsletterSection` out.
673
822
 
674
- | | archivos que lo usan | por qué |
823
+ | | files using it | why |
824
+ | --- | --- | --- |
825
+ | `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. |
826
+ | `Label` | 21, across 2 | There were seven form controls and no label. |
827
+ | `Avatar` | 19, across 3 | One for everything: there is no separate `brand/Avatar`, because a profile photo is this with a different `src`. |
828
+ | `Sheet` | 6, across 3 | It is `Dialog` with a side variant. |
829
+ | `Separator` | 8, across 2 | `hairline` was a token with no component. |
830
+ | `Popover` | 5, across 3 | The base of any dropdown selector. |
831
+ | `DateField` | — | The native control, dependency-free, for picking a date in a form. |
832
+ | `Calendar` | 6, across 3 | A navigable month calendar, for the content planner. `fullWidth` stretches it to the container's width. |
833
+
834
+ There is no `DatePicker`: it is `Popover` plus `Calendar` and it is five lines. A
835
+ third component that only glues together two that already exist is API surface to
836
+ maintain for nothing.
837
+
838
+ `Popover` requires `aria-label` or `aria-labelledby` in the type. Radix puts
839
+ `role="dialog"` on the content, and a dialog with no accessible name says nothing
840
+ to a screen reader: now it cannot be forgotten because it does not compile.
841
+
842
+ ### The fifth motion exception: the footer's caret
843
+
844
+ The CLI signature ends in a block caret that blinks, behind `motion-safe`. It is
845
+ the first exception that is not feedback about progress, so it needed a different
846
+ argument.
847
+
848
+ The signature is a **prompt** — that is why it is mono, why the `$` is in accent
849
+ and why it sits in a footer instead of a `<p>` saying «© 2026». A prompt whose
850
+ caret does not blink is a terminal that has hung, and a still block at the end of
851
+ a line reads as a stray character.
852
+
853
+ So the criterion splits in two. The first four exceptions are feedback about
854
+ progress or spatial continuity; this one is legibility: it is not decoration, it
855
+ is what makes the piece readable as what it is. `step-end` and not a fade,
856
+ because a real caret is on or off and easing it turns a terminal into a pulsing
857
+ dot. See `docs/decisions.md` § 23.
858
+
859
+ ### The second motion exception
860
+
861
+ `Sheet` slides. It is the second and last exception to «no displacement»,
862
+ approved knowingly: a panel entering from an edge, held still, would be an
863
+ off-centre modal. It lasts `--duration-standard` with `--ease-standard` — the
864
+ same time and the same curve as any color change — so it introduces no new
865
+ timing, and it sits behind `motion-safe`.
866
+
867
+ `Calendar` does **not** animate the month change: react-day-picker's `animate`
868
+ stays on its default, which is off.
869
+
870
+ ### The danger variant, and where it does not go
871
+
872
+ `Button` has `destructive` and `destructiveOutline` since 0.6.0. For four
873
+ versions it had neither, on an argument that is still half right: inside an
874
+ `AlertDialog` the confirm button is **not** red, because a title explains what is
875
+ about to happen, focus starts on cancel and clicking outside does not close it.
876
+ The context does the work and a red button on top of it is shouting.
877
+
878
+ What broke the argument is the table row. `cursos` has eight destructive buttons
879
+ in row actions and toolbars, next to «Editar» and «Duplicar», with nothing around
880
+ them doing that work — and rendered as `secondary`, «Eliminar curso» looked
881
+ exactly like «Cancelar».
882
+
883
+ The palette is not `error`, and the reason is the role. `error` is a text color:
884
+ it reads against a dark surface, so it sits mid-red. `danger` is a fill: what
885
+ reads is the ink on top of it, so it goes lighter. Same split as `accent` and
886
+ `accentOn`. In light mode both land on `#C0392B`, because over paper a red dark
887
+ enough to carry white ink is also the red that reads as text.
888
+
889
+ | | dark | light |
675
890
  | --- | --- | --- |
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. |
891
+ | ink over fill | 6.53 | 5.11 |
892
+ | ink over hover | 7.92 | 6.61 |
893
+ | fill over background | 6.71 | 4.87 |
894
+ | fill over surfaceRaised | 4.91 | **4.50** |
895
+
896
+ That last cell is exactly on the AA line, which is where every light semantic in
897
+ this palette sits. It matters because it is the outline variant's border and
898
+ text, and `surfaceRaised` is where a toolbar lives.
899
+
900
+ `destructiveOutline` fills on hover, and that is a declared exception to
901
+ «secondary is never filled» — a destructive that looks identical to a secondary
902
+ until you read it is the problem the variant exists to fix. See
903
+ `docs/decisions.md` § 21.
684
904
 
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.
905
+ ### `icon-sm`, for the one admin app
688
906
 
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.
907
+ 42×42 is the right measure for a control you hit with a thumb, and four of the
908
+ five projects are reading sites where that fits. `cursos` is the odd one out:
909
+ three actions per table row, and at 42 the row grows with them.
910
+
911
+ `size="icon-sm"` is 32×32, and it is 32 and not the 28 that project actually had:
912
+ 32 is `sm`'s height, so a dense icon button lines up with a small text button and
913
+ a toolbar mixing the two stays on one baseline. It does not replace `icon` — a
914
+ page's primary action stays at 42. See `docs/decisions.md` § 22.
915
+
916
+ ### The theme script, and the mode a site already decided
917
+
918
+ ```astro
919
+ <script is:inline set:html={themeScript({ base: 'dark' })} />
920
+ ```
692
921
 
693
- ### La segunda excepción de movimiento
922
+ Until 0.6.0 `themeScript` was a fixed string and resolved stored choice →
923
+ `prefers-color-scheme` → dark. That is the right default for a library, and it
924
+ was wrong for all five of these projects: they are dark BY DECISION, and
925
+ `eduardoalvarez.dev`'s own script said so out loud — «dark is the brand's PRIMARY
926
+ mode, so it's the default and doesn't follow the OS setting». With the OS in
927
+ charge, a reader whose machine is in light mode saw the blog in light.
694
928
 
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`.
929
+ There was no way to say otherwise, so those projects kept their own
930
+ `public/theme.js` and the library published the hard part for nobody. Migrating
931
+ just the button did not help either: `ThemeToggle` persists under
932
+ `arrecife-theme` and their script read `theme`, so the two would have gone out of
933
+ step.
700
934
 
701
- `Calendar` **no** anima el cambio de mes: `animate` de react-day-picker se queda
702
- en su valor por defecto, que es apagado.
935
+ `base` stops the OS from being consulted at all. A stored choice still wins over
936
+ it — it sets what happens when nobody has chosen yet, not what happens instead of
937
+ choosing, so the toggle keeps working.
703
938
 
704
- ### `Text` — la escala como API
939
+ ### `Text` — the scale as API
705
940
 
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:
941
+ `Text` was not on the original list and was added later, because without it the
942
+ scale only existed as loose classes and nothing stopped anyone putting
943
+ `text-display` on a paragraph. Three of the system's rules live inside the
944
+ component:
709
945
 
710
- | regla | cómo se aplica |
946
+ | rule | how it is applied |
711
947
  | --- | --- |
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 |
948
+ | display for headlines only, never body | the family is bound to the scale; no `font` prop exists |
949
+ | weight and tracking belong to the scale | they come from the `--text-*` token and are not exposed |
950
+ | maximum body measure 68ch | `body` applies it on its own; `measure={false}` removes it |
715
951
 
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.
952
+ `as` and `variant` are independent on purpose: a second-level heading that has to
953
+ look smaller is `<Text as="h2" variant="h3">`, not an `h3` that lies about the
954
+ page hierarchy.