@eduardoalvarez/arrecife 0.10.0 → 0.11.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/Proskynete/arrecife/compare/v0.10.0...v0.11.0) (2026-09-11)
4
+
5
+
6
+ ### 🚀 Novedades
7
+
8
+ * **brand:** the fin can follow the theme with background="auto" ([3021f24](https://github.com/Proskynete/arrecife/commit/3021f24575c6f9422b7f322bf825a878a735306d))
9
+ * **chart:** valueMax sets the top of the value axis ([ccebe24](https://github.com/Proskynete/arrecife/commit/ccebe24361ce4004916b433819ddf7f53f89907a))
10
+ * **components:** CourseCard takes a cover and a closing row ([a3c73d0](https://github.com/Proskynete/arrecife/commit/a3c73d0b21edffc16c2965475fce9de6e1502926))
11
+ * **components:** PageHeader lets the screen pick the title scale ([99bd553](https://github.com/Proskynete/arrecife/commit/99bd55376992109ea90c8eaedc435ffcadffdb0e))
12
+
13
+
14
+ ### 📚 Documentación
15
+
16
+ * four decisions from cursos adopting the whole library ([2a712b8](https://github.com/Proskynete/arrecife/commit/2a712b871edd73f63fddfd2733279bf3e2834e07))
17
+ * **readme:** background="auto", valueMax, and the story count ([149442c](https://github.com/Proskynete/arrecife/commit/149442cb2748da9cb06b3df23254cba46aae378a))
18
+
3
19
  ## [0.10.0](https://github.com/Proskynete/arrecife/compare/v0.9.0...v0.10.0) (2026-09-09)
4
20
 
5
21
 
package/README.md CHANGED
@@ -588,6 +588,11 @@ vertical={false}`, two axes with the line and the tick off, a `type="natural"`
588
588
  and a `strokeWidth` — which `cursos` wrote four times, once per chart. None of
589
589
  that is a decision the project made. See `docs/decisions/0.8.md` § 43.
590
590
 
591
+ A chart of a quantity with a ceiling passes it: `valueMax={100}` on a percentage.
592
+ Without it the value axis ends at the largest datum, and a horizontal ranking —
593
+ whose value axis is hidden — draws a course watched to 40 % as a full bar. See
594
+ `docs/decisions/0.11.md` § 58.
595
+
591
596
  `check:exports` verifies that the five portable ones — `./tokens`, `./theme`,
592
597
  `./variants`, `./og` and `./shiki` — bring no React into the published `dist/`,
593
598
  **by following the relative imports**. Without that the check was worthless: with `treeshake`
@@ -603,7 +608,7 @@ chunk imports it.
603
608
  | `pnpm typecheck` | `tsc --noEmit` |
604
609
  | `pnpm lint` | ESLint, including the ban on literal hexes outside `tokens.ts` |
605
610
  | `pnpm check:tokens` | fails if `src/tokens/` imports anything from outside |
606
- | `pnpm test` | compiles Tailwind and runs axe over the 208 stories, in both modes |
611
+ | `pnpm test` | compiles Tailwind and runs axe over the 248 stories, in both modes |
607
612
  | `pnpm check:exports` | verifies that `dist/` holds what `exports` promises |
608
613
  | `pnpm check:release` | validates `release-please-config.json` against the official schema |
609
614
  | `pnpm storybook` | generates the tokens and serves Storybook on 6006 |
@@ -1047,6 +1052,12 @@ adding a line to the catalog.
1047
1052
  Pixel analysis confirms it — 94 % of `fin-foam.png` is `#EDF4F3`, which is the
1048
1053
  foam token.
1049
1054
 
1055
+ On a site that switches theme, `background="auto"` renders both and the `light:`
1056
+ variant shows the one that reads, so no call site has to know which theme is on.
1057
+ A background that does not follow the theme — a dark panel on a light page —
1058
+ still names itself: `auto` reads the page, not the panel. See
1059
+ `docs/decisions/0.11.md` § 60.
1060
+
1050
1061
  **Rule 5 as API.** The wordmark comes from `naming.wordmark` and always reads
1051
1062
  «Eduardo Álvarez». There is no prop that changes that text, and Tiburoncín never
1052
1063
  appears written inside the logo.
@@ -168,18 +168,24 @@ function Isotype({
168
168
  className,
169
169
  ...props
170
170
  }) {
171
- const file = background === "dark" ? fins.foam : fins.color;
172
- return /* @__PURE__ */ jsxRuntime.jsx(
171
+ const fin = (file, mode) => /* @__PURE__ */ jsxRuntime.jsx(
173
172
  "img",
174
173
  {
175
174
  src: `${basePath}/${file}`,
176
175
  alt,
177
176
  width: 147,
178
177
  height: 111,
179
- className: cn("h-8 w-auto select-none", className),
178
+ className: cn("h-8 w-auto select-none", className, mode),
180
179
  ...props
181
180
  }
182
181
  );
182
+ if (background === "auto") {
183
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
184
+ fin(fins.foam, "light:hidden"),
185
+ fin(fins.color, "not-light:hidden")
186
+ ] });
187
+ }
188
+ return fin(background === "dark" ? fins.foam : fins.color);
183
189
  }
184
190
  function Logo({
185
191
  background = "dark",
@@ -3,21 +3,60 @@ export { A as ASSETS_PATH, a as Fin, f as faceList, b as faceUsage, c as faces,
3
3
  import * as react from 'react';
4
4
  import { ComponentPropsWithoutRef } from 'react';
5
5
 
6
+ /**
7
+ * Which background the fin sits on, or `auto` to let the theme decide.
8
+ *
9
+ * It is its own type and not a third member of `Background`, because
10
+ * `Background` is also what the OG templates read, and Satori has no CSS to
11
+ * resolve `auto` with: a template knows its mode and has to say it.
12
+ */
13
+ type IsotypeBackground = Background | 'auto';
6
14
  type IsotypeProps = Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt'> & {
7
15
  /**
8
- * Which background it sits on. Deciding is mandatory even though it has a
9
- * default: the fin's body is nearly black, so the two-blue variant disappears
10
- * over abyss. Being a prop, the rule stops being something to remember.
16
+ * Which background it sits on, or `auto` on a site that switches theme: both
17
+ * fins are rendered and CSS shows the one that reads. Deciding is mandatory
18
+ * even though it has a default: the fin's body is nearly black, so the
19
+ * two-blue variant disappears over abyss.
20
+ *
21
+ * Use `dark` or `light` when the background does not follow the theme. A
22
+ * panel that stays dark in both modes is a fixed background, and `auto` would
23
+ * pick the page's fin instead of the panel's.
11
24
  */
12
- background?: Background | undefined;
25
+ background?: IsotypeBackground | undefined;
13
26
  basePath?: string | undefined;
14
27
  /** Alt text. Empty when the isotype accompanies text that already names it. */
15
28
  alt?: string | undefined;
16
29
  };
30
+ /**
31
+ * The fin, in the variant its background asks for.
32
+ *
33
+ * `auto` renders both and lets the `light:` variant choose, which is what
34
+ * `ThemeToggle` already does with its two icons and for the same reason: the
35
+ * server does not know the theme, and a fin picked in JavaScript would be wrong
36
+ * on the first paint half the time. `cursos` had written exactly this by hand
37
+ * after a surface changed mode and its fin vanished — not looked wrong: vanished,
38
+ * with no error and no gap in the layout. See `docs/decisions/0.11.md` § 60.
39
+ *
40
+ * Dark is the system default, so the foam fin is the one shown unless
41
+ * `data-theme="light"` says otherwise, and the two-blue one hides everywhere
42
+ * else. `not-light:hidden` rather than `hidden light:block`: the visible fin
43
+ * keeps whatever `display` the call site gave it.
44
+ *
45
+ * What it costs: the hidden image is still downloaded, 14 or 21 KB. And with
46
+ * `auto` the props land on BOTH `<img>` — an `id` or a `ref` included — so a
47
+ * call site that needs to reach the one image passes the background it is on.
48
+ * The `alt` does not double: an image with `display: none` is out of the
49
+ * accessibility tree.
50
+ */
17
51
  declare function Isotype({ background, basePath, alt, className, ...props }: IsotypeProps): react.JSX.Element;
18
52
 
19
53
  type LogoProps = Omit<ComponentPropsWithoutRef<'span'>, 'children'> & {
20
- background?: Background | undefined;
54
+ /**
55
+ * The background the logo sits on, handed to its fin. `auto` follows the
56
+ * theme — see `Isotype`. The wordmark needs no help: it is `textPrimary`, which
57
+ * already follows the mode.
58
+ */
59
+ background?: IsotypeBackground | undefined;
21
60
  basePath?: string | undefined;
22
61
  /** Hides the wordmark and leaves only the fin, for very narrow bars. */
23
62
  isotypeOnly?: boolean | undefined;
@@ -69,4 +108,4 @@ type MascotFaceProps = Base & {
69
108
  */
70
109
  declare function MascotFace({ expression, basePath, alt, className, ...props }: MascotFaceProps): react.JSX.Element;
71
110
 
72
- export { Background, Face, Isotype, type IsotypeProps, Logo, type LogoProps, Mascot, MascotFace, type MascotFaceProps, type MascotProps, Pose };
111
+ export { Background, Face, Isotype, type IsotypeBackground, type IsotypeProps, Logo, type LogoProps, Mascot, MascotFace, type MascotFaceProps, type MascotProps, Pose };
@@ -3,21 +3,60 @@ export { A as ASSETS_PATH, a as Fin, f as faceList, b as faceUsage, c as faces,
3
3
  import * as react from 'react';
4
4
  import { ComponentPropsWithoutRef } from 'react';
5
5
 
6
+ /**
7
+ * Which background the fin sits on, or `auto` to let the theme decide.
8
+ *
9
+ * It is its own type and not a third member of `Background`, because
10
+ * `Background` is also what the OG templates read, and Satori has no CSS to
11
+ * resolve `auto` with: a template knows its mode and has to say it.
12
+ */
13
+ type IsotypeBackground = Background | 'auto';
6
14
  type IsotypeProps = Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt'> & {
7
15
  /**
8
- * Which background it sits on. Deciding is mandatory even though it has a
9
- * default: the fin's body is nearly black, so the two-blue variant disappears
10
- * over abyss. Being a prop, the rule stops being something to remember.
16
+ * Which background it sits on, or `auto` on a site that switches theme: both
17
+ * fins are rendered and CSS shows the one that reads. Deciding is mandatory
18
+ * even though it has a default: the fin's body is nearly black, so the
19
+ * two-blue variant disappears over abyss.
20
+ *
21
+ * Use `dark` or `light` when the background does not follow the theme. A
22
+ * panel that stays dark in both modes is a fixed background, and `auto` would
23
+ * pick the page's fin instead of the panel's.
11
24
  */
12
- background?: Background | undefined;
25
+ background?: IsotypeBackground | undefined;
13
26
  basePath?: string | undefined;
14
27
  /** Alt text. Empty when the isotype accompanies text that already names it. */
15
28
  alt?: string | undefined;
16
29
  };
30
+ /**
31
+ * The fin, in the variant its background asks for.
32
+ *
33
+ * `auto` renders both and lets the `light:` variant choose, which is what
34
+ * `ThemeToggle` already does with its two icons and for the same reason: the
35
+ * server does not know the theme, and a fin picked in JavaScript would be wrong
36
+ * on the first paint half the time. `cursos` had written exactly this by hand
37
+ * after a surface changed mode and its fin vanished — not looked wrong: vanished,
38
+ * with no error and no gap in the layout. See `docs/decisions/0.11.md` § 60.
39
+ *
40
+ * Dark is the system default, so the foam fin is the one shown unless
41
+ * `data-theme="light"` says otherwise, and the two-blue one hides everywhere
42
+ * else. `not-light:hidden` rather than `hidden light:block`: the visible fin
43
+ * keeps whatever `display` the call site gave it.
44
+ *
45
+ * What it costs: the hidden image is still downloaded, 14 or 21 KB. And with
46
+ * `auto` the props land on BOTH `<img>` — an `id` or a `ref` included — so a
47
+ * call site that needs to reach the one image passes the background it is on.
48
+ * The `alt` does not double: an image with `display: none` is out of the
49
+ * accessibility tree.
50
+ */
17
51
  declare function Isotype({ background, basePath, alt, className, ...props }: IsotypeProps): react.JSX.Element;
18
52
 
19
53
  type LogoProps = Omit<ComponentPropsWithoutRef<'span'>, 'children'> & {
20
- background?: Background | undefined;
54
+ /**
55
+ * The background the logo sits on, handed to its fin. `auto` follows the
56
+ * theme — see `Isotype`. The wordmark needs no help: it is `textPrimary`, which
57
+ * already follows the mode.
58
+ */
59
+ background?: IsotypeBackground | undefined;
21
60
  basePath?: string | undefined;
22
61
  /** Hides the wordmark and leaves only the fin, for very narrow bars. */
23
62
  isotypeOnly?: boolean | undefined;
@@ -69,4 +108,4 @@ type MascotFaceProps = Base & {
69
108
  */
70
109
  declare function MascotFace({ expression, basePath, alt, className, ...props }: MascotFaceProps): react.JSX.Element;
71
110
 
72
- export { Background, Face, Isotype, type IsotypeProps, Logo, type LogoProps, Mascot, MascotFace, type MascotFaceProps, type MascotProps, Pose };
111
+ export { Background, Face, Isotype, type IsotypeBackground, type IsotypeProps, Logo, type LogoProps, Mascot, MascotFace, type MascotFaceProps, type MascotProps, Pose };
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- export { Isotype, Logo, Mascot, MascotFace } from '../chunk-IIT3YLYN.js';
2
+ export { Isotype, Logo, Mascot, MascotFace } from '../chunk-RKGKO2TW.js';
3
3
  import '../chunk-FAAGZG7A.js';
4
4
  export { ASSETS_PATH, faceList, faceUsage, faces, fins, poseList, poses } from '../chunk-CKRSQPTX.js';
5
5
  import '../chunk-FGFNK72B.js';
@@ -300,6 +300,7 @@ var color = (series2, index) => series2.color ?? seriesColor(index);
300
300
  var MARGIN = { left: 0, right: 12, top: 8, bottom: 0 };
301
301
  var AXIS = { tickLine: false, axisLine: false };
302
302
  var tickOf = (format) => format ? { tickFormatter: format } : {};
303
+ var domainOf = (max) => max === void 0 ? {} : { domain: [0, max] };
303
304
  var legendOf = (series2, legend) => legend ?? series2.length > 1 ? /* @__PURE__ */ jsxRuntime.jsx(ChartLegend, { content: /* @__PURE__ */ jsxRuntime.jsx(ChartLegendContent, {}) }, "legend") : null;
304
305
  var tooltipOf = (formatter) => /* @__PURE__ */ jsxRuntime.jsx(
305
306
  ChartTooltip,
@@ -316,6 +317,7 @@ function AreaChart({
316
317
  formatter,
317
318
  xTickFormatter,
318
319
  yTickFormatter,
320
+ valueMax,
319
321
  legend,
320
322
  stacked = false,
321
323
  ...container
@@ -328,7 +330,7 @@ function AreaChart({
328
330
  ] }, s.key)) }),
329
331
  /* @__PURE__ */ jsxRuntime.jsx(recharts.CartesianGrid, { vertical: false }),
330
332
  /* @__PURE__ */ jsxRuntime.jsx(recharts.XAxis, { dataKey: xKey, ...AXIS, tickMargin: 8, ...tickOf(xTickFormatter) }),
331
- /* @__PURE__ */ jsxRuntime.jsx(recharts.YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }),
333
+ /* @__PURE__ */ jsxRuntime.jsx(recharts.YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter), ...domainOf(valueMax) }),
332
334
  tooltipOf(formatter),
333
335
  legendOf(series2, legend),
334
336
  series2.map((s, i) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -354,6 +356,7 @@ function BarChart({
354
356
  formatter,
355
357
  xTickFormatter,
356
358
  yTickFormatter,
359
+ valueMax,
357
360
  legend,
358
361
  orientation = "vertical",
359
362
  stacked = false,
@@ -380,7 +383,16 @@ function BarChart({
380
383
  },
381
384
  "category"
382
385
  ),
383
- /* @__PURE__ */ jsxRuntime.jsx(recharts.XAxis, { type: "number", hide: true, ...tickOf(yTickFormatter) }, "value")
386
+ /* @__PURE__ */ jsxRuntime.jsx(
387
+ recharts.XAxis,
388
+ {
389
+ type: "number",
390
+ hide: true,
391
+ ...tickOf(yTickFormatter),
392
+ ...domainOf(valueMax)
393
+ },
394
+ "value"
395
+ )
384
396
  ] : [
385
397
  /* @__PURE__ */ jsxRuntime.jsx(
386
398
  recharts.XAxis,
@@ -392,7 +404,16 @@ function BarChart({
392
404
  },
393
405
  "category"
394
406
  ),
395
- /* @__PURE__ */ jsxRuntime.jsx(recharts.YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }, "value")
407
+ /* @__PURE__ */ jsxRuntime.jsx(
408
+ recharts.YAxis,
409
+ {
410
+ ...AXIS,
411
+ width: 40,
412
+ ...tickOf(yTickFormatter),
413
+ ...domainOf(valueMax)
414
+ },
415
+ "value"
416
+ )
396
417
  ],
397
418
  tooltipOf(formatter),
398
419
  legendOf(series2, legend),
@@ -419,13 +440,14 @@ function LineChart({
419
440
  formatter,
420
441
  xTickFormatter,
421
442
  yTickFormatter,
443
+ valueMax,
422
444
  legend,
423
445
  ...container
424
446
  }) {
425
447
  return /* @__PURE__ */ jsxRuntime.jsx(ChartContainer, { ...container, children: /* @__PURE__ */ jsxRuntime.jsxs(recharts.LineChart, { data: rows(data), margin: MARGIN, children: [
426
448
  /* @__PURE__ */ jsxRuntime.jsx(recharts.CartesianGrid, { vertical: false }),
427
449
  /* @__PURE__ */ jsxRuntime.jsx(recharts.XAxis, { dataKey: xKey, ...AXIS, tickMargin: 8, ...tickOf(xTickFormatter) }),
428
- /* @__PURE__ */ jsxRuntime.jsx(recharts.YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }),
450
+ /* @__PURE__ */ jsxRuntime.jsx(recharts.YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter), ...domainOf(valueMax) }),
429
451
  tooltipOf(formatter),
430
452
  legendOf(series2, legend),
431
453
  series2.map((s, i) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -187,6 +187,22 @@ type SeriesChartProps = Omit<ChartContainerProps, 'children'> & {
187
187
  * «$1200» in the tooltip, and the axis is the one you read while comparing.
188
188
  */
189
189
  yTickFormatter?: ((value: unknown) => string) | undefined;
190
+ /**
191
+ * The top of the value axis, when the scale has one that the data does not
192
+ * reach — 100 for a percentage.
193
+ *
194
+ * Without it the axis ends at the largest datum, and on a chart of «% visto»
195
+ * a course watched to 40 % draws as a full bar when it is the highest on the
196
+ * list. On `BarChart orientation="horizontal"` nothing gives that away: the
197
+ * value axis is hidden by design, so the chart says something false and
198
+ * looks exactly like one that does not.
199
+ *
200
+ * The bottom is always zero. A bar that does not start at zero is a
201
+ * different lie, and this prop is not a way into it. It is a floor for the
202
+ * top and not a clip either: a datum above it extends the axis instead of
203
+ * running off the edge. See `docs/decisions/0.11.md` § 58.
204
+ */
205
+ valueMax?: number | undefined;
190
206
  /**
191
207
  * Shows the legend. It defaults to «only when there is more than one series»:
192
208
  * a legend naming the one line already named by the chart's own heading is a
@@ -217,7 +233,7 @@ type AreaChartProps = SeriesChartProps & {
217
233
  /** Adds the series up instead of overlaying them. */
218
234
  stacked?: boolean | undefined;
219
235
  };
220
- declare function AreaChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, stacked, ...container }: AreaChartProps): react.JSX.Element;
236
+ declare function AreaChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, stacked, ...container }: AreaChartProps): react.JSX.Element;
221
237
  /**
222
238
  * Bars, upright or lying down.
223
239
  *
@@ -241,7 +257,7 @@ type BarChartProps = SeriesChartProps & {
241
257
  /** Stacks the series instead of putting them side by side. */
242
258
  stacked?: boolean | undefined;
243
259
  };
244
- declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, orientation, stacked, ...container }: BarChartProps): react.JSX.Element;
260
+ declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, orientation, stacked, ...container }: BarChartProps): react.JSX.Element;
245
261
  /**
246
262
  * Lines, for comparing series against each other.
247
263
  *
@@ -254,6 +270,6 @@ declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTick
254
270
  * where it means «this one».
255
271
  */
256
272
  type LineChartProps = SeriesChartProps;
257
- declare function LineChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, ...container }: LineChartProps): react.JSX.Element;
273
+ declare function LineChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, ...container }: LineChartProps): react.JSX.Element;
258
274
 
259
275
  export { AreaChart, type AreaChartProps, BarChart, type BarChartProps, ChartContainer, type ChartContainerProps, type ChartDatum, ChartLegend, ChartLegendContent, type ChartLegendContentProps, type ChartPayloadItem, type ChartSeries, ChartTooltip, ChartTooltipContent, type ChartTooltipContentProps, LineChart, type LineChartProps, SERIES_COLORS, type SeriesChartProps, seriesColor };
@@ -187,6 +187,22 @@ type SeriesChartProps = Omit<ChartContainerProps, 'children'> & {
187
187
  * «$1200» in the tooltip, and the axis is the one you read while comparing.
188
188
  */
189
189
  yTickFormatter?: ((value: unknown) => string) | undefined;
190
+ /**
191
+ * The top of the value axis, when the scale has one that the data does not
192
+ * reach — 100 for a percentage.
193
+ *
194
+ * Without it the axis ends at the largest datum, and on a chart of «% visto»
195
+ * a course watched to 40 % draws as a full bar when it is the highest on the
196
+ * list. On `BarChart orientation="horizontal"` nothing gives that away: the
197
+ * value axis is hidden by design, so the chart says something false and
198
+ * looks exactly like one that does not.
199
+ *
200
+ * The bottom is always zero. A bar that does not start at zero is a
201
+ * different lie, and this prop is not a way into it. It is a floor for the
202
+ * top and not a clip either: a datum above it extends the axis instead of
203
+ * running off the edge. See `docs/decisions/0.11.md` § 58.
204
+ */
205
+ valueMax?: number | undefined;
190
206
  /**
191
207
  * Shows the legend. It defaults to «only when there is more than one series»:
192
208
  * a legend naming the one line already named by the chart's own heading is a
@@ -217,7 +233,7 @@ type AreaChartProps = SeriesChartProps & {
217
233
  /** Adds the series up instead of overlaying them. */
218
234
  stacked?: boolean | undefined;
219
235
  };
220
- declare function AreaChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, stacked, ...container }: AreaChartProps): react.JSX.Element;
236
+ declare function AreaChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, stacked, ...container }: AreaChartProps): react.JSX.Element;
221
237
  /**
222
238
  * Bars, upright or lying down.
223
239
  *
@@ -241,7 +257,7 @@ type BarChartProps = SeriesChartProps & {
241
257
  /** Stacks the series instead of putting them side by side. */
242
258
  stacked?: boolean | undefined;
243
259
  };
