@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.
- package/CHANGELOG.md +114 -0
- package/README.md +868 -467
- package/dist/brand/index.cjs +112 -95
- package/dist/brand/index.d.cts +40 -39
- package/dist/brand/index.d.ts +40 -39
- package/dist/brand/index.js +5 -4
- package/dist/catalog-D13txprv.d.cts +78 -0
- package/dist/catalog-D13txprv.d.ts +78 -0
- package/dist/chart/index.cjs +100 -83
- package/dist/chart/index.d.cts +66 -66
- package/dist/chart/index.d.ts +66 -66
- package/dist/chart/index.js +14 -12
- package/dist/chunk-2WPWEIMD.js +27 -0
- package/dist/chunk-45HVCTB7.js +70 -0
- package/dist/{chunk-ZEOQKRQ7.js → chunk-727HCBD4.js} +1 -1
- package/dist/chunk-CKRSQPTX.js +36 -0
- package/dist/chunk-E6KFUSKB.js +144 -0
- package/dist/chunk-GCRII2KQ.js +86 -0
- package/dist/{chunk-YZ2SDOVZ.js → chunk-JN3IS5OS.js} +30 -30
- package/dist/chunk-ODBFN44D.js +45 -0
- package/dist/chunk-OMKSESQB.js +300 -0
- package/dist/{chunk-VPT32GPG.js → chunk-TA7TLWW4.js} +2 -2
- package/dist/chunk-WGNIRIN7.js +42 -0
- package/dist/doctor.mjs +166 -0
- package/dist/form/index.cjs +109 -92
- package/dist/form/index.d.cts +43 -42
- package/dist/form/index.d.ts +43 -42
- package/dist/form/index.js +25 -23
- package/dist/icons/index.cjs +149 -0
- package/dist/icons/index.d.cts +94 -0
- package/dist/icons/index.d.ts +94 -0
- package/dist/icons/index.js +28 -0
- package/dist/index-DlAO2JZs.d.cts +47 -0
- package/dist/index-DlAO2JZs.d.ts +47 -0
- package/dist/index.cjs +1292 -983
- package/dist/index.d.cts +927 -806
- package/dist/index.d.ts +927 -806
- package/dist/index.js +809 -778
- package/dist/{label-DuTvJGxD.d.ts → label-MgHFKnFy.d.cts} +3 -3
- package/dist/{label-DuTvJGxD.d.cts → label-MgHFKnFy.d.ts} +3 -3
- package/dist/og/index.cjs +133 -132
- package/dist/og/index.d.cts +93 -89
- package/dist/og/index.d.ts +93 -89
- package/dist/og/index.js +106 -106
- package/dist/shiki/index.cjs +28 -30
- package/dist/shiki/index.d.cts +4 -4
- package/dist/shiki/index.d.ts +4 -4
- package/dist/shiki/index.js +12 -12
- package/dist/social/index.cjs +67 -0
- package/dist/social/index.d.cts +2 -0
- package/dist/social/index.d.ts +2 -0
- package/dist/social/index.js +2 -0
- package/dist/theme/index.cjs +97 -0
- package/dist/theme/index.d.cts +144 -0
- package/dist/theme/index.d.ts +144 -0
- package/dist/theme/index.js +2 -0
- package/dist/tokens/index.cjs +159 -88
- package/dist/tokens/index.d.cts +277 -165
- package/dist/tokens/index.d.ts +277 -165
- package/dist/tokens/index.js +2 -2
- package/dist/tokens/theme.css +165 -100
- package/dist/variants/index.cjs +195 -0
- package/dist/variants/index.d.cts +195 -0
- package/dist/variants/index.d.ts +195 -0
- package/dist/variants/index.js +3 -0
- package/llms.txt +1145 -746
- package/package.json +42 -11
- package/dist/catalogo-Du5ID-Hi.d.cts +0 -77
- package/dist/catalogo-Du5ID-Hi.d.ts +0 -77
- package/dist/chunk-E3OMP2DL.js +0 -36
- package/dist/chunk-KPZNNMV5.js +0 -83
- package/dist/chunk-NHS7ETKJ.js +0 -27
- package/dist/chunk-TSPJOM6K.js +0 -229
- package/dist/chunk-UOWIDFCB.js +0 -81
- package/dist/tema/index.cjs +0 -94
- package/dist/tema/index.d.cts +0 -110
- package/dist/tema/index.d.ts +0 -110
- package/dist/tema/index.js +0 -2
package/README.md
CHANGED
|
@@ -1,151 +1,224 @@
|
|
|
1
1
|
# Arrecife
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The component library of Eduardo Álvarez's visual identity.
|
|
4
4
|
`@eduardoalvarez/arrecife` · React 19 · TypeScript · shadcn/ui · Storybook · tsup.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Published Storybook: [arrecife.eduardoalvarez.dev](https://arrecife.eduardoalvarez.dev).
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
45
|
+
It is not documentation: `pnpm check:tokens` verifies it on every build and
|
|
46
|
+
ESLint says so in the editor.
|
|
47
47
|
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
`data-theme="light"`
|
|
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
|
-
**
|
|
68
|
-
Tailwind
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`llms.txt` —
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
`"Geist Variable"` —
|
|
112
|
-
**
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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"; /*
|
|
122
|
-
src: url("/
|
|
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
|
-
####
|
|
201
|
+
#### In Next, with `next/font`
|
|
129
202
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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/
|
|
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: '--
|
|
147
|
-
export const display = Bricolage_Grotesque({ subsets: ['latin'], variable: '--
|
|
148
|
-
export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--
|
|
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
|
-
/*
|
|
229
|
+
/* After the import, or the literal that is not loaded wins. */
|
|
157
230
|
@theme {
|
|
158
|
-
--font-sans: var(--
|
|
159
|
-
--font-display: var(--
|
|
160
|
-
--font-mono: var(--
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
244
|
+
### Token-to-utility map
|
|
172
245
|
|
|
173
|
-
| Token | Custom property |
|
|
246
|
+
| Token | Custom property | Utility |
|
|
174
247
|
| --- | --- | --- |
|
|
175
|
-
| `colors[
|
|
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` (
|
|
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
|
|
183
|
-
| `control.icon` | `--spacing-control-icon` | `size-control-icon` (
|
|
184
|
-
| `gradient[
|
|
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
|
-
|
|
264
|
+
The `light:` variant is available for the inverted light-mode cases.
|
|
192
265
|
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
`astro.config.mjs`,
|
|
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
|
-
**
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 {
|
|
295
|
+
import { articleTemplate, OG } from '@eduardoalvarez/arrecife/og';
|
|
222
296
|
|
|
223
|
-
const svg = await satori(
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
##
|
|
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">
|
|
243
|
-
<Text variant="ui" measure={false}>
|
|
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
|
-
|
|
247
|
-
|
|
319
|
+
Every component publishes its documentation page in Storybook with the props
|
|
320
|
+
table generated from the types. `Text`'s:
|
|
248
321
|
|
|
249
|
-
| prop |
|
|
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` |
|
|
254
|
-
| `measure` | `boolean` —
|
|
255
|
-
| `asChild` | `boolean` —
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
`./tokens/theme.css`
|
|
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
|
-
###
|
|
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
|
-
// ❌
|
|
337
|
+
// ❌ does not exist: the root publishes them grouped, not loose
|
|
268
338
|
import { GitHub, LinkedIn } from '@eduardoalvarez/arrecife';
|
|
269
339
|
|
|
270
|
-
// ✅
|
|
271
|
-
import {
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
390
|
+
<Icon as={GraduationCap} />
|
|
391
|
+
```
|
|
290
392
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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,
|
|
529
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent, seriesColor }
|
|
305
530
|
from '@eduardoalvarez/arrecife/chart';
|
|
306
531
|
```
|
|
307
532
|
|
|
308
|
-
`check:exports`
|
|
309
|
-
`./shiki
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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` |
|
|
544
|
+
| `pnpm build` | verifies token purity, compiles with tsup and generates `theme.css` |
|
|
319
545
|
| `pnpm typecheck` | `tsc --noEmit` |
|
|
320
|
-
| `pnpm lint` | ESLint,
|
|
321
|
-
| `pnpm check:tokens` |
|
|
322
|
-
| `pnpm test` |
|
|
323
|
-
| `pnpm check:exports` |
|
|
324
|
-
| `pnpm check:release` |
|
|
325
|
-
| `pnpm storybook` |
|
|
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
|
-
##
|
|
553
|
+
## Contrast as a test, not as a panel
|
|
328
554
|
|
|
329
|
-
`pnpm test`
|
|
330
|
-
`a11y: { test: 'error' }`.
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
`aria-hidden`
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
##
|
|
568
|
+
## Contrast corrections
|
|
343
569
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 |
|
|
575
|
+
| token | before | now | reason |
|
|
349
576
|
| --- | --- | --- | --- |
|
|
350
|
-
| `light.textMuted` | `#6B7480` | `#626A75` | 4.24
|
|
351
|
-
| `light.warning` | `#9A6A12` | `#8D6111` | 4.23
|
|
352
|
-
| `dark.error` | `#E05252` | `#E15757` | 4.35
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
386
|
-
1.149
|
|
387
|
-
|
|
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
|
-
##
|
|
632
|
+
## Publishing a version
|
|
390
633
|
|
|
391
|
-
**
|
|
392
|
-
release-please
|
|
393
|
-
`lint-pr-title`
|
|
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
|
-
|
|
638
|
+
The whole cycle lives in `.github/workflows/release.yml`:
|
|
396
639
|
|
|
397
|
-
1.
|
|
398
|
-
2. release-please
|
|
399
|
-
bump
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
3.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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:`
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
`release-please-config.json`,
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
###
|
|
662
|
+
### How release-please decides what to cut
|
|
417
663
|
|
|
418
|
-
|
|
419
|
-
release
|
|
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
|
-
|
|
|
667
|
+
| Datum | Where it comes from |
|
|
422
668
|
| --- | --- |
|
|
423
|
-
|
|
|
424
|
-
|
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
[
|
|
429
|
-
`include-component-in-tag: false`
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
outstanding - aborting`.
|
|
435
|
-
tag
|
|
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`
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
###
|
|
688
|
+
### Trusted publishing
|
|
442
689
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
696
|
+
It is configured once, on npmjs.com → the package → *Settings* → *Trusted
|
|
450
697
|
publisher*:
|
|
451
698
|
|
|
452
|
-
|
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
**
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
1.
|
|
468
|
-
|
|
469
|
-
2.
|
|
470
|
-
|
|
471
|
-
3.
|
|
472
|
-
4. **
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
workflow*
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
-
|
|
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/`
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
### `Hero`
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
**`Hero`.**
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
prop
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
**`NewsletterForm`.**
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
`Nav`, `Footer`, `Breadcrumb`
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
|
876
|
+
| identifier | `textPrimary` | 16.42:1 |
|
|
566
877
|
| literal | `accent` | 10.05:1 |
|
|
567
|
-
|
|
|
568
|
-
|
|
|
569
|
-
|
|
|
878
|
+
| keyword | `warm` | 9.05:1 |
|
|
879
|
+
| comment | `textMuted` | 5.43:1 |
|
|
880
|
+
| invalid | `error` | 4.97:1 |
|
|
570
881
|
|
|
571
|
-
`brand.body` (#3E7CB1)
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
###
|
|
891
|
+
### Nestable themes
|
|
580
892
|
|
|
581
|
-
`theme.css`
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
###
|
|
901
|
+
### The cards and rule 6
|
|
590
902
|
|
|
591
|
-
`ArticleCard`, `TalkCard`, `CourseCard`
|
|
592
|
-
|
|
593
|
-
|
|
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`
|
|
596
|
-
102 %,
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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` —
|
|
915
|
+
### `AudioPlayer` — what changed on migration
|
|
603
916
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
**
|
|
609
|
-
`src/lib/glyphs.tsx`
|
|
610
|
-
|
|
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
|
-
**
|
|
613
|
-
`mode="full" | "compact" | "banner"`,
|
|
614
|
-
|
|
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
|
-
**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
**
|
|
623
|
-
`surfaceRaised`: 4.07:1
|
|
624
|
-
|
|
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
|
-
**
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
###
|
|
944
|
+
### The brand
|
|
632
945
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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,
|
|
953
|
+
import { Logo, Mascot, MascotFace, faceList } from '@eduardoalvarez/arrecife/brand';
|
|
641
954
|
```
|
|
642
955
|
|
|
643
956
|
| | |
|
|
644
957
|
| --- | --- |
|
|
645
|
-
|
|
|
646
|
-
|
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
**
|
|
653
|
-
`
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
**
|
|
658
|
-
«Eduardo Álvarez».
|
|
659
|
-
|
|
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
|
-
**
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
###
|
|
982
|
+
### The eight added afterwards
|
|
669
983
|
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
| |
|
|
988
|
+
| | files using it | why |
|
|
675
989
|
| --- | --- | --- |
|
|
676
|
-
| `Card` | 34,
|
|
677
|
-
| `Label` | 21,
|
|
678
|
-
| `Avatar` | 19,
|
|
679
|
-
| `Sheet` | 6,
|
|
680
|
-
| `Separator` | 8,
|
|
681
|
-
| `Popover` | 5,
|
|
682
|
-
| `DateField` | — |
|
|
683
|
-
| `Calendar` | 6,
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
`
|
|
690
|
-
`
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
`
|
|
702
|
-
|
|
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` —
|
|
1104
|
+
### `Text` — the scale as API
|
|
705
1105
|
|
|
706
|
-
`Text`
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
|
1111
|
+
| rule | how it is applied |
|
|
711
1112
|
| --- | --- |
|
|
712
|
-
| display
|
|
713
|
-
|
|
|
714
|
-
|
|
|
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`
|
|
717
|
-
|
|
718
|
-
|
|
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.
|