@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.
- package/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- 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
|
+
}
|