244
- declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, orientation, stacked, ...container }: BarChartProps): react.JSX.Element;
260
+ declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, orientation, stacked, ...container }: BarChartProps): react.JSX.Element;
245
261
  /**
246
262
  * Lines, for comparing series against each other.
247
263
  *
@@ -254,6 +270,6 @@ declare function BarChart({ data, series, xKey, formatter, xTickFormatter, yTick
254
270
  * where it means «this one».
255
271
  */
256
272
  type LineChartProps = SeriesChartProps;
257
- declare function LineChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, legend, ...container }: LineChartProps): react.JSX.Element;
273
+ declare function LineChart({ data, series, xKey, formatter, xTickFormatter, yTickFormatter, valueMax, legend, ...container }: LineChartProps): react.JSX.Element;
258
274
 
259
275
  export { AreaChart, type AreaChartProps, BarChart, type BarChartProps, ChartContainer, type ChartContainerProps, type ChartDatum, ChartLegend, ChartLegendContent, type ChartLegendContentProps, type ChartPayloadItem, type ChartSeries, ChartTooltip, ChartTooltipContent, type ChartTooltipContentProps, LineChart, type LineChartProps, SERIES_COLORS, type SeriesChartProps, seriesColor };
@@ -106,6 +106,7 @@ var color = (series, index) => series.color ?? seriesColor(index);
106
106
  var MARGIN = { left: 0, right: 12, top: 8, bottom: 0 };
