@charcuterie/ui 0.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +161 -0
  3. package/dist/Alert/Alert.d.ts +88 -0
  4. package/dist/Alert/Alert.d.ts.map +1 -0
  5. package/dist/Alert/Alert.js +67 -0
  6. package/dist/Alert/Alert.js.map +1 -0
  7. package/dist/Badge/Badge.d.ts +54 -0
  8. package/dist/Badge/Badge.d.ts.map +1 -0
  9. package/dist/Badge/Badge.js +54 -0
  10. package/dist/Badge/Badge.js.map +1 -0
  11. package/dist/Badge/useIsTextClipped.d.ts +22 -0
  12. package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
  13. package/dist/Badge/useIsTextClipped.js +57 -0
  14. package/dist/Badge/useIsTextClipped.js.map +1 -0
  15. package/dist/Button/Button.d.ts +58 -0
  16. package/dist/Button/Button.d.ts.map +1 -0
  17. package/dist/Button/Button.js +37 -0
  18. package/dist/Button/Button.js.map +1 -0
  19. package/dist/Card/Card.d.ts +44 -0
  20. package/dist/Card/Card.d.ts.map +1 -0
  21. package/dist/Card/Card.js +59 -0
  22. package/dist/Card/Card.js.map +1 -0
  23. package/dist/EmptyState/EmptyState.d.ts +42 -0
  24. package/dist/EmptyState/EmptyState.d.ts.map +1 -0
  25. package/dist/EmptyState/EmptyState.js +37 -0
  26. package/dist/EmptyState/EmptyState.js.map +1 -0
  27. package/dist/IconButton/IconButton.d.ts +34 -0
  28. package/dist/IconButton/IconButton.d.ts.map +1 -0
  29. package/dist/IconButton/IconButton.js +23 -0
  30. package/dist/IconButton/IconButton.js.map +1 -0
  31. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
  32. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
  33. package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
  34. package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
  35. package/dist/MediaTile/MediaTile.d.ts +46 -0
  36. package/dist/MediaTile/MediaTile.d.ts.map +1 -0
  37. package/dist/MediaTile/MediaTile.js +79 -0
  38. package/dist/MediaTile/MediaTile.js.map +1 -0
  39. package/dist/MediaTile/mediaStatus.d.ts +22 -0
  40. package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
  41. package/dist/MediaTile/mediaStatus.js +24 -0
  42. package/dist/MediaTile/mediaStatus.js.map +1 -0
  43. package/dist/Modal/Modal.d.ts +66 -0
  44. package/dist/Modal/Modal.d.ts.map +1 -0
  45. package/dist/Modal/Modal.js +111 -0
  46. package/dist/Modal/Modal.js.map +1 -0
  47. package/dist/Modal/lockScrollBehind.d.ts +22 -0
  48. package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
  49. package/dist/Modal/lockScrollBehind.js +48 -0
  50. package/dist/Modal/lockScrollBehind.js.map +1 -0
  51. package/dist/Popover/Popover.d.ts +66 -0
  52. package/dist/Popover/Popover.d.ts.map +1 -0
  53. package/dist/Popover/Popover.js +88 -0
  54. package/dist/Popover/Popover.js.map +1 -0
  55. package/dist/ProgressBar/ProgressBar.d.ts +51 -0
  56. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
  57. package/dist/ProgressBar/ProgressBar.js +50 -0
  58. package/dist/ProgressBar/ProgressBar.js.map +1 -0
  59. package/dist/ProgressBar/progressValue.d.ts +44 -0
  60. package/dist/ProgressBar/progressValue.d.ts.map +1 -0
  61. package/dist/ProgressBar/progressValue.js +55 -0
  62. package/dist/ProgressBar/progressValue.js.map +1 -0
  63. package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
  64. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  65. package/dist/SegmentedControl/SegmentedControl.js +129 -0
  66. package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
  67. package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
  68. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
  69. package/dist/SegmentedControl/SegmentedOption.js +40 -0
  70. package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
  71. package/dist/Skeleton/Skeleton.d.ts +30 -0
  72. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  73. package/dist/Skeleton/Skeleton.js +43 -0
  74. package/dist/Skeleton/Skeleton.js.map +1 -0
  75. package/dist/Spinner/Spinner.d.ts +37 -0
  76. package/dist/Spinner/Spinner.d.ts.map +1 -0
  77. package/dist/Spinner/Spinner.js +33 -0
  78. package/dist/Spinner/Spinner.js.map +1 -0
  79. package/dist/Tabs/TabTrigger.d.ts +32 -0
  80. package/dist/Tabs/TabTrigger.d.ts.map +1 -0
  81. package/dist/Tabs/TabTrigger.js +62 -0
  82. package/dist/Tabs/TabTrigger.js.map +1 -0
  83. package/dist/Tabs/Tabs.d.ts +95 -0
  84. package/dist/Tabs/Tabs.d.ts.map +1 -0
  85. package/dist/Tabs/Tabs.js +195 -0
  86. package/dist/Tabs/Tabs.js.map +1 -0
  87. package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
  88. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  89. package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
  90. package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
  91. package/dist/controlStyles.d.ts +50 -0
  92. package/dist/controlStyles.d.ts.map +1 -0
  93. package/dist/controlStyles.js +60 -0
  94. package/dist/controlStyles.js.map +1 -0
  95. package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
  96. package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
  97. package/dist/expectNoAxeViolations.testHelpers.js +32 -0
  98. package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
  99. package/dist/index.d.ts +75 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +55 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/intentStyles.d.ts +76 -0
  104. package/dist/intentStyles.d.ts.map +1 -0
  105. package/dist/intentStyles.js +150 -0
  106. package/dist/intentStyles.js.map +1 -0
  107. package/dist/mountStory.testHelpers.d.ts +30 -0
  108. package/dist/mountStory.testHelpers.d.ts.map +1 -0
  109. package/dist/mountStory.testHelpers.js +19 -0
  110. package/dist/mountStory.testHelpers.js.map +1 -0
  111. package/dist/statusIntent.d.ts +33 -0
  112. package/dist/statusIntent.d.ts.map +1 -0
  113. package/dist/statusIntent.js +99 -0
  114. package/dist/statusIntent.js.map +1 -0
  115. package/dist/styles.css +130 -0
  116. package/dist/testing/expectAgentDrivable.d.ts +53 -0
  117. package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
  118. package/dist/testing/expectAgentDrivable.js +163 -0
  119. package/dist/testing/expectAgentDrivable.js.map +1 -0
  120. package/dist/testing/index.d.ts +11 -0
  121. package/dist/testing/index.d.ts.map +1 -0
  122. package/dist/testing/index.js +10 -0
  123. package/dist/testing/index.js.map +1 -0
  124. package/dist/toClassName.d.ts +12 -0
  125. package/dist/toClassName.d.ts.map +1 -0
  126. package/dist/toClassName.js +12 -0
  127. package/dist/toClassName.js.map +1 -0
  128. package/dist/tokens.d.ts +16 -0
  129. package/dist/tokens.d.ts.map +1 -0
  130. package/dist/tokens.js +16 -0
  131. package/dist/tokens.js.map +1 -0
  132. package/package.json +70 -0
  133. package/src/Alert/Alert.mdx +70 -0
  134. package/src/Alert/Alert.stories.tsx +218 -0
  135. package/src/Alert/Alert.test.tsx +112 -0
  136. package/src/Alert/Alert.tsx +166 -0
  137. package/src/Badge/Badge.mdx +43 -0
  138. package/src/Badge/Badge.stories.tsx +229 -0
  139. package/src/Badge/Badge.test.tsx +153 -0
  140. package/src/Badge/Badge.tsx +130 -0
  141. package/src/Badge/useIsTextClipped.ts +77 -0
  142. package/src/Button/Button.mdx +44 -0
  143. package/src/Button/Button.stories.tsx +175 -0
  144. package/src/Button/Button.test.tsx +114 -0
  145. package/src/Button/Button.tsx +125 -0
  146. package/src/Card/Card.mdx +53 -0
  147. package/src/Card/Card.stories.tsx +207 -0
  148. package/src/Card/Card.test.tsx +56 -0
  149. package/src/Card/Card.tsx +142 -0
  150. package/src/EmptyState/EmptyState.mdx +40 -0
  151. package/src/EmptyState/EmptyState.stories.tsx +183 -0
  152. package/src/EmptyState/EmptyState.test.tsx +41 -0
  153. package/src/EmptyState/EmptyState.tsx +114 -0
  154. package/src/IconButton/IconButton.mdx +34 -0
  155. package/src/IconButton/IconButton.stories.tsx +191 -0
  156. package/src/IconButton/IconButton.test.tsx +70 -0
  157. package/src/IconButton/IconButton.tsx +54 -0
  158. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
  159. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
  160. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
  161. package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
  162. package/src/MediaTile/MediaTile.mdx +51 -0
  163. package/src/MediaTile/MediaTile.stories.tsx +211 -0
  164. package/src/MediaTile/MediaTile.test.tsx +41 -0
  165. package/src/MediaTile/MediaTile.tsx +227 -0
  166. package/src/MediaTile/mediaStatus.ts +28 -0
  167. package/src/Modal/Modal.mdx +113 -0
  168. package/src/Modal/Modal.stories.tsx +272 -0
  169. package/src/Modal/Modal.test.tsx +248 -0
  170. package/src/Modal/Modal.tsx +228 -0
  171. package/src/Modal/lockScrollBehind.ts +59 -0
  172. package/src/Popover/Popover.mdx +102 -0
  173. package/src/Popover/Popover.stories.tsx +247 -0
  174. package/src/Popover/Popover.test.tsx +191 -0
  175. package/src/Popover/Popover.tsx +164 -0
  176. package/src/ProgressBar/ProgressBar.mdx +48 -0
  177. package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
  178. package/src/ProgressBar/ProgressBar.test.tsx +74 -0
  179. package/src/ProgressBar/ProgressBar.tsx +172 -0
  180. package/src/ProgressBar/progressValue.test.ts +101 -0
  181. package/src/ProgressBar/progressValue.ts +87 -0
  182. package/src/SegmentedControl/SegmentedControl.mdx +71 -0
  183. package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
  184. package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
  185. package/src/SegmentedControl/SegmentedControl.tsx +219 -0
  186. package/src/SegmentedControl/SegmentedOption.tsx +94 -0
  187. package/src/Skeleton/Skeleton.mdx +30 -0
  188. package/src/Skeleton/Skeleton.stories.tsx +130 -0
  189. package/src/Skeleton/Skeleton.test.tsx +47 -0
  190. package/src/Skeleton/Skeleton.tsx +106 -0
  191. package/src/Spinner/Spinner.mdx +36 -0
  192. package/src/Spinner/Spinner.stories.tsx +118 -0
  193. package/src/Spinner/Spinner.test.tsx +38 -0
  194. package/src/Spinner/Spinner.tsx +92 -0
  195. package/src/Tabs/TabTrigger.tsx +126 -0
  196. package/src/Tabs/Tabs.mdx +126 -0
  197. package/src/Tabs/Tabs.stories.tsx +187 -0
  198. package/src/Tabs/Tabs.test.tsx +283 -0
  199. package/src/Tabs/Tabs.tsx +333 -0
  200. package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
  201. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
  202. package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  203. package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
  204. package/src/argTypes.storyHelpers.ts +115 -0
  205. package/src/board.storyHelpers.tsx +150 -0
  206. package/src/controlStyles.ts +79 -0
  207. package/src/expectNoAxeViolations.testHelpers.ts +41 -0
  208. package/src/icons.storyHelpers.tsx +116 -0
  209. package/src/index.ts +132 -0
  210. package/src/intentStyles.ts +207 -0
  211. package/src/mdxReferences.test.ts +95 -0
  212. package/src/mountStory.testHelpers.ts +41 -0
  213. package/src/sourceRules.test.ts +303 -0
  214. package/src/statusIntent.test.ts +136 -0
  215. package/src/statusIntent.ts +137 -0
  216. package/src/storyControls.test.ts +128 -0
  217. package/src/styles.css +130 -0
  218. package/src/tailwindCandidates.test.ts +377 -0
  219. package/src/testing/expectAgentDrivable.test.tsx +71 -0
  220. package/src/testing/expectAgentDrivable.ts +273 -0
  221. package/src/testing/index.ts +17 -0
  222. package/src/toClassName.ts +13 -0
  223. package/src/tokens.ts +16 -0
