@elabs-ai/components-ui 4.0.0 → 4.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/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -19,6 +19,26 @@ describe("SplitPanel", () => {
19
19
  expect(endPane.className).toMatch(/border-s/);
20
20
  });
21
21
 
22
+ // Issue #163 — WCAG 1.4.11: with both tones "plain" (the shipped default),
23
+ // the hairline is the ONLY cue between the two panes (no fill/elevation
24
+ // difference), so it must be the strong, ≥3:1 rung — `border-strong ≥ 3:1
25
+ // on --card/--background` is proven for every theme in
26
+ // `@elabs-ai/components-tokens`'s `themes-contrast.test.ts`; this test locks
27
+ // that `SplitPanel` actually reaches for that token in this configuration,
28
+ // not just the bare `border-s`/`border-t` class the previous assertion
29
+ // above already passed on the unfixed code.
30
+ it("plain/plain divider uses the strong (≥3:1) border rung, not the subtle default", () => {
31
+ const { getByText } = render(<SplitPanel start={<>List</>} end={<>Detail</>} />);
32
+ expect(getByText("Detail").className).toMatch(/border-border-strong/);
33
+ });
34
+
35
+ it("muted/card tones keep the subtle divider rung — the fill difference already carries the boundary", () => {
36
+ const { getByText } = render(
37
+ <SplitPanel startTone="muted" endTone="card" start={<>List</>} end={<>Detail</>} />,
38
+ );
39
+ expect(getByText("Detail").className).not.toMatch(/border-border-strong/);
40
+ });
41
+
22
42
  it("applies the ground-offset tones: muted well + raised card", () => {
23
43
  const { getByText } = render(
24
44
  <SplitPanel
@@ -5,15 +5,20 @@ import { cn } from "../../lib/cn";
5
5
  /**
6
6
  * Per-pane surface tone — the ground-offset tiering axis (research 08 §H, #193).
7
7
  * `plain` keeps the ambient ground (default; non-breaking). `card` RAISES the pane as
8
- * a white `bg-card` + `shadow-sm` and is robust across all three themes — a card is
8
+ * a white `bg-card` + `shadow-sm` and is robust across every theme — a card is
9
9
  * lighter than the page ground in BOTH light and dark, so it always reads as raised.
10
10
  * `muted` (`bg-surface-muted`) reads as a recessed well ONLY in light themes; in dark
11
11
  * themes `--surface-muted` is lighter than `--card`, so a `muted` pane reads as a faint
12
12
  * raised tint, not a recess (dark-mode layering only goes UP — there is no surface below
13
13
  * the page ground). For a guaranteed cross-theme raise/recess contrast, raise the focus
14
14
  * pane with `card` and leave the other `plain`.
15
+ *
16
+ * Exported so the same tone system reaches a `Layout/Resizable` pane — a draggable
17
+ * `ResizablePanel` composes `splitPaneVariants({ tone })` for the identical ground-offset
18
+ * tiering `SplitPanel` gives its `startTone`/`endTone`, instead of a hand-rolled second
19
+ * copy of these three classes. See `Layout/Resizable`'s `Tiered` story.
15
20
  */
16
- const paneToneVariants = cva("min-h-0 min-w-0 overflow-auto", {
21
+ export const splitPaneVariants = cva("min-h-0 min-w-0 overflow-auto", {
17
22
  variants: {
18
23
  tone: {
19
24
  plain: "",
@@ -24,7 +29,7 @@ const paneToneVariants = cva("min-h-0 min-w-0 overflow-auto", {
24
29
  defaultVariants: { tone: "plain" },
25
30
  });
26
31
 
27
- export type SplitPanelTone = NonNullable<VariantProps<typeof paneToneVariants>["tone"]>;
32
+ export type SplitPanelTone = NonNullable<VariantProps<typeof splitPaneVariants>["tone"]>;
28
33
 
29
34
  export interface SplitPanelProps {
30
35
  start: ReactNode;
@@ -49,8 +54,12 @@ export interface SplitPanelProps {
49
54
 
50
55
  /**
51
56
  * Two-pane layout for master/detail, editor/preview and inspector workflows.
52
- * Static sizing by design (predictable in tests/SSR); for drag-to-resize wrap
53
- * with a resize hook or compose `react-resizable-panels` at the app level.
57
+ * Static sizing by design (predictable in tests/SSR) `startSize` is a CSS grid
58
+ * track (`"320px"`, `"40%"`, `"1fr"`), not a percentage. For panes the user can
59
+ * drag to resize, reach for `Layout/Resizable`'s `ResizablePanelGroup`/
60
+ * `ResizablePanel`/`ResizableHandle` instead and apply `splitPaneVariants({ tone })`
61
+ * to a panel for the same ground-offset tiering. See
62
+ * [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).
54
63
  *
55
64
  * For a master/detail surface where the detail should read as a raised card on a
56
65
  * recessed list, use `startTone="muted" endTone="card"` (ground-offset tiering).
@@ -80,11 +89,22 @@ export function SplitPanel({
80
89
  )}
81
90
  style={style}
82
91
  >
83
- <div className={cn(paneToneVariants({ tone: startTone }), startClassName)}>{start}</div>
92
+ <div className={cn(splitPaneVariants({ tone: startTone }), startClassName)}>{start}</div>
84
93
  <div
85
94
  className={cn(
86
- paneToneVariants({ tone: endTone }),
87
- divider && (isHorizontal ? "border-s" : "border-t"),
95
+ splitPaneVariants({ tone: endTone }),
96
+ divider &&
97
+ (isHorizontal ? "border-s" : "border-t") +
98
+ // #163 — with both panes "plain" there is no fill/elevation
99
+ // difference between them, so the hairline is the ONLY cue and
100
+ // must clear the 3:1 non-text bar (WCAG 1.4.11):
101
+ // `border-border-strong` (`border-strong ≥ 3:1 on --card/
102
+ // --background` is proven for every theme in
103
+ // @elabs-ai/components-tokens's themes-contrast.test.ts).
104
+ // With either tone "muted"/"card" the fill change already
105
+ // carries the boundary, so the subtle default rung stays —
106
+ // the redundant-cue exemption in styling-and-tokens.md.
107
+ (startTone === "plain" && endTone === "plain" ? " border-border-strong" : ""),
88
108
  endClassName,
89
109
  )}
90
110
  >
@@ -1,6 +1,11 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Button } from "../button";
3
3
  import { StatePanel } from "./state-panel";
4
+ import {
5
+ EmptyListIllustration,
6
+ NoAccessIllustration,
7
+ ErrorIllustration,
8
+ } from "../../illustrations";
4
9
 
5
10
  const meta = {
6
11
  title: "States/StatePanel",
@@ -78,3 +83,50 @@ export const EmptyNoIcon: Story = {
78
83
  description: "Items will appear here once available.",
79
84
  },
80
85
  };
86
+
87
+ export const WithIllustration: Story = {
88
+ name: "Empty (with illustration, #24)",
89
+ args: {
90
+ kind: "empty",
91
+ title: "No projects yet",
92
+ description: "Create your first project to get started.",
93
+ illustration: <EmptyListIllustration />,
94
+ actions: <Button size="sm">New project</Button>,
95
+ },
96
+ };
97
+
98
+ export const NoAccess: Story = {
99
+ name: "Empty (no-access illustration)",
100
+ args: {
101
+ // "No access" is a blocked/informational state, not a system failure —
102
+ // kind stays "empty" (no alarm-red "Error" eyebrow) rather than "error".
103
+ kind: "empty",
104
+ title: "You don’t have access",
105
+ description: "Ask a workspace admin to grant you permission.",
106
+ illustration: <NoAccessIllustration />,
107
+ actions: (
108
+ <Button size="sm" variant="outline">
109
+ Request access
110
+ </Button>
111
+ ),
112
+ },
113
+ };
114
+
115
+ export const ErrorWithIllustration: Story = {
116
+ name: "Error (with illustration, #24 P0-2)",
117
+ args: {
118
+ // The one panel `kind` that re-tints the illustration slot (`text-destructive`)
119
+ // — pairing it with an illustration is what surfaces whether the accent
120
+ // follows the tint or clashes against it. See P0-2 in the fix-round review:
121
+ // no shipped story previously rendered an illustration inside `kind="error"`.
122
+ kind: "error",
123
+ title: "Failed to load data",
124
+ description: "The server returned an unexpected response.",
125
+ illustration: <ErrorIllustration />,
126
+ actions: (
127
+ <Button size="sm" variant="outline">
128
+ Try again
129
+ </Button>
130
+ ),
131
+ },
132
+ };
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
3
  import { StatePanel } from "./state-panel";
4
+ import {
5
+ EmptyListIllustration,
6
+ ErrorIllustration,
7
+ ILLUSTRATION_ACCENT_VAR,
8
+ } from "../../illustrations";
4
9
 
5
10
  describe("StatePanel", () => {
6
11
  it("renders empty kind with title and description", () => {
@@ -24,6 +29,48 @@ describe("StatePanel", () => {
24
29
  expect(alert).toHaveAttribute("data-kind", "error");
25
30
  });
26
31
 
32
+ it("error eyebrow uses the destructive INK rung, not the fill rung (#40)", () => {
33
+ // The eyebrow is running TEXT on the panel's own `bg-destructive/5` wash, so
34
+ // it must reach for `text-destructive-text` (>= 4.5:1, the text-rung
35
+ // contract) — never the bare `text-destructive` fill rung, whose contract
36
+ // is only the 3:1 mark bar. See styling-and-tokens.md "Which status rung a
37
+ // graphical MARK reaches for" (#381).
38
+ render(<StatePanel kind="error" />);
39
+ const eyebrow = screen.getByText("Error");
40
+ expect(eyebrow.className).toContain("text-destructive-text");
41
+ expect(eyebrow.className).not.toMatch(/(?<!-)\btext-destructive\b(?!-)/);
42
+ });
43
+
44
+ it("error icon keeps the destructive FILL rung (a mark, not text)", () => {
45
+ // Inverse of the eyebrow lock above: an icon IS a mark, so it correctly
46
+ // stays on the fill rung — the fix must not "tidy" it onto -text too.
47
+ const { container } = render(<StatePanel kind="error" />);
48
+ const iconWrapper = container.querySelector("svg")?.parentElement;
49
+ expect(iconWrapper?.className ?? "").toMatch(/\btext-destructive\b/);
50
+ expect(iconWrapper?.className ?? "").not.toContain("text-destructive-text");
51
+ });
52
+
53
+ it("error rail carries the width cue (border-s-4) AND a destructive-family colour, not the neutral border-strong grey (#71)", () => {
54
+ // The rail's WIDTH (4px vs 1px) is the monochrome-survivable structural
55
+ // cue (per styling-and-tokens.md's border/border-strong decision test);
56
+ // its HUE should match the rest of the destructive family, not fall back
57
+ // to the neutral `border-strong` rung — that reads as a CSS-specificity
58
+ // bug, a 4px grey rail beside three red hairlines, not a deliberate
59
+ // accent (#71).
60
+ const { container } = render(<StatePanel kind="error" />);
61
+ const root = container.firstChild as HTMLElement;
62
+ expect(root.className).toMatch(/\bborder-s-4\b/);
63
+ expect(root.className).toMatch(/\bborder-s-destructive\b/);
64
+ expect(root.className).not.toMatch(/\bborder-s-border-strong\b/);
65
+ });
66
+
67
+ it("empty kind's rail carries neither the error width nor its destructive colour", () => {
68
+ const { container } = render(<StatePanel kind="empty" />);
69
+ const root = container.firstChild as HTMLElement;
70
+ expect(root.className).not.toMatch(/\bborder-s-4\b/);
71
+ expect(root.className).not.toMatch(/\bborder-s-destructive\b/);
72
+ });
73
+
27
74
  it("renders error kind with custom title", () => {
28
75
  render(<StatePanel kind="error" title="Custom error" description="Custom description" />);
29
76
  expect(screen.getByRole("alert")).toBeInTheDocument();
@@ -61,4 +108,82 @@ describe("StatePanel", () => {
61
108
  );
62
109
  expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
63
110
  });
111
+
112
+ it("renders an illustration without the size-10 icon clamp, decoratively (#24)", () => {
113
+ const { container } = render(
114
+ <StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
115
+ );
116
+ const svg = container.querySelector("svg");
117
+ expect(svg).not.toBeNull();
118
+ expect(svg).toHaveAttribute("aria-hidden", "true");
119
+ // The illustration's own wrapper must NOT carry the `[&_svg]:size-10`
120
+ // clamp the default icon slot uses — its rem sizing governs instead.
121
+ const wrapper = svg?.parentElement;
122
+ expect(wrapper?.className ?? "").not.toContain("size-10");
123
+ });
124
+
125
+ it("illustration path renders the title at the subtitle role, distinct from the icon path's text-sm (#47)", () => {
126
+ // A 112px+ illustration needs a heavier title to group with — bumping the
127
+ // title to `text-subtitle` (styling-and-tokens.md's typography-scale
128
+ // roles) only on the illustration path, so the plain `icon` path (a
129
+ // fixed 40x40 glyph) stays byte-identical to before.
130
+ const { container: withIllustration } = render(
131
+ <StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
132
+ );
133
+ const titleWithIllustration = withIllustration.querySelector("h3");
134
+ expect(titleWithIllustration?.className ?? "").toContain("text-subtitle");
135
+ expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-sm\b/);
136
+
137
+ const { container: withIcon } = render(
138
+ <StatePanel kind="empty" title="No items" icon={<span data-testid="i" />} />,
139
+ );
140
+ const titleWithIcon = withIcon.querySelector("h3");
141
+ expect(titleWithIcon?.className ?? "").toMatch(/\btext-sm\b/);
142
+ expect(titleWithIcon?.className ?? "").not.toContain("text-subtitle");
143
+ });
144
+
145
+ it("error-kind illustration retints the accent to the TEXT rung, distinct from the retinted subject's FILL rung (#48)", () => {
146
+ // StatePanel wraps the illustration slot in `text-destructive` (the FILL
147
+ // rung — a mark, per styling-and-tokens.md "which status rung a
148
+ // graphical MARK reaches for") for kind="error". If the ambient
149
+ // `--illustration-accent` override it sets ALSO resolved to
150
+ // `--destructive`, the accent would collapse onto the subject the moment
151
+ // both read the same token (#48 finding 2). It must resolve to the TEXT
152
+ // rung instead — matching the convention every other illustration's own
153
+ // default fallback already uses (`--primary-text`, `--success-text`).
154
+ const { container } = render(<StatePanel kind="error" illustration={<ErrorIllustration />} />);
155
+ const svg = container.querySelector("svg");
156
+ const wrapper = svg?.parentElement as HTMLElement;
157
+ expect(wrapper.className).toMatch(/\btext-destructive\b/);
158
+ expect(wrapper.style.getPropertyValue(ILLUSTRATION_ACCENT_VAR)).toBe("var(--destructive-text)");
159
+ // Sanity: ErrorIllustration's own fallback (used when no ambient override
160
+ // is set) also reaches for the TEXT rung, not the FILL token the subject
161
+ // above just read.
162
+ const accentRect = container.querySelector("rect");
163
+ expect(accentRect?.getAttribute("style") ?? "").toContain("--destructive-text");
164
+ });
165
+
166
+ it("prefers illustration over icon when both are given", () => {
167
+ render(
168
+ <StatePanel
169
+ kind="empty"
170
+ title="Both given"
171
+ icon={<span data-testid="custom-icon">icon</span>}
172
+ illustration={<EmptyListIllustration />}
173
+ />,
174
+ );
175
+ expect(screen.queryByTestId("custom-icon")).toBeNull();
176
+ });
177
+
178
+ it("titleAs renders the requested heading level (#385)", () => {
179
+ render(<StatePanel kind="empty" title="No data" titleAs="h2" />);
180
+ const heading = screen.getByRole("heading", { level: 2, name: "No data" });
181
+ expect(heading.tagName).toBe("H2");
182
+ });
183
+
184
+ it("default titleAs is still h3 — unchanged output for every existing caller (#385)", () => {
185
+ render(<StatePanel kind="empty" title="No data" />);
186
+ const heading = screen.getByRole("heading", { level: 3, name: "No data" });
187
+ expect(heading.tagName).toBe("H3");
188
+ });
64
189
  });
@@ -1,7 +1,8 @@
1
- import { type ReactNode } from "react";
1
+ import { type CSSProperties, type ReactNode } from "react";
2
2
  import { CircleAlert } from "lucide-react";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../../lib/cn";
5
+ import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
5
6
 
6
7
  // ─── Variants ────────────────────────────────────────────────────────────────
7
8
 
@@ -14,11 +15,22 @@ export const statePanelVariants = cva(
14
15
  variants: {
15
16
  kind: {
16
17
  empty: "border border-dashed bg-surface",
17
- // error: primary cue is color (destructive bg/border); structural cue
18
- // (border-s-4 + border-border-strong top strip) survives monochrome /
19
- // a monochrome palette so the panel is distinguishable from empty/loading even
20
- // without hue. border-border-strong satisfies WCAG 1.4.11 non-text contrast.
21
- error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-border-strong",
18
+ // error: the non-color, monochrome-survivable cue is the rail's WIDTH
19
+ // (border-s-4, 4px, vs the 1px hairline on the other three edges) —
20
+ // not its hue (per styling-and-tokens.md's border/border-strong
21
+ // decision test: "if I deleted this line, could a sighted user still
22
+ // tell the two regions apart?" here it's the thickness that answers
23
+ // yes). So the rail's COLOR should stay in the destructive family
24
+ // like the rest of the panel, not fall back to the neutral
25
+ // `border-strong` rung — a 4px grey rail beside three red hairlines
26
+ // read as a CSS-specificity bug, not a deliberate accent (#71).
27
+ // `border-s-destructive` is the FILL rung (styling-and-tokens.md
28
+ // "which status rung a graphical MARK reaches for") — guaranteed
29
+ // >=3:1 against --card/--background in every theme, so it still
30
+ // satisfies the WCAG 1.4.11 non-text-contrast guarantee the old
31
+ // `border-border-strong` comment claimed. The icon (:87-91) and the
32
+ // eyebrow (:148-152 below) are the third and fourth non-color cues.
33
+ error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-destructive",
22
34
  loading: "",
23
35
  },
24
36
  },
@@ -41,11 +53,29 @@ export interface StatePanelProps extends VariantProps<typeof statePanelVariants>
41
53
  /** Supporting description below the title. */
42
54
  description?: ReactNode;
43
55
  /**
44
- * Icon or illustration shown above the title.
45
- * For `"error"` defaults to a built-in warning triangle.
46
- * For `"loading"` defaults to an animated spinner.
56
+ * Icon shown above the title, clamped to a uniform 40×40 so mismatched
57
+ * glyphs stay tidy. For `"error"` defaults to a built-in warning triangle.
58
+ * For `"loading"` defaults to an animated spinner. Ignored when
59
+ * `illustration` is also provided — see `illustration`.
47
60
  */
48
61
  icon?: ReactNode;
62
+ /**
63
+ * A larger illustration (one of `@elabs-ai/components-ui`'s shipped
64
+ * `*Illustration` components, or a consumer's own `ReactNode`) shown above
65
+ * the title instead of `icon`. Unlike `icon`, it is **not** clamped to
66
+ * 40×40 — an illustration governs its own size (see each illustration's
67
+ * `size` prop, `rem`-based, ~64px–160px). Takes precedence over `icon`
68
+ * when both are given.
69
+ */
70
+ illustration?: ReactNode;
71
+ /**
72
+ * Heading level for the panel's title (#328 convention). Set this to the
73
+ * level that correctly follows whatever heading precedes this panel in
74
+ * the document outline — e.g. `"h3"` inside a `ChartCard` whose own title
75
+ * is an `<h2>`.
76
+ * @default "h3"
77
+ */
78
+ titleAs?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
49
79
  /** Accessible label for the spinner in loading state. @default "Loading…" */
50
80
  loadingLabel?: string;
51
81
  /** Spinner size (loading kind only). @default "md" */
@@ -91,6 +121,8 @@ export function StatePanel({
91
121
  title,
92
122
  description,
93
123
  icon,
124
+ illustration,
125
+ titleAs: Tag = "h3",
94
126
  loadingLabel = "Loading…",
95
127
  size = "md",
96
128
  actions,
@@ -124,17 +156,48 @@ export function StatePanel({
124
156
  return (
125
157
  <div data-kind={kind} className={cn(statePanelVariants({ kind }), className)} {...roleProps}>
126
158
  {/* Error eyebrow: "Error" label shown above the icon so the panel reads
127
- as an error structurally even in monochrome themes. The
128
- eyebrow uses text-destructive which resolves to a drawable
129
- foreground at high decorationcombined with the thick left border from
130
- statePanelVariants it gives two non-color structural cues. */}
159
+ as an error structurally even in monochrome themes. This is running
160
+ TEXT, not a mark, so it takes the ink rung `text-destructive-text`
161
+ (>= 4.5:1, gated in themes-contrast.test.ts) not the fill rung
162
+ `text-destructive`, whose contract is only the 3:1 mark bar (see
163
+ "Which status rung a graphical MARK reaches for", styling-and-tokens.md
164
+ #381). Combined with the thick left border from statePanelVariants it
165
+ gives two non-color structural cues. The icon below deliberately
166
+ KEEPS text-destructive: an icon is a mark, so the fill rung is
167
+ correct there — do not "tidy" it onto -text too. */}
131
168
  {isError && (
132
- <span className="text-xs font-semibold uppercase tracking-widest text-destructive">
169
+ <span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
133
170
  Error
134
171
  </span>
135
172
  )}
136
173
 
137
- {resolvedIcon ? (
174
+ {illustration ? (
175
+ // Illustration wins over `icon` when both are given. No `[&_svg]:size-10`
176
+ // clamp here — an illustration governs its own (rem-based) size, unlike
177
+ // the fixed-size icon slot below. For `kind="error"` this also sets the
178
+ // `--illustration-accent` custom property every illustration's accent
179
+ // ink reads through, so ANY illustration (not just `ErrorIllustration`)
180
+ // retints its accent to `--destructive-text` instead of staying pinned
181
+ // to its default hue inside a red-tinted slot (#24 fix round 1, P0-2).
182
+ // TEXT rung, not the FILL token below: the wrapper's `text-destructive`
183
+ // class already puts the SUBJECT (silhouette) on the fill rung, so an
184
+ // accent override reading the same `--destructive` token collapsed
185
+ // onto it and disappeared (#48 finding 2) — `--destructive-text` is a
186
+ // deliberately distinct, deeper rung (matches the fallback every
187
+ // other illustration's own accent already uses: `--primary-text`,
188
+ // `--success-text`) so the accent stays visible against the retinted
189
+ // silhouette.
190
+ <div
191
+ className={cn(isError ? "text-destructive" : "text-muted-foreground")}
192
+ style={
193
+ isError
194
+ ? ({ [ILLUSTRATION_ACCENT_VAR]: "var(--destructive-text)" } as CSSProperties)
195
+ : undefined
196
+ }
197
+ >
198
+ {illustration}
199
+ </div>
200
+ ) : resolvedIcon ? (
138
201
  // Error icon keeps text-destructive (not muted) so it stays visible even
139
202
  // when rendered at low contrast. In monochrome themes the icon shape
140
203
  // (CircleAlert) carries the cue; the color is supplemental.
@@ -146,9 +209,25 @@ export function StatePanel({
146
209
  ) : null}
147
210
 
148
211
  {(resolvedTitle || resolvedDescription) && (
149
- <div className="space-y-1">
212
+ // A 112px+ illustration (default `size="7rem"`) needs a heavier title
213
+ // to group with, and more air between the artwork and the copy than
214
+ // between the title and its own description — otherwise the caption
215
+ // reads as an afterthought under the artwork instead of one grouped
216
+ // unit (#47). `mt-2` (on top of the root's `gap-3`) only fires on the
217
+ // illustration path; the plain `icon` path (a fixed 40x40 glyph) is
218
+ // untouched. The title/description gap stays the tight `space-y-1`
219
+ // either way — that's the "one visually distinct unit" the two lines
220
+ // read as.
221
+ <div className={cn("space-y-1", illustration && "mt-2")}>
150
222
  {resolvedTitle && (
151
- <h3 className="text-sm font-semibold text-foreground">{resolvedTitle}</h3>
223
+ <Tag
224
+ className={cn(
225
+ "font-semibold text-foreground",
226
+ illustration ? "text-subtitle" : "text-sm",
227
+ )}
228
+ >
229
+ {resolvedTitle}
230
+ </Tag>
152
231
  )}
153
232
  {resolvedDescription && (
154
233
  <p className="mx-auto max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
@@ -6,6 +6,14 @@ const meta = {
6
6
  title: "Core/StatusBadge",
7
7
  component: StatusBadge,
8
8
  tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The closed seven-state execution-status vocabulary, each state carrying its own icon as well as its own tone so two states are still tellable apart in greyscale. A neutral label or count with no state semantics is `Core/Badge`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
9
17
  args: { status: "complete" },
10
18
  argTypes: {
11
19
  status: {
@@ -11,7 +11,7 @@ export const Switch = forwardRef<
11
11
  ref={ref}
12
12
  className={cn(
13
13
  "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-fast ease-standard",
14
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
14
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
15
15
  "disabled:cursor-not-allowed disabled:opacity-50",
16
16
  "data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
17
17
  className,
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table";
3
4
  const meta = {
4
5
  title: "Data/Table",
@@ -8,6 +9,9 @@ const meta = {
8
9
  docs: {
9
10
  description: {
10
11
  component:
12
+ "The STATIC table markup you lay out yourself; the interactive grid is " +
13
+ "`Data/DataTable` — see " +
14
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
11
15
  "Low-level static table primitives (header/body/row/cell styling). For sorting, filtering, " +
12
16
  "pagination, virtualization, row selection and column management, use the TanStack-powered " +
13
17
  "DataTable (see Data/DataTable, @elabs-ai/components-data) instead.",
@@ -51,3 +55,61 @@ export const Default: Story = {
51
55
  </Table>
52
56
  ),
53
57
  };
58
+
59
+ // ─── Scrolling (#366) ───────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * A fixed-height container with more rows than fit — `Table`'s own scroll
63
+ * wrapper is genuinely `overflow-auto` here, and that is exactly what no
64
+ * `Table` story ever exercised before #366: the wrapper's own keyboard
65
+ * affordance (WCAG 2.1.1 / axe `scrollable-region-focusable`) shipped
66
+ * unguarded on this package's own turf, only incidentally covered elsewhere.
67
+ * `Tab` reaches the region, and it carries the accessible name naming it a
68
+ * scrollable region — see `table.test.tsx` for the fitting-table negative.
69
+ */
70
+ export const Scrolling: Story = {
71
+ render: () => (
72
+ // `Table`'s own scroll wrapper has no explicit height of its own — like any
73
+ // block box it grows to fit its content unless something makes it a flex
74
+ // item with a bounded main-axis size. A plain `<div style={{height:160}}>`
75
+ // ancestor does NOT do that (block children ignore a parent's height), so
76
+ // this outer div is a column flex container: its one child (the wrapper,
77
+ // which already carries `overflow-auto`) gets an automatic flex-basis
78
+ // minimum size of 0 per the flexbox spec's overflow carve-out, letting it
79
+ // shrink to the 160px bound instead of growing to its ~750px content.
80
+ <div style={{ height: 160, display: "flex", flexDirection: "column" }}>
81
+ <Table>
82
+ <TableHeader>
83
+ <TableRow>
84
+ <TableHead>Service</TableHead>
85
+ <TableHead>Status</TableHead>
86
+ </TableRow>
87
+ </TableHeader>
88
+ <TableBody>
89
+ {Array.from({ length: 20 }, (_, i) => (
90
+ <TableRow key={i}>
91
+ <TableCell>service-{i}</TableCell>
92
+ <TableCell>{i % 2 === 0 ? "healthy" : "degraded"}</TableCell>
93
+ </TableRow>
94
+ ))}
95
+ </TableBody>
96
+ </Table>
97
+ </div>
98
+ ),
99
+ play: async ({ canvasElement, userEvent }) => {
100
+ const scrollRegion = await waitFor(() => {
101
+ const el = canvasElement.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
102
+ if (!el || el.scrollHeight <= el.clientHeight) throw new Error("not overflowing yet");
103
+ return el;
104
+ });
105
+ await expect(scrollRegion).toHaveAttribute("tabindex", "0");
106
+ await expect(scrollRegion).toHaveAccessibleName("Table contents, scrollable");
107
+ await expect(scrollRegion).not.toHaveAttribute("role");
108
+
109
+ // The region is the only focusable element the story renders, so a
110
+ // single Tab from nothing focused reaches it — the real WCAG 2.1.1
111
+ // reproduction from the issue ("press Tab from the top").
112
+ await userEvent.tab();
113
+ await waitFor(() => expect(document.activeElement).toBe(scrollRegion));
114
+ },
115
+ };