107
107
  var AXIS = { tickLine: false, axisLine: false };
108
108
  var tickOf = (format) => format ? { tickFormatter: format } : {};
109
+ var domainOf = (max) => max === void 0 ? {} : { domain: [0, max] };
109
110
  var legendOf = (series, legend) => legend ?? series.length > 1 ? /* @__PURE__ */ jsx(ChartLegend, { content: /* @__PURE__ */ jsx(ChartLegendContent, {}) }, "legend") : null;
110
111
  var tooltipOf = (formatter) => /* @__PURE__ */ jsx(
111
112
  ChartTooltip,
@@ -122,6 +123,7 @@ function AreaChart({
122
123
  formatter,
123
124
  xTickFormatter,
124
125
  yTickFormatter,
126
+ valueMax,
125
127
  legend,
126
128
  stacked = false,
127
129
  ...container
@@ -134,7 +136,7 @@ function AreaChart({
134
136
  ] }, s.key)) }),
135
137
  /* @__PURE__ */ jsx(CartesianGrid, { vertical: false }),
136
138
  /* @__PURE__ */ jsx(XAxis, { dataKey: xKey, ...AXIS, tickMargin: 8, ...tickOf(xTickFormatter) }),
137
- /* @__PURE__ */ jsx(YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }),
139
+ /* @__PURE__ */ jsx(YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter), ...domainOf(valueMax) }),
138
140
  tooltipOf(formatter),
