@charcuterie/ui 3.9.0 → 3.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.
Files changed (40) hide show
  1. package/README.md +11 -0
  2. package/dist/Badge/Badge.d.ts +2 -37
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +15 -30
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Badge/useBadgeShape.d.ts +57 -0
  7. package/dist/Badge/useBadgeShape.d.ts.map +1 -0
  8. package/dist/Badge/useBadgeShape.js +35 -0
  9. package/dist/Badge/useBadgeShape.js.map +1 -0
  10. package/dist/BadgeButton/BadgeButton.d.ts +59 -0
  11. package/dist/BadgeButton/BadgeButton.d.ts.map +1 -0
  12. package/dist/BadgeButton/BadgeButton.js +64 -0
  13. package/dist/BadgeButton/BadgeButton.js.map +1 -0
  14. package/dist/Card/Card.d.ts +12 -1
  15. package/dist/Card/Card.d.ts.map +1 -1
  16. package/dist/Card/Card.js +14 -2
  17. package/dist/Card/Card.js.map +1 -1
  18. package/dist/Card/cardAccentEdge.d.ts +101 -0
  19. package/dist/Card/cardAccentEdge.d.ts.map +1 -0
  20. package/dist/Card/cardAccentEdge.js +92 -0
  21. package/dist/Card/cardAccentEdge.js.map +1 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/Badge/Badge.tsx +30 -113
  28. package/src/Badge/useBadgeShape.tsx +142 -0
  29. package/src/BadgeButton/BadgeButton.mdx +67 -0
  30. package/src/BadgeButton/BadgeButton.stories.tsx +199 -0
  31. package/src/BadgeButton/BadgeButton.test.tsx +164 -0
  32. package/src/BadgeButton/BadgeButton.tsx +129 -0
  33. package/src/Card/Card.mdx +21 -0
  34. package/src/Card/Card.stories.tsx +91 -0
  35. package/src/Card/Card.test.tsx +92 -1
  36. package/src/Card/Card.tsx +34 -0
  37. package/src/Card/cardAccentEdge.test.ts +105 -0
  38. package/src/Card/cardAccentEdge.ts +135 -0
  39. package/src/index.ts +4 -0
  40. package/src/sourceRules.test.ts +8 -1
