@charcuterie/ui 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/ActionTiles/ActionTiles.d.ts +66 -8
  2. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
  3. package/dist/ActionTiles/ActionTiles.js +88 -11
  4. package/dist/ActionTiles/ActionTiles.js.map +1 -1
  5. package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
  6. package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
  7. package/dist/PortraitTiles/PortraitAvatar.js +29 -0
  8. package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
  9. package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
  10. package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
  11. package/dist/PortraitTiles/PortraitTiles.js +263 -0
  12. package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
  13. package/dist/PortraitTiles/portraitColour.d.ts +49 -0
  14. package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
  15. package/dist/PortraitTiles/portraitColour.js +55 -0
  16. package/dist/PortraitTiles/portraitColour.js.map +1 -0
  17. package/dist/categoricalStyles.d.ts +27 -0
  18. package/dist/categoricalStyles.d.ts.map +1 -1
  19. package/dist/categoricalStyles.js +49 -0
  20. package/dist/categoricalStyles.js.map +1 -1
  21. package/dist/index.d.ts +4 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/tileStyles.d.ts +15 -0
  26. package/dist/tileStyles.d.ts.map +1 -1
  27. package/dist/tileStyles.js +26 -1
  28. package/dist/tileStyles.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/ActionTiles/ActionTiles.mdx +64 -6
  31. package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
  32. package/src/ActionTiles/ActionTiles.test.tsx +205 -5
  33. package/src/ActionTiles/ActionTiles.tsx +274 -110
  34. package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
  35. package/src/PortraitTiles/PortraitTiles.mdx +153 -0
  36. package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
  37. package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
  38. package/src/PortraitTiles/PortraitTiles.tsx +577 -0
  39. package/src/PortraitTiles/portraitColour.test.ts +39 -0
  40. package/src/PortraitTiles/portraitColour.ts +67 -0
  41. package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
  42. package/src/categoricalStyles.ts +57 -0
  43. package/src/index.ts +9 -0
  44. package/src/sourceRules.test.ts +15 -1
  45. package/src/tileStyles.ts +30 -1