139
141
  legendOf(series, legend),
140
142
  series.map((s, i) => /* @__PURE__ */ jsx(
@@ -160,6 +162,7 @@ function BarChart({
160
162
  formatter,
161
163
  xTickFormatter,
162
164
  yTickFormatter,
165
+ valueMax,
163
166
  legend,
164
167
  orientation = "vertical",
165
168
  stacked = false,
@@ -186,7 +189,16 @@ function BarChart({
186
189
  },
187
190
  "category"
188
191
  ),
189
- /* @__PURE__ */ jsx(XAxis, { type: "number", hide: true, ...tickOf(yTickFormatter) }, "value")
192
+ /* @__PURE__ */ jsx(
193
+ XAxis,
194
+ {
195
+ type: "number",
196
+ hide: true,
197
+ ...tickOf(yTickFormatter),
198
+ ...domainOf(valueMax)
199
+ },
200
+ "value"
201
+ )
190
202
  ] : [
191
203
  /* @__PURE__ */ jsx(
192
204
  XAxis,
@@ -198,7 +210,16 @@ function BarChart({
198
210
  },
199
211
  "category"
200
212
  ),
201
- /* @__PURE__ */ jsx(YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }, "value")
213
+ /* @__PURE__ */ jsx(
214
+ YAxis,
215
+ {
216
+ ...AXIS,
217
+ width: 40,
218
+ ...tickOf(yTickFormatter),
219
+ ...domainOf(valueMax)
220
+ },
221
+ "value"
222
+ )
202
223
  ],
203
224
  tooltipOf(formatter),
204
225
  legendOf(series, legend),
@@ -225,13 +246,14 @@ function LineChart({
225
246
  formatter,
226
247
  xTickFormatter,
227
248
  yTickFormatter,
249
+ valueMax,
228
250
  legend,
229
251
  ...container
230
252
  }) {
231
253
  return /* @__PURE__ */ jsx(ChartContainer, { ...container, children: /* @__PURE__ */ jsxs(LineChart$1, { data: rows(data), margin: MARGIN, children: [
232
254
  /* @__PURE__ */ jsx(CartesianGrid, { vertical: false }),
233
255
  /* @__PURE__ */ jsx(XAxis, { dataKey: xKey, ...AXIS, tickMargin: 8, ...tickOf(xTickFormatter) }),
234
- /* @__PURE__ */ jsx(YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter) }),
256
+ /* @__PURE__ */ jsx(YAxis, { ...AXIS, width: 40, ...tickOf(yTickFormatter), ...domainOf(valueMax) }),
235
257
  tooltipOf(formatter),
236
258
  legendOf(series, legend),
237
259
  series.map((s, i) => /* @__PURE__ */ jsx(
@@ -1,7 +1,7 @@
1
1
  import { cn } from './chunk-FAAGZG7A.js';
2
2
  import { fins, ASSETS_PATH, poses, faces } from './chunk-CKRSQPTX.js';
3
3
  import { naming, tagline } from './chunk-FGFNK72B.js';
4
- import { jsx, jsxs } from 'react/jsx-runtime';
4
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
5
5
 
6
6
  function Isotype({
7
7
  background = "dark",
@@ -10,18 +10,24 @@ function Isotype({
10
10
  className,
11
11
  ...props
12
12
  }) {
13
- const file = background === "dark" ? fins.foam : fins.color;
14
- return /* @__PURE__ */ jsx(
13
+ const fin = (file, mode) => /* @__PURE__ */ jsx(
15
14
  "img",
16
15
  {
17
16
  src: `${basePath}/${file}`,
18
17
  alt,
19
18
  width: 147,
20
19
  height: 111,
21
- className: cn("h-8 w-auto select-none", className),
20
+ className: cn("h-8 w-auto select-none", className, mode),
22
21
  ...props
23
22
  }
24
23
  );
24
+ if (background === "auto") {
25
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
26
+ fin(fins.foam, "light:hidden"),
27
+ fin(fins.color, "not-light:hidden")
28
+ ] });
29
+ }
30
+ return fin(background === "dark" ? fins.foam : fins.color);
25
31
  }
26
32
  function Logo({
27
33
  background = "dark",
package/dist/index.cjs CHANGED
@@ -2874,25 +2874,62 @@ function CourseCard({
2874
2874
  meta,
2875
2875
  status,
2876
2876
  progress,
2877
+ media,
2878
+ footer,
2877
2879
  className,
2878
2880
  ...props
2879
2881
  }) {
2880
- return /* @__PURE__ */ jsxRuntime.jsx(CardShell, { className: cn("p-step-lg", className), ...props, children: /* @__PURE__ */ jsxRuntime.jsxs("article", { className: "gap-step-sm flex h-full flex-col", children: [
2881
- status ? /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(Badge, { variant: "warm", children: status }) }) : null,
2882
- /* @__PURE__ */ jsxRuntime.jsx(Text, { as: "h3", variant: "h3", className: "group-hover:text-accent transition-standard", children: title }),
2883
- summary ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "ui", tone: "secondary", className: "line-clamp-3", children: summary }) : null,
2884
- meta && meta.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "label", tone: "muted", as: "p", className: "gap-step-xs flex flex-wrap items-center", children: meta.map((m, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "gap-step-xs flex items-center", children: [
2885
- i > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: "\xB7" }) : null,
2886
- m
2887
- ] }, i)) }) : null,
2888
- typeof progress === "number" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "gap-step-xs mt-auto flex flex-col pt-step-sm", children: [
2889
- /* @__PURE__ */ jsxRuntime.jsx(Progress, { value: progress, tone: "warm", label: `Progreso del curso: ${progress}%` }),
2890
- /* @__PURE__ */ jsxRuntime.jsxs(Text, { variant: "label", tone: "muted", as: "p", children: [
2891
- progress,
2892
- "% cursado"
2893
- ] })
2894
- ] }) : null
2895
- ] }) });
2882
+ const hasProgress = typeof progress === "number";
2883
+ return (
2884
+ // `overflow-hidden` only with a cover: it is what rounds the cover's top
2885
+ // corners to the card's. It clips the content and not the `<a>` itself, so
2886
+ // the focus ring — an outline three pixels outside — still shows.
2887
+ /* @__PURE__ */ jsxRuntime.jsx(
2888
+ CardShell,
2889
+ {
2890
+ className: cn("p-step-lg", media ? "overflow-hidden" : null, className),
2891
+ ...props,
2892
+ children: /* @__PURE__ */ jsxRuntime.jsxs("article", { className: "gap-step-sm flex h-full flex-col", children: [
2893
+ media ? (
2894
+ // The bleed is the card's own padding, negated. A call site that
2895
+ // changes the padding through `className` has to change this too, and
2896
+ // that is the trade for keeping the padding on the shell like every
2897
+ // other card instead of in a second place only when there is a cover.
2898
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "-mx-step-lg -mt-step-lg mb-step-xs", children: media })
2899
+ ) : null,
2900
+ status ? /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(Badge, { variant: "warm", children: status }) }) : null,
2901
+ /* @__PURE__ */ jsxRuntime.jsx(Text, { as: "h3", variant: "h3", className: "group-hover:text-accent transition-standard", children: title }),
2902
+ summary ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "ui", tone: "secondary", className: "line-clamp-3", children: summary }) : null,
2903
+ meta && meta.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "label", tone: "muted", as: "p", className: "gap-step-xs flex flex-wrap items-center", children: meta.map((m, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "gap-step-xs flex items-center", children: [
2904
+ i > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: "\xB7" }) : null,
2905
+ m
2906
+ ] }, i)) }) : null,
2907
+ hasProgress ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "gap-step-xs mt-auto flex flex-col pt-step-sm", children: [
2908
+ /* @__PURE__ */ jsxRuntime.jsx(Progress, { value: progress, tone: "warm", label: `Progreso del curso: ${progress}%` }),
2909
+ /* @__PURE__ */ jsxRuntime.jsxs(Text, { variant: "label", tone: "muted", as: "p", children: [
2910
+ progress,
2911
+ "% cursado"
2912
+ ] })
2913
+ ] }) : null,
2914
+ footer ? (
2915
+ // Only one of the two takes `mt-auto`. Two auto margins in one column
2916
+ // split the free space between them, and the progress bar would float
2917
+ // halfway down the card.
2918
+ /* @__PURE__ */ jsxRuntime.jsx(
2919
+ "div",
2920
+ {
2921
+ className: cn(
2922
+ "gap-step-sm pt-step-sm flex flex-wrap items-center justify-between",
2923
+ hasProgress ? null : "mt-auto"
2924
+ ),
2925
+ children: footer
2926
+ }
2927
+ )
2928
+ ) : null
2929
+ ] })
2930
+ }
2931
+ )
2932
+ );
2896
2933
  }
2897
2934
 
2898
2935
  // src/brand/catalog.ts
