@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,377 @@
1
+ /**
2
+ * Every class name in this package, compiled through the real
3
+ * Tailwind, asserted to produce actual CSS.
4
+ *
5
+ * This is the test that makes the whole utility-class approach safe.
6
+ * Tailwind v4 scans source text for *complete* class strings, so a
7
+ * name it does not recognise — a typo, an interpolation, a utility
8
+ * that needed a `@theme` bridge nobody added — generates **nothing
9
+ * at all**. No error, no warning, no failing build: the element
10
+ * simply renders unstyled, which reads as "the token layer is
11
+ * broken" rather than as "the scanner never saw it". That is the
12
+ * single most expensive failure mode in a Tailwind component
13
+ * library, and it is entirely mechanical to catch.
14
+ *
15
+ * Two properties are asserted:
16
+ *
17
+ * 1. **No interpolated class names.** `` `bg-intent-${intent}-solid` ``
18
+ * is rejected at the AST level, which is why `intentStyles.ts`
19
+ * is 48 literals rather than a loop.
20
+ * 2. **Every literal resolves.** `candidatesToCss` returns `null`
21
+ * for a candidate Tailwind cannot generate, and any `null` fails
22
+ * here with the offending class and file.
23
+ *
24
+ * The design system is built in memory from
25
+ * `@charcuterie/tokens`' generator rather than from its `dist`, so
26
+ * this also fails the moment a token bridge is removed — `text-md`
27
+ * and `shadow-low` exist only because `theme.css` publishes
28
+ * `--text-*` and `--shadow-*`.
29
+ */
30
+
31
+ import { readdir, readFile } from "node:fs/promises"
32
+ import { createRequire } from "node:module"
33
+ import { dirname, join, relative, resolve } from "node:path"
34
+
35
+ import {
36
+ buildThemeCss,
37
+ buildVariablesCss,
38
+ } from "@charcuterie/tokens/src/buildCss.ts"
39
+ import { variants } from "@charcuterie/tokens/src/variants/index.ts"
40
+ import { __unstable__loadDesignSystem } from "tailwindcss"
41
+ import ts from "typescript"
42
+ import { expect, test } from "vitest"
43
+
44
+ const sourceDirectory = resolve(import.meta.dirname)
45
+
46
+ const require_ = createRequire(import.meta.url)
47
+
48
+ const getSourceFilePaths = async (
49
+ directory: string,
50
+ ): Promise<string[]> => {
51
+ const entries = await readdir(directory, {
52
+ withFileTypes: true,
53
+ })
54
+
55
+ const paths = await Promise.all(
56
+ entries.map(async (entry) => {
57
+ const path = join(directory, entry.name)
58
+
59
+ if (entry.isDirectory()) {
60
+ return getSourceFilePaths(path)
61
+ }
62
+
63
+ if (
64
+ /\.tsx?$/.test(entry.name) &&
65
+ !entry.name.endsWith(".test.ts")
66
+ ) {
67
+ return [path]
68
+ }
69
+
70
+ return []
71
+ }),
72
+ )
73
+
74
+ return paths.flat()
75
+ }
76
+
77
+ type Candidate = {
78
+ className: string
79
+ file: string
80
+ }
81
+
82
+ const collectFileCandidates = (
83
+ path: string,
84
+ contents: string,
85
+ ) => {
86
+ const sourceFile = ts.createSourceFile(
87
+ path,
88
+ contents,
89
+ ts.ScriptTarget.ESNext,
90
+ true,
91
+ path.endsWith(".tsx")
92
+ ? ts.ScriptKind.TSX
93
+ : ts.ScriptKind.TS,
94
+ )
95
+
96
+ const candidates: Candidate[] = []
97
+
98
+ const interpolations: string[] = []
99
+
100
+ const file = relative(sourceDirectory, path)
101
+
102
+ const COMPARISON_OPERATORS = new Set<ts.SyntaxKind>([
103
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
104
+ ts.SyntaxKind.EqualsEqualsToken,
105
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
106
+ ts.SyntaxKind.ExclamationEqualsToken,
107
+ ])
108
+
109
+ const collectStrings = (node: ts.Node) => {
110
+ // `sizing === "icon" ? a : b` — the operand of a comparison is a
111
+ // prop value, not a class name. Descending into it is how
112
+ // `"icon"` ends up looking like a missing utility.
113
+ if (ts.isConditionalExpression(node)) {
114
+ collectStrings(node.whenTrue)
115
+
116
+ collectStrings(node.whenFalse)
117
+
118
+ return
119
+ }
120
+
121
+ if (ts.isBinaryExpression(node)) {
122
+ if (
123
+ COMPARISON_OPERATORS.has(node.operatorToken.kind)
124
+ ) {
125
+ return
126
+ }
127
+
128
+ if (
129
+ node.operatorToken.kind ===
130
+ ts.SyntaxKind.AmpersandAmpersandToken
131
+ ) {
132
+ collectStrings(node.right)
133
+
134
+ return
135
+ }
136
+ }
137
+
138
+ if (
139
+ ts.isStringLiteral(node) ||
140
+ ts.isNoSubstitutionTemplateLiteral(node)
141
+ ) {
142
+ for (const className of node.text.split(/\s+/)) {
143
+ if (className.length > 0) {
144
+ candidates.push({ className, file })
145
+ }
146
+ }
147
+
148
+ return
149
+ }
150
+
151
+ // The silent-failure case, refused outright: Tailwind cannot see
152
+ // a class name that only exists at runtime.
153
+ if (ts.isTemplateExpression(node)) {
154
+ interpolations.push(
155
+ `${file}: ${node.getText().split("\n")[0]}`,
156
+ )
157
+
158
+ return
159
+ }
160
+
161
+ ts.forEachChild(node, collectStrings)
162
+ }
163
+
164
+ const visit = (node: ts.Node) => {
165
+ if (
166
+ ts.isJsxAttribute(node) &&
167
+ node.name.getText() === "className" &&
168
+ node.initializer
169
+ ) {
170
+ collectStrings(node.initializer)
171
+ }
172
+
173
+ if (
174
+ ts.isCallExpression(node) &&
175
+ node.expression.getText() === "toClassName"
176
+ ) {
177
+ for (const argument of node.arguments) {
178
+ collectStrings(argument)
179
+ }
180
+ }
181
+
182
+ // The class maps — `INTENT_APPEARANCE_CLASS`,
183
+ // `CONTROL_SIZE_CLASS`, `FOCUS_RING_CLASS`, … — where every
184
+ // string literal is by definition a class list.
185
+ if (
186
+ ts.isVariableDeclaration(node) &&
187
+ /_CLASS$/.test(node.name.getText()) &&
188
+ node.initializer
189
+ ) {
190
+ collectStrings(node.initializer)
191
+ }
192
+
193
+ ts.forEachChild(node, visit)
194
+ }
195
+
196
+ visit(sourceFile)
197
+
198
+ return { candidates, interpolations }
199
+ }
200
+
201
+ const loadStylesheet = async (
202
+ id: string,
203
+ _base: string,
204
+ ) => {
205
+ if (id === "tailwindcss") {
206
+ const path = require_.resolve("tailwindcss/index.css")
207
+
208
+ return {
209
+ path,
210
+ base: dirname(path),
211
+ content: await readFile(path, "utf8"),
212
+ }
213
+ }
214
+
215
+ // Generated in memory, so this test fails on a *generator*
216
+ // regression rather than on a stale `dist`.
217
+ if (id === "./variables.css") {
218
+ return {
219
+ path: "/virtual/variables.css",
220
+ base: "/virtual",
221
+ content: buildVariablesCss(variants, "daylight"),
222
+ }
223
+ }
224
+
225
+ throw new Error(`Unexpected stylesheet import: ${id}`)
226
+ }
227
+
228
+ const collected = await Promise.all(
229
+ (await getSourceFilePaths(sourceDirectory)).map(
230
+ async (path) =>
231
+ collectFileCandidates(
232
+ path,
233
+ await readFile(path, "utf8"),
234
+ ),
235
+ ),
236
+ )
237
+
238
+ const candidates = collected.flatMap(
239
+ (result) => result.candidates,
240
+ )
241
+
242
+ const interpolations = collected.flatMap(
243
+ (result) => result.interpolations,
244
+ )
245
+
246
+ test("no class name is built by interpolation", () => {
247
+ // `` className={`bg-${intent}`} `` renders unstyled and passes
248
+ // every other test in the repo.
249
+ expect(interpolations).toEqual([])
250
+ })
251
+
252
+ test("this package has class names to check at all", () => {
253
+ // A regex refactor that quietly stops matching would otherwise
254
+ // turn the next test into a green no-op.
255
+ expect(candidates.length).toBeGreaterThan(200)
256
+ })
257
+
258
+ /**
259
+ * The two class names in this package that are deliberately not
260
+ * utilities.
261
+ *
262
+ * `group` is Tailwind's own marker — it generates no CSS by design,
263
+ * it is what `group-hover:` reads. The `charcuterie-*` four are the
264
+ * looping affordances, which cannot be utilities because Tailwind's
265
+ * own `animate-spin` hardcodes 1s past `prefers-reduced-motion`;
266
+ * they live in `styles.css` and are checked against it below.
267
+ */
268
+ const TAILWIND_MARKER_CLASSES = ["group"]
269
+
270
+ test("every `charcuterie-` class a component uses is defined in styles.css", async () => {
271
+ // The other half of the "no silently-unstyled component" gate. A
272
+ // component reaching for `.charcuterie-glow` fails here rather
273
+ // than rendering a static bar nobody notices.
274
+ const styles = await readFile(
275
+ join(sourceDirectory, "styles.css"),
276
+ "utf8",
277
+ )
278
+
279
+ const used = [
280
+ ...new Set(
281
+ candidates
282
+ .map((one) => one.className)
283
+ .filter((className) =>
284
+ className.startsWith("charcuterie-"),
285
+ ),
286
+ ),
287
+ ]
288
+
289
+ expect(used.length).toBeGreaterThan(0)
290
+
291
+ for (const className of used) {
292
+ expect(styles).toContain(`.${className}`)
293
+ }
294
+
295
+ // And every one of them is switched off under reduced motion —
296
+ // `variables.css` zeroing the duration is not enough, because a
297
+ // `0ms` looping animation still holds its first keyframe.
298
+ const reducedMotionBlock = styles.slice(
299
+ styles.indexOf(
300
+ "@media (prefers-reduced-motion: reduce)",
301
+ ),
302
+ )
303
+
304
+ for (const className of used) {
305
+ expect(reducedMotionBlock).toContain(`.${className}`)
306
+ }
307
+ })
308
+
309
+ test("every class name compiles to real CSS", async () => {
310
+ const design = await __unstable__loadDesignSystem(
311
+ `@import "tailwindcss";\n${buildThemeCss()}`,
312
+ {
313
+ base: sourceDirectory,
314
+ loadStylesheet,
315
+ loadModule: () => {
316
+ throw new Error(
317
+ "The token layer is plain CSS — no Tailwind plugin should be loaded.",
318
+ )
319
+ },
320
+ },
321
+ )
322
+
323
+ const unique = [
324
+ ...new Set(candidates.map((one) => one.className)),
325
+ ].filter(
326
+ (className) =>
327
+ !className.startsWith("charcuterie-") &&
328
+ !TAILWIND_MARKER_CLASSES.includes(className),
329
+ )
330
+
331
+ const generated = design.candidatesToCss(unique)
332
+
333
+ const unknown = unique
334
+ .filter((_, index) => generated[index] === null)
335
+ .map((className) => {
336
+ const source = candidates.find(
337
+ (one) => one.className === className,
338
+ )
339
+
340
+ return `${className} (${source?.file})`
341
+ })
342
+
343
+ expect(unknown).toEqual([])
344
+ })
345
+
346
+ test("the token bridges are load-bearing, not decorative", async () => {
347
+ // The mutation check for the test above: drop `theme.css` and the
348
+ // colour utilities, the type ramp, and the elevation scale all
349
+ // stop resolving. If this passes with Tailwind alone, the
350
+ // components are not actually reading our tokens.
351
+ const design = await __unstable__loadDesignSystem(
352
+ `@import "tailwindcss";\n`,
353
+ {
354
+ base: sourceDirectory,
355
+ loadStylesheet,
356
+ loadModule: () => {
357
+ throw new Error("no plugins")
358
+ },
359
+ },
360
+ )
361
+
362
+ const withoutTokens = design.candidatesToCss([
363
+ "bg-intent-accent-solid",
364
+ "text-content-primary",
365
+ "shadow-low",
366
+ "text-md",
367
+ "cq-sm:flex-row",
368
+ ])
369
+
370
+ expect(withoutTokens).toEqual([
371
+ null,
372
+ null,
373
+ null,
374
+ null,
375
+ null,
376
+ ])
377
+ })
@@ -0,0 +1,71 @@
1
+ import { expect } from "storybook/test"
2
+ import { afterEach, test } from "vitest"
3
+
4
+ import { expectAgentDrivable } from "./expectAgentDrivable.ts"
5
+
6
+ /**
7
+ * The mutation check on the roving-tabindex rule.
8
+ *
9
+ * M4 found this helper rejecting every roving-tabindex member
10
+ * outright and replaced the rejection with the rule itself — so the
11
+ * *failing* half has to be proved too, or "exactly one tab stop" is
12
+ * a sentence that never runs.
13
+ *
14
+ * It lived inside `Tabs`' `AllStates` play until the story/test
15
+ * split, where it was neither about `Tabs` nor about the board it
16
+ * was rendered beside. `AgentQueries` is structural on purpose,
17
+ * which is what lets a four-line stub stand in for a canvas here.
18
+ */
19
+ let tablist: HTMLDivElement | undefined
20
+
21
+ afterEach(() => {
22
+ tablist?.remove()
23
+
24
+ tablist = undefined
25
+ })
26
+
27
+ const mountTablist = (...tabIndexes: number[]) => {
28
+ tablist = document.createElement("div")
29
+
30
+ tablist.setAttribute("role", "tablist")
31
+
32
+ tablist.innerHTML = tabIndexes
33
+ .map(
34
+ (tabIndex, index) =>
35
+ `<button role="tab" tabindex="${tabIndex}">${index}</button>`,
36
+ )
37
+ .join("")
38
+
39
+ // Attached, because the helper walks up to find the group and a
40
+ // detached tree would be the stub cheating.
41
+ document.body.append(tablist)
42
+
43
+ const tabs = Array.from(
44
+ tablist.querySelectorAll("button"),
45
+ )
46
+
47
+ return () => {
48
+ expectAgentDrivable(
49
+ { queryAllByRole: () => [tabs[0] as HTMLElement] },
50
+ { role: "tab" },
51
+ )
52
+ }
53
+ }
54
+
55
+ test("two tab stops in one group is a failure", () => {
56
+ // Tab lands inside the group twice and the arrow keys are
57
+ // decoration.
58
+ expect(mountTablist(-1, 0, 0)).toThrow(/2 tab stops/)
59
+ })
60
+
61
+ test("exactly one tab stop passes", () => {
62
+ expect(mountTablist(-1, 0, -1)).not.toThrow()
63
+ })
64
+
65
+ test("no tab stop at all is also a failure", () => {
66
+ // The other half of "exactly one": a group nothing can Tab into
67
+ // is unreachable, and a rule that only catches the excess would
68
+ // call it fine. Matched on the count so it cannot pass by
69
+ // throwing for some unrelated reason.
70
+ expect(mountTablist(-1, -1, -1)).toThrow(/0 tab stops/)
71
+ })
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Agent-drivability as a red/green property, not a hope.
3
+ *
4
+ * A stated goal of this library is that **Playwright MCP and AI
5
+ * agents can drive the fleet's apps**, and that reduces to one
6
+ * mechanical requirement: `getByRole(role, { name })` finds exactly
7
+ * one thing. The fleet fails it comprehensively today —
8
+ * plex-channels' icon buttons are bare glyphs (`↶`, `↷`, `▶`),
9
+ * mux-magic's status pills are `<div>`s with no role, xander has
10
+ * zero `aria-*` attributes — and every one of those failures is
11
+ * invisible in a browser and invisible to axe.
12
+ *
13
+ * So every component's story calls this, and the assertion is the
14
+ * same one Playwright will make.
15
+ *
16
+ * Deliberately dependency-free — no `expect`, no jest-dom, no
17
+ * testing-library import. This ships in `@charcuterie/ui/testing`
18
+ * for consumers to use on *their* components, and a published
19
+ * package should not drag a test framework into an app's dependency
20
+ * graph to do it. Plain `Error`s with actionable messages work in a
21
+ * story `play`, in Vitest, in Playwright, and in a scratch script.
22
+ */
23
+
24
+ /**
25
+ * The subset of a testing-library query object this needs —
26
+ * structural, so Storybook's `canvas`, a `within(element)` result,
27
+ * and `screen` all satisfy it without an import.
28
+ */
29
+ export type AgentQueries = {
30
+ queryAllByRole: (
31
+ role: string,
32
+ options?: { name?: RegExp | string },
33
+ ) => HTMLElement[]
34
+ }
35
+
36
+ export type AgentTarget = {
37
+ name?: RegExp | string
38
+ role: string
39
+ }
40
+
41
+ const describeTarget = ({ name, role }: AgentTarget) =>
42
+ name === undefined
43
+ ? `role "${role}"`
44
+ : `role "${role}" named ${
45
+ typeof name === "string"
46
+ ? `"${name}"`
47
+ : String(name)
48
+ }`
49
+
50
+ /**
51
+ * The composite widgets whose members are *supposed* to carry
52
+ * `tabindex="-1"`.
53
+ *
54
+ * Found by M4. `Tabs` is the first component here to use the
55
+ * roving-tabindex pattern, and this helper rejected it outright:
56
+ * "has a negative tabindex, so it can be clicked but never reached
57
+ * with Tab". That rule is right for a standalone button and wrong
58
+ * for a tab — a roving group's whole design is that exactly one
59
+ * member is in the tab order and the arrow keys reach the rest, so
60
+ * a tab list where every tab had `tabindex="0"` would be the bug.
61
+ *
62
+ * So the check is not skipped for these; it is **replaced by the
63
+ * roving rule itself**, which is a stronger assertion than the one
64
+ * it stands in for.
65
+ */
66
+ const COMPOSITE_ROLES = new Set([
67
+ "grid",
68
+ "listbox",
69
+ "menu",
70
+ "menubar",
71
+ "radiogroup",
72
+ "tablist",
73
+ "toolbar",
74
+ "tree",
75
+ "treegrid",
76
+ ])
77
+
78
+ const COMPOSITE_MEMBER_ROLES = new Set([
79
+ "gridcell",
80
+ "menuitem",
81
+ "menuitemcheckbox",
82
+ "menuitemradio",
83
+ "option",
84
+ "radio",
85
+ "tab",
86
+ "treeitem",
87
+ ])
88
+
89
+ const getCompositeGroup = (element: Element) => {
90
+ if (
91
+ !COMPOSITE_MEMBER_ROLES.has(
92
+ element.getAttribute("role") ?? "",
93
+ )
94
+ ) {
95
+ return null
96
+ }
97
+
98
+ let current = element.parentElement
99
+
100
+ while (current) {
101
+ if (
102
+ COMPOSITE_ROLES.has(
103
+ current.getAttribute("role") ?? "",
104
+ )
105
+ ) {
106
+ return current
107
+ }
108
+
109
+ current = current.parentElement
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ const getIsHiddenFromAgents = (element: Element) => {
116
+ let current: Element | null = element
117
+
118
+ while (current) {
119
+ if (
120
+ current.getAttribute("aria-hidden") === "true" ||
121
+ current.hasAttribute("inert")
122
+ ) {
123
+ return true
124
+ }
125
+
126
+ current = current.parentElement
127
+ }
128
+
129
+ return false
130
+ }
131
+
132
+ /**
133
+ * Asserts the control an agent would reach for is reachable, unique,
134
+ * and keyboard-operable. Returns it, so a caller can go on to assert
135
+ * something specific about it.
136
+ */
137
+ export const expectAgentDrivable = (
138
+ canvas: AgentQueries,
139
+ target: AgentTarget,
140
+ ): HTMLElement => {
141
+ const matches = canvas.queryAllByRole(
142
+ target.role,
143
+ target.name === undefined
144
+ ? undefined
145
+ : { name: target.name },
146
+ )
147
+
148
+ if (matches.length === 0) {
149
+ throw new Error(
150
+ `Not agent-drivable: nothing matches ${describeTarget(
151
+ target,
152
+ )}. An agent cannot click what it cannot name — give the control an accessible name (visible text, \`aria-label\`, or a \`VisuallyHidden\` label) rather than a \`data-testid\`.`,
153
+ )
154
+ }
155
+
156
+ // Ambiguity is a failure, not a warning. Two "Start" buttons on a
157
+ // page mean an agent picks one at random, which is worse than not
158
+ // finding either — and the fix is a `Card` heading turning each
159
+ // one into its own `region`.
160
+ if (matches.length > 1) {
161
+ throw new Error(
162
+ `Not agent-drivable: ${
163
+ matches.length
164
+ } elements match ${describeTarget(
165
+ target,
166
+ )}. Scope them — a labelled region per group — or make the names distinct.`,
167
+ )
168
+ }
169
+
170
+ const [element] = matches
171
+
172
+ if (!element) {
173
+ throw new Error(
174
+ `Not agent-drivable: ${describeTarget(
175
+ target,
176
+ )} matched an empty element.`,
177
+ )
178
+ }
179
+
180
+ if (getIsHiddenFromAgents(element)) {
181
+ throw new Error(
182
+ `Not agent-drivable: ${describeTarget(
183
+ target,
184
+ )} is inside an \`aria-hidden\` or \`inert\` subtree, so a real screen reader and a real agent both skip it.`,
185
+ )
186
+ }
187
+
188
+ if (element.querySelector("[data-testid]")) {
189
+ throw new Error(
190
+ `\`data-testid\` found inside ${describeTarget(
191
+ target,
192
+ )}. This library has none by rule: a testid is a name only the test suite can see, which is exactly the gap that leaves an app untestable by an agent.`,
193
+ )
194
+ }
195
+
196
+ const isNativelyInteractive = [
197
+ "a",
198
+ "button",
199
+ "input",
200
+ "select",
201
+ "textarea",
202
+ ].includes(element.tagName.toLowerCase())
203
+
204
+ const isDisabled =
205
+ element.hasAttribute("disabled") ||
206
+ element.getAttribute("aria-disabled") === "true"
207
+
208
+ if (
209
+ isNativelyInteractive &&
210
+ !isDisabled &&
211
+ element.tabIndex < 0
212
+ ) {
213
+ const group = getCompositeGroup(element)
214
+
215
+ if (!group) {
216
+ throw new Error(
217
+ `Not agent-drivable: ${describeTarget(
218
+ target,
219
+ )} has a negative tabindex, so it can be clicked but never reached with Tab.`,
220
+ )
221
+ }
222
+
223
+ // A roving group, so the question changes: not "is this
224
+ // element in the tab order" — it must not be — but "is the
225
+ // *group* reachable, exactly once". Zero tab stops strands the
226
+ // whole widget; several put every member back in the tab
227
+ // order, which is the pattern not being implemented at all.
228
+ const tabStops = Array.from(
229
+ group.querySelectorAll("[role]"),
230
+ ).filter(
231
+ (member) =>
232
+ COMPOSITE_MEMBER_ROLES.has(
233
+ member.getAttribute("role") ?? "",
234
+ ) &&
235
+ !member.hasAttribute("disabled") &&
236
+ member.getAttribute("aria-disabled") !== "true" &&
237
+ (member as HTMLElement).tabIndex >= 0,
238
+ )
239
+
240
+ if (tabStops.length !== 1) {
241
+ throw new Error(
242
+ `Not agent-drivable: ${describeTarget(
243
+ target,
244
+ )} is inside a \`${group.getAttribute(
245
+ "role",
246
+ )}\` with ${
247
+ tabStops.length
248
+ } tab stops. A roving-tabindex group needs exactly one — Tab enters and leaves it once, and the arrow keys move within it.`,
249
+ )
250
+ }
251
+ }
252
+
253
+ return element
254
+ }
255
+
256
+ /**
257
+ * The other half of the contract, for decoration.
258
+ *
259
+ * A `Skeleton` that assistive technology can see is a screen reader
260
+ * reading three empty bars, and an agent finding a "loading" element
261
+ * it cannot act on. Asserting the *absence* of a role is how that
262
+ * stays true — axe will not tell you, because there is nothing
263
+ * invalid about it.
264
+ */
265
+ export const expectHiddenFromAgents = (
266
+ element: Element,
267
+ ): void => {
268
+ if (!getIsHiddenFromAgents(element)) {
269
+ throw new Error(
270
+ 'Expected this element to be hidden from assistive technology (`aria-hidden="true"` or `inert`). Decoration that is announced is noise: the load has to be announced by the region that owns it, not by its placeholder.',
271
+ )
272
+ }
273
+ }