@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,207 @@
1
+ /**
2
+ * One intent → class map, for the whole fleet.
3
+ *
4
+ * This file is what M5 deletes from rip-deck. `TONE_CLASS` is
5
+ * declared *identically* in `VerdictBadge.tsx` and
6
+ * `TowerAlerts.tsx` today — hardcoded hexes and `slate-400`s, no
7
+ * light mode, no relationship to mux-magic's `statusClassMap` which
8
+ * spells the same idea a fourth way. Six intents x four
9
+ * appearances, named once, is the generalisation.
10
+ *
11
+ * **Every class name is written out in full, and that is not
12
+ * negotiable.** Tailwind v4 scans source text for *complete* class
13
+ * strings, so `` `bg-intent-${intent}-solid` `` generates nothing
14
+ * at all — the element renders unstyled with no error, no warning,
15
+ * and no failing test unless something checks. Something does:
16
+ * `tailwindCandidates.test.ts` compiles every literal in this
17
+ * package through the real Tailwind and fails on any candidate
18
+ * Tailwind cannot generate. That test is the reason this file is
19
+ * allowed to be 24 lines of near-duplication instead of a loop.
20
+ */
21
+
22
+ import type { IntentName } from "@charcuterie/tokens"
23
+
24
+ /**
25
+ * Four treatments, and the split is semantic rather than cosmetic:
26
+ *
27
+ * - `solid` — the saturated fill. One per view, usually; it is the
28
+ * thing you are meant to press.
29
+ * - `soft` — the tinted treatment the fleet already reaches for on
30
+ * status pills (`bg-blue-950 text-blue-300` in mux-magic,
31
+ * `bg-amber-950/40 text-amber-200` in rip-deck).
32
+ * - `outline` — border and content only. Reads as secondary
33
+ * without claiming a surface.
34
+ * - `ghost` — nothing until hovered. Toolbars and icon rows.
35
+ */
36
+ export type IntentAppearance =
37
+ | "ghost"
38
+ | "outline"
39
+ | "soft"
40
+ | "solid"
41
+
42
+ export const INTENT_APPEARANCE_CLASS: Record<
43
+ IntentName,
44
+ Record<IntentAppearance, string>
45
+ > = {
46
+ neutral: {
47
+ ghost:
48
+ "border-transparent bg-transparent text-content-primary",
49
+ outline:
50
+ "border-intent-neutral-border bg-transparent text-intent-neutral-content",
51
+ soft: "border-intent-neutral-border bg-intent-neutral-surface text-intent-neutral-content",
52
+ solid:
53
+ "border-transparent bg-intent-neutral-solid text-intent-neutral-on-solid",
54
+ },
55
+ accent: {
56
+ ghost:
57
+ "border-transparent bg-transparent text-intent-accent-content",
58
+ outline:
59
+ "border-intent-accent-border bg-transparent text-intent-accent-content",
60
+ soft: "border-intent-accent-border bg-intent-accent-surface text-intent-accent-content",
61
+ solid:
62
+ "border-transparent bg-intent-accent-solid text-intent-accent-on-solid",
63
+ },
64
+ success: {
65
+ ghost:
66
+ "border-transparent bg-transparent text-intent-success-content",
67
+ outline:
68
+ "border-intent-success-border bg-transparent text-intent-success-content",
69
+ soft: "border-intent-success-border bg-intent-success-surface text-intent-success-content",
70
+ solid:
71
+ "border-transparent bg-intent-success-solid text-intent-success-on-solid",
72
+ },
73
+ warning: {
74
+ ghost:
75
+ "border-transparent bg-transparent text-intent-warning-content",
76
+ outline:
77
+ "border-intent-warning-border bg-transparent text-intent-warning-content",
78
+ soft: "border-intent-warning-border bg-intent-warning-surface text-intent-warning-content",
79
+ solid:
80
+ "border-transparent bg-intent-warning-solid text-intent-warning-on-solid",
81
+ },
82
+ danger: {
83
+ ghost:
84
+ "border-transparent bg-transparent text-intent-danger-content",
85
+ outline:
86
+ "border-intent-danger-border bg-transparent text-intent-danger-content",
87
+ soft: "border-intent-danger-border bg-intent-danger-surface text-intent-danger-content",
88
+ solid:
89
+ "border-transparent bg-intent-danger-solid text-intent-danger-on-solid",
90
+ },
91
+ info: {
92
+ ghost:
93
+ "border-transparent bg-transparent text-intent-info-content",
94
+ outline:
95
+ "border-intent-info-border bg-transparent text-intent-info-content",
96
+ soft: "border-intent-info-border bg-intent-info-surface text-intent-info-content",
97
+ solid:
98
+ "border-transparent bg-intent-info-solid text-intent-info-on-solid",
99
+ },
100
+ }
101
+
102
+ /**
103
+ * Hover, split out rather than folded into the appearance above —
104
+ * because most things wearing an intent are **not** interactive.
105
+ *
106
+ * A `Badge` that lightens under the cursor is telling the user it
107
+ * can be clicked, and rip-deck's verdict pills (which cannot) would
108
+ * inherit exactly that if the two maps were one. So an interactive
109
+ * component opts in, and a static one cannot accidentally opt in.
110
+ *
111
+ * `solid` hovers toward `solidHover`; the other three land on the
112
+ * intent's tinted surface, which is what gives a ghost toolbar
113
+ * button something to become.
114
+ */
115
+ export const INTENT_HOVER_CLASS: Record<
116
+ IntentName,
117
+ Record<IntentAppearance, string>
118
+ > = {
119
+ neutral: {
120
+ ghost: "hover:bg-intent-neutral-surface",
121
+ outline: "hover:bg-intent-neutral-surface",
122
+ soft: "hover:bg-intent-neutral-surface-hover",
123
+ solid: "hover:bg-intent-neutral-solid-hover",
124
+ },
125
+ accent: {
126
+ ghost: "hover:bg-intent-accent-surface",
127
+ outline: "hover:bg-intent-accent-surface",
128
+ soft: "hover:bg-intent-accent-surface-hover",
129
+ solid: "hover:bg-intent-accent-solid-hover",
130
+ },
131
+ success: {
132
+ ghost: "hover:bg-intent-success-surface",
133
+ outline: "hover:bg-intent-success-surface",
134
+ soft: "hover:bg-intent-success-surface-hover",
135
+ solid: "hover:bg-intent-success-solid-hover",
136
+ },
137
+ warning: {
138
+ ghost: "hover:bg-intent-warning-surface",
139
+ outline: "hover:bg-intent-warning-surface",
140
+ soft: "hover:bg-intent-warning-surface-hover",
141
+ solid: "hover:bg-intent-warning-solid-hover",
142
+ },
143
+ danger: {
144
+ ghost: "hover:bg-intent-danger-surface",
145
+ outline: "hover:bg-intent-danger-surface",
146
+ soft: "hover:bg-intent-danger-surface-hover",
147
+ solid: "hover:bg-intent-danger-solid-hover",
148
+ },
149
+ info: {
150
+ ghost: "hover:bg-intent-info-surface",
151
+ outline: "hover:bg-intent-info-surface",
152
+ soft: "hover:bg-intent-info-surface-hover",
153
+ solid: "hover:bg-intent-info-solid-hover",
154
+ },
155
+ }
156
+
157
+ /**
158
+ * The fill colour on its own, for the parts of a component that are
159
+ * a bar or a dot rather than a box — a `ProgressBar`'s fill, a
160
+ * `LiveStatusIndicator`'s dot. Same values, no border and no hover,
161
+ * because neither of those is a surface anyone can point at.
162
+ */
163
+ export const INTENT_SOLID_FILL_CLASS: Record<
164
+ IntentName,
165
+ string
166
+ > = {
167
+ neutral: "bg-intent-neutral-solid",
168
+ accent: "bg-intent-accent-solid",
169
+ success: "bg-intent-success-solid",
170
+ warning: "bg-intent-warning-solid",
171
+ danger: "bg-intent-danger-solid",
172
+ info: "bg-intent-info-solid",
173
+ }
174
+
175
+ /** The same six as text, for a glyph or a dot's ring. */
176
+ export const INTENT_CONTENT_CLASS: Record<
177
+ IntentName,
178
+ string
179
+ > = {
180
+ neutral: "text-intent-neutral-content",
181
+ accent: "text-intent-accent-content",
182
+ success: "text-intent-success-content",
183
+ warning: "text-intent-warning-content",
184
+ danger: "text-intent-danger-content",
185
+ info: "text-intent-info-content",
186
+ }
187
+
188
+ /**
189
+ * The focus ring, in one place, reading the variant's own width and
190
+ * offset.
191
+ *
192
+ * `focus-visible` rather than `focus`: a mouse click on a button
193
+ * should not leave a ring behind, and every hand-rolled button in
194
+ * the inventory either rings on click or — more often — has
195
+ * `outline-none` with nothing put back.
196
+ */
197
+ export const FOCUS_RING_CLASS =
198
+ "focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-focus-ring"
199
+
200
+ /**
201
+ * WCAG 1.4.3 exempts *inactive* controls from contrast, which is
202
+ * the only reason `content.disabled` is allowed to be this quiet —
203
+ * and the exemption is honest only on something actually disabled.
204
+ * Keeping it in one constant is what stops it drifting onto prose.
205
+ */
206
+ export const DISABLED_CLASS =
207
+ "disabled:cursor-not-allowed disabled:border-border-subtle disabled:bg-surface-sunken disabled:text-content-disabled disabled:shadow-none"
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Every `<Canvas of={XStories.Y} />` in an `.mdx` names a story that
3
+ * still exists.
4
+ *
5
+ * A missing one does not fail the build, does not fail typecheck,
6
+ * and does not fail `vitest` — MDX resolves `of` at *runtime*, so a
7
+ * renamed story turns into `of={undefined}` and the docs page
8
+ * renders Storybook's "component failed to render" panel instead of
9
+ * the page. `yarn smoke:storybook` catches it, which is good, but
10
+ * only after a full Storybook build; this catches it in under a
11
+ * second, which is the difference between noticing during the edit
12
+ * and noticing in CI.
13
+ *
14
+ * Found the expensive way: the M4 story/test split renamed
15
+ * `Tabs.Interactive` to `Tabs.Manual` and deleted three
16
+ * assertion-only stories, and every gate stayed green while
17
+ * `Components/Tabs › Docs` was broken.
18
+ */
19
+
20
+ import { readdir, readFile } from "node:fs/promises"
21
+ import { join, resolve } from "node:path"
22
+
23
+ import { expect, test } from "vitest"
24
+
25
+ const sourceDirectory = resolve(import.meta.dirname)
26
+
27
+ const componentNames = (
28
+ await readdir(sourceDirectory, { withFileTypes: true })
29
+ )
30
+ .filter(
31
+ (entry) =>
32
+ entry.isDirectory() && /^[A-Z]/.test(entry.name),
33
+ )
34
+ .map((entry) => entry.name)
35
+ .sort()
36
+
37
+ const readIfPresent = async (path: string) => {
38
+ try {
39
+ return await readFile(path, "utf8")
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ const documented = (
46
+ await Promise.all(
47
+ componentNames.map(async (name) => {
48
+ const [mdx, story] = await Promise.all([
49
+ readIfPresent(
50
+ join(sourceDirectory, name, `${name}.mdx`),
51
+ ),
52
+ readIfPresent(
53
+ join(
54
+ sourceDirectory,
55
+ name,
56
+ `${name}.stories.tsx`,
57
+ ),
58
+ ),
59
+ ])
60
+
61
+ return mdx && story ? [{ mdx, name, story }] : []
62
+ }),
63
+ )
64
+ ).flat()
65
+
66
+ test("every component with stories has a docs page", () => {
67
+ // A canary on the derivation — an empty list would make the rule
68
+ // below pass forever.
69
+ expect(documented.length).toBeGreaterThanOrEqual(14)
70
+ })
71
+
72
+ test("no docs page references a story that no longer exists", () => {
73
+ const offenders = documented.flatMap(
74
+ ({ mdx, name, story }) => {
75
+ const exported = new Set(
76
+ Array.from(
77
+ story.matchAll(/^export const (\w+): Story/gm),
78
+ (match) => match[1],
79
+ ),
80
+ )
81
+
82
+ return Array.from(
83
+ mdx.matchAll(/of=\{\w+Stories\.(\w+)\}/g),
84
+ (match) => match[1],
85
+ )
86
+ .filter((reference) => !exported.has(reference))
87
+ .map(
88
+ (reference) =>
89
+ `${name}.mdx references ${name}Stories.${reference}, which is not exported`,
90
+ )
91
+ },
92
+ )
93
+
94
+ expect(offenders).toEqual([])
95
+ })
@@ -0,0 +1,41 @@
1
+ import { within } from "storybook/test"
2
+
3
+ type Canvas = ReturnType<typeof within>
4
+
5
+ /**
6
+ * Structural on purpose — the one thing this helper needs off a
7
+ * composed story. `ComposedStoryFn` is invariant in its `Args`, so
8
+ * naming it here would force every caller to restate the props of
9
+ * the component it is testing.
10
+ */
11
+ type MountableStory = {
12
+ run: (context: {
13
+ canvasElement: HTMLElement
14
+ }) => Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Mount a composed story into a fresh canvas and hand back the same
19
+ * two things a `play` function used to receive.
20
+ *
21
+ * `run()` — rather than rendering the component directly — is what
22
+ * keeps these tests honest after the story/test split: the subject
23
+ * is the story a reader sees, with its args, its decorators, and its
24
+ * loaders, not a second assembly of the same component that can
25
+ * drift from it. It is also what triggers the a11y addon's
26
+ * `afterEach`, so every mount here is an axe run.
27
+ */
28
+ export const mountStory = async (
29
+ story: MountableStory,
30
+ ): Promise<{
31
+ canvas: Canvas
32
+ canvasElement: HTMLElement
33
+ }> => {
34
+ const canvasElement = document.createElement("div")
35
+
36
+ document.body.append(canvasElement)
37
+
38
+ await story.run({ canvasElement })
39
+
40
+ return { canvas: within(canvasElement), canvasElement }
41
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The three rules about this package's source that no compiler and no
3
+ * axe run can see.
4
+ *
5
+ * All three come straight from the plan's verification list, and each
6
+ * one is a thing the fleet does today that the library exists to
7
+ * stop.
8
+ */
9
+
10
+ import { readdir, readFile } from "node:fs/promises"
11
+ import { join, relative, resolve } from "node:path"
12
+
13
+ import { expect, test } from "vitest"
14
+
15
+ const sourceDirectory = resolve(import.meta.dirname)
16
+
17
+ const packageDirectory = resolve(sourceDirectory, "..")
18
+
19
+ const getSourceFiles = async (
20
+ directory: string,
21
+ ): Promise<{ contents: string; file: string }[]> => {
22
+ const entries = await readdir(directory, {
23
+ withFileTypes: true,
24
+ })
25
+
26
+ const files = await Promise.all(
27
+ entries.map(async (entry) => {
28
+ const path = join(directory, entry.name)
29
+
30
+ if (entry.isDirectory()) {
31
+ return getSourceFiles(path)
32
+ }
33
+
34
+ if (!/\.(css|tsx?)$/.test(entry.name)) {
35
+ return []
36
+ }
37
+
38
+ return [
39
+ {
40
+ contents: await readFile(path, "utf8"),
41
+ file: relative(sourceDirectory, path),
42
+ },
43
+ ]
44
+ }),
45
+ )
46
+
47
+ return files.flat()
48
+ }
49
+
50
+ const sourceFiles = await getSourceFiles(sourceDirectory)
51
+
52
+ const componentFiles = sourceFiles.filter(
53
+ (one) =>
54
+ !one.file.endsWith(".test.ts") &&
55
+ !one.file.endsWith(".test.tsx"),
56
+ )
57
+
58
+ /**
59
+ * Comments are prose about the fleet, not code. `intentStyles.ts`
60
+ * quotes mux-magic's `bg-blue-950 text-blue-300` as the evidence for
61
+ * the intent scale existing at all, and a rule that cannot tell a
62
+ * citation from a usage is a rule that gets switched off.
63
+ */
64
+ const stripComments = (contents: string) =>
65
+ contents
66
+ .replace(/\/\*[\s\S]*?\*\//g, "")
67
+ .replace(/^\s*\/\/.*$/gm, "")
68
+
69
+ test("`data-testid` appears nowhere in @charcuterie/ui", () => {
70
+ // A rule, not a preference. A testid is a name only the test suite
71
+ // can see, so reaching for one is how a component ends up
72
+ // unnameable by a screen reader *and* undrivable by an agent — the
73
+ // exact gap that makes the fleet's current UIs unautomatable. If a
74
+ // control is hard to query, the fix is an accessible name.
75
+ // `data-testid=` — the attribute in *use*. `testing/` mentions the
76
+ // name in a selector and an error message, which is the opposite
77
+ // of the offence; and this file has to spell it to search for it,
78
+ // which is why the scan is over shipped source and stories rather
79
+ // than over the tests.
80
+ const offenders = componentFiles
81
+ .filter((one) => /data-testid=/.test(one.contents))
82
+ .map((one) => one.file)
83
+
84
+ expect(offenders).toEqual([])
85
+ })
86
+
87
+ test("no component reaches past tier 2 for a colour", () => {
88
+ // The M6 problem, prevented here rather than repeated: mux-magic
89
+ // has **993 hardcoded `*-slate-*` utilities across 134 files**, and
90
+ // every one of them is why that app cannot have a light mode. A
91
+ // component may name a semantic role — `bg-surface-raised`,
92
+ // `text-intent-danger-content` — and nothing else.
93
+ //
94
+ // Tier 1 ramps (`colour.slate.500`) exist so a *variant author*
95
+ // has something to build tier 2 out of. A component naming one
96
+ // pins itself to a palette.
97
+ const palettePattern =
98
+ /\b(?:bg|text|border|from|to|via|outline|ring|fill|stroke|shadow|decoration|accent|caret|divide)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/
99
+
100
+ const hexPattern = /#[0-9a-fA-F]{3,8}\b/
101
+
102
+ const offenders = componentFiles
103
+ .filter(
104
+ (one) =>
105
+ // The story fixtures are allowed literal hexes: the SVG
106
+ // poster data-URI in `MediaTile.stories.tsx` is a stand-in
107
+ // for an app's own image, not component styling.
108
+ !one.file.endsWith(".stories.tsx"),
109
+ )
110
+ .filter((one) => {
111
+ const code = stripComments(one.contents)
112
+
113
+ return (
114
+ palettePattern.test(code) || hexPattern.test(code)
115
+ )
116
+ })
117
+ .map((one) => one.file)
118
+
119
+ expect(offenders).toEqual([])
120
+ })
121
+
122
+ test("nothing queries a container it declares itself", () => {
123
+ // A container query matches **descendants of the container**, never
124
+ // the container element. So `className="@container cq-md:px-8"`
125
+ // compiles, generates real CSS, passes `tailwindCandidates.test.ts`
126
+ // — and never fires. Nothing errors; the padding is simply always
127
+ // the small one, forever.
128
+ //
129
+ // Caught here because it is invisible everywhere else: axe does not
130
+ // care, the class exists, and a reviewer sees a plausible-looking
131
+ // className. It cost this milestone one round of screenshots.
132
+ const offenders = componentFiles
133
+ .filter((one) => /\.tsx$/.test(one.file))
134
+ .flatMap((one) => {
135
+ const classStrings =
136
+ stripComments(one.contents).match(/"[^"\n]*"/g) ??
137
+ []
138
+
139
+ return classStrings
140
+ .filter(
141
+ (candidate) =>
142
+ candidate.includes("@container") &&
143
+ /\bcq-(?:xs|sm|md|lg|xl):/.test(candidate),
144
+ )
145
+ .map((candidate) => `${one.file}: ${candidate}`)
146
+ })
147
+
148
+ expect(offenders).toEqual([])
149
+ })
150
+
151
+ test("a container-query component is never storied in a shrink-to-fit cell", () => {
152
+ // The twin of the rule above, and the one the M3 screenshots
153
+ // actually caught: the component is right, the *story* is wrong.
154
+ //
155
+ // `container-type: inline-size` implies `contain: inline-size`,
156
+ // which forbids the element from being sized by its own contents.
157
+ // In a default `StoryCell` — `items-start`, so shrink-to-fit — the
158
+ // card has no width to shrink *to*, collapses to min-content, and
159
+ // every line of text wraps after one word. Valid CSS, no error, no
160
+ // axe violation, and it only shows up if somebody looks at a
161
+ // screenshot. `align="stretch"` hands it a definite inline size
162
+ // from the grid track.
163
+ //
164
+ // The container list is derived rather than hardcoded, so the
165
+ // component M4 adds with an `@container` on it joins this rule the
166
+ // moment it lands.
167
+ const containerComponents = componentFiles
168
+ .flatMap((one) => {
169
+ const match = /^([A-Z]\w+)\/\1\.tsx$/.exec(one.file)
170
+
171
+ return match?.[1] &&
172
+ stripComments(one.contents).includes("@container")
173
+ ? [match[1]]
174
+ : []
175
+ })
176
+ .sort()
177
+
178
+ // A canary on the derivation itself: if this ever comes back
179
+ // empty the rule silently passes forever.
180
+ expect(containerComponents).toEqual([
181
+ "Alert",
182
+ "Card",
183
+ "EmptyState",
184
+ "MediaTile",
185
+ ])
186
+
187
+ // `StoryCell`s are never nested, so the lazy match really does
188
+ // pair each opening tag with its own closing one.
189
+ const cellPattern =
190
+ /<StoryCell\b([^>]*)>([\s\S]*?)<\/StoryCell>/g
191
+
192
+ const offenders = componentFiles
193
+ .filter((one) => one.file.endsWith(".stories.tsx"))
194
+ .flatMap((one) =>
195
+ Array.from(
196
+ stripComments(one.contents).matchAll(cellPattern),
197
+ )
198
+ .filter(
199
+ ([, attributes, body]) =>
200
+ !attributes.includes('align="stretch"') &&
201
+ containerComponents.some((name) =>
202
+ body.includes(`<${name}`),
203
+ ),
204
+ )
205
+ .map(
206
+ ([, attributes]) =>
207
+ `${one.file}: <StoryCell${attributes}>`,
208
+ ),
209
+ )
210
+
211
+ expect(offenders).toEqual([])
212
+ })
213
+
214
+ test("the barrel is the only place components are re-exported", async () => {
215
+ // One sanctioned barrel per package. A component importing a
216
+ // sibling *through* `index.ts` turns every component into a
217
+ // dependency of every other one, which is how a 3 KB `Spinner`
218
+ // starts pulling in `MediaTile`.
219
+ const offenders = componentFiles
220
+ .filter((one) => one.file !== "index.ts")
221
+ .filter(
222
+ (one) =>
223
+ /from "\.\.?\/index\.ts"/.test(one.contents) ||
224
+ /from "@charcuterie\/ui"/.test(one.contents),
225
+ )
226
+ .map((one) => one.file)
227
+
228
+ expect(offenders).toEqual([])
229
+
230
+ // And the barrel really does export every component, so a new one
231
+ // cannot land unexported — which would make it invisible to every
232
+ // consumer while all its own tests pass.
233
+ const barrel = await readFile(
234
+ join(sourceDirectory, "index.ts"),
235
+ "utf8",
236
+ )
237
+
238
+ // `Button/Button.tsx` — the component files, as distinct from
239
+ // their stories and helpers.
240
+ const componentNames = componentFiles
241
+ .map((one) => /^([A-Z]\w+)\/\1\.tsx$/.exec(one.file))
242
+ .flatMap((match) => (match?.[1] ? [match[1]] : []))
243
+
244
+ // M3's ten P0 components, plus `VisuallyHidden` — a Layer-0
245
+ // primitive `Spinner`, `ProgressBar`, and `LiveStatusIndicator` all
246
+ // need, so it shipped here rather than being stubbed three times.
247
+ // M4 adds the three overlays: `Modal`, `Popover`, `Tabs`.
248
+ //
249
+ // M5 adds the two the first consumer proved were shared rather
250
+ // than app-specific: `Alert` (rip-deck spells it four times, twice
251
+ // with a byte-identical `TONE_CLASS`) and `SegmentedControl`.
252
+ //
253
+ // `TabTrigger` and `SegmentedOption` are deliberately not in this
254
+ // count and not exported. Each is its own component's member —
255
+ // its own file only because both registrations are effects and an
256
+ // effect cannot run in a loop — and the `<Name>/<Name>.tsx`
257
+ // pattern this regex matches is what keeps them out.
258
+ expect(componentNames.length).toBe(16)
259
+
260
+ for (const name of componentNames) {
261
+ expect(barrel).toContain(`export { ${name} }`)
262
+ }
263
+ })
264
+
265
+ test("dependency direction is tokens ← logic ← ui", async () => {
266
+ // CI-enforced by the plan, and this is the enforcement. `ui` may
267
+ // depend on both; `logic` may depend on `tokens`; `tokens` depends
268
+ // on nothing, which is what lets `castkit/packages/views` read a
269
+ // colour without a React tree.
270
+ const readPackage = async (name: string) =>
271
+ JSON.parse(
272
+ await readFile(
273
+ join(packageDirectory, "..", name, "package.json"),
274
+ "utf8",
275
+ ),
276
+ ) as {
277
+ dependencies?: Record<string, string>
278
+ peerDependencies?: Record<string, string>
279
+ }
280
+
281
+ const getCharcuterieDependencies = (manifest: {
282
+ dependencies?: Record<string, string>
283
+ peerDependencies?: Record<string, string>
284
+ }) =>
285
+ Object.keys({
286
+ ...manifest.dependencies,
287
+ ...manifest.peerDependencies,
288
+ }).filter((name) => name.startsWith("@charcuterie/"))
289
+
290
+ expect(
291
+ getCharcuterieDependencies(await readPackage("tokens")),
292
+ ).toEqual([])
293
+
294
+ expect(
295
+ getCharcuterieDependencies(await readPackage("logic")),
296
+ ).toEqual([])
297
+
298
+ expect(
299
+ getCharcuterieDependencies(
300
+ await readPackage("ui"),
301
+ ).sort(),
302
+ ).toEqual(["@charcuterie/logic", "@charcuterie/tokens"])
303
+ })