@@ -3643,12 +3680,21 @@ function PageHeader({
3643
3680
  action,
3644
3681
  size: size2,
3645
3682
  as = "h1",
3683
+ titleVariant,
3646
3684
  className,
3647
3685
  ...props
3648
3686
  }) {
3649
3687
  return /* @__PURE__ */ jsxRuntime.jsxs("header", { className: cn(header({ size: size2 }), className), ...props, children: [
3650
3688
  eyebrow ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "eyebrow", tone: "accent", as: "p", children: eyebrow }) : null,
3651
- /* @__PURE__ */ jsxRuntime.jsx(Text, { as, variant: size2 === "display" ? "display" : "h1", className: "max-w-measure", children: title }),
3689
+ /* @__PURE__ */ jsxRuntime.jsx(
3690
+ Text,
3691
+ {
3692
+ as,
3693
+ variant: titleVariant ?? (size2 === "display" ? "display" : "h1"),
3694
+ className: "max-w-measure",
3695
+ children: title
3696
+ }
3697
+ ),
3652
3698
  description ? /* @__PURE__ */ jsxRuntime.jsx(Text, { variant: "body", tone: "secondary", children: description }) : null,
3653
3699
  action ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "gap-step-sm mt-step-sm flex flex-wrap items-center", children: action }) : null
3654
3700
  ] });
@@ -3895,18 +3941,24 @@ function Isotype({
3895
3941
  className,
3896
3942
  ...props
3897
3943
  }) {
3898
- const file = background === "dark" ? fins.foam : fins.color;
3899
- return /* @__PURE__ */ jsxRuntime.jsx(
3944
+ const fin = (file, mode) => /* @__PURE__ */ jsxRuntime.jsx(
3900
3945
  "img",
3901
3946
  {
3902
3947
  src: `${basePath}/${file}`,
3903
3948
  alt,
3904
3949
  width: 147,
3905
3950
  height: 111,
3906
- className: cn("h-8 w-auto select-none", className),
3951
+ className: cn("h-8 w-auto select-none", className, mode),
3907
3952
  ...props
3908
3953
  }
3909
3954
  );
3955
+ if (background === "auto") {
3956
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3957
+ fin(fins.foam, "light:hidden"),
3958
+ fin(fins.color, "not-light:hidden")
3959
+ ] });
3960
+ }
3961
+ return fin(background === "dark" ? fins.foam : fins.color);
3910
3962
  }
