@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,136 @@
1
+ /**
2
+ * The status → intent join, and the reachability of the machines it
3
+ * reads.
4
+ *
5
+ * The interesting assertions here are not "connected is green" —
6
+ * they are the two things that make a shared machine better than the
7
+ * `Record<string, string>` it replaces: **every** state has a colour
8
+ * and a wording, and every state is actually reachable.
9
+ */
10
+
11
+ import type {
12
+ AsyncStatus,
13
+ ConnectionStatus,
14
+ } from "@charcuterie/logic"
15
+ import {
16
+ asyncTransitions,
17
+ connectionTransitions,
18
+ getUnreachableStates,
19
+ } from "@charcuterie/logic"
20
+ import { expect, test } from "vitest"
21
+
22
+ import { mediaTransitions } from "./MediaTile/mediaStatus.ts"
23
+ import {
24
+ getAsyncIntent,
25
+ getAsyncLabel,
26
+ getConnectionIntent,
27
+ getConnectionLabel,
28
+ getIsConnectionBusy,
29
+ } from "./statusIntent.ts"
30
+
31
+ const connectionStatuses = Object.keys(
32
+ connectionTransitions,
33
+ ) as ConnectionStatus[]
34
+
35
+ const asyncStatuses = Object.keys(
36
+ asyncTransitions,
37
+ ) as AsyncStatus[]
38
+
39
+ test("every connection state has an intent and a wording", () => {
40
+ // mux-magic's `statusClassMap` is keyed by `string`, so an
41
+ // unrecognised status renders with no colour at all. The
42
+ // exhaustive switch makes that a compile error; this makes it a
43
+ // test failure too, for the JS consumers who have no compiler.
44
+ for (const status of connectionStatuses) {
45
+ expect(getConnectionIntent(status)).toBeTruthy()
46
+
47
+ expect(getConnectionLabel(status)).toBeTruthy()
48
+ }
49
+
50
+ expect(connectionStatuses).toHaveLength(4)
51
+ })
52
+
53
+ test("reconnecting is not the same as connecting", () => {
54
+ // The distinction the whole shared machine exists for, and the one
55
+ // all four repos currently lose.
56
+ expect(getConnectionIntent("connecting")).toBe("info")
57
+
58
+ expect(getConnectionIntent("reconnecting")).toBe(
59
+ "warning",
60
+ )
61
+
62
+ expect(getConnectionLabel("connecting")).not.toBe(
63
+ getConnectionLabel("reconnecting"),
64
+ )
65
+ })
66
+
67
+ test("only the in-flight states animate", () => {
68
+ expect(getIsConnectionBusy("connecting")).toBe(true)
69
+
70
+ expect(getIsConnectionBusy("reconnecting")).toBe(true)
71
+
72
+ expect(getIsConnectionBusy("connected")).toBe(false)
73
+
74
+ expect(getIsConnectionBusy("disconnected")).toBe(false)
75
+ })
76
+
77
+ test("connection wordings are distinct", () => {
78
+ // Two states that read identically on a wall-mounted panel are two
79
+ // states the user cannot tell apart, which defeats the point.
80
+ const labels = connectionStatuses.map(getConnectionLabel)
81
+
82
+ expect(new Set(labels).size).toBe(labels.length)
83
+ })
84
+
85
+ test("every async state has an intent and a wording", () => {
86
+ for (const status of asyncStatuses) {
87
+ expect(getAsyncIntent(status)).toBeTruthy()
88
+
89
+ expect(getAsyncLabel(status)).toBeTruthy()
90
+ }
91
+
92
+ expect(
93
+ new Set(asyncStatuses.map(getAsyncLabel)).size,
94
+ ).toBe(asyncStatuses.length)
95
+ })
96
+
97
+ test("only failure is red", () => {
98
+ expect(getAsyncIntent("error")).toBe("danger")
99
+
100
+ expect(getAsyncIntent("idle")).toBe("neutral")
101
+
102
+ expect(getAsyncIntent("success")).toBe("success")
103
+ })
104
+
105
+ test("no state of any machine this package renders is unreachable", () => {
106
+ // A `failed` nobody can transition to is a badge variant that will
107
+ // never render — almost always a typo in a transition table. The
108
+ // check belongs to the *definition*, so each machine's consumer
109
+ // runs it.
110
+ expect(
111
+ getUnreachableStates(
112
+ connectionTransitions,
113
+ "connecting",
114
+ ),
115
+ ).toEqual([])
116
+
117
+ expect(
118
+ getUnreachableStates(asyncTransitions, "idle"),
119
+ ).toEqual([])
120
+
121
+ expect(
122
+ getUnreachableStates(mediaTransitions, "loading"),
123
+ ).toEqual([])
124
+ })
125
+
126
+ test("a loaded image cannot become an error", () => {
127
+ // MediaTile's one deliberate omission. A browser does not fire
128
+ // `error` on an image it has already decoded, so an app that
129
+ // thinks it saw that is misreading its own event wiring — and a
130
+ // fallback flickering over a good poster is worse than a throw.
131
+ expect(mediaTransitions.loaded).not.toContain("error")
132
+
133
+ expect(mediaTransitions.loaded).toContain("loading")
134
+
135
+ expect(mediaTransitions.error).toContain("loading")
136
+ })
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The two shared machines → an intent and a human label.
3
+ *
4
+ * This is the join M2 promised and the reason `Status` earned its
5
+ * place as a fifth state kind. `Badge` and `LiveStatusIndicator`
6
+ * consume `connectionTransitions` / `asyncTransitions` through
7
+ * these functions, so a connection state has **one** colour and
8
+ * **one** wording across the fleet — against four repos that each
9
+ * spell the same four states differently today, and one
10
+ * (`mux-magic`) whose `statusClassMap` is a
11
+ * `Record<string, string>` that silently renders an unknown status
12
+ * with no colour at all.
13
+ *
14
+ * Every switch here ends in `assertNeverStatus`, which is the whole
15
+ * point: add a state to `connectionTransitions` and this file stops
16
+ * compiling. A `Record<string, string>` would have shipped a blank
17
+ * badge instead.
18
+ */
19
+
20
+ import type {
21
+ AsyncStatus,
22
+ ConnectionStatus,
23
+ } from "@charcuterie/logic"
24
+ import { assertNeverStatus } from "@charcuterie/logic"
25
+ import type { IntentName } from "@charcuterie/tokens"
26
+
27
+ export const getConnectionIntent = (
28
+ status: ConnectionStatus,
29
+ ): IntentName => {
30
+ switch (status) {
31
+ case "connected":
32
+ return "success"
33
+
34
+ case "connecting":
35
+ return "info"
36
+
37
+ // Warning rather than danger, and this is the distinction the
38
+ // shared machine exists for: a user who has seen data wants
39
+ // "lost it, getting it back", not the cold-start spinner they
40
+ // already sat through, and not the red of a dead link either.
41
+ case "reconnecting":
42
+ return "warning"
43
+
44
+ case "disconnected":
45
+ return "danger"
46
+
47
+ default:
48
+ return assertNeverStatus(status)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The ellipsis is a real `…`, not three dots, and the trailing one
54
+ * is load-bearing: it is what makes "Reconnecting…" read as
55
+ * in-progress to someone who glances at a wall-mounted panel from
56
+ * across the room.
57
+ */
58
+ export const getConnectionLabel = (
59
+ status: ConnectionStatus,
60
+ ): string => {
61
+ switch (status) {
62
+ case "connected":
63
+ return "Connected"
64
+
65
+ case "connecting":
66
+ return "Connecting…"
67
+
68
+ case "reconnecting":
69
+ return "Reconnecting…"
70
+
71
+ case "disconnected":
72
+ return "Disconnected"
73
+
74
+ default:
75
+ return assertNeverStatus(status)
76
+ }
77
+ }
78
+
79
+ /** Whether the state is one a moving affordance should mark. */
80
+ export const getIsConnectionBusy = (
81
+ status: ConnectionStatus,
82
+ ): boolean => {
83
+ switch (status) {
84
+ case "connecting":
85
+ case "reconnecting":
86
+ return true
87
+
88
+ case "connected":
89
+ case "disconnected":
90
+ return false
91
+
92
+ default:
93
+ return assertNeverStatus(status)
94
+ }
95
+ }
96
+
97
+ export const getAsyncIntent = (
98
+ status: AsyncStatus,
99
+ ): IntentName => {
100
+ switch (status) {
101
+ case "idle":
102
+ return "neutral"
103
+
104
+ case "loading":
105
+ return "info"
106
+
107
+ case "success":
108
+ return "success"
109
+
110
+ case "error":
111
+ return "danger"
112
+
113
+ default:
114
+ return assertNeverStatus(status)
115
+ }
116
+ }
117
+
118
+ export const getAsyncLabel = (
119
+ status: AsyncStatus,
120
+ ): string => {
121
+ switch (status) {
122
+ case "idle":
123
+ return "Idle"
124
+
125
+ case "loading":
126
+ return "Loading…"
127
+
128
+ case "success":
129
+ return "Done"
130
+
131
+ case "error":
132
+ return "Failed"
133
+
134
+ default:
135
+ return assertNeverStatus(status)
136
+ }
137
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The rule that would have caught image 4 of the M4 review: a `size`
3
+ * prop rendering as a `{}` JSON textarea in the Controls panel,
4
+ * because Storybook could not see that `ControlSize` is three
5
+ * strings.
6
+ *
7
+ * `react-docgen` follows **relative** imports and stops at bare
8
+ * package specifiers. So a prop typed from `../intentStyles.ts`
9
+ * arrives as a resolved union and gets a radio for free, while the
10
+ * same prop typed from `@charcuterie/tokens` arrives as an opaque
11
+ * name — and Storybook's fallback for an unknown type is the object
12
+ * control. Nothing errors. The story renders. The control is just
13
+ * wrong, in a way only a human opening that panel will notice.
14
+ *
15
+ * So: a prop whose type comes from a bare specifier must have an
16
+ * explicit `argTypes` entry in its story.
17
+ */
18
+
19
+ import { readdir, readFile } from "node:fs/promises"
20
+ import { join, resolve } from "node:path"
21
+
22
+ import { expect, test } from "vitest"
23
+
24
+ const sourceDirectory = resolve(import.meta.dirname)
25
+
26
+ const readComponent = async (name: string) => {
27
+ const [component, story] = await Promise.all([
28
+ readFile(
29
+ join(sourceDirectory, name, `${name}.tsx`),
30
+ "utf8",
31
+ ),
32
+ readFile(
33
+ join(sourceDirectory, name, `${name}.stories.tsx`),
34
+ "utf8",
35
+ ),
36
+ ])
37
+
38
+ return { component, name, story }
39
+ }
40
+
41
+ const componentNames = (
42
+ await readdir(sourceDirectory, { withFileTypes: true })
43
+ )
44
+ .filter(
45
+ (entry) =>
46
+ entry.isDirectory() && /^[A-Z]/.test(entry.name),
47
+ )
48
+ .map((entry) => entry.name)
49
+ .sort()
50
+
51
+ const components = await Promise.all(
52
+ componentNames.map(readComponent),
53
+ )
54
+
55
+ /**
56
+ * `import type { A, B } from "@scope/pkg"` — bare specifiers only.
57
+ * A relative one is exactly the case docgen *can* resolve.
58
+ */
59
+ const getForeignTypeNames = (source: string) =>
60
+ new Set(
61
+ Array.from(
62
+ source.matchAll(
63
+ /import type \{([^}]+)\} from "([^".][^"]*)"/g,
64
+ ),
65
+ )
66
+ .filter(
67
+ ([, , specifier]) => !specifier?.startsWith("."),
68
+ )
69
+ .flatMap(([, names]) =>
70
+ (names ?? "")
71
+ .split(",")
72
+ .map((one) => one.trim())
73
+ .filter(Boolean),
74
+ ),
75
+ )
76
+
77
+ const getPropsBlock = (source: string) =>
78
+ /export type \w+Props =[\s\S]*?\n\}/.exec(source)?.[0] ??
79
+ ""
80
+
81
+ test("every component's props are declared where a story can find them", () => {
82
+ // A canary on the derivation: if the directory scan ever comes
83
+ // back empty this whole file passes forever.
84
+ expect(componentNames).toContain("Button")
85
+ expect(componentNames.length).toBeGreaterThanOrEqual(14)
86
+ })
87
+
88
+ test("a prop typed from another package has an explicit control", () => {
89
+ const offenders = components.flatMap(
90
+ ({ component, name, story }) => {
91
+ const foreignTypes = getForeignTypeNames(component)
92
+
93
+ if (foreignTypes.size === 0) {
94
+ return []
95
+ }
96
+
97
+ // `intent?: IntentName` / `size?: ControlSize`. React's own
98
+ // types are excluded: `ReactNode` and friends are not
99
+ // enumerable and are not meant to have a radio.
100
+ const declarations = Array.from(
101
+ getPropsBlock(component).matchAll(
102
+ /^\s{2}(\w+)\??:\s*([A-Za-z_]\w*)\s*$/gm,
103
+ ),
104
+ )
105
+
106
+ return declarations
107
+ .filter(
108
+ ([, , typeName]) =>
109
+ typeName &&
110
+ foreignTypes.has(typeName) &&
111
+ !typeName.startsWith("React"),
112
+ )
113
+ .filter(
114
+ ([, propName]) =>
115
+ // The story has to name the prop inside its `argTypes`.
116
+ !new RegExp(
117
+ `argTypes:[\\s\\S]*?\\b${propName}:`,
118
+ ).test(story),
119
+ )
120
+ .map(
121
+ ([, propName, typeName]) =>
122
+ `${name}.${propName} is \`${typeName}\`, imported from another package, with no argTypes entry`,
123
+ )
124
+ },
125
+ )
126
+
127
+ expect(offenders).toEqual([])
128
+ })
package/src/styles.css ADDED
@@ -0,0 +1,130 @@
1
+ /* `@charcuterie/ui/styles.css` — the four looping affordances, and
2
+ * nothing else.
3
+ *
4
+ * Every other visual in this package is a Tailwind utility reading
5
+ * a token, which is what makes a scheme/density/variant flip a
6
+ * repaint with no re-render. Animation is the one thing utilities
7
+ * cannot express against our tokens: Tailwind's own `animate-spin`
8
+ * and `animate-pulse` hardcode 1s and 2s, ignore
9
+ * `prefers-reduced-motion`, and are therefore beyond the theme's
10
+ * reach — which is exactly why `MotionTokens` carries a separate
11
+ * `loopFast`/`loopSlow` ramp.
12
+ *
13
+ * Consumers import this once, after `@charcuterie/tokens/theme.css`.
14
+ *
15
+ * The reduced-motion block is not a nicety. `variables.css` already
16
+ * collapses every duration to `0ms`, and for a *transition* that is
17
+ * the whole fix — but `animation: sweep 0ms infinite` still paints
18
+ * its first keyframe forever, and the first keyframe of a sweep is
19
+ * an off-screen bar. So each loop is switched off outright and owes
20
+ * a static fallback that still reads correctly, which is what the
21
+ * `@media` block below supplies. A spinner that stops spinning must
22
+ * still say "loading", and it does: the label is text in a
23
+ * `role="status"`, not the animation. */
24
+
25
+ @keyframes charcuterie-spin {
26
+ to {
27
+ transform: rotate(360deg);
28
+ }
29
+ }
30
+
31
+ @keyframes charcuterie-shimmer {
32
+ from {
33
+ background-position: 100% 0;
34
+ }
35
+
36
+ to {
37
+ background-position: -100% 0;
38
+ }
39
+ }
40
+
41
+ /* Logical, not `translateX`. A sweep is the one animation whose
42
+ * direction carries meaning — it reads as "moving forward" — so in
43
+ * RTL it has to move the other way, and `inset-inline-start` is
44
+ * what makes that free. */
45
+ @keyframes charcuterie-sweep {
46
+ from {
47
+ inset-inline-start: -40%;
48
+ }
49
+
50
+ to {
51
+ inset-inline-start: 100%;
52
+ }
53
+ }
54
+
55
+ @keyframes charcuterie-pulse {
56
+ 0% {
57
+ opacity: 1;
58
+ transform: scale(1);
59
+ }
60
+
61
+ 50% {
62
+ opacity: 0.45;
63
+ transform: scale(0.82);
64
+ }
65
+
66
+ 100% {
67
+ opacity: 1;
68
+ transform: scale(1);
69
+ }
70
+ }
71
+
72
+ .charcuterie-spin {
73
+ animation: charcuterie-spin var(--duration-loop-fast)
74
+ linear infinite;
75
+ }
76
+
77
+ .charcuterie-shimmer {
78
+ background-image: linear-gradient(
79
+ 90deg,
80
+ var(--color-surface-sunken) 0%,
81
+ var(--color-surface-raised) 50%,
82
+ var(--color-surface-sunken) 100%
83
+ );
84
+ background-size: 200% 100%;
85
+ animation: charcuterie-shimmer var(--duration-loop-slow)
86
+ var(--easing-standard) infinite;
87
+ }
88
+
89
+ .charcuterie-sweep {
90
+ animation: charcuterie-sweep var(--duration-loop-slow)
91
+ var(--easing-standard) infinite;
92
+ }
93
+
94
+ .charcuterie-pulse {
95
+ animation: charcuterie-pulse var(--duration-loop-slow)
96
+ var(--easing-standard) infinite;
97
+ }
98
+
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .charcuterie-spin,
101
+ .charcuterie-shimmer,
102
+ .charcuterie-sweep,
103
+ .charcuterie-pulse {
104
+ animation: none;
105
+ }
106
+
107
+ /* Flat, not mid-gradient: a stopped shimmer left holding its
108
+ * first keyframe is a lopsided highlight that reads as a
109
+ * rendering bug rather than as a placeholder. */
110
+ .charcuterie-shimmer {
111
+ background-image: none;
112
+ background-color: var(--color-surface-sunken);
113
+ }
114
+
115
+ /* The sweep's static fallback carries the whole meaning of the
116
+ * indeterminate state — "working, no measurable progress" — so it
117
+ * fills the track with hatching rather than parking a bar at one
118
+ * end, where it would read as either finished or stuck. */
119
+ .charcuterie-sweep {
120
+ inset-inline-start: 0;
121
+ inline-size: 100%;
122
+ background-image: repeating-linear-gradient(
123
+ 135deg,
124
+ currentColor 0,
125
+ currentColor 2px,
126
+ transparent 2px,
127
+ transparent 6px
128
+ );
129
+ }
130
+ }