@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @charcuterie/ui
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Initial public release of the Charcuterie fleet library: the design tokens, the five state kinds (Visibility, VisibilityGroup, SinglePicker, MultiplePicker, RovingFocus, Status), the component set, and the shared ESLint + Biome configs.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @charcuterie/tokens@0.1.0
13
+ - @charcuterie/logic@0.1.0
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # `@charcuterie/ui`
2
+
3
+ The components. Look from `@charcuterie/tokens`, state from `@charcuterie/logic`, and ARIA
4
+ that Playwright and an AI agent can actually drive.
5
+
6
+ M3 ships the **P0 pure-presentation** set: `Spinner`, `Skeleton`, `Button`, `IconButton`,
7
+ `Badge`, `ProgressBar`, `EmptyState`, `Card`, `LiveStatusIndicator`, `MediaTile` — plus
8
+ `VisuallyHidden`, which the first three of those need. M4 adds the overlays: `Modal`,
9
+ `Popover`, `Tabs`.
10
+
11
+ **M5 adds two, and they came from the first consumer rather than from the plan** —
12
+ [the rule](../../docs/decisions/2026-07-30-a-consumer-milestone-adds-components.md):
13
+
14
+ - **`Alert`** — rip-deck spells this shape four times, two of them carrying a
15
+ byte-identical `TONE_CLASS` map of hardcoded hexes. It is the app's largest single
16
+ duplication and no P0 component is a banner.
17
+ - **`SegmentedControl`** — `SinglePicker` + `RovingFocus` with the panels taken away, which
18
+ is a composition that could not be expressed until
19
+ [`Tabs` moved onto `SinglePicker`](../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
20
+
21
+ ## Install and wire
22
+
23
+ ```ts
24
+ import { Button, Card } from "@charcuterie/ui"
25
+ ```
26
+
27
+ ```css
28
+ @import "tailwindcss";
29
+ @import "@charcuterie/tokens/theme.css"; /* colours, type ramp, radii, motion */
30
+ @import "@charcuterie/tokens/fonts.css"; /* Baloo 2, Outfit, Victor Mono */
31
+ @import "@charcuterie/ui/styles.css"; /* the four looping affordances */
32
+
33
+ @source "../node_modules/@charcuterie/ui/dist";
34
+ ```
35
+
36
+ That last line is not optional. Tailwind v4 scans **source text** for complete class
37
+ strings, and it does not scan your dependencies by default — without it every component
38
+ renders unstyled, with no error.
39
+
40
+ **Both package names, and the CSS line above is why.** The token *types and values* are
41
+ re-exported at `@charcuterie/ui/tokens`, so your TypeScript only ever names one package —
42
+ but the *stylesheet* is imported from `@charcuterie/tokens` directly. A `./tokens.css`
43
+ export pointing inside this package resolved to nothing on the first real consumer, because
44
+ a hoisting linker puts `@charcuterie/tokens` at the project root rather than in here, and a
45
+ CSS `@import` that misses fails **silently**: no error, no utilities, an unstyled app.
46
+
47
+ The testing gates are at `@charcuterie/ui/testing`.
48
+
49
+ ### While the packages are unpublished: three lines, and you need all three
50
+
51
+ A consumer links by `portal:` until this publishes
52
+ ([decision](../../docs/decisions/2026-07-29-consumers-link-tokens-by-portal-until-publish.md)).
53
+ M5 spent an hour on the second and third of these, so M5b and M6 do not have to:
54
+
55
+ ```jsonc
56
+ // the app package
57
+ "dependencies": {
58
+ "@charcuterie/tokens": "portal:../../../charcuterie/packages/tokens",
59
+ "@charcuterie/ui": "portal:../../../charcuterie/packages/ui"
60
+ }
61
+
62
+ // the PROJECT ROOT — `ui` declares its siblings as `workspace:*`, and that
63
+ // descriptor cannot resolve outside charcuterie's own workspace. Bare keys, not
64
+ // `@charcuterie/ui/@charcuterie/tokens`: the scoped form yields a different
65
+ // locator string from the app's own dependency on the same directory, and Yarn
66
+ // rejects the pair as conflicting.
67
+ "resolutions": {
68
+ "@charcuterie/logic": "portal:../charcuterie/packages/logic",
69
+ "@charcuterie/tokens": "portal:../charcuterie/packages/tokens"
70
+ }
71
+ ```
72
+
73
+ ```ts
74
+ // vite.config.ts AND vitest.config.ts
75
+ resolve: { dedupe: ["react", "react-dom"] }
76
+ ```
77
+
78
+ **That last one is the expensive one.** A `portal:` is a symlink, and Node and Vite both
79
+ resolve a symlinked module from its **real path** — so a component living in this package
80
+ resolves *its own* `react` by walking up from here, landing on charcuterie's copy while
81
+ your app renders with yours. Every component with a hook fails at once with:
82
+
83
+ ```
84
+ TypeError: Cannot read properties of null (reading 'useRef')
85
+ ```
86
+
87
+ which mentions neither symlinks nor React identity. Keep the line after publish: it costs
88
+ nothing with one copy, and it is the difference between a working `yarn link` session and
89
+ an hour of confusion.
90
+
91
+ ## How a component is put together
92
+
93
+ | Layer | Where it comes from |
94
+ | --- | --- |
95
+ | Colour | `intentStyles.ts` — six intents x four appearances, written out in full |
96
+ | Size | `controlStyles.ts` — `h-(--control-height-md)`, so `[data-density]` decides |
97
+ | Type | `text-sm`/`text-md`/`text-lg`, which are **ours**: `theme.css` bridges `--text-*` onto the density-scaled `--font-size-*` |
98
+ | Motion | `styles.css`, at `--duration-loop-*`, switched off under `prefers-reduced-motion` |
99
+ | State | `@charcuterie/logic` — `useUniqueId` for label wiring, `useStatus` for `MediaTile`'s three image states |
100
+
101
+ **Class names are never interpolated.** `` `bg-intent-${intent}-solid` `` generates nothing
102
+ at all and fails silently, so `intentStyles.ts` is 48 literals rather than a loop, and
103
+ `tailwindCandidates.test.ts` compiles every literal in the package through the real Tailwind
104
+ and fails on any candidate Tailwind cannot generate.
105
+
106
+ ## Tests
107
+
108
+ ```bash
109
+ yarn vitest run --project ui # Node: class maps, status switches, clamping, boundaries
110
+ yarn vitest run --project ui-dom # chromium: Component.test.tsx — behaviour, keyboard, ARIA
111
+ yarn vitest run --project storybook # chromium: every story renders, axe at test: "error"
112
+ ```
113
+
114
+ **Stories are demos and carry no assertions**
115
+ ([decision](../../docs/decisions/2026-07-30-stories-are-demos-tests-are-tests.md), which
116
+ supersedes M3's). The DOM half lives in `Component.test.tsx` beside the component, and each
117
+ test mounts the **composed story** through `run()` rather than re-assembling the component —
118
+ so there is still only one rendering stack, and the subject of a test is the story a reader
119
+ sees.
120
+
121
+ Every component's test calls `expectAgentDrivable(canvas, { role, name })`, which is the
122
+ same query an agent will write; `data-testid` appears nowhere in this package and a test
123
+ enforces that.
124
+
125
+ Axe runs once per mount, when `run()` resolves — i.e. before the test has clicked anything.
126
+ A state you had to *drive* to reach (an open dialog, a shown popover) audits itself with
127
+ `expectNoAxeViolations(canvasElement)`.
128
+
129
+ Two rules about the docs panel are tests rather than conventions, because both fail
130
+ invisibly: `storyControls.test.ts` (a prop typed from another package needs an explicit
131
+ `argTypes` entry, or Storybook renders a `{}` textarea for it) and `mdxReferences.test.ts`
132
+ (an `.mdx` may not reference a story that no longer exists — `of` resolves at runtime, so a
133
+ rename breaks only the rendered page).
134
+
135
+ ## Adding a component
136
+
137
+ 1. `src/<Name>/<Name>.tsx`, `.stories.tsx`, `.mdx`, `.test.tsx` — siblings, matching
138
+ mux-magic. The story shows it; the test drives it. Neither does the other's job.
139
+ 2. Colours from `intentStyles.ts`, sizes from `controlStyles.ts`. No hex, no `*-slate-*`
140
+ (a test checks).
141
+ 3. The five stories: `Default`, `AllVariants`, `AllStates`, `Responsive` (three container
142
+ widths, via `ContainerBoard`), `Interactive` (the complete keyboard path).
143
+ 4. Export from `src/index.ts` — the one sanctioned barrel. Components import each other
144
+ directly, never through it.
145
+ 5. **If it is an overlay, use the platform's top layer — never a portal.** `<dialog>` +
146
+ `showModal()`, or `popover="manual"`. Both stay where they are in the DOM, so
147
+ `canvas.getByRole(…)` still finds the panel; a portal moves it to `document.body`, out
148
+ of the element a test or an agent scoped its queries to
149
+ ([decision](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
150
+ 6. **If it is built on a registering kind, look at its first paint.** Members register
151
+ from effects, so before those run a `RovingFocus` has no active value and a
152
+ `SinglePicker` / `VisibilityGroup` has no selected key — only a pending one. Two of M4's
153
+ four bugs were exactly that, and the isolated story runner saw neither —
154
+ `yarn smoke:storybook` did.
155
+ 7. **If it declares `@container`, every `StoryCell` holding it needs `align="stretch"`.**
156
+ `container-type: inline-size` forbids the element from being sized by its own
157
+ contents, so a default (shrink-to-fit) cell collapses it to min-content and every
158
+ line wraps after one word. Valid CSS, no error, nothing for axe to say — it only
159
+ shows up in a screenshot, which is how it shipped in M3's `LiveStatusIndicator`
160
+ board. `sourceRules.test.ts` now derives the container-declaring components from
161
+ source and fails on this, so the component M4 adds joins the rule automatically.
@@ -0,0 +1,88 @@
1
+ import type { IntentName } from "@charcuterie/tokens";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+ export type AlertSize = "md" | "sm";
4
+ export type AlertProps = Omit<ComponentPropsWithRef<"section">, "children"> & {
5
+ /** Buttons. Laid out beside the text at `cq-sm` and up. */
6
+ actions?: ReactNode;
7
+ /** The quiet second line — counts, timestamps, provenance. */
8
+ description?: ReactNode;
9
+ /**
10
+ * The evidence, one line each. A list rather than a paragraph
11
+ * because that is what it is, and because a screen reader then
12
+ * announces "list, 3 items" instead of running three measurements
13
+ * together into one sentence.
14
+ */
15
+ details?: readonly string[];
16
+ /**
17
+ * The sentence. A `ReactNode` rather than a `string` because
18
+ * rip-deck's verdicts qualify themselves inline — *"…(suspected —
19
+ * retry in another drive to confirm)"* — and splitting that across
20
+ * two props would let the qualifier drift away from the claim it
21
+ * qualifies.
22
+ */
23
+ heading: ReactNode;
24
+ intent?: IntentName;
25
+ /**
26
+ * Present ⇒ this is a **named landmark** and
27
+ * `getByRole("region", { name })` finds it. Absent ⇒ a plain
28
+ * block, because a `<section>` with no accessible name is
29
+ * `generic` rather than `region`. See below; the default is
30
+ * deliberate.
31
+ */
32
+ label?: string;
33
+ size?: AlertSize;
34
+ };
35
+ /**
36
+ * The thing four of rip-deck's components each spell differently,
37
+ * and the reason `TONE_CLASS` was declared twice in one package.
38
+ *
39
+ * `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
40
+ * `VerdictBadge` are one shape: an intent-coloured block holding a
41
+ * sentence, a quieter line under it, and sometimes a list of
42
+ * evidence. Two of them carried a byte-identical `TONE_CLASS` map of
43
+ * hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
44
+ * same idea a third way in `statusClassMap`. Here it is
45
+ * `INTENT_APPEARANCE_CLASS`, which every other component in this
46
+ * package already reads.
47
+ *
48
+ * ### It is a block by default, and a landmark only when named
49
+ *
50
+ * `label` is what decides, because the evidence divides cleanly:
51
+ *
52
+ * - The **banners** are one alert at the top of a page and want to
53
+ * be addressable — `getByRole("region", { name: "USB connection
54
+ * alert" })`. They pass a `label`.
55
+ * - The **verdict** renders inside every bay card, and one of its
56
+ * two forms is the single sentence *"Part of the tower-wide
57
+ * problem above."* Nine landmarks sharing one name is axe's
58
+ * `landmark-unique`, so it passes none.
59
+ *
60
+ * A component that always took the landmark would have made the
61
+ * second case fail its own a11y gate, and one that never took it
62
+ * would leave three `<section aria-label>` wrappers hand-rolled in
63
+ * the consumer — which is what rip-deck has today.
64
+ *
65
+ * ### The heading is not a heading element
66
+ *
67
+ * An alert's headline is a *sentence* — "Bay 7 is reading with
68
+ * errors; clean the disc and try again" — not a node in the document
69
+ * outline. Nine bays' worth of those as `<h3>`s builds a table of
70
+ * contents out of nine complaints, which is worse for a screen
71
+ * reader than no outline entry at all. `Card` and `EmptyState` take
72
+ * a real `headingLevel` precisely because their headings *are* short
73
+ * outline nodes; this one is styled text, and `label` is how it
74
+ * becomes findable.
75
+ *
76
+ * ### It does not announce, and that is a component boundary
77
+ *
78
+ * No `role="alert"`, no `aria-live`. A live region takes **no
79
+ * accessible name from its content**
80
+ * ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
81
+ * so an announcing variant would need a name that duplicates the
82
+ * sentence it is announcing — and `role="alert"` on a `<section>`
83
+ * overrides the landmark, so it cannot be both. A thing that
84
+ * interrupts you is a `Toast` (M6). This is a thing that is *on the
85
+ * page*.
86
+ */
87
+ export declare const Alert: ({ actions, className, description, details, heading, intent, label, size, ...sectionProps }: AlertProps) => ReactNode;
88
+ //# sourceMappingURL=Alert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Alert.d.ts","sourceRoot":"","sources":["../../src/Alert/Alert.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAKd,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAA;AAEnC,MAAM,MAAM,UAAU,GAAG,IAAI,CAC3B,qBAAqB,CAAC,SAAS,CAAC,EAChC,UAAU,CACX,GAAG;IACF,2DAA2D;IAC3D,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B;;;;;;OAMG;IACH,OAAO,EAAE,SAAS,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,KAAK,GAAI,6FAUnB,UAAU,KAAG,SAoDf,CAAA"}
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { INTENT_APPEARANCE_CLASS } from "../intentStyles.js";
3
+ import { toClassName } from "../toClassName.js";
4
+ const SIZE_CLASS = {
5
+ sm: "gap-0.5 rounded-md px-2 py-1 text-sm",
6
+ md: "gap-1 rounded-xl px-3.5 py-2.5 text-md",
7
+ };
8
+ /**
9
+ * The thing four of rip-deck's components each spell differently,
10
+ * and the reason `TONE_CLASS` was declared twice in one package.
11
+ *
12
+ * `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
13
+ * `VerdictBadge` are one shape: an intent-coloured block holding a
14
+ * sentence, a quieter line under it, and sometimes a list of
15
+ * evidence. Two of them carried a byte-identical `TONE_CLASS` map of
16
+ * hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
17
+ * same idea a third way in `statusClassMap`. Here it is
18
+ * `INTENT_APPEARANCE_CLASS`, which every other component in this
19
+ * package already reads.
20
+ *
21
+ * ### It is a block by default, and a landmark only when named
22
+ *
23
+ * `label` is what decides, because the evidence divides cleanly:
24
+ *
25
+ * - The **banners** are one alert at the top of a page and want to
26
+ * be addressable — `getByRole("region", { name: "USB connection
27
+ * alert" })`. They pass a `label`.
28
+ * - The **verdict** renders inside every bay card, and one of its
29
+ * two forms is the single sentence *"Part of the tower-wide
30
+ * problem above."* Nine landmarks sharing one name is axe's
31
+ * `landmark-unique`, so it passes none.
32
+ *
33
+ * A component that always took the landmark would have made the
34
+ * second case fail its own a11y gate, and one that never took it
35
+ * would leave three `<section aria-label>` wrappers hand-rolled in
36
+ * the consumer — which is what rip-deck has today.
37
+ *
38
+ * ### The heading is not a heading element
39
+ *
40
+ * An alert's headline is a *sentence* — "Bay 7 is reading with
41
+ * errors; clean the disc and try again" — not a node in the document
42
+ * outline. Nine bays' worth of those as `<h3>`s builds a table of
43
+ * contents out of nine complaints, which is worse for a screen
44
+ * reader than no outline entry at all. `Card` and `EmptyState` take
45
+ * a real `headingLevel` precisely because their headings *are* short
46
+ * outline nodes; this one is styled text, and `label` is how it
47
+ * becomes findable.
48
+ *
49
+ * ### It does not announce, and that is a component boundary
50
+ *
51
+ * No `role="alert"`, no `aria-live`. A live region takes **no
52
+ * accessible name from its content**
53
+ * ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
54
+ * so an announcing variant would need a name that duplicates the
55
+ * sentence it is announcing — and `role="alert"` on a `<section>`
56
+ * overrides the landmark, so it cannot be both. A thing that
57
+ * interrupts you is a `Toast` (M6). This is a thing that is *on the
58
+ * page*.
59
+ */
60
+ export const Alert = ({ actions, className, description, details, heading, intent = "neutral", label, size = "md", ...sectionProps }) => {
61
+ return (_jsxs("section", { ...sectionProps, "aria-label": label, className: toClassName(
62
+ // `@container` so the actions drop below the text in a
63
+ // sidebar and sit beside it full-bleed — the case a media
64
+ // query cannot tell apart.
65
+ "@container border", INTENT_APPEARANCE_CLASS[intent].soft, SIZE_CLASS[size], className), children: [_jsxs("div", { className: "flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("p", { className: "m-0 font-semibold", children: heading }), description ? (_jsx("p", { className: "m-0 text-sm opacity-80", children: description })) : null] }), actions ? (_jsx("div", { className: "flex shrink-0 items-center gap-1", children: actions })) : null] }), details && details.length > 0 ? (_jsx("ul", { className: "m-0 list-none p-0 text-sm opacity-80", children: details.map((line, index) => (_jsx("li", { children: line }, `${String(index)}-${line}`))) })) : null] }));
66
+ };
67
+ //# sourceMappingURL=Alert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Alert.js","sourceRoot":"","sources":["../../src/Alert/Alert.tsx"],"names":[],"mappings":";AAMA,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAuC/C,MAAM,UAAU,GAA8B;IAC5C,EAAE,EAAE,sCAAsC;IAC1C,EAAE,EAAE,wCAAwC;CAC7C,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,OAAO,EACP,SAAS,EACT,WAAW,EACX,OAAO,EACP,OAAO,EACP,MAAM,GAAG,SAAS,EAClB,KAAK,EACL,IAAI,GAAG,IAAI,EACX,GAAG,YAAY,EACJ,EAAa,EAAE;IAC1B,OAAO,CAQL,sBACM,YAAY,gBACJ,KAAK,EACjB,SAAS,EAAE,WAAW;QACpB,uDAAuD;QACvD,0DAA0D;QAC1D,2BAA2B;QAC3B,mBAAmB,EACnB,uBAAuB,CAAC,MAAM,CAAC,CAAC,IAAI,EACpC,UAAU,CAAC,IAAI,CAAC,EAChB,SAAS,CACV,aAED,eAAK,SAAS,EAAC,4EAA4E,aACzF,eAAK,SAAS,EAAC,uBAAuB,aACpC,YAAG,SAAS,EAAC,mBAAmB,YAAE,OAAO,GAAK,EAE7C,WAAW,CAAC,CAAC,CAAC,CACb,YAAG,SAAS,EAAC,wBAAwB,YAClC,WAAW,GACV,CACL,CAAC,CAAC,CAAC,IAAI,IACJ,EAEL,OAAO,CAAC,CAAC,CAAC,CACT,cAAK,SAAS,EAAC,kCAAkC,YAC9C,OAAO,GACJ,CACP,CAAC,CAAC,CAAC,IAAI,IACJ,EAEL,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAC/B,aAAI,SAAS,EAAC,sCAAsC,YACjD,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAI5B,uBAAsC,IAAI,IAAjC,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAa,CACjD,CAAC,GACC,CACN,CAAC,CAAC,CAAC,IAAI,IACA,CACX,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,54 @@
1
+ import type { IntentName } from "@charcuterie/tokens";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+ import type { BadgeSize } from "../controlStyles.ts";
4
+ import type { IntentAppearance } from "../intentStyles.ts";
5
+ export type BadgeProps = ComponentPropsWithRef<"span"> & {
6
+ appearance?: Exclude<IntentAppearance, "ghost">;
7
+ children: ReactNode;
8
+ icon?: ReactNode;
9
+ intent?: IntentName;
10
+ /**
11
+ * What happens when the label is wider than the space it is given.
12
+ *
13
+ * - `truncate` — one line, capped at the container, ellipsis. The
14
+ * default, because a status pill that changes its row's height
15
+ * when a message gets longer breaks the table it sits in.
16
+ * - `wrap` — the pill grows taller and shows everything. For the
17
+ * kiosk and any touch context, where the hover readout that
18
+ * `truncate` relies on does not exist.
19
+ */
20
+ overflow?: "truncate" | "wrap";
21
+ size?: BadgeSize;
22
+ };
23
+ /**
24
+ * Four repos, and rip-deck declares the **identical** `TONE_CLASS`
25
+ * map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
26
+ * hardcoded hexes, no relationship to each other beyond
27
+ * copy-paste. mux-magic's `statusClassMap` is the same idea spelled
28
+ * a third way, keyed by a bare `string` so an unrecognised status
29
+ * renders with no colour at all and no error.
30
+ *
31
+ * `intent` replaces all of it. The **status** half of the problem —
32
+ * which state maps to which intent, and what it is called in
33
+ * English — lives in `statusIntent.ts` as exhaustive switches over
34
+ * `@charcuterie/logic`'s shared machines, so
35
+ * `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
36
+ * is the whole of mux-magic's `StatusBadge`, and adding a state to
37
+ * the machine is a compile error rather than a blank pill.
38
+ *
39
+ * **No implicit role.** A badge is a word about something else; it
40
+ * is not a live region, and giving it `role="status"` would make
41
+ * every re-render announce itself. Where a status genuinely needs
42
+ * announcing that is `LiveStatusIndicator`'s job, and where a badge
43
+ * needs a name beyond its text, `aria-label` still works. The story
44
+ * asserts the text is queryable, which is what an agent actually
45
+ * matches on.
46
+ *
47
+ * **It is capped at its container.** `shrink-0` and
48
+ * `whitespace-nowrap` alone let a long label paint straight through
49
+ * the neighbouring column — no clipping, no error, just a pill lying
50
+ * across the next cell. `max-inline-size: 100%` is what stops it, and
51
+ * `overflow` is what happens next.
52
+ */
53
+ export declare const Badge: ({ appearance, children, className, icon, intent, overflow, size, title, ...spanProps }: BadgeProps) => ReactNode;
54
+ //# sourceMappingURL=Badge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Badge.d.ts","sourceRoot":"","sources":["../../src/Badge/Badge.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAEd,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAEpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAK1D,MAAM,MAAM,UAAU,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC,gBAAgB,EAAE,OAAO,CAAC,CAAA;IAC/C,QAAQ,EAAE,SAAS,CAAA;IACnB,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,MAAM,CAAA;IAC9B,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,KAAK,GAAI,wFAUnB,UAAU,KAAG,SAyDf,CAAA"}
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BADGE_SIZE_CLASS } from "../controlStyles.js";
3
+ import { INTENT_APPEARANCE_CLASS } from "../intentStyles.js";
4
+ import { toClassName } from "../toClassName.js";
5
+ import { useIsTextClipped } from "./useIsTextClipped.js";
6
+ /**
7
+ * Four repos, and rip-deck declares the **identical** `TONE_CLASS`
8
+ * map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
9
+ * hardcoded hexes, no relationship to each other beyond
10
+ * copy-paste. mux-magic's `statusClassMap` is the same idea spelled
11
+ * a third way, keyed by a bare `string` so an unrecognised status
12
+ * renders with no colour at all and no error.
13
+ *
14
+ * `intent` replaces all of it. The **status** half of the problem —
15
+ * which state maps to which intent, and what it is called in
16
+ * English — lives in `statusIntent.ts` as exhaustive switches over
17
+ * `@charcuterie/logic`'s shared machines, so
18
+ * `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
19
+ * is the whole of mux-magic's `StatusBadge`, and adding a state to
20
+ * the machine is a compile error rather than a blank pill.
21
+ *
22
+ * **No implicit role.** A badge is a word about something else; it
23
+ * is not a live region, and giving it `role="status"` would make
24
+ * every re-render announce itself. Where a status genuinely needs
25
+ * announcing that is `LiveStatusIndicator`'s job, and where a badge
26
+ * needs a name beyond its text, `aria-label` still works. The story
27
+ * asserts the text is queryable, which is what an agent actually
28
+ * matches on.
29
+ *
30
+ * **It is capped at its container.** `shrink-0` and
31
+ * `whitespace-nowrap` alone let a long label paint straight through
32
+ * the neighbouring column — no clipping, no error, just a pill lying
33
+ * across the next cell. `max-inline-size: 100%` is what stops it, and
34
+ * `overflow` is what happens next.
35
+ */
36
+ export const Badge = ({ appearance = "soft", children, className, icon, intent = "neutral", overflow = "truncate", size = "md", title, ...spanProps }) => {
37
+ const [labelRef, clippedText] = useIsTextClipped();
38
+ return (_jsxs("span", { ...spanProps, className: toClassName("inline-flex shrink-0 items-center rounded-full border font-medium",
39
+ // The cap. Without it every other rule here is decoration.
40
+ "max-w-full", overflow === "wrap"
41
+ ? // A stadium end-cap on a three-line box reads as a
42
+ // rendering fault rather than a badge, so the wrapping
43
+ // pill relaxes to a rounded rectangle.
44
+ "rounded-2xl"
45
+ : "rounded-full", BADGE_SIZE_CLASS[size], INTENT_APPEARANCE_CLASS[intent][appearance], className),
46
+ // Only when something is actually hidden. A tooltip on every
47
+ // short pill in a bay list is noise, and a `title` that
48
+ // duplicates fully-visible text is a screen-reader duplicate
49
+ // for no gain.
50
+ title: title ?? clippedText, children: [icon ? (_jsx("span", { "aria-hidden": "true", className: "contents shrink-0", children: icon })) : null, _jsx("span", { className: overflow === "wrap"
51
+ ? "min-w-0"
52
+ : "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap", ref: labelRef, children: children })] }));
53
+ };
54
+ //# sourceMappingURL=Badge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Badge.js","sourceRoot":"","sources":["../../src/Badge/Badge.tsx"],"names":[],"mappings":";AAOA,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAEtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAqBxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,UAAU,GAAG,MAAM,EACnB,QAAQ,EACR,SAAS,EACT,IAAI,EACJ,MAAM,GAAG,SAAS,EAClB,QAAQ,GAAG,UAAU,EACrB,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,SAAS,EACD,EAAa,EAAE;IAC1B,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,gBAAgB,EAAE,CAAA;IAElD,OAAO,CACL,mBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,mEAAmE;QACnE,2DAA2D;QAC3D,YAAY,EACZ,QAAQ,KAAK,MAAM;YACjB,CAAC,CAAC,mDAAmD;gBACnD,uDAAuD;gBACvD,uCAAuC;gBACvC,aAAa;YACf,CAAC,CAAC,cAAc,EAClB,gBAAgB,CAAC,IAAI,CAAC,EACtB,uBAAuB,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,EAC3C,SAAS,CACV;QACD,6DAA6D;QAC7D,wDAAwD;QACxD,6DAA6D;QAC7D,eAAe;QACf,KAAK,EAAE,KAAK,IAAI,WAAW,aAE1B,IAAI,CAAC,CAAC,CAAC,CACN,8BACc,MAAM,EAClB,SAAS,EAAC,mBAAmB,YAE5B,IAAI,GACA,CACR,CAAC,CAAC,CAAC,IAAI,EAYR,eACE,SAAS,EACP,QAAQ,KAAK,MAAM;oBACjB,CAAC,CAAC,SAAS;oBACX,CAAC,CAAC,yDAAyD,EAE/D,GAAG,EAAE,QAAQ,YAEZ,QAAQ,GACJ,IACF,CACR,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,22 @@
1
+ import type { RefObject } from "react";
2
+ /**
3
+ * The element's full text, but only while that text is wider than
4
+ * the box it was given — i.e. only while an ellipsis is being
5
+ * painted. `undefined` the rest of the time.
6
+ *
7
+ * `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
8
+ * painted by the layout engine and the whole string stays in
9
+ * `textContent`. That is the good news — selection, copy, and every
10
+ * screen reader still get all of it — and the reason this hook has
11
+ * to exist, because nothing in the DOM says "this one is cut off",
12
+ * so a hover affordance cannot be attached declaratively.
13
+ *
14
+ * Measured rather than assumed, since the alternative — always
15
+ * setting a `title` — puts a tooltip on every short status pill in a
16
+ * bay list, which is worse than the problem it solves.
17
+ *
18
+ * The text is returned rather than a boolean so the caller never
19
+ * reads `ref.current` during render.
20
+ */
21
+ export declare const useIsTextClipped: () => [RefObject<HTMLSpanElement | null>, string | undefined];
22
+ //# sourceMappingURL=useIsTextClipped.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIsTextClipped.d.ts","sourceRoot":"","sources":["../../src/Badge/useIsTextClipped.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtC;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,QAAO,CAClC,SAAS,CAAC,eAAe,GAAG,IAAI,CAAC,EACjC,MAAM,GAAG,SAAS,CAoDnB,CAAA"}
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ /**
3
+ * The element's full text, but only while that text is wider than
4
+ * the box it was given — i.e. only while an ellipsis is being
5
+ * painted. `undefined` the rest of the time.
6
+ *
7
+ * `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
8
+ * painted by the layout engine and the whole string stays in
9
+ * `textContent`. That is the good news — selection, copy, and every
10
+ * screen reader still get all of it — and the reason this hook has
11
+ * to exist, because nothing in the DOM says "this one is cut off",
12
+ * so a hover affordance cannot be attached declaratively.
13
+ *
14
+ * Measured rather than assumed, since the alternative — always
15
+ * setting a `title` — puts a tooltip on every short status pill in a
16
+ * bay list, which is worse than the problem it solves.
17
+ *
18
+ * The text is returned rather than a boolean so the caller never
19
+ * reads `ref.current` during render.
20
+ */
21
+ export const useIsTextClipped = () => {
22
+ const elementRef = useRef(null);
23
+ const [clippedText, setClippedText] = useState(undefined);
24
+ // No dependency array: `children` is a `ReactNode`, so there is
25
+ // nothing stable to compare against, and a re-render is exactly
26
+ // when the text may have changed. `setState` bails out on an equal
27
+ // value, so the common case costs one `scrollWidth` read.
28
+ useEffect(() => {
29
+ const element = elementRef.current;
30
+ if (!element) {
31
+ return;
32
+ }
33
+ setClippedText(element.scrollWidth > element.clientWidth
34
+ ? (element.textContent ?? undefined)
35
+ : undefined);
36
+ });
37
+ // And a resize of the *container* changes the answer without
38
+ // re-rendering anything — which is the container-query case this
39
+ // whole library is built around.
40
+ useEffect(() => {
41
+ const element = elementRef.current;
42
+ if (!element) {
43
+ return;
44
+ }
45
+ const observer = new ResizeObserver(() => {
46
+ setClippedText(element.scrollWidth > element.clientWidth
47
+ ? (element.textContent ?? undefined)
48
+ : undefined);
49
+ });
50
+ observer.observe(element);
51
+ return () => {
52
+ observer.disconnect();
53
+ };
54
+ }, []);
55
+ return [elementRef, clippedText];
56
+ };
57
+ //# sourceMappingURL=useIsTextClipped.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIsTextClipped.js","sourceRoot":"","sources":["../../src/Badge/useIsTextClipped.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEnD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAG9B,EAAE;IACF,MAAM,UAAU,GAAG,MAAM,CAAkB,IAAI,CAAC,CAAA;IAEhD,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAE5C,SAAS,CAAC,CAAA;IAEZ,gEAAgE;IAChE,gEAAgE;IAChE,mEAAmE;IACnE,0DAA0D;IAC1D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAA;QAElC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,cAAc,CACZ,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC,WAAW;YACvC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,IAAI,SAAS,CAAC;YACpC,CAAC,CAAC,SAAS,CACd,CAAA;IACH,CAAC,CAAC,CAAA;IAEF,6DAA6D;IAC7D,iEAAiE;IACjE,iCAAiC;IACjC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAA;QAElC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,GAAG,EAAE;YACvC,cAAc,CACZ,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC,WAAW;gBACvC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,IAAI,SAAS,CAAC;gBACpC,CAAC,CAAC,SAAS,CACd,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QAEzB,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,UAAU,EAAE,CAAA;QACvB,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,CAAC,UAAU,EAAE,WAAW,CAAC,CAAA;AAClC,CAAC,CAAA"}
@@ -0,0 +1,58 @@
1
+ import type { ControlSize, IntentName } from "@charcuterie/tokens";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+ import type { IntentAppearance } from "../intentStyles.ts";
4
+ export type ButtonProps = Omit<ComponentPropsWithRef<"button">, "disabled"> & {
5
+ appearance?: IntentAppearance;
6
+ iconEnd?: ReactNode;
7
+ iconStart?: ReactNode;
8
+ intent?: IntentName;
9
+ isDisabled?: boolean;
10
+ isFullWidth?: boolean;
11
+ isLoading?: boolean;
12
+ /** What the spinner announces. Not shown. */
13
+ loadingLabel?: string;
14
+ size?: ControlSize;
15
+ /**
16
+ * Two sizing modes rather than a second component's worth of
17
+ * markup. `icon` is square on the control height with no inline
18
+ * padding, which is what lines an `IconButton` up with the text
19
+ * button beside it in a toolbar.
20
+ *
21
+ * It lives here rather than in `IconButton` because appending a
22
+ * competing `size-*`/`px-0` to this component's `className` would
23
+ * be a specificity coin-flip: two utilities setting the same
24
+ * property, and the winner decided by Tailwind's internal
25
+ * ordering rather than by the caller.
26
+ */
27
+ sizing?: "control" | "icon";
28
+ };
29
+ /**
30
+ * Hand-rolled in **seven repos**, each with its own
31
+ * primary/secondary/danger/ghost, and this is the component that
32
+ * erases the most duplication in the fleet.
33
+ *
34
+ * Four decisions worth knowing, all of them fixing something an
35
+ * inventoried repo gets wrong:
36
+ *
37
+ * - **`type="button"` by default.** A `<button>` inside a form
38
+ * submits it unless told otherwise, and "clicking the copy icon
39
+ * reloaded the page" is a bug every app in the fleet has shipped
40
+ * at least once.
41
+ * - **`isLoading` disables and announces.** The button goes
42
+ * `aria-busy`, the spinner replaces `iconStart` rather than
43
+ * being appended (so the label does not jump), and the label
44
+ * itself stays — a spinner that swallows the text leaves the
45
+ * user unsure what they pressed.
46
+ * - **`focus-visible`, never `focus`.** See `FOCUS_RING_CLASS`.
47
+ * - **Sizing comes from the density axis**, not from a prop table:
48
+ * `size="md"` is 2.25rem on a desktop and 3.25rem on the kiosk,
49
+ * with no prop change. `MIN_TOUCH_TARGET_CLASS` is available for
50
+ * a control that must clear 44px regardless.
51
+ *
52
+ * The accessible name is the children. `IconButton` exists because
53
+ * a glyph is not a name, and enforcing that in *this* component's
54
+ * types would mean rejecting `<Button>{icon} Copy</Button>`, which
55
+ * is fine.
56
+ */
57
+ export declare const Button: ({ appearance, children, className, iconEnd, iconStart, intent, isDisabled, isFullWidth, isLoading, loadingLabel, size, sizing, type, ...buttonProps }: ButtonProps) => ReactNode;
58
+ //# sourceMappingURL=Button.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../src/Button/Button.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAMd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAU1D,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,CACX,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,6CAA6C;IAC7C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,SAAS,GAAG,MAAM,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,MAAM,GAAI,uJAepB,WAAW,KAAG,SA6BhB,CAAA"}