3911
3963
  function Logo({
3912
3964
  background = "dark",
package/dist/index.d.cts CHANGED
@@ -26,7 +26,7 @@ import * as ToastPrimitive from '@radix-ui/react-toast';
26
26
  import * as TooltipPrimitive from '@radix-ui/react-tooltip';
27
27
  import { F as Face, P as Pose } from './catalog-D13txprv.cjs';
28
28
  export { A as ASSETS_PATH, B as Background, a as Fin, f as faceList, b as faceUsage, c as faces, d as fins, p as poseList, e as poses } from './catalog-D13txprv.cjs';
29
- export { Isotype, IsotypeProps, Logo, LogoProps, Mascot, MascotFace, MascotFaceProps, MascotProps } from './brand/index.cjs';
29
+ export { Isotype, IsotypeBackground, IsotypeProps, Logo, LogoProps, Mascot, MascotFace, MascotFaceProps, MascotProps } from './brand/index.cjs';
30
30
  import { ClassValue } from 'clsx';
31
31
  import '@radix-ui/react-label';
32
32
 
@@ -869,7 +869,13 @@ type CodeBlockProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'> & {
869
869
  };
870
870
  declare function CodeBlock({ children, language, copyText, className, ...props }: CodeBlockProps): react.JSX.Element;
871
871
 
872
- type CourseCardProps = Omit<CardShellProps, 'children' | 'title'> & {
872
+ /**
873
+ * `media` is left out of the shell's props because `<a>` already has an
874
+ * attribute by that name — a media query for the linked resource, a string —
875
+ * and intersected with the slot it would make the slot accept only strings.
876
+ * No browser acts on it and no project was passing it.
877
+ */
878
+ type CourseCardProps = Omit<CardShellProps, 'children' | 'title' | 'media'> & {
873
879
  title: ReactNode;
874
880
  summary?: ReactNode;
875
881
  /** Level, duration, number of lessons: whatever the project wants to list. */
@@ -881,8 +887,42 @@ type CourseCardProps = Omit<CardShellProps, 'children' | 'title'> & {
881
887
  * when passed, the bar goes in sand, which is the color of course progress.
882
888
  */
883
889
  progress?: number | undefined;
890
+ /**
891
+ * The cover, at the top and bleeding to the card's edges, with `alt=""`: the
892
+ * whole card is one link and `title` already names it.
893
+ *
894
+ * The project owns the element — an `<img>`, a framework's `Image`, a
895
+ * generated cover — and its ratio; the card clips it to its own corners. An
896
+ * `alt` that repeats the title makes a screen reader say the course twice
897
+ * before anything else.
898
+ */
899
+ media?: ReactNode;
900
+ /**
901
+ * The closing row, under `meta`: the rating, the price, whatever the project
902
+ * sells the course with. It sits at the bottom of the card, so the rows of a
903
+ * grid line up whatever the length of each summary.
904
+ */
905
+ footer?: ReactNode;
884
906
  };
885
- declare function CourseCard({ title, summary, meta, status, progress, className, ...props }: CourseCardProps): react.JSX.Element;
907
+ /**
908
+ * The course, as a card that links to it.
909
+ *
910
+ * `media` and `footer` are slots rather than props for a cover URL, a rating
911
+ * and a price, because none of those three is the identity's. The price comes
912
+ * formatted in a currency the library does not know, the rating is drawn by a
913
+ * component the project already has, and the cover is an image pipeline. What
914
+ * IS the identity's stays here: the title's scale and its hover, the sand bar,
915
+ * the status badge. See `docs/decisions/0.11.md` § 59.
916
+ *
917
+ * The title does NOT go over the cover. It stays in the body at `h3`, with the
918
+ * hover every other card has. Text on a photograph needs a scrim, and a scrim's
919
+ * contrast depends on the photograph: it cannot be measured once and recorded,
920
+ * which is the only way contrast is decided in this system.
921
+ *
922
+ * And the cover does not move on hover. Rule 6 is the border and nothing else:
923
+ * no zoom, no scale, no displacement.
924
+ */
925
+ declare function CourseCard({ title, summary, meta, status, progress, media, footer, className, ...props }: CourseCardProps): react.JSX.Element;
886
926
 
887
927
  /**
888
928
  * The mascot's most important rule, finally as code.
@@ -1392,6 +1432,15 @@ declare function NewsletterForm({ title, description, state, onSubmitEmail, succ
1392
1432
  *
1393
1433
  * `display` for covers, `page` for section headers.
1394
1434
  *
1435
+ * The size picks the title's scale by default, and `titleVariant` lets the
1436
+ * screen pick another one. The default is the document's — «h1 44/700» on the
1437
+ * six interior pages of the reading site — and it is right there. It is not
1438
+ * right in the two admin apps: `blog-content-manager` titles its twelve screens
1439
+ * at 24px, and `cursos` titles 29 of its 32 at `h3` — every one in the panel —
1440
+ * and the other three, the public catalog pages, at `h2`. Both are rungs the
1441
+ * scale already has, and a third `size` could only have named one of them. See
1442
+ * `docs/decisions/0.11.md` § 57.
1443
+ *
1395
1444
  * It takes no mascot face, at either scale: faces go in empty states,
1396
1445
  * confirmations, errors, course progress and celebration.
1397
1446
  *
@@ -1416,8 +1465,21 @@ type PageHeaderProps = Omit<ComponentPropsWithoutRef<'header'>, 'title'> & Varia
1416
1465
  action?: ReactNode | undefined;
1417
1466
  /** The headline's level. `h1` unless the page already has one. */
1418
1467
  as?: 'h1' | 'h2' | undefined;
1468
+ /**
1469
+ * The headline's scale, when the screen needs a different one from what
1470
+ * `size` gives — `display` for `display`, `h1` for `page`.
1471
+ *
1472
+ * It is the same split `Text` makes: `as` is the level, this is how big it
1473
+ * looks. A panel's `<h1>` at `h3` is still the page's only `h1`. The padding
1474
+ * stays with `size`, so a header inside a layout that already spaces its
1475
+ * content passes `className="py-0"`.
1476
+ *
1477
+ * Only the four headline scales, all in the display family. `stat` is for
1478
+ * numbers and `body` is not a headline.
1479
+ */
1480
+ titleVariant?: 'display' | 'h1' | 'h2' | 'h3' | undefined;
1419
1481
  };
1420
- declare function PageHeader({ title, eyebrow, description, action, size, as, className, ...props }: PageHeaderProps): react.JSX.Element;
1482
+ declare function PageHeader({ title, eyebrow, description, action, size, as, titleVariant, className, ...props }: PageHeaderProps): react.JSX.Element;
1421
1483
 
1422
1484
  /**
1423
1485
  * How much you have read. It is NOT `Progress` under another name.
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ import * as ToastPrimitive from '@radix-ui/react-toast';
26
26
  import * as TooltipPrimitive from '@radix-ui/react-tooltip';
27
27
  import { F as Face, P as Pose } from './catalog-D13txprv.js';
28
28
  export { A as ASSETS_PATH, B as Background, a as Fin, f as faceList, b as faceUsage, c as faces, d as fins, p as poseList, e as poses } from './catalog-D13txprv.js';
29
- export { Isotype, IsotypeProps, Logo, LogoProps, Mascot, MascotFace, MascotFaceProps, MascotProps } from './brand/index.js';
29
+ export { Isotype, IsotypeBackground, IsotypeProps, Logo, LogoProps, Mascot, MascotFace, MascotFaceProps, MascotProps } from './brand/index.js';
30
30
  import { ClassValue } from 'clsx';
31
31
  import '@radix-ui/react-label';
32
32
 
@@ -869,7 +869,13 @@ type CodeBlockProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'> & {
869
869
  };
870
870
  declare function CodeBlock({ children, language, copyText, className, ...props }: CodeBlockProps): react.JSX.Element;
871
871
 
872
- type CourseCardProps = Omit<CardShellProps, 'children' | 'title'> & {
872
+ /**
873
+ * `media` is left out of the shell's props because `<a>` already has an
874
+ * attribute by that name — a media query for the linked resource, a string —
875
+ * and intersected with the slot it would make the slot accept only strings.
876
+ * No browser acts on it and no project was passing it.
877
+ */
878
+ type CourseCardProps = Omit<CardShellProps, 'children' | 'title' | 'media'> & {
873
879
  title: ReactNode;
874
880
  summary?: ReactNode;
875
881
  /** Level, duration, number of lessons: whatever the project wants to list. */
@@ -881,8 +887,42 @@ type CourseCardProps = Omit<CardShellProps, 'children' | 'title'> & {
881
887
  * when passed, the bar goes in sand, which is the color of course progress.
882
888
  */
883
889
  progress?: number | undefined;
890
+ /**
891
+ * The cover, at the top and bleeding to the card's edges, with `alt=""`: the
892
+ * whole card is one link and `title` already names it.
893
+ *
894
+ * The project owns the element — an `<img>`, a framework's `Image`, a
895
+ * generated cover — and its ratio; the card clips it to its own corners. An
896
+ * `alt` that repeats the title makes a screen reader say the course twice
897
+ * before anything else.
898
+ */
899
+ media?: ReactNode;
900
+ /**
901
+ * The closing row, under `meta`: the rating, the price, whatever the project
902
+ * sells the course with. It sits at the bottom of the card, so the rows of a
903
+ * grid line up whatever the length of each summary.
904
+ */
905
+ footer?: ReactNode;
884
906
  };
885
- declare function CourseCard({ title, summary, meta, status, progress, className, ...props }: CourseCardProps): react.JSX.Element;
907
+ /**
908
+ * The course, as a card that links to it.
909
+ *
910
+ * `media` and `footer` are slots rather than props for a cover URL, a rating
911
+ * and a price, because none of those three is the identity's. The price comes
912
+ * formatted in a currency the library does not know, the rating is drawn by a
913
+ * component the project already has, and the cover is an image pipeline. What
914
+ * IS the identity's stays here: the title's scale and its hover, the sand bar,
915
+ * the status badge. See `docs/decisions/0.11.md` § 59.
916
+ *
917
+ * The title does NOT go over the cover. It stays in the body at `h3`, with the
918
+ * hover every other card has. Text on a photograph needs a scrim, and a scrim's
919
+ * contrast depends on the photograph: it cannot be measured once and recorded,
920
+ * which is the only way contrast is decided in this system.
921
+ *
922
+ * And the cover does not move on hover. Rule 6 is the border and nothing else:
923
+ * no zoom, no scale, no displacement.
924
+ */
925
+ declare function CourseCard({ title, summary, meta, status, progress, media, footer, className, ...props }: CourseCardProps): react.JSX.Element;
886
926
 
887
927
  /**
888
928
  * The mascot's most important rule, finally as code.
@@ -1392,6 +1432,15 @@ declare function NewsletterForm({ title, description, state, onSubmitEmail, succ
1392
1432
  *
1393
1433
  * `display` for covers, `page` for section headers.
1394
1434
  *
1435
+ * The size picks the title's scale by default, and `titleVariant` lets the
1436
+ * screen pick another one. The default is the document's — «h1 44/700» on the
1437
+ * six interior pages of the reading site — and it is right there. It is not
1438
+ * right in the two admin apps: `blog-content-manager` titles its twelve screens
1439
+ * at 24px, and `cursos` titles 29 of its 32 at `h3` — every one in the panel —
1440
+ * and the other three, the public catalog pages, at `h2`. Both are rungs the
1441
+ * scale already has, and a third `size` could only have named one of them. See
1442
+ * `docs/decisions/0.11.md` § 57.
1443
+ *
1395
1444
  * It takes no mascot face, at either scale: faces go in empty states,
1396
1445
  * confirmations, errors, course progress and celebration.
1397
1446
  *
@@ -1416,8 +1465,21 @@ type PageHeaderProps = Omit<ComponentPropsWithoutRef<'header'>, 'title'> & Varia
1416
1465
  action?: ReactNode | undefined;
1417
1466
  /** The headline's level. `h1` unless the page already has one. */
1418
1467
  as?: 'h1' | 'h2' | undefined;
1468
+ /**
1469
+ * The headline's scale, when the screen needs a different one from what
1470
+ * `size` gives — `display` for `display`, `h1` for `page`.
1471
+ *
1472
+ * It is the same split `Text` makes: `as` is the level, this is how big it
1473
+ * looks. A panel's `<h1>` at `h3` is still the page's only `h1`. The padding
1474
+ * stays with `size`, so a header inside a layout that already spaces its
1475
+ * content passes `className="py-0"`.
1476
+ *
1477
+ * Only the four headline scales, all in the display family. `stat` is for
1478
+ * numbers and `body` is not a headline.
1479
+ */
1480
+ titleVariant?: 'display' | 'h1' | 'h2' | 'h3' | undefined;
1419
1481
  };
1420
- declare function PageHeader({ title, eyebrow, description, action, size, as, className, ...props }: PageHeaderProps): react.JSX.Element;
1482
+ declare function PageHeader({ title, eyebrow, description, action, size, as, titleVariant, className, ...props }: PageHeaderProps): react.JSX.Element;
1421
1483
 
1422
1484
  /**
1423
1485
  * How much you have read. It is NOT `Progress` under another name.
package/dist/index.js CHANGED
@@ -9,8 +9,8 @@ export { THEME_ATTRIBUTE, THEME_EVENT, THEME_KEY, applyTheme, currentTheme, pref
9
9
  import { alert, button, avatar, badge, category, metric, CARD_SURFACE, CARD } from './chunk-XXDATT3A.js';
10
10
  export { CARD, CARD_HOVER, CARD_SURFACE, alert as alertVariants, avatar as avatarVariants, badge as badgeVariants, button as buttonVariants, category as categoryBadgeVariants, metric as metricBadgeVariants } from './chunk-XXDATT3A.js';
11
11
  export { text as textVariants } from './chunk-ODBFN44D.js';
12
- import { MascotFace, Mascot } from './chunk-IIT3YLYN.js';
13
- export { Isotype, Logo, Mascot, MascotFace } from './chunk-IIT3YLYN.js';
12
+ import { MascotFace, Mascot } from './chunk-RKGKO2TW.js';
13
+ export { Isotype, Logo, Mascot, MascotFace } from './chunk-RKGKO2TW.js';
14
14
  import { Icon } from './chunk-ZSCSKCTY.js';
15
15
  import { cn } from './chunk-FAAGZG7A.js';
16
16
  export { cn } from './chunk-FAAGZG7A.js';
@@ -2181,25 +2181,62 @@ function CourseCard({
2181
2181
  meta,
2182
2182
  status,
2183
2183
  progress,
2184
+ media,
2185
+ footer,
2184
2186
  className,
2185
2187
  ...props
2186
2188
  }) {
2187
- return /* @__PURE__ */ jsx(CardShell, { className: cn("p-step-lg", className), ...props, children: /* @__PURE__ */ jsxs("article", { className: "gap-step-sm flex h-full flex-col", children: [
2188
- status ? /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Badge, { variant: "warm", children: status }) }) : null,
2189
- /* @__PURE__ */ jsx(Text, { as: "h3", variant: "h3", className: "group-hover:text-accent transition-standard", children: title }),
2190
- summary ? /* @__PURE__ */ jsx(Text, { variant: "ui", tone: "secondary", className: "line-clamp-3", children: summary }) : null,
2191
- meta && meta.length > 0 ? /* @__PURE__ */ jsx(Text, { variant: "label", tone: "muted", as: "p", className: "gap-step-xs flex flex-wrap items-center", children: meta.map((m, i) => /* @__PURE__ */ jsxs("span", { className: "gap-step-xs flex items-center", children: [
2192
- i > 0 ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }) : null,
2193
- m
2194
- ] }, i)) }) : null,
2195
- typeof progress === "number" ? /* @__PURE__ */ jsxs("div", { className: "gap-step-xs mt-auto flex flex-col pt-step-sm", children: [
2196
- /* @__PURE__ */ jsx(Progress, { value: progress, tone: "warm", label: `Progreso del curso: ${progress}%` }),
2197
- /* @__PURE__ */ jsxs(Text, { variant: "label", tone: "muted", as: "p", children: [
2198
- progress,
2199
- "% cursado"
2200
- ] })
2201
- ] }) : null
2202
- ] }) });
2189
+ const hasProgress = typeof progress === "number";
2190
+ return (
2191
+ // `overflow-hidden` only with a cover: it is what rounds the cover's top
2192
+ // corners to the card's. It clips the content and not the `<a>` itself, so
2193
+ // the focus ring — an outline three pixels outside — still shows.
2194
+ /* @__PURE__ */ jsx(
2195
+ CardShell,
2196
+ {
2197
+ className: cn("p-step-lg", media ? "overflow-hidden" : null, className),
2198
+ ...props,
2199
+ children: /* @__PURE__ */ jsxs("article", { className: "gap-step-sm flex h-full flex-col", children: [
2200
+ media ? (
2201
+ // The bleed is the card's own padding, negated. A call site that
2202
+ // changes the padding through `className` has to change this too, and
2203
+ // that is the trade for keeping the padding on the shell like every
2204
+ // other card instead of in a second place only when there is a cover.
2205
+ /* @__PURE__ */ jsx("div", { className: "-mx-step-lg -mt-step-lg mb-step-xs", children: media })
2206
+ ) : null,
2207
+ status ? /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Badge, { variant: "warm", children: status }) }) : null,
2208
+ /* @__PURE__ */ jsx(Text, { as: "h3", variant: "h3", className: "group-hover:text-accent transition-standard", children: title }),
2209
+ summary ? /* @__PURE__ */ jsx(Text, { variant: "ui", tone: "secondary", className: "line-clamp-3", children: summary }) : null,
2210
+ meta && meta.length > 0 ? /* @__PURE__ */ jsx(Text, { variant: "label", tone: "muted", as: "p", className: "gap-step-xs flex flex-wrap items-center", children: meta.map((m, i) => /* @__PURE__ */ jsxs("span", { className: "gap-step-xs flex items-center", children: [
2211
+ i > 0 ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }) : null,
2212
+ m
2213
+ ] }, i)) }) : null,
2214
+ hasProgress ? /* @__PURE__ */ jsxs("div", { className: "gap-step-xs mt-auto flex flex-col pt-step-sm", children: [
2215
+ /* @__PURE__ */ jsx(Progress, { value: progress, tone: "warm", label: `Progreso del curso: ${progress}%` }),
2216
+ /* @__PURE__ */ jsxs(Text, { variant: "label", tone: "muted", as: "p", children: [
2217
+ progress,
2218
+ "% cursado"
2219
+ ] })
2220
+ ] }) : null,
2221
+ footer ? (
2222
+ // Only one of the two takes `mt-auto`. Two auto margins in one column
2223
+ // split the free space between them, and the progress bar would float
2224
+ // halfway down the card.
2225
+ /* @__PURE__ */ jsx(
2226
+ "div",
2227
+ {
2228
+ className: cn(
2229
+ "gap-step-sm pt-step-sm flex flex-wrap items-center justify-between",
2230
+ hasProgress ? null : "mt-auto"
2231
+ ),
2232
+ children: footer
2233
+ }
2234
+ )
2235
+ ) : null
2236
+ ] })
2237
+ }
2238
+ )
2239
+ );
2203
2240
  }
