@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.
- package/CHANGELOG.md +52 -0
- package/README.md +706 -470
- 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-25YNFCIF.js +141 -0
- package/dist/{chunk-YZ2SDOVZ.js → chunk-6O3KWB6P.js} +30 -30
- package/dist/chunk-CKRSQPTX.js +36 -0
- package/dist/{chunk-ZEOQKRQ7.js → chunk-DKCN7BAL.js} +1 -1
- package/dist/chunk-GCRII2KQ.js +86 -0
- package/dist/chunk-JMOOFZ3B.js +42 -0
- package/dist/chunk-O4TAH7YJ.js +276 -0
- package/dist/chunk-ODBFN44D.js +45 -0
- package/dist/{chunk-VPT32GPG.js → chunk-PMN7NR3G.js} +2 -2
- package/dist/chunk-XKYHTOUJ.js +27 -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/index.cjs +1068 -929
- package/dist/index.d.cts +770 -773
- package/dist/index.d.ts +770 -773
- package/dist/index.js +629 -675
- 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 +130 -130
- 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/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 +133 -86
- package/dist/tokens/index.d.cts +246 -161
- package/dist/tokens/index.d.ts +246 -161
- package/dist/tokens/index.js +2 -2
- package/dist/tokens/theme.css +133 -98
- package/dist/variants/index.cjs +192 -0
- package/dist/variants/index.d.cts +192 -0
- package/dist/variants/index.d.ts +192 -0
- package/dist/variants/index.js +3 -0
- package/llms.txt +810 -744
- package/package.json +20 -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,168 @@
|
|
|
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` —
|
|
85
|
-
|
|
86
|
-
> **
|
|
87
|
-
>
|
|
88
|
-
>
|
|
89
|
-
>
|
|
90
|
-
|
|
91
|
-
>
|
|
92
|
-
>
|
|
93
|
-
>
|
|
94
|
-
>
|
|
95
|
-
>
|
|
96
|
-
>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
`"Geist Variable"` —
|
|
112
|
-
**
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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"; /*
|
|
122
|
-
src: url("/
|
|
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
|
-
####
|
|
145
|
+
#### In Next, with `next/font`
|
|
129
146
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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/
|
|
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: '--
|
|
147
|
-
export const display = Bricolage_Grotesque({ subsets: ['latin'], variable: '--
|
|
148
|
-
export const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--
|
|
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
|
-
/*
|
|
173
|
+
/* After the import, or the literal that is not loaded wins. */
|
|
157
174
|
@theme {
|
|
158
|
-
--font-sans: var(--
|
|
159
|
-
--font-display: var(--
|
|
160
|
-
--font-mono: var(--
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
188
|
+
### Token-to-utility map
|
|
172
189
|
|
|
173
|
-
| Token | Custom property |
|
|
190
|
+
| Token | Custom property | Utility |
|
|
174
191
|
| --- | --- | --- |
|
|
175
|
-
| `colors[
|
|
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` (
|
|
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
|
|
183
|
-
| `control.icon` | `--spacing-control-icon` | `size-control-icon` (
|
|
184
|
-
| `gradient[
|
|
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
|
-
|
|
208
|
+
The `light:` variant is available for the inverted light-mode cases.
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
`astro.config.mjs`,
|
|
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
|
-
**
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 {
|
|
239
|
+
import { articleTemplate, OG } from '@eduardoalvarez/arrecife/og';
|
|
222
240
|
|
|
223
|
-
const svg = await satori(
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
##
|
|
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">
|
|
243
|
-
<Text variant="ui" measure={false}>
|
|
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
|
-
|
|
247
|
-
|
|
263
|
+
Every component publishes its documentation page in Storybook with the props
|
|
264
|
+
table generated from the types. `Text`'s:
|
|
248
265
|
|
|
249
|
-
| prop |
|
|
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` |
|
|
254
|
-
| `measure` | `boolean` —
|
|
255
|
-
| `asChild` | `boolean` —
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
`./tokens/theme.css`
|
|
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
|
-
###
|
|
278
|
+
### The social icons are namespaced
|
|
262
279
|
|
|
263
|
-
|
|
264
|
-
natural
|
|
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
|
-
// ❌
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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,
|
|
383
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent, seriesColor }
|
|
305
384
|
from '@eduardoalvarez/arrecife/chart';
|
|
306
385
|
```
|
|
307
386
|
|
|
308
|
-
`check:exports`
|
|
309
|
-
`./shiki
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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` |
|
|
398
|
+
| `pnpm build` | verifies token purity, compiles with tsup and generates `theme.css` |
|
|
319
399
|
| `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` |
|
|
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
|
-
##
|
|
407
|
+
## Contrast as a test, not as a panel
|
|
328
408
|
|
|
329
|
-
`pnpm test`
|
|
330
|
-
`a11y: { test: 'error' }`.
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
`aria-hidden`
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
##
|
|
422
|
+
## Contrast corrections
|
|
343
423
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 |
|
|
429
|
+
| token | before | now | reason |
|
|
349
430
|
| --- | --- | --- | --- |
|
|
350
|
-
| `light.textMuted` | `#6B7480` | `#626A75` | 4.24
|
|
351
|
-
| `light.warning` | `#9A6A12` | `#8D6111` | 4.23
|
|
352
|
-
| `dark.error` | `#E05252` | `#E15757` | 4.35
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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`
|
|
362
|
-
`textSecondary`,
|
|
442
|
+
`textMuted` never goes over `surfaceRaised`: in dark it gives 4.07. Menus use
|
|
443
|
+
`textSecondary`, which gives 6.96.
|
|
363
444
|
|
|
364
|
-
###
|
|
445
|
+
### The third correction: a semantic color is not a text color over its own tint
|
|
365
446
|
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
451
|
+
The light semantics are calibrated to pass **just** over paper. Tinting the
|
|
452
|
+
background with them sinks them below AA:
|
|
371
453
|
|
|
372
|
-
|
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
386
|
-
1.149
|
|
387
|
-
|
|
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
|
-
##
|
|
471
|
+
## Publishing a version
|
|
390
472
|
|
|
391
|
-
**
|
|
392
|
-
release-please
|
|
393
|
-
`lint-pr-title`
|
|
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
|
-
|
|
477
|
+
The whole cycle lives in `.github/workflows/release.yml`:
|
|
396
478
|
|
|
397
|
-
1.
|
|
398
|
-
2. release-please
|
|
399
|
-
bump
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
3.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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:`
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
`release-please-config.json`,
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
###
|
|
501
|
+
### How release-please decides what to cut
|
|
417
502
|
|
|
418
|
-
|
|
419
|
-
release
|
|
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
|
-
|
|
|
506
|
+
| Datum | Where it comes from |
|
|
422
507
|
| --- | --- |
|
|
423
|
-
|
|
|
424
|
-
|
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
[
|
|
429
|
-
`include-component-in-tag: false`
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
outstanding - aborting`.
|
|
435
|
-
tag
|
|
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`
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
###
|
|
527
|
+
### Trusted publishing
|
|
442
528
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
535
|
+
It is configured once, on npmjs.com → the package → *Settings* → *Trusted
|
|
450
536
|
publisher*:
|
|
451
537
|
|
|
452
|
-
|
|
|
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
|
-
|
|
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
|
-
-
|
|
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/`
|
|
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
|
-
| 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
|
-
|
|
|
711
|
+
| identifier | `textPrimary` | 16.42:1 |
|
|
566
712
|
| literal | `accent` | 10.05:1 |
|
|
567
|
-
|
|
|
568
|
-
|
|
|
569
|
-
|
|
|
713
|
+
| keyword | `warm` | 9.05:1 |
|
|
714
|
+
| comment | `textMuted` | 5.43:1 |
|
|
715
|
+
| invalid | `error` | 4.97:1 |
|
|
570
716
|
|
|
571
|
-
`brand.body` (#3E7CB1)
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
###
|
|
726
|
+
### Nestable themes
|
|
580
727
|
|
|
581
|
-
`theme.css`
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
###
|
|
736
|
+
### The cards and rule 6
|
|
590
737
|
|
|
591
|
-
`ArticleCard`, `TalkCard`, `CourseCard`
|
|
592
|
-
|
|
593
|
-
|
|
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`
|
|
596
|
-
102 %,
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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` —
|
|
750
|
+
### `AudioPlayer` — what changed on migration
|
|
603
751
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
**
|
|
609
|
-
`src/lib/glyphs.tsx`
|
|
610
|
-
|
|
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
|
-
**
|
|
613
|
-
`mode="full" | "compact" | "banner"`,
|
|
614
|
-
|
|
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
|
-
**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
**
|
|
623
|
-
`surfaceRaised`: 4.07:1
|
|
624
|
-
|
|
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
|
-
**
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
###
|
|
779
|
+
### The brand
|
|
632
780
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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,
|
|
788
|
+
import { Logo, Mascot, MascotFace, faceList } from '@eduardoalvarez/arrecife/brand';
|
|
641
789
|
```
|
|
642
790
|
|
|
643
791
|
| | |
|
|
644
792
|
| --- | --- |
|
|
645
|
-
|
|
|
646
|
-
|
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
**
|
|
653
|
-
`
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
**
|
|
658
|
-
«Eduardo Álvarez».
|
|
659
|
-
|
|
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
|
-
**
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
###
|
|
817
|
+
### The eight added afterwards
|
|
669
818
|
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
| |
|
|
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
|
-
|
|
|
677
|
-
|
|
|
678
|
-
|
|
|
679
|
-
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
`
|
|
702
|
-
|
|
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` —
|
|
939
|
+
### `Text` — the scale as API
|
|
705
940
|
|
|
706
|
-
`Text`
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
|
946
|
+
| rule | how it is applied |
|
|
711
947
|
| --- | --- |
|
|
712
|
-
| display
|
|
713
|
-
|
|
|
714
|
-
|
|
|
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`
|
|
717
|
-
|
|
718
|
-
|
|
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.
|