@@ -0,0 +1,577 @@
1
+ import type {
2
+ CategoricalIndex,
3
+ ControlSize,
4
+ } from "@charcuterie/tokens"
5
+ import {
6
+ CATEGORICAL_INDEX_COUNT,
7
+ CATEGORICAL_INDEXES,
8
+ } from "@charcuterie/tokens"
9
+ import type { CSSProperties, ReactNode } from "react"
10
+
11
+ import {
12
+ CATEGORICAL_APPEARANCE_CLASS,
13
+ CATEGORICAL_CONTENT_CLASS,
14
+ CATEGORICAL_HOVER_BORDER_CLASS,
15
+ CATEGORICAL_RING_CLASS,
16
+ } from "../categoricalStyles.ts"
17
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
18
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
19
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
20
+ import {
21
+ TILE_BOX_CLASS,
22
+ TILE_COLUMNS_CLASS,
23
+ TILE_MIN_INLINE_SIZE_PROPERTY,
24
+ } from "../tileStyles.ts"
25
+ import { toClassName } from "../toClassName.ts"
26
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
27
+ import { PortraitAvatar } from "./PortraitAvatar.tsx"
28
+ import { getPortraitColourProperties } from "./portraitColour.ts"
29
+
30
+ /**
31
+ * Where one portrait's colour comes from. Two arms, and the fleet
32
+ * has both — the same split
33
+ * [`cardAccentEdge.ts`](../Card/cardAccentEdge.ts) already draws,
34
+ * for the same reason.
35
+ *
36
+ * - **`categorical`** — one of the ten audited hues. A colour the
37
+ * library owns, re-themed by `data-variant`, gated in both
38
+ * schemes. Right whenever the colour is a *choice from what we
39
+ * offered*.
40
+ * - **`color`** — any CSS colour, from data. Right when the colour
41
+ * **arrived from the world** and re-theming it would be lying
42
+ * about a physical object. points-market is exactly this: each
43
+ * kid's colour matches the NFC card they tap, by the workspace's
44
+ * 2026-07-20 `kid-identity-colors-match-nfc-cards` record. Ten
45
+ * palette hues cannot represent that, and swapping them in would
46
+ * have made the app disagree with the cards on the fridge.
47
+ *
48
+ * Neither is a default. A portrait that names no colour takes the
49
+ * next `categorical` hue by position.
50
+ */
51
+ type PortraitTileColour =
52
+ | { categorical?: CategoricalIndex; color?: never }
53
+ | { categorical?: never; color: string }
54
+
55
+ export type PortraitTileItem = PortraitTileColour & {
56
+ /**
57
+ * The quiet line under the number, naming its unit — "points",
58
+ * "episodes left", "due today".
59
+ *
60
+ * Inside the tile, so it is part of the accessible name. "1,240"
61
+ * on its own tells a screen-reader user nothing.
62
+ */
63
+ hint?: ReactNode
64
+ /** Makes the portrait a real `<a href>`. Same rules as `ActionTiles`. */
65
+ href?: string
66
+ /**
67
+ * A picture of the subject. Falls back to {@link initials} when
68
+ * absent, and **also** when the file 404s — a broken portrait grid
69
+ * is the one failure this shape cannot absorb.
70
+ */
71
+ imageSrc?: string
72
+ /**
73
+ * Drawn in the hue's solid fill when there is no picture. One or
74
+ * two characters; longer strings are the caller's problem to
75
+ * shorten, because "which letters" is a naming question this
76
+ * component cannot answer (a mononym, a family that shares an
77
+ * initial).
78
+ */
79
+ initials?: string
80
+ isDisabled?: boolean
81
+ /** Opens in a new tab, and says so to a screen reader. `href` only. */
82
+ isExternal?: boolean
83
+ /** The name. */
84
+ label: ReactNode
85
+ /**
86
+ * The one number this portrait is chosen by — a balance, a count,
87
+ * a streak. Optional: a picker that is only ever "who are you"
88
+ * passes none and the tile closes up around the name.
89
+ */
90
+ stat?: ReactNode
91
+ /** Identity. The React key, and what `onChoose` is handed. */
92
+ value: string
93
+ }
94
+
95
+ /**
96
+ * How a portrait arranges itself.
97
+ *
98
+ * - `auto` — a **row** while the set is narrow, a **column** once
99
+ * it has room. The switch is a container query on the set, not a
100
+ * media query: a picker in a 320px sidebar is a list of rows on a
101
+ * 2560px monitor, and the same picker filling a phone is a column
102
+ * per screenful.
103
+ * - `row` / `column` — fixed, for a layout that has already decided.
104
+ */
105
+ export type PortraitTilesLayout = "auto" | "column" | "row"
106
+
107
+ export type PortraitTilesProps = {
108
+ className?: string
109
+ items: readonly PortraitTileItem[]
110
+ /** The set's accessible name. Required. */
111
+ label: string
112
+ layout?: PortraitTilesLayout
113
+ /** The narrowest a portrait track may be, in CSS px. */
114
+ minTileInlineSize?: number
115
+ /** What `isExternal` announces. Not shown. */
116
+ newTabLabel?: string
117
+ /** Fired by a portrait that carries no `href`. */
118
+ onChoose?: (value: string) => void
119
+ size?: ControlSize
120
+ }
121
+
122
+ /**
123
+ * The two forms, spelled as complete class strings rather than
124
+ * assembled.
125
+ *
126
+ * `cq-sm` is 24rem. Below it the set is one narrow column — a phone,
127
+ * a sidebar — and a 144px face would push the number off the bottom
128
+ * of the fold; above it there is room for the shape points-market
129
+ * built, which is what its kiosk screen shows today.
130
+ *
131
+ * The `auto` strings carry BOTH forms, because a container query
132
+ * variant is a media query in the stylesheet and cannot be chosen in
133
+ * JavaScript. `layout="row"` and `layout="column"` exist so a caller
134
+ * that already knows can skip the query rather than fight it.
135
+ */
136
+ const TILE_LAYOUT_CLASS: Record<
137
+ PortraitTilesLayout,
138
+ string
139
+ > = {
140
+ auto: "flex-row items-center text-start cq-sm:flex-col cq-sm:items-center cq-sm:text-center",
141
+ column: "flex-col items-center text-center",
142
+ row: "flex-row items-center text-start",
143
+ }
144
+
145
+ const TEXT_LAYOUT_CLASS: Record<
146
+ PortraitTilesLayout,
147
+ string
148
+ > = {
149
+ auto: "items-start cq-sm:items-center",
150
+ column: "items-center",
151
+ row: "items-start",
152
+ }
153
+
154
+ const TILE_PADDING_CLASS: Record<
155
+ PortraitTilesLayout,
156
+ string
157
+ > = {
158
+ auto: "p-4 cq-sm:p-6",
159
+ column: "p-6",
160
+ row: "p-4",
161
+ }
162
+
163
+ /**
164
+ * Every length here moves together, and none of them is a pixel
165
+ * count in an app.
166
+ *
167
+ * The face grows when the set does, and the type ramp does the rest
168
+ * on its own — `text-2xl` is 30px at `comfortable` and 38px at
169
+ * `kiosk`, which is how points-market's wall-mounted picker gets its
170
+ * size back without a single override. That the density axis already
171
+ * carries this is the reason the owner's "it is meant to scale" does
172
+ * not need a fourth size here.
173
+ */
174
+ const AVATAR_SIZE_CLASS: Record<
175
+ ControlSize,
176
+ Record<PortraitTilesLayout, string>
177
+ > = {
178
+ sm: {
179
+ auto: "size-12 cq-sm:size-20",
180
+ column: "size-20",
181
+ row: "size-12",
182
+ },
183
+ md: {
184
+ auto: "size-16 cq-sm:size-28",
185
+ column: "size-28",
186
+ row: "size-16",
187
+ },
188
+ lg: {
189
+ auto: "size-20 cq-sm:size-36",
190
+ column: "size-36",
191
+ row: "size-20",
192
+ },
193
+ }
194
+
195
+ /**
196
+ * The initials, sized to the circle they sit in.
197
+ *
198
+ * Its own map rather than a `calc()` off the avatar size, because
199
+ * the size arrives as a Tailwind class and there is no number here
200
+ * to compute from. Inheriting the tile's text size — which is what
201
+ * the first version did — put a 17px letter in the middle of a 144px
202
+ * circle and looked like a bug in the picture loader.
203
+ *
204
+ * The ramp stops at `2xl`, so the largest column avatar is
205
+ * deliberately a letter with air around it rather than a letter that
206
+ * fills the disc. Going past the ramp for one glyph would be the
207
+ * hardcoded length every other size in this file avoids.
208
+ */
209
+ const AVATAR_TEXT_CLASS: Record<
210
+ ControlSize,
211
+ Record<PortraitTilesLayout, string>
212
+ > = {
213
+ sm: {
214
+ auto: "text-md cq-sm:text-xl",
215
+ column: "text-xl",
216
+ row: "text-md",
217
+ },
218
+ md: {
219
+ auto: "text-lg cq-sm:text-2xl",
220
+ column: "text-2xl",
221
+ row: "text-lg",
222
+ },
223
+ lg: {
224
+ auto: "text-xl cq-sm:text-2xl",
225
+ column: "text-2xl",
226
+ row: "text-xl",
227
+ },
228
+ }
229
+
230
+ const NAME_TEXT_CLASS: Record<ControlSize, string> = {
231
+ sm: "text-sm",
232
+ md: "text-md",
233
+ lg: "text-lg",
234
+ }
235
+
236
+ const STAT_TEXT_CLASS: Record<
237
+ ControlSize,
238
+ Record<PortraitTilesLayout, string>
239
+ > = {
240
+ sm: {
241
+ auto: "text-lg cq-sm:text-xl",
242
+ column: "text-xl",
243
+ row: "text-lg",
244
+ },
245
+ md: {
246
+ auto: "text-xl cq-sm:text-2xl",
247
+ column: "text-2xl",
248
+ row: "text-xl",
249
+ },
250
+ lg: {
251
+ auto: "text-2xl cq-sm:text-2xl",
252
+ column: "text-2xl",
253
+ row: "text-2xl",
254
+ },
255
+ }
256
+
257
+ const GAP_CLASS: Record<ControlSize, string> = {
258
+ sm: "gap-3",
259
+ md: "gap-4",
260
+ lg: "gap-5",
261
+ }
262
+
263
+ /**
264
+ * Positional hue, wrapping at ten. Same rule `ActionTiles` uses.
265
+ *
266
+ * `null` when the portrait brought its own `color` — there is no
267
+ * palette index to fall back to in that arm, and picking one anyway
268
+ * would paint a second, unrelated hue onto the same tile.
269
+ */
270
+ const getPortraitCategorical = (
271
+ item: PortraitTileItem,
272
+ position: number,
273
+ ): CategoricalIndex | null =>
274
+ item.color === undefined
275
+ ? (item.categorical ??
276
+ (CATEGORICAL_INDEXES[
277
+ position % CATEGORICAL_INDEX_COUNT
278
+ ] as CategoricalIndex))
279
+ : null
280
+
281
+ /**
282
+ * The classes that read the four custom properties, spelled whole.
283
+ *
284
+ * Tailwind scans SOURCE TEXT for complete class strings, so these
285
+ * cannot be built from a variable — and the failure is silent CSS
286
+ * that never gets generated rather than an error anywhere.
287
+ */
288
+ const COLOUR_TILE_HOVER_CLASS =
289
+ "hover:border-(--charcuterie-portrait-fill)"
290
+
291
+ const COLOUR_AVATAR_CLASS =
292
+ "bg-(--charcuterie-portrait-fill) text-(--charcuterie-portrait-initials) ring-(--charcuterie-portrait-halo) inset-ring-1 inset-ring-black/10"
293
+
294
+ const COLOUR_STAT_CLASS =
295
+ "text-(--charcuterie-portrait-stat)"
296
+
297
+ /**
298
+ * A set of PEOPLE — or of anything else picked by its face — drawn
299
+ * as round pictures with a name and one big number.
300
+ *
301
+ * points-market's "Who's shopping?" is the shape this is taken from,
302
+ * and it is deliberately **not** an `ActionTiles` variant. The two
303
+ * answer different questions:
304
+ *
305
+ * - `ActionTiles` asks *what do you want to do* — a name, a line of
306
+ * help, an icon that illustrates the verb.
307
+ * - This asks *who is this* — a face, and one number that decides
308
+ * it. There is no description, because a person is not explained.
309
+ *
310
+ * Collapsing them would mean one component whose icon is sometimes a
311
+ * 20px glyph beside the title and sometimes a 144px circle above it,
312
+ * with `hint` meaning "what this does" in one mode and "points" in
313
+ * the other. That is two components wearing one name, which is the
314
+ * mistake the
315
+ * [2026-08-25 record](../../../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
316
+ * warns about from the other direction.
317
+ *
318
+ * What they DO share is the box — `TILE_BOX_CLASS` and the same
319
+ * `auto-fill` grid — so a page carrying both does not show two
320
+ * different cards.
321
+ *
322
+ * ### It scales, and the scaling is the point
323
+ *
324
+ * The owner's one requirement when this moved into the library:
325
+ * *"that one is meant to scale. I don't wanna lose that
326
+ * functionality."* Two axes carry it.
327
+ *
328
+ * 1. **The container.** `layout="auto"` is a row while the set is
329
+ * narrow and a column once it has room, off a `cq-sm` query on
330
+ * the set itself. Nothing reads the viewport, so the same picker
331
+ * works in a sidebar and on a wall.
332
+ * 2. **The density.** Every length is a token, so `data-density="kiosk"`
333
+ * grows the type ramp and the control heights under it. points-market
334
+ * runs `kiosk` today; that is where its big numbers come from,
335
+ * and it keeps them.
336
+ *
337
+ * ### The colour is the person's, not the position's
338
+ *
339
+ * A positional hue is the default because it costs a call site
340
+ * nothing. It is also the wrong default for a household: add a
341
+ * fifth member and the first four change colour, which is exactly
342
+ * the identity the picker was using to be fast. Any set whose
343
+ * subjects persist should name its colour per subject.
344
+ *
345
+ * There are two ways to name one, and the difference is where the
346
+ * colour came from:
347
+ *
348
+ * - **`categorical`** — an index into the ten audited hues. A
349
+ * choice from what the library offered, so the library keeps
350
+ * owning it: re-themed by `data-variant`, contrast-gated in both
351
+ * schemes. The same call Docket makes for a project.
352
+ * - **`color`** — any CSS colour, straight from data. For a colour
353
+ * the system does **not** own and cannot re-theme without lying:
354
+ * points-market's kids are coloured to match the NFC cards they
355
+ * tap, so the picker and the cards on the fridge have to agree,
356
+ * and ten palette hues cannot promise that.
357
+ *
358
+ * The second arm is the narrow one. Reach for it only when the
359
+ * colour is a fact about the subject rather than a decision about
360
+ * the design — the same line `Swatch` draws, for the same reason.
361
+ */
362
+ export const PortraitTiles = ({
363
+ className,
364
+ items,
365
+ label,
366
+ layout = "auto",
367
+ minTileInlineSize = 200,
368
+ newTabLabel = "(opens in a new tab)",
369
+ onChoose,
370
+ size = "md",
371
+ }: PortraitTilesProps): ReactNode => {
372
+ const RouterLink = useRouterLink()
373
+
374
+ return (
375
+ // The container and the thing querying it cannot be the same
376
+ // element — a container query matches descendants only, so
377
+ // `@container cq-sm:flex-col` on one box silently never fires
378
+ // and the layout stays a row forever with nothing to report it.
379
+ // `EmptyState` hit this first and its comment says the same.
380
+ <div className={toClassName("@container", className)}>
381
+ {/* biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics, a border and form-reset behaviour onto a set of links that is not a form. Same call `ActionTiles` and `Menu`'s group already made. */}
382
+ <div
383
+ aria-label={label}
384
+ className={toClassName(
385
+ "grid",
386
+ TILE_COLUMNS_CLASS,
387
+ GAP_CLASS[size],
388
+ )}
389
+ role="group"
390
+ style={
391
+ {
392
+ [TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
393
+ } as CSSProperties
394
+ }
395
+ >
396
+ {items.map((item, position) => {
397
+ const {
398
+ hint,
399
+ href,
400
+ imageSrc,
401
+ initials,
402
+ isDisabled = false,
403
+ isExternal = false,
404
+ label: tileLabel,
405
+ stat,
406
+ value,
407
+ } = item
408
+
409
+ const categorical = getPortraitCategorical(
410
+ item,
411
+ position,
412
+ )
413
+
414
+ const { color } = item
415
+
416
+ const tileClassName = toClassName(
417
+ "flex min-w-0 wrap-anywhere transition-[background-color,border-color,box-shadow,transform] duration-(--duration-fast) ease-standard",
418
+ TILE_BOX_CLASS,
419
+ TILE_LAYOUT_CLASS[layout],
420
+ TILE_PADDING_CLASS[layout],
421
+ GAP_CLASS[size],
422
+ isDisabled
423
+ ? "cursor-not-allowed opacity-60"
424
+ : toClassName(
425
+ "cursor-pointer hover:shadow-medium hover:-translate-y-1",
426
+ // The lift is the one piece of motion in this
427
+ // library, kept because it is what the owner
428
+ // pointed at in points-market — and turned off
429
+ // for anyone who has asked the OS for less. The
430
+ // shadow and the border stay, so the hover is
431
+ // still legible without it.
432
+ "motion-reduce:hover:translate-y-0",
433
+ categorical === null
434
+ ? COLOUR_TILE_HOVER_CLASS
435
+ : CATEGORICAL_HOVER_BORDER_CLASS[
436
+ categorical
437
+ ],
438
+ ),
439
+ FOCUS_RING_CLASS,
440
+ )
441
+
442
+ const avatarClassName = toClassName(
443
+ "flex shrink-0 items-center justify-center overflow-hidden rounded-full object-cover font-display font-semibold ring-4",
444
+ AVATAR_SIZE_CLASS[size][layout],
445
+ AVATAR_TEXT_CLASS[size][layout],
446
+ categorical === null
447
+ ? COLOUR_AVATAR_CLASS
448
+ : toClassName(
449
+ CATEGORICAL_APPEARANCE_CLASS[categorical]
450
+ .solid,
451
+ CATEGORICAL_RING_CLASS[categorical],
452
+ ),
453
+ )
454
+
455
+ // The one sanctioned escape hatch for a runtime value,
456
+ // and the same one `Swatch` uses: a colour out of a
457
+ // database cannot be a class, because Tailwind generates
458
+ // its CSS at build time from source text.
459
+ const tileStyle =
460
+ color === undefined
461
+ ? undefined
462
+ : (getPortraitColourProperties(
463
+ color,
464
+ ) as CSSProperties)
465
+
466
+ const content = (
467
+ <>
468
+ <PortraitAvatar
469
+ className={avatarClassName}
470
+ imageSrc={imageSrc}
471
+ initials={initials}
472
+ />
473
+
474
+ <span
475
+ className={toClassName(
476
+ "flex min-w-0 flex-col",
477
+ TEXT_LAYOUT_CLASS[layout],
478
+ )}
479
+ >
480
+ <span
481
+ className={toClassName(
482
+ "font-semibold text-content-primary",
483
+ NAME_TEXT_CLASS[size],
484
+ )}
485
+ >
486
+ {tileLabel}
487
+ </span>
488
+
489
+ {stat === undefined ? null : (
490
+ <span
491
+ className={toClassName(
492
+ "font-bold tabular-nums",
493
+ STAT_TEXT_CLASS[size][layout],
494
+ categorical === null
495
+ ? COLOUR_STAT_CLASS
496
+ : CATEGORICAL_CONTENT_CLASS[
497
+ categorical
498
+ ],
499
+ )}
500
+ >
501
+ {stat}
502
+ </span>
503
+ )}
504
+
505
+ {hint === undefined ? null : (
506
+ <span className="text-xs text-content-muted">
507
+ {hint}
508
+ </span>
509
+ )}
510
+ </span>
511
+
512
+ {isExternal && href !== undefined ? (
513
+ <VisuallyHidden>
514
+ {newTabLabel}
515
+ </VisuallyHidden>
516
+ ) : null}
517
+ </>
518
+ )
519
+
520
+ if (href !== undefined && !isDisabled) {
521
+ const LinkElement =
522
+ isExternal || !getIsRoutedHref(href)
523
+ ? "a"
524
+ : RouterLink
525
+
526
+ return (
527
+ <LinkElement
528
+ className={tileClassName}
529
+ href={href}
530
+ key={value}
531
+ rel={
532
+ isExternal
533
+ ? "noopener noreferrer"
534
+ : undefined
535
+ }
536
+ style={tileStyle}
537
+ target={isExternal ? "_blank" : undefined}
538
+ >
539
+ {content}
540
+ </LinkElement>
541
+ )
542
+ }
543
+
544
+ if (href !== undefined) {
545
+ return (
546
+ // biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced. `ActionTiles` and `ButtonLink` make the same call.
547
+ <a
548
+ aria-disabled="true"
549
+ className={tileClassName}
550
+ key={value}
551
+ role="link"
552
+ style={tileStyle}
553
+ >
554
+ {content}
555
+ </a>
556
+ )
557
+ }
558
+
559
+ return (
560
+ <button
561
+ className={tileClassName}
562
+ disabled={isDisabled}
563
+ key={value}
564
+ onClick={() => {
565
+ onChoose?.(value)
566
+ }}
567
+ style={tileStyle}
568
+ type="button"
569
+ >
570
+ {content}
571
+ </button>
572
+ )
573
+ })}
574
+ </div>
575
+ </div>
576
+ )
577
+ }
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import {
4
+ getPortraitColourProperties,
5
+ PORTRAIT_FILL_PROPERTY,
6
+ PORTRAIT_HALO_PROPERTY,
7
+ PORTRAIT_INITIALS_PROPERTY,
8
+ PORTRAIT_STAT_PROPERTY,
9
+ } from "./portraitColour.ts"
10
+
11
+ /**
12
+ * Four properties out of one colour, and each of them is read by a
13
+ * class that Tailwind generated from a literal. A wrong name here is
14
+ * not an error anywhere — the property is simply never read and the
15
+ * portrait paints with no fill at all, which looks like an image
16
+ * that failed to load.
17
+ */
18
+ test("one colour becomes the four properties the paint reads", () => {
19
+ const properties = getPortraitColourProperties("#8FD3F4")
20
+
21
+ expect(properties[PORTRAIT_FILL_PROPERTY]).toBe("#8FD3F4")
22
+
23
+ // Pale fill, so the letters go dark. `getReadableTextColour` owns
24
+ // that choice and `contrast.test.ts` covers it.
25
+ expect(properties[PORTRAIT_INITIALS_PROPERTY]).toBe(
26
+ "#000000",
27
+ )
28
+
29
+ // A wash of the hue, standing in for `categorical-N-surface`, and
30
+ // the number pulled toward the scheme's own text colour so it
31
+ // moves with the scheme instead of staying pale on a pale surface.
32
+ expect(properties[PORTRAIT_HALO_PROPERTY]).toBe(
33
+ "color-mix(in srgb, #8FD3F4 28%, transparent)",
34
+ )
35
+
36
+ expect(properties[PORTRAIT_STAT_PROPERTY]).toBe(
37
+ "color-mix(in srgb, #8FD3F4 62%, var(--color-content-primary))",
38
+ )
39
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The paint for a portrait whose colour came from DATA rather than
3
+ * from the palette.
4
+ *
5
+ * `Swatch` already argues the case this file serves: a colour that
6
+ * "arrived from the world" — a sticker, an extracted album accent,
7
+ * the NFC card a child taps — is not a token, so it cannot be
8
+ * re-themed and cannot be a Tailwind class. It reaches the paint as
9
+ * custom properties on the tile, and the classes read those
10
+ * properties.
11
+ *
12
+ * Four properties rather than one, because the same hue has to do
13
+ * four jobs and only one of them can use it raw. Each is named for
14
+ * the PART it paints, following `--charcuterie-accent-edge` — a
15
+ * property called `-colour` would say only what type it holds, which
16
+ * is the one thing already obvious from where it is used:
17
+ *
18
+ * - the **fill** behind the initials — the colour itself, exactly;
19
+ * - the **initials** on top of it — black or white, whichever the
20
+ * fill can actually carry;
21
+ * - the **halo** around the face — a transparent wash of the hue,
22
+ * standing in for what `categorical-N-surface` is on the palette
23
+ * side;
24
+ * - the **number** — the hue pulled toward the scheme's own text
25
+ * colour, so a pale card colour stays readable on a pale surface
26
+ * and a dark one stays readable on a dark surface. Mixing toward
27
+ * `content-primary` moves lightness and keeps hue, which is the
28
+ * smallest change that keeps the number *this person's colour*
29
+ * while remaining a number somebody can read.
30
+ */
31
+
32
+ import { getReadableTextColour } from "@charcuterie/tokens"
33
+
34
+ export const PORTRAIT_FILL_PROPERTY =
35
+ "--charcuterie-portrait-fill"
36
+
37
+ export const PORTRAIT_INITIALS_PROPERTY =
38
+ "--charcuterie-portrait-initials"
39
+
40
+ export const PORTRAIT_HALO_PROPERTY =
41
+ "--charcuterie-portrait-halo"
42
+
43
+ export const PORTRAIT_STAT_PROPERTY =
44
+ "--charcuterie-portrait-stat"
45
+
46
+ /**
47
+ * The four properties for one portrait.
48
+ *
49
+ * `srgb` rather than `oklab` for both mixes, matching `Swatch`'s
50
+ * existing `color-mix(in srgb, …)` — one interpolation space across
51
+ * the library, so two components handed the same colour do not
52
+ * produce two different washes of it.
53
+ *
54
+ * The initials colour is `getReadableTextColour`, which lives in
55
+ * `@charcuterie/tokens` beside the contrast maths rather than here.
56
+ * It is total on purpose: it runs inside a render, on a colour out
57
+ * of a database, and a throw would blank the whole picker.
58
+ */
59
+ export const getPortraitColourProperties = (
60
+ color: string,
61
+ ): Record<string, string> => ({
62
+ [PORTRAIT_FILL_PROPERTY]: color,
63
+ [PORTRAIT_HALO_PROPERTY]: `color-mix(in srgb, ${color} 28%, transparent)`,
64
+ [PORTRAIT_INITIALS_PROPERTY]:
65
+ getReadableTextColour(color),
66
+ [PORTRAIT_STAT_PROPERTY]: `color-mix(in srgb, ${color} 62%, var(--color-content-primary))`,
67
+ })