2204
2241
  var FACE = "w-[66px] max-w-none";
2205
2242
  function EmptyState(props) {
@@ -2881,12 +2918,21 @@ function PageHeader({
2881
2918
  action,
2882
2919
  size: size2,
2883
2920
  as = "h1",
2921
+ titleVariant,
2884
2922
  className,
2885
2923
  ...props
2886
2924
  }) {
2887
2925
  return /* @__PURE__ */ jsxs("header", { className: cn(header({ size: size2 }), className), ...props, children: [
2888
2926
  eyebrow ? /* @__PURE__ */ jsx(Text, { variant: "eyebrow", tone: "accent", as: "p", children: eyebrow }) : null,
2889
- /* @__PURE__ */ jsx(Text, { as, variant: size2 === "display" ? "display" : "h1", className: "max-w-measure", children: title }),
2927
+ /* @__PURE__ */ jsx(
2928
+ Text,
2929
+ {
2930
+ as,
2931
+ variant: titleVariant ?? (size2 === "display" ? "display" : "h1"),
2932
+ className: "max-w-measure",
2933
+ children: title
2934
+ }
2935
+ ),
2890
2936
  description ? /* @__PURE__ */ jsx(Text, { variant: "body", tone: "secondary", children: description }) : null,
2891
2937
  action ? /* @__PURE__ */ jsx("div", { className: "gap-step-sm mt-step-sm flex flex-wrap items-center", children: action }) : null
2892
2938
  ] });
package/llms.txt CHANGED
@@ -480,6 +480,13 @@ re-exported, because they are unchanged and wrapping them buys nothing.
480
480
  down for a ranking. Recharts calls that same thing `layout="vertical"` — if you
481
481
  are porting code, the value flips.
482
482
 
483
+ **A percentage passes `valueMax={100}`.** Without it the value axis ends at the
484
+ largest datum, and on a horizontal ranking — whose value axis is hidden — a
485
+ course watched to 40 % draws as a full bar when it is the highest on the list.
486
+ The bottom is always zero, and it is a floor rather than a clip: a datum above
487
+ `valueMax` widens the axis instead of running off the edge. It is on all three
488
+ types. See `decisions/0.11.md` § 58.
489
+
483
490
  `stacked` on `AreaChart` and `BarChart` adds the series up. Without it areas
484
491
  overlap, which is honest and rarely what you want with more than one series: to
485
492
  COMPARE rather than add up, the type is `LineChart`.
@@ -590,6 +597,10 @@ compiles and looks wrong, or that fails the project's accessibility audit.
590
597
  the only member of the second criterion § 23 opened for it.
591
598
  6. **Semantics and scale are independent.** An `h2` that has to look small is
592
599
  `<Text as="h2" variant="h3">`, never an `h3` that lies about the hierarchy.
600
+ `PageHeader` makes the same split: `as` is the level and `titleVariant` the
601
+ scale. An admin panel's title is `<PageHeader title="Ventas"
602
+ titleVariant="h3" />` — still the page's only `h1`, at 25px instead of 44. See
603
+ `decisions/0.11.md` § 57.
593
604
  7. **`textMuted` never goes over `surfaceRaised`**: it gives 4.07 in dark. Over a
594
605
  raised surface — menus, active tabs — the token is `textSecondary`.
595
606
  8. **A background tinted with a semantic color carries text from a text token**,
@@ -610,6 +621,11 @@ compiles and looks wrong, or that fails the project's accessibility audit.
610
621
  not the humour contract. See `decisions/0.7.md` § 27.
611
622
  12. **The fin is not a free parameter**: `foam` on a dark background, `color` on a
612
623
  light one. The components already choose it from the background.
624
+ **On a site that switches theme, pass `background="auto"`** to `Isotype` or
625
+ `Logo`: both fins are rendered and the `light:` variant shows the one that
626
+ reads, so no call site has to know the theme. A surface that keeps one mode
627
+ whatever the page does — a dark panel on a light page — is a fixed
628
+ background, and it still says `dark`. See `decisions/0.11.md` § 60.
613
629
 
614
630
  ## What the library does NOT do, on purpose
615
631
 
@@ -625,6 +641,12 @@ These are the confusions people run into most often when consuming it.
625
641
  provider.
626
642
  - **It ships no router.** The components with links accept `asChild` to wrap the
627
643
  framework's `Link`.
644
+ - **It does not know your prices, ratings or images.** `CourseCard` takes
645
+ `media` — the cover, bleeding to the edges, with `alt=""` because the title
646
+ already names the card — and `footer`, the closing row where a project puts the
647
+ rating and the price. Both are nodes the project draws; the card keeps the
648
+ title, its hover and the sand progress bar. The title does not go over the
649
+ cover. See `decisions/0.11.md` § 59.
628
650
  - **It ships no `data-testid`.** A composed part your test suite has to reach is
629
651
  reached with a slot: `ArticleCard`'s `tagAsChild`, `Breadcrumb`'s and
630
652
  `TableOfContents`'s `linkAsChild`. They hand you the element and its
@@ -1389,11 +1411,15 @@ Source: `src/components/code-block/index.tsx`
1389
1411
 
1390
1412
  Source: `src/components/course-card/index.tsx`
1391
1413
 
1392
- - Extends: `Omit<CardShellProps, 'children' \| 'title'>`
1414
+ The course, as a card that links to it.
1415
+
1416
+ - Extends: `Omit<CardShellProps, 'children' \| 'title' \| 'media'>`
1393
1417
 
1394
1418
  | prop | type | req. | default | what it does |
1395
1419
  | --- | --- | --- | --- | --- |
1396
1420
  | `asChild` | `boolean \| undefined` | | | Renders the child instead of an `<a>`. It is how Next's or Astro's `Link` plugs in without the library depending on any router. |
1421
+ | `footer` | `ReactNode` | | | The closing row, under `meta`: the rating, the price, whatever the project sells the course with. It sits at the bottom of the card, so the rows of a grid line up whatever the length of each summary. |
1422
+ | `media` | `ReactNode` | | | The cover, at the top and bleeding to the card's edges, with `alt=""`: the whole card is one link and `title` already names it. |
1397
1423
  | `meta` | `readonly ReactNode[]` | | | Level, duration, number of lessons: whatever the project wants to list. |
1398
1424
  | `progress` | `number` | | | Percentage completed. It only makes sense for someone already enrolled; when passed, the bar goes in sand, which is the color of course progress. |
1399
1425
  | `status` | `ReactNode` | | | Status label: «próximamente», «gratis», «nuevo». |
@@ -1557,6 +1583,7 @@ One header at two scales, not two components.
1557
1583
  | `eyebrow` | `ReactNode` | | | Mono, small caps, in accent. It is the section the page belongs to. |
1558
1584
  | `size` | `"display" \| "page"` | | `page` | |
1559
1585
  | `title` | `ReactNode` | yes | | |
1586
+ | `titleVariant` | `"display" \| "h1" \| "h2" \| "h3"` | | | The headline's scale, when the screen needs a different one from what `size` gives — `display` for `display`, `h1` for `page`. |
1560
1587
 
1561
1588
  ### ScrollingProgressBar
1562
1589
 
@@ -1645,12 +1672,14 @@ Imported from `@eduardoalvarez/arrecife` or `@eduardoalvarez/arrecife/brand`. 4
1645
1672
 
1646
1673
  Source: `src/brand/isotype.tsx`
1647
1674
 
1675
+ The fin, in the variant its background asks for.
1676
+
1648
1677
  - Extends: `Omit<ComponentPropsWithoutRef<'img'>, 'src' \| 'alt'>`
1649
1678
 
1650
1679
  | prop | type | req. | default | what it does |
1651
1680
  | --- | --- | --- | --- | --- |
1652
1681
  | `alt` | `string` | | | Alt text. Empty when the isotype accompanies text that already names it. |
1653
- | `background` | `"dark" \| "light"` | | `dark` | Which background it sits on. Deciding is mandatory even though it has a default: the fin's body is nearly black, so the two-blue variant disappears over abyss. Being a prop, the rule stops being something to remember. |
1682
+ | `background` | `"dark" \| "light" \| "auto"` | | `dark` | Which background it sits on, or `auto` on a site that switches theme: both fins are rendered and CSS shows the one that reads. Deciding is mandatory even though it has a default: the fin's body is nearly black, so the two-blue variant disappears over abyss. |
1654
1683
  | `basePath` | `string` | | `ASSETS_PATH` | |
1655
1684
 
1656
1685
  ### Logo
@@ -1663,7 +1692,7 @@ The wordmark comes from `naming.wordmark`, not from a hand-written string, and i
1663
1692
 
1664
1693
  | prop | type | req. | default | what it does |
1665
1694
  | --- | --- | --- | --- | --- |
1666
- | `background` | `"dark" \| "light"` | | `dark` | |
1695
+ | `background` | `"dark" \| "light" \| "auto"` | | `dark` | The background the logo sits on, handed to its fin. `auto` follows the theme — see `Isotype`. The wordmark needs no help: it is `textPrimary`, which already follows the mode. |
1667
1696
  | `basePath` | `string` | | `ASSETS_PATH` | |
1668
1697
  | `isotypeOnly` | `boolean \| undefined` | | `false` | Hides the wordmark and leaves only the fin, for very narrow bars. |
1669
1698
  | `withTagline` | `boolean \| undefined` | | `false` | Adds the tagline under the wordmark, separated from the fin by a divider. |
@@ -1819,6 +1848,7 @@ A series over time, with the fill fading out underneath it.
1819
1848
  | `series` | `readonly ChartSeries[]` | yes | | |
1820
1849
  | `stacked` | `boolean \| undefined` | | `false` | Adds the series up instead of overlaying them. |
1821
1850
  | `summary` | `ReactNode` | | | What the chart says, in words. It goes in a visually hidden `figcaption`. |
1851
+ | `valueMax` | `number` | | | The top of the value axis, when the scale has one that the data does not reach — 100 for a percentage. |
1822
1852
  | `xKey` | `string` | yes | | The key on the category axis: the day, the month, the course. |
1823
1853
  | `xTickFormatter` | `(value: unknown) => string` | | | Formats the TICK on the category axis. Returns a string, because an axis tick is an SVG `<text>` and not a place a node can go. |
1824
1854
  | `yTickFormatter` | `(value: unknown) => string` | | | Formats the tick on the VALUE axis — the currency symbol, the thousands separator, the percent sign. |
@@ -1839,6 +1869,7 @@ Bars, upright or lying down.
1839
1869
  | `series` | `readonly ChartSeries[]` | yes | | |
1840
1870
  | `stacked` | `boolean \| undefined` | | `false` | Stacks the series instead of putting them side by side. |
1841
1871
  | `summary` | `ReactNode` | | | What the chart says, in words. It goes in a visually hidden `figcaption`. |
1872
+ | `valueMax` | `number` | | | The top of the value axis, when the scale has one that the data does not reach — 100 for a percentage. |
1842
1873
  | `xKey` | `string` | yes | | The key on the category axis: the day, the month, the course. |
1843
1874
  | `xTickFormatter` | `(value: unknown) => string` | | | Formats the TICK on the category axis. Returns a string, because an axis tick is an SVG `<text>` and not a place a node can go. |
1844
1875
  | `yTickFormatter` | `(value: unknown) => string` | | | Formats the tick on the VALUE axis — the currency symbol, the thousands separator, the percent sign. |
@@ -1857,6 +1888,7 @@ Lines, for comparing series against each other.
1857
1888
  | `legend` | `boolean \| undefined` | | | Shows the legend. It defaults to «only when there is more than one series»: a legend naming the one line already named by the chart's own heading is a row of pixels that says nothing. |
1858
1889
  | `series` | `readonly ChartSeries[]` | yes | | |
1859
1890
  | `summary` | `ReactNode` | | | What the chart says, in words. It goes in a visually hidden `figcaption`. |
1891
+ | `valueMax` | `number` | | | The top of the value axis, when the scale has one that the data does not reach — 100 for a percentage. |
1860
1892
  | `xKey` | `string` | yes | | The key on the category axis: the day, the month, the course. |
1861
1893
  | `xTickFormatter` | `(value: unknown) => string` | | | Formats the TICK on the category axis. Returns a string, because an axis tick is an SVG `<text>` and not a place a node can go. |
1862
1894
  | `yTickFormatter` | `(value: unknown) => string` | | | Formats the tick on the VALUE axis — the currency symbol, the thousands separator, the percent sign. |
@@ -1937,7 +1969,7 @@ Types (2): `Theme`, `ThemeOptions`.
1937
1969
  | `poseList` | `readonly ("desk" \| "laptop-coffee" \| "peek" \| "surf")[]` | |
1938
1970
  | `poses` | `{ readonly desk: "pose-desk.png"; readonly 'laptop-coffee': "pose-laptop-coffee.png"; readonly peek: "pose-peek.png"; readonly surf: "pose-surf.png"; }` | Full-body poses. |
1939
1971
 
1940
- Types (8): `Background`, `Face`, `Fin`, `IsotypeProps`, `LogoProps`, `MascotFaceProps`, `MascotProps`, `Pose`.
1972
+ Types (9): `Background`, `Face`, `Fin`, `IsotypeBackground`, `IsotypeProps`, `LogoProps`, `MascotFaceProps`, `MascotProps`, `Pose`.
1941
1973
 
1942
1974
  ### `@eduardoalvarez/arrecife/icons`
1943
1975
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eduardoalvarez/arrecife",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The component library of Eduardo Álvarez’s visual identity",
5
5
  "license": "MIT",
6
6
  "author": "Eduardo Esteban Álvarez Castañeda <soy@eduardoalvarez.dev>",