@@ -0,0 +1,199 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ categoricalArgType,
5
+ intentArgType,
6
+ } from "../argTypes.storyHelpers.ts"
7
+ import { Badge } from "../Badge/Badge.tsx"
8
+ import {
9
+ StoryCell,
10
+ StoryGrid,
11
+ StoryRow,
12
+ StorySection,
13
+ } from "../board.storyHelpers.tsx"
14
+ import { DotIcon } from "../icons.storyHelpers.tsx"
15
+ import { BadgeButton } from "./BadgeButton.tsx"
16
+
17
+ const INTENTS = [
18
+ "neutral",
19
+ "accent",
20
+ "success",
21
+ "warning",
22
+ "danger",
23
+ "info",
24
+ ] as const
25
+
26
+ const meta = {
27
+ title: "Components/Actions/BadgeButton",
28
+ component: BadgeButton,
29
+ parameters: { layout: "padded" },
30
+ argTypes: {
31
+ categorical: categoricalArgType,
32
+ intent: intentArgType,
33
+ },
34
+ // The component's own defaults, restated — Storybook does not
35
+ // seed `args` from docgen, so an unstated default shows in the
36
+ // props table with nothing selected in its control.
37
+ args: {
38
+ appearance: "soft",
39
+ intent: "neutral",
40
+ isDisabled: false,
41
+ onClick: () => {},
42
+ overflow: "truncate",
43
+ size: "md",
44
+ },
45
+ } satisfies Meta<typeof BadgeButton>
46
+
47
+ export default meta
48
+
49
+ type Story = StoryObj<typeof meta>
50
+
51
+ /**
52
+ * A pill with a `button` role, so `getByRole("button", { name })`
53
+ * finds it — which is what an agent, and every test in the fleet,
54
+ * actually matches on.
55
+ */
56
+ export const Default: Story = {
57
+ args: { children: "1 episode", intent: "accent" },
58
+ }
59
+
60
+ /**
61
+ * The claim this component exists to make, side by side: a
62
+ * `BadgeButton` and a `Badge` with identical visual props are the
63
+ * same pill, and one of them is a real `<button>`.
64
+ *
65
+ * `BadgeButton.test.tsx` compares their computed styles rather than
66
+ * trusting this board — but the board is where a human sees it.
67
+ */
68
+ export const BesideABadge: Story = {
69
+ args: { children: "1 episode" },
70
+ render: ({ categorical, ...badgeButtonProps }) => (
71
+ <StorySection title="Same pill, different element. Tab reaches the left one and Enter fires it; the right one is a word about something else.">
72
+ <StoryRow>
73
+ <StoryCell label="BadgeButton — a <button>">
74
+ <BadgeButton {...badgeButtonProps} />
75
+ </StoryCell>
76
+
77
+ <StoryCell label="Badge — a <span>">
78
+ <Badge
79
+ appearance={badgeButtonProps.appearance}
80
+ intent={badgeButtonProps.intent}
81
+ size={badgeButtonProps.size}
82
+ >
83
+ 1 episode
84
+ </Badge>
85
+ </StoryCell>
86
+ </StoryRow>
87
+ </StorySection>
88
+ ),
89
+ }
90
+
91
+ export const AllVariants: Story = {
92
+ args: { children: "1 episode" },
93
+ // `categorical` is destructured out rather than spread: this board
94
+ // is about intents, and the type refuses a pill holding both — a
95
+ // badge is one colour.
96
+ render: ({ categorical, ...badgeButtonProps }) => (
97
+ <StorySection title="Six intents x three appearances — the same maps Badge indexes, because they are the same maps. `ghost` is excluded: a pill that paints nothing until hovered has no pill left.">
98
+ <StoryGrid columns={6}>
99
+ {(["soft", "solid", "outline"] as const).flatMap(
100
+ (appearance) =>
101
+ INTENTS.map((intent) => (
102
+ <StoryCell
103
+ key={`${appearance}-${intent}`}
104
+ label={`${appearance} · ${intent}`}
105
+ >
106
+ <BadgeButton
107
+ {...badgeButtonProps}
108
+ appearance={appearance}
109
+ intent={intent}
110
+ >
111
+ {intent}
112
+ </BadgeButton>
113
+ </StoryCell>
114
+ )),
115
+ )}
116
+ </StoryGrid>
117
+ </StorySection>
118
+ ),
119
+ }
120
+
121
+ /**
122
+ * The states a `Badge` does not have, which is the whole reason not
123
+ * to solve this with a `className`.
124
+ */
125
+ export const AllStates: Story = {
126
+ args: { children: "1 episode" },
127
+ render: ({ categorical, ...badgeButtonProps }) => (
128
+ <StorySection title="Hover, focus and disabled — hover one, Tab to one, and read the third.">
129
+ <StoryGrid columns={2}>
130
+ <StoryCell label="sm">
131
+ <BadgeButton {...badgeButtonProps} size="sm" />
132
+ </StoryCell>
133
+
134
+ <StoryCell label="md">
135
+ <BadgeButton {...badgeButtonProps} size="md" />
136
+ </StoryCell>
137
+
138
+ <StoryCell label="with a glyph">
139
+ <BadgeButton
140
+ {...badgeButtonProps}
141
+ icon={<DotIcon />}
142
+ intent="success"
143
+ >
144
+ verified
145
+ </BadgeButton>
146
+ </StoryCell>
147
+
148
+ <StoryCell label="disabled">
149
+ <BadgeButton {...badgeButtonProps} isDisabled>
150
+ 1 episode
151
+ </BadgeButton>
152
+ </StoryCell>
153
+ </StoryGrid>
154
+ </StorySection>
155
+ ),
156
+ }
157
+
158
+ /**
159
+ * Where it came from: QueuePilot's poster tile, whose per-entry
160
+ * settings are TAGS. A default entry says nothing and every tag you
161
+ * do see is a deviation worth reading — and each one opens the panel
162
+ * that changes it, which is why they are pressable.
163
+ */
164
+ export const InATileFooter: Story = {
165
+ args: { children: "Edit" },
166
+ render: ({ categorical, ...badgeButtonProps }) => (
167
+ <StorySection title="A row of chips under a poster: three settings and the editor that changes them.">
168
+ <div className="flex max-w-64 flex-wrap items-center gap-1 rounded-md border border-border-subtle bg-surface-raised p-2">
169
+ <span className="w-full text-content-primary text-sm">
170
+ Blade Runner (1982)
171
+ </span>
172
+
173
+ <BadgeButton
174
+ {...badgeButtonProps}
175
+ intent="accent"
176
+ size="sm"
177
+ >
178
+ 3 episodes
179
+ </BadgeButton>
180
+
181
+ <BadgeButton
182
+ {...badgeButtonProps}
183
+ intent="info"
184
+ size="sm"
185
+ >
186
+ 2x weight
187
+ </BadgeButton>
188
+
189
+ <BadgeButton
190
+ {...badgeButtonProps}
191
+ appearance="outline"
192
+ size="sm"
193
+ >
194
+ Edit
195
+ </BadgeButton>
196
+ </div>
197
+ </StorySection>
198
+ ),
199
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import { expect, fn, userEvent } from "storybook/test"
6
+ import { test } from "vitest"
7
+
8
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
9
+ import { mountStory } from "../mountStory.testHelpers.ts"
10
+ import { expectAgentDrivable } from "../testing/index.ts"
11
+ import meta, * as stories from "./BadgeButton.stories.tsx"
12
+
13
+ const { BesideABadge, Default, InATileFooter } =
14
+ composeStories(stories)
15
+
16
+ /**
17
+ * The properties a `<span onClick>` cannot have, and the reason this
18
+ * is a component rather than a `className` on `Badge`.
19
+ */
20
+ test("is a button, not a span", async () => {
21
+ const { canvas } = await mountStory(Default)
22
+
23
+ const chip = expectAgentDrivable(canvas, {
24
+ name: "1 episode",
25
+ role: "button",
26
+ })
27
+
28
+ await expect(chip.tagName).toBe("BUTTON")
29
+ // Never `submit`: these chips sit inside forms, and the platform's
30
+ // default would make "change the start point" save the dialog.
31
+ await expect(chip).toHaveAttribute("type", "button")
32
+ })
33
+
34
+ test("Tab reaches it and Enter fires it", async () => {
35
+ const onClick = fn()
36
+
37
+ const { canvas } = await mountStory(
38
+ composeStory(
39
+ { args: { children: "1 episode", onClick } },
40
+ meta,
41
+ ),
42
+ )
43
+
44
+ const chip = canvas.getByRole("button", {
45
+ name: "1 episode",
46
+ })
47
+
48
+ await userEvent.tab()
49
+ await expect(chip).toHaveFocus()
50
+
51
+ await userEvent.keyboard("{Enter}")
52
+ await expect(onClick).toHaveBeenCalledTimes(1)
53
+
54
+ // Space too — the second half of what the platform gives a
55
+ // `<button>` and gives a pressable `<span>` never.
56
+ await userEvent.keyboard(" ")
57
+ await expect(onClick).toHaveBeenCalledTimes(2)
58
+ })
59
+
60
+ /**
61
+ * The claim the component is named for, measured.
62
+ *
63
+ * `Badge` and `BadgeButton` both assemble their pill through
64
+ * `useBadgeShape`, so there is one source — but a shared hook still
65
+ * leaves room for a stray utility, a different base string, or a
66
+ * wrapper element that changes the box. Comparing **computed**
67
+ * styles is the version of "paints identically" that a refactor
68
+ * cannot quietly break.
69
+ *
70
+ * `cursor` is deliberately NOT in this list: the pressable one is a
71
+ * pointer and the static one is not, which is the difference being
72
+ * asserted elsewhere rather than a drift to catch here.
73
+ */
74
+ const PAINTED_PROPERTIES = [
75
+ "background-color",
76
+ "border-color",
77
+ "border-radius",
78
+ "border-width",
79
+ "color",
80
+ "display",
81
+ "font-size",
82
+ "font-weight",
83
+ "padding-block-end",
84
+ "padding-block-start",
85
+ "padding-inline-end",
86
+ "padding-inline-start",
87
+ ]
88
+
89
+ const getPaint = (element: Element) => {
90
+ const computed = getComputedStyle(element)
91
+
92
+ return Object.fromEntries(
93
+ PAINTED_PROPERTIES.map((property) => [
94
+ property,
95
+ computed.getPropertyValue(property),
96
+ ]),
97
+ )
98
+ }
99
+
100
+ test("paints identically to a Badge with the same props", async () => {
101
+ const { canvas } = await mountStory(BesideABadge)
102
+
103
+ const chip = canvas.getByRole("button", {
104
+ name: "1 episode",
105
+ })
106
+
107
+ // The `Badge` has no role by design, so it is found by its text —
108
+ // which is the handle its own story asserts on.
109
+ const badge = canvas.getByText("1 episode", {
110
+ selector: "span span",
111
+ })
112
+
113
+ await expect(getPaint(chip)).toEqual(
114
+ getPaint(badge.parentElement as Element),
115
+ )
116
+ })
117
+
118
+ test("disabled is the native attribute, and it stops the click", async () => {
119
+ const onClick = fn()
120
+
121
+ const { canvas } = await mountStory(
122
+ composeStory(
123
+ {
124
+ args: {
125
+ children: "1 episode",
126
+ isDisabled: true,
127
+ onClick,
128
+ },
129
+ },
130
+ meta,
131
+ ),
132
+ )
133
+
134
+ const chip = canvas.getByRole("button", {
135
+ name: "1 episode",
136
+ })
137
+
138
+ // The NATIVE attribute, not `aria-disabled`: an inert `<button>`
139
+ // is out of the tab order and ignores a click by the platform's
140
+ // own rules, which is what a pill wearing `aria-disabled` and a
141
+ // live handler never gets.
142
+ await expect(chip).toBeDisabled()
143
+
144
+ await userEvent.click(chip, { pointerEventsCheck: 0 })
145
+
146
+ await expect(onClick).not.toHaveBeenCalled()
147
+ })
148
+
149
+ test("a row of chips is drivable and clean", async () => {
150
+ const { canvas, canvasElement } =
151
+ await mountStory(InATileFooter)
152
+
153
+ for (const name of ["3 episodes", "2x weight", "Edit"]) {
154
+ expectAgentDrivable(canvas, { name, role: "button" })
155
+ }
156
+
157
+ await expectNoAxeViolations(canvasElement)
158
+ })
159
+
160
+ test("the story meta names the component", async () => {
161
+ await expect(meta.title).toBe(
162
+ "Components/Actions/BadgeButton",
163
+ )
164
+ })
@@ -0,0 +1,129 @@
1
+ import type {
2
+ ComponentPropsWithRef,
3
+ ReactNode,
4
+ } from "react"
5
+
6
+ import type { BadgeShapeProps } from "../Badge/useBadgeShape.tsx"
7
+ import { useBadgeShape } from "../Badge/useBadgeShape.tsx"
8
+ import { CATEGORICAL_HOVER_CLASS } from "../categoricalStyles.ts"
9
+ import {
10
+ DISABLED_CLASS,
11
+ FOCUS_RING_CLASS,
12
+ INTENT_HOVER_CLASS,
13
+ } from "../intentStyles.ts"
14
+ import { toClassName } from "../toClassName.ts"
15
+
16
+ export type BadgeButtonProps = Omit<
17
+ ComponentPropsWithRef<"button">,
18
+ "disabled"
19
+ > &
20
+ BadgeShapeProps & {
21
+ /**
22
+ * Required, and that is the component's argument in one line: a
23
+ * pill nobody can press is a `Badge`, and a pill that looks
24
+ * pressable and is not is the defect this exists to stop.
25
+ */
26
+ onClick: NonNullable<
27
+ ComponentPropsWithRef<"button">["onClick"]
28
+ >
29
+ /**
30
+ * `isDisabled`, not `disabled` — the same prop `Button` keeps
31
+ * instead of the native one, so a control's disabled state is
32
+ * spelled one way across the library.
33
+ */
34
+ isDisabled?: boolean
35
+ /**
36
+ * See `BadgeShapeProps`. Restated only to refuse the case where
37
+ * both colours arrive.
38
+ */
39
+ } & ({ categorical?: never } | { intent?: never })
40
+
41
+ /**
42
+ * A badge you can press.
43
+ *
44
+ * A sibling component rather than a prop, exactly as `ButtonLink` is
45
+ * to `Button`: the paint is shared (both call `useBadgeShape`, so
46
+ * there is one class list and `BadgeButton.test.tsx` compares the
47
+ * two elements' *computed* styles rather than trusting that), and
48
+ * what differs is the element and everything the platform hangs off
49
+ * it.
50
+ *
51
+ * **Why not `asChild`, and why not `Badge` growing an `onClick`.**
52
+ * This library has no polymorphism pattern and has not needed one;
53
+ * adding it here would mean every future component answering "can it
54
+ * be something else?" instead of "what is it?". And an element type
55
+ * that changes as a *side effect* of passing a handler is a footgun
56
+ * with no error: forget the handler and a control silently ships as
57
+ * a `<span>` — not focusable, not in the tab order, invisible to
58
+ * `getByRole("button")` and to every agent driving the app.
59
+ *
60
+ * **What the element buys, and none of it is paint.** Focus, the tab
61
+ * order, Enter and Space, `:disabled`, form participation, and a
62
+ * `button` role a screen reader announces as pressable. QueuePilot
63
+ * had six pills doing all of this with a hand-rolled
64
+ * `<button className="badge …">`, which is where this came from —
65
+ * six chips that open an editor, change a start point, or drop a
66
+ * title from a pool.
67
+ *
68
+ * **`type="button"` by default.** These chips sit inside forms —
69
+ * QueuePilot's entry sheet is one — and the platform's default of
70
+ * `submit` would make a "change the start point" chip save the whole
71
+ * dialog. Overridable, because a badge-shaped submit is a real thing
72
+ * and the default is a safe floor rather than a ban.
73
+ *
74
+ * **`ghost` is still excluded**, inherited from `Badge`: a pill that
75
+ * paints nothing until hovered has no pill left, and a control that
76
+ * only advertises itself on hover cannot be found by touch.
77
+ */
78
+ export const BadgeButton = ({
79
+ appearance = "soft",
80
+ categorical,
81
+ children,
82
+ className,
83
+ icon,
84
+ intent = "neutral",
85
+ isDisabled = false,
86
+ overflow,
87
+ size,
88
+ title,
89
+ type = "button",
90
+ ...buttonProps
91
+ }: BadgeButtonProps): ReactNode => {
92
+ const shape = useBadgeShape({
93
+ appearance,
94
+ categorical,
95
+ children,
96
+ icon,
97
+ intent,
98
+ overflow,
99
+ size,
100
+ title,
101
+ })
102
+
103
+ return (
104
+ <button
105
+ {...buttonProps}
106
+ className={toClassName(
107
+ shape.className,
108
+ "cursor-pointer",
109
+ // The three states the `<span>` has no business carrying,
110
+ // keyed the same way the appearance above was.
111
+ categorical === undefined
112
+ ? INTENT_HOVER_CLASS[intent][appearance]
113
+ : CATEGORICAL_HOVER_CLASS[categorical][
114
+ appearance
115
+ ],
116
+ FOCUS_RING_CLASS,
117
+ DISABLED_CLASS,
118
+ // Last, so a caller's layout beats the defaults above — the
119
+ // same order `getControlClassName` uses.
120
+ className,
121
+ )}
122
+ disabled={isDisabled}
123
+ title={shape.title}
124
+ type={type}
125
+ >
126
+ {shape.content}
127
+ </button>
128
+ )
129
+ }
package/src/Card/Card.mdx CHANGED
@@ -51,3 +51,24 @@ The header stacks below `--cq-sm` and sits on one row above it, and the containe
51
51
  cannot tell apart, and it is where the fleet's poster grids look wrong today.
52
52
 
53
53
  <Canvas of={CardStories.Responsive} />
54
+
55
+ ## The accent edge follows the corner
56
+
57
+ `accentEdge` draws a coloured bar down the leading edge. `{ categorical: 3 }` is a colour a
58
+ user picked, from the ten-wide contrast-audited family; `{ color }` is one the app computed,
59
+ for the case where ten indices are not enough — Folio hashes a repo's name so a repo added
60
+ tomorrow already has a hue and nobody maintains a palette.
61
+
62
+ > **It is a pseudo-element, not a border, and that is the whole point.** Three apps grew this
63
+ > shape independently and all three drew a straight border beside a rounded box, so the bar
64
+ > ran past the curve and stopped square while the card was round. A border is painted on the
65
+ > border box; it cannot follow a corner. The overlay here takes `border-radius: inherit`, so
66
+ > the bar reads the radius off whatever card it is on and nobody states one twice.
67
+
68
+ Two shapes this deliberately is not. **Not `box-shadow: inset` on the card itself** — `Card`
69
+ carries `shadow-low` from the elevation scale, and a `box-shadow` at the call site outranks
70
+ that utility and takes the elevation with it. **Not a 3px-wide pseudo-element with a rounded
71
+ leading edge** — a `0.5rem` radius on a 3px box is clamped to fit, so it caps the bar instead
72
+ of following the curve.
73
+
74
+ <Canvas of={CardStories.AccentEdge} />
@@ -13,12 +13,18 @@ import { IconButton } from "../IconButton/IconButton.tsx"
13
13
  import { SettingsIcon } from "../icons.storyHelpers.tsx"
14
14
  import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
15
15
  import { Card, ELEVATION_CLASS } from "./Card.tsx"
16
+ import { ACCENT_EDGE_CATEGORICAL_CLASS } from "./cardAccentEdge.ts"
16
17
 
17
18
  const meta = {
18
19
  title: "Components/Layout/Card",
19
20
  component: Card,
20
21
  parameters: { layout: "padded" },
21
22
  argTypes: {
23
+ // An object union, so an object control is the right one — but
24
+ // it is stated rather than inferred, because docgen's fallback
25
+ // for a type it cannot read is also an object control and the
26
+ // two are indistinguishable in the panel.
27
+ accentEdge: { control: "object" },
22
28
  elevation: toStoryChoice(
23
29
  Object.keys(
24
30
  ELEVATION_CLASS,
@@ -205,3 +211,88 @@ export const Interactive: Story = {
205
211
  </div>
206
212
  ),
207
213
  }
214
+
215
+ /**
216
+ * The leading edge, and the two answers the fleet has for what
217
+ * colour it is.
218
+ *
219
+ * A **categorical index** is a colour a user picked — Docket's
220
+ * project colours come from this ten-wide family, and every pair in
221
+ * it is contrast-audited. An explicit **colour** is one the app
222
+ * computed: Folio hashes a repo's name so a repo added tomorrow
223
+ * already has a hue and nobody maintains a palette, which is 360
224
+ * answers rather than ten.
225
+ *
226
+ * Both bars follow the card's own corner, because the treatment
227
+ * inherits its radius instead of naming one. Every app that grew
228
+ * this shape by hand drew it as a straight border beside a rounded
229
+ * box, and a border cannot follow a curve.
230
+ */
231
+ export const AccentEdge: Story = {
232
+ args: { children: "Content" },
233
+ render: () => (
234
+ <StorySection title="A categorical index, and an app's own colour. Both take the card's radius.">
235
+ <StoryGrid columns={3}>
236
+ {(
237
+ Object.keys(
238
+ ACCENT_EDGE_CATEGORICAL_CLASS,
239
+ ) as unknown as (keyof typeof ACCENT_EDGE_CATEGORICAL_CLASS)[]
240
+ ).map((index) => (
241
+ <StoryCell
242
+ align="stretch"
243
+ key={index}
244
+ label={`categorical ${index}`}
245
+ >
246
+ <Card
247
+ accentEdge={{
248
+ categorical: Number(index) as 1,
249
+ }}
250
+ heading={`Project ${index}`}
251
+ >
252
+ <p className="text-content-secondary text-sm">
253
+ A colour the user picked.
254
+ </p>
255
+ </Card>
256
+ </StoryCell>
257
+ ))}
258
+
259
+ <StoryCell align="stretch" label="a hashed hue">
260
+ <Card
261
+ accentEdge={{ color: "hsl(287 55% 52%)" }}
262
+ heading="home-assistant"
263
+ >
264
+ <p className="text-content-secondary text-sm">
265
+ A hue hashed from the name, not an index.
266
+ </p>
267
+ </Card>
268
+ </StoryCell>
269
+
270
+ <StoryCell
271
+ align="stretch"
272
+ label="a rounder card, same rule"
273
+ >
274
+ <Card
275
+ accentEdge={{ color: "hsl(24 55% 52%)" }}
276
+ className="rounded-3xl"
277
+ heading="A wider radius"
278
+ >
279
+ <p className="text-content-secondary text-sm">
280
+ The bar reads the radius off the card rather
281
+ than naming one, so an override still lines
282
+ up.
283
+ </p>
284
+ </Card>
285
+ </StoryCell>
286
+
287
+ <StoryCell align="stretch" label="a link inside it">
288
+ <Card
289
+ accentEdge={{ categorical: 4 }}
290
+ heading="Still clickable"
291
+ >
292
+ <Button size="sm">Open</Button>
293
+ </Card>
294
+ </StoryCell>
295
+ </StoryGrid>
296
+ </StorySection>
297
+ ),
298
+ }