@@ -0,0 +1,218 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ intentArgType,
5
+ toStoryChoice,
6
+ } from "../argTypes.storyHelpers.ts"
7
+ import { Button } from "../Button/Button.tsx"
8
+ import {
9
+ ContainerBoard,
10
+ StoryCell,
11
+ StoryGrid,
12
+ StorySection,
13
+ } from "../board.storyHelpers.tsx"
14
+ import { Alert } from "./Alert.tsx"
15
+
16
+ const INTENTS = [
17
+ "neutral",
18
+ "accent",
19
+ "success",
20
+ "warning",
21
+ "danger",
22
+ "info",
23
+ ] as const
24
+
25
+ const meta = {
26
+ title: "Components/Alert",
27
+ component: Alert,
28
+ parameters: { layout: "padded" },
29
+ argTypes: {
30
+ intent: intentArgType,
31
+ size: toStoryChoice(["sm", "md"] as const),
32
+ },
33
+ // The component's own defaults, restated — Storybook does not seed
34
+ // `args` from docgen, so an unstated default shows in the props
35
+ // table with nothing selected in its control.
36
+ args: {
37
+ intent: "neutral",
38
+ size: "md",
39
+ },
40
+ } satisfies Meta<typeof Alert>
41
+
42
+ export default meta
43
+
44
+ type Story = StoryObj<typeof meta>
45
+
46
+ /**
47
+ * rip-deck's grouped tower alert, verbatim: one trouble, the bays it
48
+ * touches, and whether the house speakers already said it out loud.
49
+ */
50
+ export const Default: Story = {
51
+ args: {
52
+ description: "4 bays · 3, 4, 5, 6 · confirmed",
53
+ heading:
54
+ "Four bays stalled at once — check the USB hub's power",
55
+ intent: "danger",
56
+ },
57
+ }
58
+
59
+ export const AllVariants: Story = {
60
+ args: { heading: "The disc may need cleaning" },
61
+ render: (alertProps) => (
62
+ <StorySection title="Six intents. rip-deck declares this map twice, in two files, with hardcoded hexes and no light mode.">
63
+ <StoryGrid columns={3}>
64
+ {INTENTS.map((intent) => (
65
+ <StoryCell
66
+ align="stretch"
67
+ key={intent}
68
+ label={intent}
69
+ >
70
+ <Alert
71
+ {...alertProps}
72
+ heading={`intent: ${intent}`}
73
+ intent={intent}
74
+ />
75
+ </StoryCell>
76
+ ))}
77
+ </StoryGrid>
78
+ </StorySection>
79
+ ),
80
+ }
81
+
82
+ /**
83
+ * Every part is optional except the sentence, and `size="sm"` is the
84
+ * in-card form — rip-deck's verdict chip, which sits under a bay's
85
+ * progress bar rather than at the top of the page.
86
+ */
87
+ export const AllStates: Story = {
88
+ args: { heading: "The disc may need cleaning" },
89
+ render: (alertProps) => (
90
+ <StorySection title="An alert is static: no hover, no focus, no disabled. What varies is which parts are present.">
91
+ <StoryGrid columns={2}>
92
+ <StoryCell align="stretch" label="heading only">
93
+ <Alert {...alertProps} intent="warning" />
94
+ </StoryCell>
95
+
96
+ <StoryCell
97
+ align="stretch"
98
+ label="heading + description"
99
+ >
100
+ <Alert
101
+ {...alertProps}
102
+ description="Bay 4 · suspected"
103
+ intent="warning"
104
+ />
105
+ </StoryCell>
106
+
107
+ <StoryCell align="stretch" label="with evidence">
108
+ <Alert
109
+ {...alertProps}
110
+ details={[
111
+ "3 read errors in 40 s",
112
+ "throughput 1.2x against a 6.0x baseline",
113
+ ]}
114
+ intent="warning"
115
+ />
116
+ </StoryCell>
117
+
118
+ <StoryCell align="stretch" label="with an action">
119
+ <Alert
120
+ {...alertProps}
121
+ actions={
122
+ <Button size="sm" appearance="outline">
123
+ Open trays
124
+ </Button>
125
+ }
126
+ description="2 discs · slots 4, 7"
127
+ heading="There are still discs in the tower"
128
+ intent="info"
129
+ />
130
+ </StoryCell>
131
+
132
+ <StoryCell
133
+ align="stretch"
134
+ label='size="sm" — the in-card verdict'
135
+ >
136
+ <Alert
137
+ {...alertProps}
138
+ details={["seen on bays 3 and 7"]}
139
+ heading="Part of the tower-wide problem above."
140
+ intent="danger"
141
+ size="sm"
142
+ />
143
+ </StoryCell>
144
+
145
+ <StoryCell
146
+ align="stretch"
147
+ label="label ⇒ a named landmark"
148
+ >
149
+ <Alert
150
+ {...alertProps}
151
+ heading="The USB bus is flapping"
152
+ intent="danger"
153
+ label="USB connection alert"
154
+ />
155
+ </StoryCell>
156
+ </StoryGrid>
157
+ </StorySection>
158
+ ),
159
+ }
160
+
161
+ /**
162
+ * The `@container` claim: actions sit beside the sentence when there
163
+ * is room and drop under it when there is not — and the width that
164
+ * decides is the **panel's**, not the window's. A banner in a
165
+ * sidebar and the same banner full-bleed is exactly the pair a media
166
+ * query cannot tell apart.
167
+ */
168
+ export const Responsive: Story = {
169
+ args: {
170
+ description: "2 discs · slots 4, 7",
171
+ heading: "There are still discs in the tower",
172
+ intent: "info",
173
+ },
174
+ render: (alertProps) => (
175
+ <ContainerBoard>
176
+ {(width) => (
177
+ <Alert
178
+ {...alertProps}
179
+ actions={
180
+ <Button size="sm" appearance="outline">
181
+ Open trays
182
+ </Button>
183
+ }
184
+ label={`Discs still in the tower at ${width}`}
185
+ />
186
+ )}
187
+ </ContainerBoard>
188
+ ),
189
+ }
190
+
191
+ /**
192
+ * An alert is not interactive, so what there is to drive is what it
193
+ * *contains*. The action is an ordinary `Button` and stays reachable
194
+ * by role and name from inside the landmark — which is the whole
195
+ * reason `label` exists.
196
+ */
197
+ export const Interactive: Story = {
198
+ args: {
199
+ details: [
200
+ "3 read errors in 40 s",
201
+ "throughput 1.2x against a 6.0x baseline",
202
+ ],
203
+ heading:
204
+ "Bay 7 is reading with errors — clean the disc",
205
+ intent: "warning",
206
+ label: "Bay 7 verdict",
207
+ },
208
+ render: (alertProps) => (
209
+ <Alert
210
+ {...alertProps}
211
+ actions={
212
+ <Button size="sm" appearance="outline">
213
+ Retry in another bay
214
+ </Button>
215
+ }
216
+ />
217
+ ),
218
+ }
@@ -0,0 +1,112 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Alert.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive, Responsive } =
11
+ composeStories(stories)
12
+
13
+ test("a labelled alert is a landmark an agent can scope to", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Interactive)
16
+
17
+ const alert = expectAgentDrivable(canvas, {
18
+ name: "Bay 7 verdict",
19
+ role: "region",
20
+ })
21
+
22
+ // The whole reason `label` exists: the action inside is reachable
23
+ // *through* the alert, which is what disambiguates one of nine
24
+ // identical "Retry" buttons on a nine-bay tower.
25
+ await expect(alert).toContainElement(
26
+ expectAgentDrivable(canvas, {
27
+ name: "Retry in another bay",
28
+ role: "button",
29
+ }),
30
+ )
31
+
32
+ await expectNoAxeViolations(canvasElement)
33
+ })
34
+
35
+ test("an unlabelled alert is not a landmark", async () => {
36
+ const { canvas, canvasElement } =
37
+ await mountStory(Default)
38
+
39
+ // A block, deliberately. Nine bay cards each holding a named
40
+ // region called "Part of the tower-wide problem above." is axe's
41
+ // `landmark-unique`, so the default has to be the quiet one.
42
+ await expect(
43
+ canvas.queryAllByRole("region"),
44
+ ).toHaveLength(0)
45
+
46
+ await expect(
47
+ canvas.getByText(
48
+ "Four bays stalled at once — check the USB hub's power",
49
+ ),
50
+ ).toBeVisible()
51
+
52
+ await expectNoAxeViolations(canvasElement)
53
+ })
54
+
55
+ test("the evidence is a list, and repeated evidence still renders", async () => {
56
+ const { canvas, canvasElement } =
57
+ await mountStory(Interactive)
58
+
59
+ const list = canvas.getByRole("list")
60
+
61
+ await expect(
62
+ canvas.getAllByRole("listitem"),
63
+ ).toHaveLength(2)
64
+
65
+ await expect(list).toHaveTextContent(
66
+ "3 read errors in 40 s",
67
+ )
68
+
69
+ await expectNoAxeViolations(canvasElement)
70
+ })
71
+
72
+ /**
73
+ * Six landmarks in one board, and every one of them differently
74
+ * named — because two `<section>`s sharing a name is a violation
75
+ * rather than a duplicate, which is exactly why the default is a
76
+ * plain block.
77
+ */
78
+ test("the whole variants board is clean under axe", async () => {
79
+ const { canvasElement } = await mountStory(AllStates)
80
+
81
+ await expectNoAxeViolations(canvasElement)
82
+ })
83
+
84
+ test("the actions drop below the sentence in a narrow container", async () => {
85
+ const { canvas } = await mountStory(Responsive)
86
+
87
+ const narrow = expectAgentDrivable(canvas, {
88
+ name: "Discs still in the tower at 15rem",
89
+ role: "region",
90
+ })
91
+
92
+ const wide = expectAgentDrivable(canvas, {
93
+ name: "Discs still in the tower at 34rem",
94
+ role: "region",
95
+ })
96
+
97
+ const getRowDirection = (alert: HTMLElement) =>
98
+ globalThis.getComputedStyle(
99
+ alert.firstElementChild as HTMLElement,
100
+ ).flexDirection
101
+
102
+ // The **axis**, not the height. The first draft of this test
103
+ // compared the two rows' heights and passed with `cq-sm:flex-row`
104
+ // deleted — because at 15rem the sentence wraps onto more lines
105
+ // whatever the button does, so it was measuring text wrapping and
106
+ // calling it a container query. This is the thing itself: at 15rem
107
+ // the button sits under the text, at 34rem beside it, and the
108
+ // width that decided is the panel's rather than the window's.
109
+ await expect(getRowDirection(narrow)).toBe("column")
110
+
111
+ await expect(getRowDirection(wide)).toBe("row")
112
+ })
@@ -0,0 +1,166 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+
7
+ import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
8
+ import { toClassName } from "../toClassName.ts"
9
+
10
+ export type AlertSize = "md" | "sm"
11
+
12
+ export type AlertProps = Omit<
13
+ ComponentPropsWithRef<"section">,
14
+ "children"
15
+ > & {
16
+ /** Buttons. Laid out beside the text at `cq-sm` and up. */
17
+ actions?: ReactNode
18
+ /** The quiet second line — counts, timestamps, provenance. */
19
+ description?: ReactNode
20
+ /**
21
+ * The evidence, one line each. A list rather than a paragraph
22
+ * because that is what it is, and because a screen reader then
23
+ * announces "list, 3 items" instead of running three measurements
24
+ * together into one sentence.
25
+ */
26
+ details?: readonly string[]
27
+ /**
28
+ * The sentence. A `ReactNode` rather than a `string` because
29
+ * rip-deck's verdicts qualify themselves inline — *"…(suspected —
30
+ * retry in another drive to confirm)"* — and splitting that across
31
+ * two props would let the qualifier drift away from the claim it
32
+ * qualifies.
33
+ */
34
+ heading: ReactNode
35
+ intent?: IntentName
36
+ /**
37
+ * Present ⇒ this is a **named landmark** and
38
+ * `getByRole("region", { name })` finds it. Absent ⇒ a plain
39
+ * block, because a `<section>` with no accessible name is
40
+ * `generic` rather than `region`. See below; the default is
41
+ * deliberate.
42
+ */
43
+ label?: string
44
+ size?: AlertSize
45
+ }
46
+
47
+ const SIZE_CLASS: Record<AlertSize, string> = {
48
+ sm: "gap-0.5 rounded-md px-2 py-1 text-sm",
49
+ md: "gap-1 rounded-xl px-3.5 py-2.5 text-md",
50
+ }
51
+
52
+ /**
53
+ * The thing four of rip-deck's components each spell differently,
54
+ * and the reason `TONE_CLASS` was declared twice in one package.
55
+ *
56
+ * `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
57
+ * `VerdictBadge` are one shape: an intent-coloured block holding a
58
+ * sentence, a quieter line under it, and sometimes a list of
59
+ * evidence. Two of them carried a byte-identical `TONE_CLASS` map of
60
+ * hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
61
+ * same idea a third way in `statusClassMap`. Here it is
62
+ * `INTENT_APPEARANCE_CLASS`, which every other component in this
63
+ * package already reads.
64
+ *
65
+ * ### It is a block by default, and a landmark only when named
66
+ *
67
+ * `label` is what decides, because the evidence divides cleanly:
68
+ *
69
+ * - The **banners** are one alert at the top of a page and want to
70
+ * be addressable — `getByRole("region", { name: "USB connection
71
+ * alert" })`. They pass a `label`.
72
+ * - The **verdict** renders inside every bay card, and one of its
73
+ * two forms is the single sentence *"Part of the tower-wide
74
+ * problem above."* Nine landmarks sharing one name is axe's
75
+ * `landmark-unique`, so it passes none.
76
+ *
77
+ * A component that always took the landmark would have made the
78
+ * second case fail its own a11y gate, and one that never took it
79
+ * would leave three `<section aria-label>` wrappers hand-rolled in
80
+ * the consumer — which is what rip-deck has today.
81
+ *
82
+ * ### The heading is not a heading element
83
+ *
84
+ * An alert's headline is a *sentence* — "Bay 7 is reading with
85
+ * errors; clean the disc and try again" — not a node in the document
86
+ * outline. Nine bays' worth of those as `<h3>`s builds a table of
87
+ * contents out of nine complaints, which is worse for a screen
88
+ * reader than no outline entry at all. `Card` and `EmptyState` take
89
+ * a real `headingLevel` precisely because their headings *are* short
90
+ * outline nodes; this one is styled text, and `label` is how it
91
+ * becomes findable.
92
+ *
93
+ * ### It does not announce, and that is a component boundary
94
+ *
95
+ * No `role="alert"`, no `aria-live`. A live region takes **no
96
+ * accessible name from its content**
97
+ * ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
98
+ * so an announcing variant would need a name that duplicates the
99
+ * sentence it is announcing — and `role="alert"` on a `<section>`
100
+ * overrides the landmark, so it cannot be both. A thing that
101
+ * interrupts you is a `Toast` (M6). This is a thing that is *on the
102
+ * page*.
103
+ */
104
+ export const Alert = ({
105
+ actions,
106
+ className,
107
+ description,
108
+ details,
109
+ heading,
110
+ intent = "neutral",
111
+ label,
112
+ size = "md",
113
+ ...sectionProps
114
+ }: AlertProps): ReactNode => {
115
+ return (
116
+ // Always a `<section>`, and the landmark still depends on
117
+ // `label` — because HTML already says so. A `<section>` maps to
118
+ // `region` **only when it has an accessible name**; without one
119
+ // it is `generic`, which is exactly the "plain block" the
120
+ // in-card verdict wants. Branching the element type here would
121
+ // have been a second, weaker statement of a rule the platform
122
+ // already enforces.
123
+ <section
124
+ {...sectionProps}
125
+ aria-label={label}
126
+ className={toClassName(
127
+ // `@container` so the actions drop below the text in a
128
+ // sidebar and sit beside it full-bleed — the case a media
129
+ // query cannot tell apart.
130
+ "@container border",
131
+ INTENT_APPEARANCE_CLASS[intent].soft,
132
+ SIZE_CLASS[size],
133
+ className,
134
+ )}
135
+ >
136
+ <div className="flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between">
137
+ <div className="flex min-w-0 flex-col">
138
+ <p className="m-0 font-semibold">{heading}</p>
139
+
140
+ {description ? (
141
+ <p className="m-0 text-sm opacity-80">
142
+ {description}
143
+ </p>
144
+ ) : null}
145
+ </div>
146
+
147
+ {actions ? (
148
+ <div className="flex shrink-0 items-center gap-1">
149
+ {actions}
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+
154
+ {details && details.length > 0 ? (
155
+ <ul className="m-0 list-none p-0 text-sm opacity-80">
156
+ {details.map((line, index) => (
157
+ // The line's own text is not a key: evidence repeats.
158
+ // Two drives reporting "3 read errors" is the normal
159
+ // case, not an edge one.
160
+ <li key={`${String(index)}-${line}`}>{line}</li>
161
+ ))}
162
+ </ul>
163
+ ) : null}
164
+ </section>
165
+ )
166
+ }
@@ -0,0 +1,43 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as BadgeStories from "./Badge.stories";
4
+
5
+ <Meta of={BadgeStories} />
6
+
7
+ # Badge
8
+
9
+ Four repos, and rip-deck declares the **identical** `TONE_CLASS` map twice —
10
+ `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five hardcoded hexes, related only by
11
+ copy-paste. mux-magic's `statusClassMap` is the same idea spelled a third way, keyed by a
12
+ bare `string`, so an unrecognised status renders with no colour and no error.
13
+
14
+ <Canvas of={BadgeStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## Where the status half lives
21
+
22
+ `intent` covers the colour. Which *state* maps to which intent — and what it is called in
23
+ English — lives in `statusIntent.ts` as exhaustive switches over the machines
24
+ `@charcuterie/logic` ships:
25
+
26
+ ```tsx
27
+ <Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>
28
+ ```
29
+
30
+ That is the whole of mux-magic's `StatusBadge`, and adding a state to the machine is a
31
+ compile error here rather than a blank pill in production.
32
+
33
+ <Canvas of={BadgeStories.Interactive} />
34
+
35
+ ## No implicit role
36
+
37
+ A badge is a word about something else. It is not a live region, and `role="status"` would
38
+ make every re-render announce itself. Where a status genuinely needs announcing, that is
39
+ `LiveStatusIndicator`; where a badge needs a name beyond its text, `aria-label` still
40
+ works. Hover styling is deliberately absent too — `INTENT_HOVER_CLASS` is opt-in, so a
41
+ verdict pill cannot imply it is clickable.
42
+
43
+ <Canvas of={BadgeStories.AllVariants} />