@tangle-network/ui 11.12.0 → 11.13.1

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 (276) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/_virtual/_rolldown/runtime.js +13 -0
  3. package/dist/auth/auth.d.ts +53 -0
  4. package/dist/{auth-DOETfnd9.js → auth/auth.js} +7 -37
  5. package/dist/auth/login-layout.d.ts +20 -0
  6. package/dist/auth/login-layout.js +36 -0
  7. package/dist/auth.d.ts +2 -1
  8. package/dist/auth.js +2 -1
  9. package/dist/chat/agent-timeline.d.ts +83 -0
  10. package/dist/chat/agent-timeline.js +236 -0
  11. package/dist/chat/chat-container.d.ts +51 -0
  12. package/dist/chat/chat-container.js +309 -0
  13. package/dist/chat/chat-message.d.ts +22 -0
  14. package/dist/chat/chat-message.js +38 -0
  15. package/dist/chat/message-list.d.ts +32 -0
  16. package/dist/chat/message-list.js +37 -0
  17. package/dist/chat/thinking-indicator.d.ts +6 -0
  18. package/dist/chat/thinking-indicator.js +38 -0
  19. package/dist/chat/user-message.d.ts +21 -0
  20. package/dist/chat/user-message.js +42 -0
  21. package/dist/chat.d.ts +6 -1
  22. package/dist/chat.js +6 -1
  23. package/dist/editor/document-editor-pane.d.ts +37 -0
  24. package/dist/editor/document-editor-pane.js +199 -0
  25. package/dist/editor/editor-lazy.js +37 -0
  26. package/dist/editor/editor-loading.js +21 -0
  27. package/dist/editor/editor-peers.d.ts +6 -0
  28. package/dist/editor/editor-peers.js +155 -0
  29. package/dist/editor/editor-provider.d.ts +97 -0
  30. package/dist/editor/editor-provider.js +253 -0
  31. package/dist/editor/editor-toolbar.d.ts +11 -0
  32. package/dist/editor/editor-toolbar.js +106 -0
  33. package/dist/editor/markdown-conversion.js +23 -0
  34. package/dist/editor/markdown-document-editor.js +103 -0
  35. package/dist/editor/tiptap-editor.d.ts +45 -0
  36. package/dist/editor/tiptap-editor.js +271 -0
  37. package/dist/editor/use-editor.d.ts +67 -0
  38. package/dist/editor/use-editor.js +158 -0
  39. package/dist/editor.d.ts +6 -119
  40. package/dist/editor.js +5 -1
  41. package/dist/files/file-artifact-pane.d.ts +47 -0
  42. package/dist/files/file-artifact-pane.js +114 -0
  43. package/dist/files/file-format.d.ts +51 -0
  44. package/dist/{file-format-BgXbr-Ib.js → files/file-format.js} +1 -1
  45. package/dist/files/file-preview.d.ts +27 -0
  46. package/dist/files/file-preview.js +359 -0
  47. package/dist/files/file-tabs.d.ts +19 -0
  48. package/dist/files/file-tabs.js +48 -0
  49. package/dist/files/file-tree.d.ts +31 -0
  50. package/dist/files/file-tree.js +135 -0
  51. package/dist/files/rich-file-tree.d.ts +62 -0
  52. package/dist/files/rich-file-tree.js +122 -0
  53. package/dist/files.d.ts +6 -1
  54. package/dist/files.js +6 -2
  55. package/dist/hooks/use-auth.d.ts +40 -0
  56. package/dist/hooks/use-auth.js +91 -0
  57. package/dist/hooks/use-auto-scroll.d.ts +11 -0
  58. package/dist/hooks/use-auto-scroll.js +48 -0
  59. package/dist/hooks/use-dropdown-menu.d.ts +12 -0
  60. package/dist/hooks/use-dropdown-menu.js +31 -0
  61. package/dist/hooks/use-live-time.d.ts +7 -0
  62. package/dist/hooks/use-live-time.js +22 -0
  63. package/dist/hooks/use-realtime-session.d.ts +32 -0
  64. package/dist/hooks/use-realtime-session.js +235 -0
  65. package/dist/hooks/use-run-collapse-state.d.ts +13 -0
  66. package/dist/hooks/use-run-collapse-state.js +24 -0
  67. package/dist/hooks/use-run-groups.d.ts +11 -0
  68. package/dist/hooks/use-run-groups.js +85 -0
  69. package/dist/hooks/use-sdk-session.d.ts +59 -0
  70. package/dist/hooks/use-sdk-session.js +322 -0
  71. package/dist/hooks/use-sse-stream.d.ts +124 -0
  72. package/dist/hooks/use-sse-stream.js +229 -0
  73. package/dist/hooks/use-tool-call-stream.d.ts +23 -0
  74. package/dist/hooks/use-tool-call-stream.js +75 -0
  75. package/dist/hooks.d.ts +11 -49
  76. package/dist/hooks.js +10 -110
  77. package/dist/index.d.ts +99 -25
  78. package/dist/index.js +86 -36
  79. package/dist/lib/focus.d.ts +55 -0
  80. package/dist/{focus-BcE_SHqz.js → lib/focus.js} +1 -1
  81. package/dist/lib/utils.d.ts +4 -0
  82. package/dist/{utils-C_uf36nf.js → lib/utils.js} +1 -1
  83. package/dist/{code-block-B9miUPht.d.ts → markdown/code-block.d.ts} +5 -6
  84. package/dist/{code-block-B36KVUSN.js → markdown/code-block.js} +60 -55
  85. package/dist/{markdown-C0atSabr.d.ts → markdown/markdown.d.ts} +4 -5
  86. package/dist/{markdown-Db08KAYF.js → markdown/markdown.js} +3 -3
  87. package/dist/markdown.d.ts +2 -2
  88. package/dist/markdown.js +2 -2
  89. package/dist/{openui-artifact-renderer-fkBQaoLO.d.ts → openui/openui-artifact-renderer.d.ts} +20 -21
  90. package/dist/{openui-artifact-renderer-DG_i2lOR.js → openui/openui-artifact-renderer.js} +9 -7
  91. package/dist/{schema-BpW9rSSG.js → openui/schema-compiled.js} +2 -354
  92. package/dist/{schema-DAstk-J4.d.ts → openui/schema-data.d.ts} +3 -24
  93. package/dist/openui/schema-data.js +206 -0
  94. package/dist/openui-schema.d.ts +22 -3
  95. package/dist/openui-schema.js +138 -1
  96. package/dist/openui.d.ts +3 -2
  97. package/dist/openui.js +3 -2
  98. package/dist/{artifact-pane-BL_DFWNl.d.ts → primitives/artifact-pane.d.ts} +3 -4
  99. package/dist/{artifact-pane-DMXT9a8U.js → primitives/artifact-pane.js} +2 -2
  100. package/dist/primitives/avatar.d.ts +8 -0
  101. package/dist/primitives/avatar.js +25 -0
  102. package/dist/primitives/badge.d.ts +22 -0
  103. package/dist/primitives/badge.js +47 -0
  104. package/dist/{button-yoIv77e2.d.ts → primitives/button.d.ts} +2 -2
  105. package/dist/primitives/button.js +113 -0
  106. package/dist/primitives/card.d.ts +18 -0
  107. package/dist/primitives/card.js +52 -0
  108. package/dist/primitives/code-block.d.ts +6 -0
  109. package/dist/primitives/code-block.js +13 -0
  110. package/dist/primitives/dialog.d.ts +23 -0
  111. package/dist/primitives/dialog.js +59 -0
  112. package/dist/primitives/drop-zone.d.ts +23 -0
  113. package/dist/primitives/drop-zone.js +104 -0
  114. package/dist/primitives/dropdown-menu.d.ts +29 -0
  115. package/dist/{dropdown-menu-BpcLa5vF.js → primitives/dropdown-menu.js} +2 -23
  116. package/dist/primitives/empty-state.d.ts +11 -0
  117. package/dist/primitives/empty-state.js +32 -0
  118. package/dist/primitives/heading.d.ts +23 -0
  119. package/dist/primitives/heading.js +38 -0
  120. package/dist/primitives/input.d.ts +23 -0
  121. package/dist/primitives/input.js +110 -0
  122. package/dist/primitives/label.d.ts +6 -0
  123. package/dist/primitives/label.js +16 -0
  124. package/dist/primitives/metric-strip.d.ts +48 -0
  125. package/dist/primitives/metric-strip.js +52 -0
  126. package/dist/primitives/page-header.d.ts +25 -0
  127. package/dist/primitives/page-header.js +47 -0
  128. package/dist/primitives/page-shell.d.ts +14 -0
  129. package/dist/primitives/page-shell.js +17 -0
  130. package/dist/primitives/progress.d.ts +9 -0
  131. package/dist/primitives/progress.js +29 -0
  132. package/dist/primitives/segmented-control.d.ts +40 -0
  133. package/dist/primitives/segmented-control.js +57 -0
  134. package/dist/primitives/select.d.ts +15 -0
  135. package/dist/primitives/select.js +74 -0
  136. package/dist/primitives/sidebar-drop-zone.d.ts +21 -0
  137. package/dist/primitives/sidebar-drop-zone.js +88 -0
  138. package/dist/primitives/skeleton.d.ts +10 -0
  139. package/dist/primitives/skeleton.js +50 -0
  140. package/dist/primitives/stat-card.d.ts +16 -0
  141. package/dist/primitives/stat-card.js +60 -0
  142. package/dist/primitives/status-pill.d.ts +44 -0
  143. package/dist/primitives/status-pill.js +119 -0
  144. package/dist/primitives/switch.d.ts +6 -0
  145. package/dist/primitives/switch.js +16 -0
  146. package/dist/primitives/table.d.ts +22 -0
  147. package/dist/primitives/table.js +63 -0
  148. package/dist/primitives/tabs.d.ts +13 -0
  149. package/dist/{tabs-BVHR6NkO.js → primitives/tabs.js} +3 -3
  150. package/dist/primitives/terminal-display.d.ts +25 -0
  151. package/dist/primitives/terminal-display.js +109 -0
  152. package/dist/primitives/text-shimmer.d.ts +16 -0
  153. package/dist/{text-shimmer-BUUo6I-g.js → primitives/text-shimmer.js} +2 -2
  154. package/dist/primitives/theme-toggle.d.ts +11 -0
  155. package/dist/primitives/theme-toggle.js +154 -0
  156. package/dist/primitives/toast.d.ts +29 -0
  157. package/dist/primitives/toast.js +142 -0
  158. package/dist/primitives/toolbar.d.ts +55 -0
  159. package/dist/primitives/toolbar.js +37 -0
  160. package/dist/primitives/upload-progress.d.ts +23 -0
  161. package/dist/primitives/upload-progress.js +72 -0
  162. package/dist/primitives.d.ts +35 -5
  163. package/dist/primitives.js +35 -9
  164. package/dist/{index-COgqsFAQ.d.ts → redaction/redacted-document.d.ts} +6 -7
  165. package/dist/{redaction-CGGB2ENG.js → redaction/redacted-document.js} +2 -2
  166. package/dist/redaction.d.ts +1 -1
  167. package/dist/redaction.js +1 -1
  168. package/dist/run/expandable-content.js +41 -0
  169. package/dist/run/expanded-tool-detail.d.ts +12 -0
  170. package/dist/run/expanded-tool-detail.js +163 -0
  171. package/dist/run/inline-thinking-item.d.ts +11 -0
  172. package/dist/run/inline-thinking-item.js +60 -0
  173. package/dist/run/inline-tool-item.d.ts +25 -0
  174. package/dist/run/inline-tool-item.js +63 -0
  175. package/dist/run/run-group.d.ts +26 -0
  176. package/dist/{run-group-BWGeesgs.js → run/run-group.js} +9 -60
  177. package/dist/run/run-item-primitives.d.ts +5 -0
  178. package/dist/{run-ZsIKPA6J.js → run/run-item-primitives.js} +2 -2
  179. package/dist/run/run-row-shell.d.ts +53 -0
  180. package/dist/run/run-row-shell.js +114 -0
  181. package/dist/{tool-call-feed-BeB1_4pX.d.ts → run/tool-call-feed.d.ts} +7 -12
  182. package/dist/{tool-call-feed-BpmMLpPX.js → run/tool-call-feed.js} +4 -4
  183. package/dist/run/tool-call-step.d.ts +5 -0
  184. package/dist/run/tool-call-step.js +82 -0
  185. package/dist/run.d.ts +8 -2
  186. package/dist/run.js +7 -4
  187. package/dist/sdk-hooks.d.ts +8 -1
  188. package/dist/sdk-hooks.js +8 -2
  189. package/dist/stores/active-sessions-store.d.ts +83 -0
  190. package/dist/{active-sessions-store-Gt1R0rFG.js → stores/active-sessions-store.js} +1 -1
  191. package/dist/stores/chat-store.d.ts +14 -0
  192. package/dist/stores/chat-store.js +33 -0
  193. package/dist/stores.d.ts +3 -16
  194. package/dist/stores.js +2 -33
  195. package/dist/tool-previews/command-preview.d.ts +11 -0
  196. package/dist/tool-previews/command-preview.js +91 -0
  197. package/dist/tool-previews/diff-preview.d.ts +7 -0
  198. package/dist/tool-previews/diff-preview.js +76 -0
  199. package/dist/tool-previews/glob-results-preview.d.ts +7 -0
  200. package/dist/tool-previews/glob-results-preview.js +53 -0
  201. package/dist/tool-previews/grep-results-preview.d.ts +7 -0
  202. package/dist/tool-previews/grep-results-preview.js +99 -0
  203. package/dist/tool-previews/preview-primitives.js +58 -0
  204. package/dist/tool-previews/question-preview.d.ts +7 -0
  205. package/dist/tool-previews/question-preview.js +63 -0
  206. package/dist/tool-previews/web-search-preview.d.ts +7 -0
  207. package/dist/tool-previews/web-search-preview.js +78 -0
  208. package/dist/tool-previews/write-file-preview.d.ts +11 -0
  209. package/dist/tool-previews/write-file-preview.js +52 -0
  210. package/dist/tool-previews.d.ts +7 -1
  211. package/dist/tool-previews.js +7 -1
  212. package/dist/{branding-26d5ARIr.d.ts → types/branding.d.ts} +2 -3
  213. package/dist/{message-vhUI0c5G.d.ts → types/message.d.ts} +2 -3
  214. package/dist/{parts-5mhy9yJ3.d.ts → types/parts.d.ts} +8 -9
  215. package/dist/{run-OyWmIDqu.d.ts → types/run.d.ts} +9 -10
  216. package/dist/{tool-display-DgmL5rIU.d.ts → types/tool-display.d.ts} +5 -6
  217. package/dist/types.d.ts +5 -5
  218. package/dist/utils/copy-text.d.ts +7 -0
  219. package/dist/{utils-Bl_CZ0Qp.js → utils/copy-text.js} +1 -11
  220. package/dist/utils/format.d.ts +22 -0
  221. package/dist/{format-DfcLWqMa.js → utils/format.js} +1 -1
  222. package/dist/utils/time-ago.d.ts +3 -0
  223. package/dist/utils/time-ago.js +11 -0
  224. package/dist/utils/tool-display.d.ts +16 -0
  225. package/dist/{tool-display-BAuAVa43.js → utils/tool-display.js} +1 -1
  226. package/dist/utils.d.ts +6 -1
  227. package/dist/utils.js +6 -5
  228. package/package.json +2 -2
  229. package/src/files/rich-file-tree.stories.tsx +1 -1
  230. package/src/files/rich-file-tree.tsx +12 -9
  231. package/src/markdown/code-block.test.tsx +80 -2
  232. package/src/markdown/code-block.tsx +76 -80
  233. package/src/primitives/button.stories.tsx +37 -1
  234. package/src/primitives/button.test.tsx +230 -0
  235. package/src/primitives/button.tsx +62 -14
  236. package/src/primitives/card-anatomy.stories.tsx +51 -0
  237. package/src/primitives/card.test.tsx +71 -0
  238. package/src/primitives/card.tsx +25 -13
  239. package/src/primitives/heading.stories.tsx +35 -0
  240. package/src/primitives/heading.test.tsx +56 -0
  241. package/src/primitives/heading.tsx +76 -0
  242. package/src/primitives/index.ts +8 -0
  243. package/src/primitives/input.stories.tsx +28 -0
  244. package/src/primitives/input.test.tsx +161 -0
  245. package/src/primitives/input.tsx +34 -28
  246. package/src/primitives/page-header.stories.tsx +51 -0
  247. package/src/primitives/page-header.test.tsx +48 -0
  248. package/src/primitives/page-header.tsx +35 -35
  249. package/src/primitives/page-shell.stories.tsx +89 -0
  250. package/src/primitives/page-shell.test.tsx +24 -0
  251. package/src/primitives/page-shell.tsx +26 -0
  252. package/src/primitives/table-wrapper.stories.tsx +54 -0
  253. package/src/primitives/table.test.tsx +72 -0
  254. package/src/primitives/table.tsx +33 -12
  255. package/src/primitives/theme-toggle.test.tsx +167 -0
  256. package/src/primitives/theme-toggle.tsx +111 -39
  257. package/dist/active-sessions-store-nl70ZnMC.d.ts +0 -84
  258. package/dist/button-C87nEe2R.js +0 -78
  259. package/dist/chat-DDvF2oGQ.js +0 -672
  260. package/dist/diff-preview-BcK3nRXb.js +0 -533
  261. package/dist/document-editor-pane-BilJfiCo.d.ts +0 -136
  262. package/dist/document-editor-pane-POsq5Q0o.js +0 -1279
  263. package/dist/files-hywocVZz.js +0 -753
  264. package/dist/index-2UntyjBb.d.ts +0 -52
  265. package/dist/index-BgUHilZm.d.ts +0 -108
  266. package/dist/index-CJ8wRfiC.d.ts +0 -236
  267. package/dist/index-CPDET_s1.d.ts +0 -549
  268. package/dist/index-Cg6gYAqh.d.ts +0 -73
  269. package/dist/index-Cs7j8V7l.d.ts +0 -203
  270. package/dist/index-DjKxO357.d.ts +0 -128
  271. package/dist/primitives-Bx59fT-U.js +0 -1239
  272. package/dist/table-Bss73DhB.js +0 -146
  273. package/dist/tool-call-step-DNebqhmX.js +0 -434
  274. package/dist/use-auto-scroll-C5Pjeqon.js +0 -153
  275. package/dist/use-sse-stream-DjqRuDft.js +0 -884
  276. package/dist/use-sse-stream-Dm6R5YWz.d.ts +0 -282
@@ -1,13 +1,91 @@
1
- import { describe, it, expect } from "vitest";
2
- import { render } from "@testing-library/react";
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { renderToString } from "react-dom/server";
3
+ import SyntaxHighlighter from "react-syntax-highlighter";
4
+ import { afterEach, describe, expect, it } from "vitest";
3
5
  import { CodeBlock, CopyButton } from "./code-block";
4
6
 
7
+ afterEach(cleanup);
8
+
9
+ describe("CodeBlock scoped semantic colors", () => {
10
+ it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
11
+ const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
12
+ expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
13
+ await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"));
14
+ expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
15
+ expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
16
+ expect(container.querySelector(".react-syntax-highlighter-line-number")).not.toBeNull();
17
+ expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
18
+ });
19
+
20
+ it("inherits a named scope by default without inserting its own mode", () => {
21
+ const { container } = render(<section data-theme="arena-light"><CodeBlock data-testid="code" code="const x = 1" language="javascript" /></section>);
22
+ const code = screen.getByTestId("code");
23
+ expect(code.hasAttribute("data-theme")).toBe(false);
24
+ const before = code.innerHTML;
25
+ container.querySelector("section")!.setAttribute("data-theme", "arena");
26
+ expect(code.innerHTML).toBe(before); // References stay live; the browser fixture checks the computed colors.
27
+ });
28
+
29
+ it.each([true, false])("preserves explicit light=%s as an opposite nested mode", (light) => {
30
+ render(<section data-theme={light ? "dark" : "light"}><CodeBlock data-testid="code" code="x" light={light} /></section>);
31
+ expect(screen.getByTestId("code").getAttribute("data-theme")).toBe(light ? "light" : "dark");
32
+ });
33
+
34
+ it("preserves caller attributes, labels, children and custom scopes", () => {
35
+ render(<CodeBlock data-testid="code" data-theme="aubergine" className="caller" code="echo hello" language="bash" label="BASHRC"><button>Copy example</button></CodeBlock>);
36
+ expect(screen.getByTestId("code").getAttribute("data-theme")).toBe("aubergine");
37
+ expect(screen.getByTestId("code").classList.contains("caller")).toBe(true);
38
+ expect(screen.getByText("BASHRC")).toBeTruthy();
39
+ expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
40
+ });
41
+
42
+ it("server-renders readable code with the same semantic references independent of document theme", () => {
43
+ const source = <CodeBlock code="const x = 1" language="javascript" />;
44
+ document.documentElement.className = "dark";
45
+ const dark = renderToString(source);
46
+ expect(dark).toContain("const x = 1");
47
+ expect(dark).toContain("var(--syntax-foreground, currentColor)");
48
+ document.documentElement.className = "light";
49
+ expect(renderToString(source)).toBe(dark);
50
+ document.documentElement.removeAttribute("class");
51
+ });
52
+ });
53
+
54
+ // Retain the existing header/label/copy-slot contracts alongside theme coverage.
5
55
  describe("CodeBlock", () => {
6
56
  it("renders the code through the syntax highlighter", () => {
7
57
  const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
8
58
  expect(container.querySelector("code")).not.toBeNull();
9
59
  });
10
60
 
61
+ it.each([
62
+ ["javascript", "const answer = 42;"],
63
+ ["js", "const answer = 42;"],
64
+ ["brainfuck", "++[>+++<-]"],
65
+ ["not-a-language", "const answer = 42;"],
66
+ ])("keeps the default highlighter's tokens for language %s", async (language, code) => {
67
+ const baseline = document.createElement("div");
68
+ baseline.innerHTML = renderToString(<SyntaxHighlighter language={language}>{code}</SyntaxHighlighter>);
69
+ const expected = Array.from(baseline.querySelectorAll("pre code span"), (span) => span.textContent);
70
+ expect(expected.length).toBeGreaterThan(0);
71
+
72
+ const { container } = render(<CodeBlock code={code} language={language} />);
73
+ await waitFor(() => expect(container.querySelectorAll("pre code span").length).toBe(expected.length));
74
+ expect(Array.from(container.querySelectorAll("pre code span"), (span) => span.textContent)).toEqual(expected);
75
+ expect(container.querySelector("pre code")?.textContent).toBe(code);
76
+ });
77
+
78
+ it("preserves whitespace and line numbering after the renderer loads", async () => {
79
+ const code = "\tfirst \n second\n";
80
+ const baseline = document.createElement("div");
81
+ baseline.innerHTML = renderToString(<SyntaxHighlighter language="text" showLineNumbers>{code}</SyntaxHighlighter>);
82
+ const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
83
+ expect(container.querySelector("pre code")?.textContent).toBe(code);
84
+ await waitFor(() => expect(container.querySelectorAll(".react-syntax-highlighter-line-number").length)
85
+ .toBe(baseline.querySelectorAll(".react-syntax-highlighter-line-number").length));
86
+ expect(container.querySelector("pre code")?.textContent).toBe(baseline.querySelector("pre code")?.textContent);
87
+ });
88
+
11
89
  it("uses `label` as the header text, overriding `language`", () => {
12
90
  const { getByText, queryByText } = render(
13
91
  <CodeBlock code={"x"} language="typescript" label="config.ts" />,
@@ -7,28 +7,24 @@ import {
7
7
  type HTMLAttributes,
8
8
  type ReactNode,
9
9
  } from "react";
10
- import SyntaxHighlighter from "react-syntax-highlighter";
10
+ import type SyntaxHighlighter from "react-syntax-highlighter";
11
11
  import { Check, Copy } from "lucide-react";
12
12
  import { focusRing } from "../lib/focus";
13
13
  import { cn } from "../lib/utils";
14
14
 
15
- // Theme-aware syntax highlighting — reads CSS custom properties at render time.
16
- // Override --syntax-* tokens in tokens.css per theme.
17
- function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
18
- const el = typeof document !== "undefined" ? document.documentElement : null;
19
- const v = (name: string, fallback: string) =>
20
- el ? getComputedStyle(el).getPropertyValue(name).trim() || fallback : fallback;
21
-
22
- const comment = v("--syntax-comment", "#6B7094");
23
- const keyword = v("--syntax-keyword", "#A78FFF");
24
- const string = v("--syntax-string", "#10b981");
25
- const fn = v("--syntax-function", "#6D9FFF");
26
- const number = v("--syntax-number", "#FFB347");
27
- const meta = v("--syntax-meta", "#8263FF");
28
- const error = v("--syntax-error", "#FF4D6D");
29
- const variable = v("--syntax-variable", "#C4C0D8");
30
- const fg = v("--syntax-foreground", "#E8E6F6");
31
-
15
+ // Leave variables unresolved: the browser resolves them on each token span,
16
+ // including nested themes, inline overrides and CSS/media changes. No document
17
+ // sampling, observers, layout reads or React re-render are needed to recolor code.
18
+ const syntaxTheme: { [key: string]: React.CSSProperties } = (() => {
19
+ const comment = "var(--syntax-comment, currentColor)";
20
+ const keyword = "var(--syntax-keyword, currentColor)";
21
+ const string = "var(--syntax-string, currentColor)";
22
+ const fn = "var(--syntax-function, currentColor)";
23
+ const number = "var(--syntax-number, currentColor)";
24
+ const meta = "var(--syntax-meta, currentColor)";
25
+ const error = "var(--syntax-error, currentColor)";
26
+ const variable = "var(--syntax-variable, currentColor)";
27
+ const fg = "var(--syntax-foreground, currentColor)";
32
28
  return {
33
29
  "hljs-comment": { color: comment, fontStyle: "italic" },
34
30
  "hljs-quote": { color: comment, fontStyle: "italic" },
@@ -67,11 +63,22 @@ function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
67
63
  "hljs-emphasis": { fontStyle: "italic" },
68
64
  "hljs": { color: fg, background: "transparent" },
69
65
  };
66
+ })();
67
+
68
+ type Highlighter = typeof SyntaxHighlighter;
69
+ let highlighterPromise: Promise<Highlighter> | undefined;
70
+
71
+ function loadHighlighter(): Promise<Highlighter> {
72
+ // Preserve the default renderer's language behavior, including alias inputs
73
+ // and unknown-language auto-detection. Its light async variant differs.
74
+ return highlighterPromise ??= import("react-syntax-highlighter/dist/esm/default-highlight")
75
+ .then((module) => module.default)
76
+ .catch((error: unknown) => {
77
+ highlighterPromise = undefined;
78
+ throw error;
79
+ });
70
80
  }
71
81
 
72
- // tangleLight removed — getSyntaxTheme() reads --syntax-* CSS vars which are overridden
73
- // per theme in tokens.css (vault, dawn themes set light values).
74
-
75
82
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
76
83
  code: string;
77
84
  language?: string;
@@ -82,55 +89,45 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
82
89
  */
83
90
  label?: string;
84
91
  showLineNumbers?: boolean;
85
- /** Force light theme; defaults to dark */
92
+ /** Force light (true) or dark (false); omitted inherits the nearest theme. */
86
93
  light?: boolean;
87
94
  children?: ReactNode;
88
95
  }
89
96
 
90
- const LIGHT_THEMES = new Set(["vault", "dawn"]);
91
-
92
- function detectLightTheme() {
93
- return (
94
- typeof document !== "undefined" &&
95
- LIGHT_THEMES.has(document.documentElement.getAttribute("data-sandbox-theme") ?? "")
96
- );
97
- }
98
-
99
- function useIsLightTheme(): boolean {
100
- const [isLight, setIsLight] = useState(detectLightTheme);
101
-
102
- useEffect(() => {
103
- if (typeof document === "undefined") return;
104
- setIsLight(detectLightTheme());
105
- const observer = new MutationObserver(() => setIsLight(detectLightTheme()));
106
- observer.observe(document.documentElement, {
107
- attributes: true,
108
- attributeFilter: ["data-sandbox-theme"],
109
- });
110
- return () => observer.disconnect();
111
- }, []);
112
-
113
- return isLight;
114
- }
115
-
116
97
  export const CodeBlock = memo(
117
- ({ code, language, label, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
118
- const isLight = useIsLightTheme();
119
- const light = lightProp ?? isLight;
120
- const theme = getSyntaxTheme();
121
- const bg = "bg-card border-border";
122
- const headerBg = light ? "bg-muted/50 border-border" : "bg-background border-border";
123
- const langColor = "text-muted-foreground";
98
+ ({ code, language, label, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
99
+ const [Highlighter, setHighlighter] = useState<Highlighter | null>(null);
100
+ useEffect(() => {
101
+ let active = true;
102
+ void loadHighlighter().then((component) => {
103
+ if (active) setHighlighter(() => component);
104
+ }).catch(() => {
105
+ // A failed chunk leaves the code readable; a later mount can retry.
106
+ });
107
+ return () => { active = false; };
108
+ }, []);
109
+
124
110
  const headerLabel = label ?? language;
111
+ const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
112
+ const customStyle = {
113
+ margin: 0,
114
+ padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
115
+ background: "transparent",
116
+ fontSize: "var(--code-font-size, 0.8125rem)",
117
+ lineHeight: "var(--code-line-height, 1.5)",
118
+ overflowX: "auto" as const,
119
+ };
120
+ const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
125
121
 
126
122
  return (
127
123
  <div
128
- className={cn("group relative overflow-hidden rounded-lg border font-mono", bg, className)}
124
+ className={cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className)}
129
125
  {...props}
126
+ {...mode}
130
127
  >
131
128
  {headerLabel && (
132
- <div className={cn("flex items-center justify-between border-b px-3 py-1", headerBg)}>
133
- <span className={cn("text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest", langColor)}>
129
+ <div className="flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]">
130
+ <span className="text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest text-muted-foreground">
134
131
  {headerLabel}
135
132
  </span>
136
133
  {children}
@@ -141,28 +138,27 @@ export const CodeBlock = memo(
141
138
  {children}
142
139
  </div>
143
140
  )}
144
- <SyntaxHighlighter
145
- language={language ?? "text"}
146
- style={theme}
147
- showLineNumbers={showLineNumbers}
148
- lineNumberStyle={{
149
- color: light ? "#8B92B8" : "#4A4D6A",
150
- minWidth: "2.5em",
151
- paddingRight: "1em",
152
- }}
153
- customStyle={{
154
- margin: 0,
155
- padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
156
- background: "transparent",
157
- fontSize: "var(--code-font-size, 0.8125rem)",
158
- lineHeight: "var(--code-line-height, 1.5)",
159
- overflowX: "auto",
160
- }}
161
- codeTagProps={{ style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } }}
162
- wrapLines={false}
163
- >
164
- {code}
165
- </SyntaxHighlighter>
141
+ {Highlighter ? (
142
+ <Highlighter
143
+ language={language ?? "text"}
144
+ style={syntaxTheme}
145
+ showLineNumbers={showLineNumbers}
146
+ lineNumberStyle={{
147
+ color: "var(--syntax-comment, currentColor)",
148
+ minWidth: "2.5em",
149
+ paddingRight: "1em",
150
+ }}
151
+ customStyle={customStyle}
152
+ codeTagProps={codeTagProps}
153
+ wrapLines={false}
154
+ >
155
+ {code}
156
+ </Highlighter>
157
+ ) : (
158
+ <pre style={{ ...syntaxTheme.hljs, ...customStyle }}>
159
+ <code style={{ ...codeTagProps.style, whiteSpace: "pre" }}>{code}</code>
160
+ </pre>
161
+ )}
166
162
  </div>
167
163
  );
168
164
  },
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useId, useState } from 'react'
2
3
  import { Button } from './button'
3
4
 
4
5
  const meta: Meta<typeof Button> = {
@@ -17,7 +18,7 @@ export const Secondary: Story = { args: { variant: 'secondary' } }
17
18
  export const Outline: Story = { args: { variant: 'outline' } }
18
19
  export const Ghost: Story = { args: { variant: 'ghost' } }
19
20
  export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete' } }
20
- export const Loading: Story = { args: { loading: true, children: 'Loading...' } }
21
+ export const Loading: Story = { args: { loading: true, children: 'Save changes' } }
21
22
 
22
23
  export const AllSizes: Story = {
23
24
  name: 'All Sizes',
@@ -46,3 +47,38 @@ export const AllVariants: Story = {
46
47
  </div>
47
48
  ),
48
49
  }
50
+
51
+ export const ActionContracts: Story = {
52
+ parameters: {
53
+ docs: {
54
+ description: {
55
+ story: 'Toggle disabled/loading, then try pointer, Enter and Space activation. Native buttons retain their submit default; links keep their action name while unavailable. Emulate prefers-reduced-motion to check the spinner and press effect.',
56
+ },
57
+ },
58
+ },
59
+ render: function ActionContracts() {
60
+ const resultId = useId()
61
+ const [disabled, setDisabled] = useState(false)
62
+ const [loading, setLoading] = useState(false)
63
+ const [submits, setSubmits] = useState(0)
64
+ const [opens, setOpens] = useState(0)
65
+ return (
66
+ <div className="flex max-w-lg flex-col gap-4">
67
+ <div className="flex gap-4">
68
+ <label><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} /> Disabled</label>
69
+ <label><input type="checkbox" checked={loading} onChange={event => setLoading(event.target.checked)} /> Loading</label>
70
+ </div>
71
+ <form className="flex flex-wrap gap-3" onSubmit={event => { event.preventDefault(); setSubmits(value => value + 1) }}>
72
+ <Button disabled={disabled} loading={loading}>Save changes</Button>
73
+ <Button asChild disabled={disabled} loading={loading}>
74
+ <button disabled={false} aria-busy={false}>Submit slotted button</button>
75
+ </Button>
76
+ <Button asChild disabled={disabled} loading={loading}>
77
+ <a href={`#${resultId}`} onClick={() => setOpens(value => value + 1)}>Open result</a>
78
+ </Button>
79
+ </form>
80
+ <p id={resultId} role="status">Submissions: {submits}. Link activations: {opens}.</p>
81
+ </div>
82
+ )
83
+ },
84
+ }
@@ -0,0 +1,230 @@
1
+ import * as React from "react";
2
+ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { hydrateRoot, type Root } from "react-dom/client";
5
+ import { renderToString } from "react-dom/server";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { Button } from "./button";
8
+
9
+ afterEach(cleanup);
10
+
11
+ describe.each(["disabled", "loading"] as const)("%s controls", (state) => {
12
+ it.each([false, true])("natively disables button elements (asChild=%s)", async (asChild) => {
13
+ const user = userEvent.setup();
14
+ const click = vi.fn();
15
+ const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
16
+ render(<form onSubmit={submit}>
17
+ <Button {...{ [state]: true }} asChild={asChild} onClick={click}>
18
+ {asChild ? <button disabled={false} aria-busy={false}>Save</button> : "Save"}
19
+ </Button>
20
+ <input aria-label="Next field" />
21
+ </form>);
22
+ const button = screen.getByRole("button", { name: "Save" });
23
+ expect(button).toBeDisabled();
24
+ expect(button).toHaveAccessibleName("Save");
25
+ if (state === "loading") expect(button).toHaveAttribute("aria-busy", "true");
26
+ await user.click(button);
27
+ button.click();
28
+ button.focus();
29
+ expect(button).not.toHaveFocus();
30
+ await user.keyboard("{Enter} ");
31
+ expect(click).not.toHaveBeenCalled();
32
+ expect(submit).not.toHaveBeenCalled();
33
+ await user.tab();
34
+ expect(screen.getByRole("textbox")).toHaveFocus();
35
+ });
36
+
37
+ it("blocks slotted anchor activation before child AND slot capture/bubble handlers", async () => {
38
+ const user = userEvent.setup();
39
+ const activation = vi.fn();
40
+ const handlers = {
41
+ onClick: activation, onClickCapture: activation,
42
+ onAuxClick: activation, onAuxClickCapture: activation,
43
+ onDoubleClick: activation, onDoubleClickCapture: activation,
44
+ onPointerDown: activation, onPointerDownCapture: activation,
45
+ onPointerUp: activation, onPointerUpCapture: activation,
46
+ onMouseDown: activation, onMouseDownCapture: activation,
47
+ onMouseUp: activation, onMouseUpCapture: activation,
48
+ onKeyDown: activation, onKeyDownCapture: activation,
49
+ onKeyUp: activation, onKeyUpCapture: activation,
50
+ };
51
+ render(<div onClick={activation}>
52
+ <Button asChild {...{ [state]: true }} aria-busy={false} {...handlers}>
53
+ <a href="#destination" tabIndex={0} aria-disabled={false} aria-busy={false} {...handlers}>
54
+ <span>Open account</span>
55
+ </a>
56
+ </Button>
57
+ <button>Next action</button>
58
+ </div>);
59
+ const link = screen.getByRole("link", { name: "Open account" });
60
+ expect(link).toHaveAttribute("aria-disabled", "true");
61
+ expect(link).toHaveAttribute("tabindex", "-1");
62
+ expect(link).not.toHaveAttribute("disabled");
63
+ expect(link).not.toHaveAttribute("href");
64
+ if (state === "loading") expect(link).toHaveAttribute("aria-busy", "true");
65
+ const target = screen.getByText("Open account");
66
+ expect(fireEvent.click(target)).toBe(false);
67
+ expect(fireEvent.doubleClick(target)).toBe(false);
68
+ expect(fireEvent(target, new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }))).toBe(false);
69
+ expect(fireEvent.pointerDown(target)).toBe(false);
70
+ expect(fireEvent.pointerUp(target)).toBe(false);
71
+ expect(fireEvent.mouseDown(target)).toBe(false);
72
+ expect(fireEvent.mouseUp(target)).toBe(false);
73
+ for (const key of ["Enter", " "]) {
74
+ expect(fireEvent.keyDown(link, { key })).toBe(false);
75
+ expect(fireEvent.keyUp(link, { key })).toBe(false);
76
+ }
77
+ await user.click(target);
78
+ link.click();
79
+ // Even programmatically focused, a disabled link cannot activate by keyboard.
80
+ link.focus();
81
+ await user.keyboard("{Enter} ");
82
+ expect(activation).not.toHaveBeenCalled();
83
+ link.blur();
84
+ await user.tab();
85
+ expect(screen.getByRole("button", { name: "Next action" })).toHaveFocus();
86
+ });
87
+ });
88
+
89
+ it("retains the action name and hides the reduced-motion spinner from accessibility", () => {
90
+ const { rerender } = render(<Button loading aria-busy={false}>Save changes</Button>);
91
+ const button = screen.getByRole("button", { name: "Save changes" });
92
+ expect(button).toHaveAttribute("aria-busy", "true");
93
+ const spinner = button.querySelector("svg");
94
+ expect(spinner).toHaveAttribute("aria-hidden", "true");
95
+ expect(spinner).toHaveAttribute("focusable", "false");
96
+ expect(spinner).toHaveClass("motion-reduce:animate-none");
97
+ expect(button).toHaveClass("motion-reduce:transition-none", "motion-reduce:active:scale-100");
98
+ expect(button.querySelector("title")).toBeNull();
99
+ rerender(<Button loading size="icon" aria-label="Refresh reports"><span aria-hidden="true">↻</span></Button>);
100
+ expect(screen.getByRole("button")).toHaveAccessibleName("Refresh reports");
101
+ });
102
+
103
+ it.each(["disabled", "loading"] as const)("restores link attributes and original Slot handler order when %s ends", async (state) => {
104
+ const user = userEvent.setup();
105
+ const calls: string[] = [];
106
+ const ui = (unavailable: boolean) => <Button asChild {...{ [state]: unavailable }} onClick={() => calls.push("slot")}>
107
+ <a href="#destination" tabIndex={2} onClick={(event) => { event.preventDefault(); calls.push("child"); }}>Open</a>
108
+ </Button>;
109
+ const { rerender } = render(ui(true));
110
+ const link = screen.getByRole("link");
111
+ rerender(ui(false));
112
+ expect(link).toHaveAttribute("href", "#destination");
113
+ expect(link).toHaveAttribute("tabindex", "2");
114
+ expect(link).not.toHaveAttribute("aria-disabled");
115
+ expect(link).not.toHaveAttribute("aria-busy");
116
+ expect(link).not.toHaveAttribute("role");
117
+ await user.click(link);
118
+ expect(calls).toEqual(["child", "slot"]);
119
+ calls.length = 0;
120
+ link.focus();
121
+ await user.keyboard("{Enter}");
122
+ expect(calls).toEqual(["child", "slot"]);
123
+ });
124
+
125
+ it("preserves non-activation keyboard handlers on disabled links", () => {
126
+ const calls: string[] = [];
127
+ render(<Button asChild disabled onKeyDownCapture={() => calls.push("slot capture")} onKeyDown={() => calls.push("slot bubble")}>
128
+ <a href="#destination" onKeyDownCapture={() => calls.push("child capture")} onKeyDown={() => calls.push("child bubble")}>Open</a>
129
+ </Button>);
130
+ expect(fireEvent.keyDown(screen.getByRole("link"), { key: "ArrowRight" })).toBe(true);
131
+ expect(calls).toEqual(["child capture", "slot capture", "child bubble", "slot bubble"]);
132
+ });
133
+
134
+ it("respects a native child's own disabled state", () => {
135
+ render(<Button asChild disabled={false}><button disabled>Save</button></Button>);
136
+ expect(screen.getByRole("button")).toBeDisabled();
137
+ });
138
+
139
+ it("composes refs on a slotted native button", () => {
140
+ const outerRef = React.createRef<HTMLButtonElement>();
141
+ const childRef = React.createRef<HTMLButtonElement>();
142
+ const { unmount } = render(<Button asChild ref={outerRef}><button ref={childRef}>Save</button></Button>);
143
+ expect(outerRef.current).toBe(screen.getByRole("button"));
144
+ expect(childRef.current).toBe(outerRef.current);
145
+ unmount();
146
+ expect(outerRef.current).toBeNull();
147
+ expect(childRef.current).toBeNull();
148
+ });
149
+
150
+ it("preserves caller busy state when Button is not loading", () => {
151
+ render(<><Button aria-busy>Save</Button><Button asChild aria-busy><a href="#destination">Open</a></Button></>);
152
+ expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
153
+ expect(screen.getByRole("link")).toHaveAttribute("aria-busy", "true");
154
+ });
155
+
156
+ it("preserves implicit submission, explicit types, and enabled keyboard activation", async () => {
157
+ const user = userEvent.setup();
158
+ const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
159
+ render(<form onSubmit={submit}>
160
+ <Button>Implicit submit</Button>
161
+ <Button asChild><button>Slotted submit</button></Button>
162
+ <Button type="button">Explicit button</Button>
163
+ <Button type="submit">Explicit submit</Button>
164
+ <Button asChild type="submit"><button type="button">Child button</button></Button>
165
+ </form>);
166
+ const implicit = screen.getByRole("button", { name: "Implicit submit" });
167
+ expect(implicit).not.toHaveAttribute("type");
168
+ await user.click(implicit);
169
+ await user.click(screen.getByRole("button", { name: "Slotted submit" }));
170
+ await user.click(screen.getByRole("button", { name: "Explicit submit" }));
171
+ expect(submit).toHaveBeenCalledTimes(3);
172
+ await user.click(screen.getByRole("button", { name: "Explicit button" }));
173
+ await user.click(screen.getByRole("button", { name: "Child button" }));
174
+ expect(submit).toHaveBeenCalledTimes(3);
175
+ implicit.focus();
176
+ await user.keyboard("{Enter} ");
177
+ expect(submit).toHaveBeenCalledTimes(5);
178
+ });
179
+
180
+ it("forwards native refs and composes both refs on a slotted anchor", () => {
181
+ const nativeRef = React.createRef<HTMLButtonElement>();
182
+ const anchorRef = React.createRef<HTMLAnchorElement>();
183
+ const slotRef = vi.fn();
184
+ const { rerender, unmount } = render(<>
185
+ <Button ref={nativeRef}>Save</Button>
186
+ <Button asChild loading ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
187
+ </>);
188
+ const link = screen.getByRole("link");
189
+ expect(nativeRef.current).toBe(screen.getByRole("button"));
190
+ expect(anchorRef.current).toBe(link);
191
+ expect(slotRef).toHaveBeenLastCalledWith(link);
192
+ rerender(<>
193
+ <Button ref={nativeRef}>Save</Button>
194
+ <Button asChild ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
195
+ </>);
196
+ expect(anchorRef.current).toBe(link);
197
+ expect(slotRef).toHaveBeenLastCalledWith(link);
198
+ unmount();
199
+ expect(nativeRef.current).toBeNull();
200
+ expect(anchorRef.current).toBeNull();
201
+ expect(slotRef).toHaveBeenLastCalledWith(null);
202
+ });
203
+
204
+ it("hydrates disabled/loading native and slotted controls without changing their names", async () => {
205
+ const ui = <>
206
+ <Button loading>Save</Button>
207
+ <Button asChild loading><button>Submit</button></Button>
208
+ <Button asChild disabled><a href="#destination">Open</a></Button>
209
+ </>;
210
+ const container = document.createElement("div");
211
+ container.innerHTML = renderToString(ui);
212
+ document.body.appendChild(container);
213
+ const html = container.innerHTML;
214
+ const recoverableError = vi.fn();
215
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
216
+ let root: Root | undefined;
217
+ try {
218
+ await act(async () => { root = hydrateRoot(container, ui, { onRecoverableError: recoverableError }); });
219
+ expect(container.innerHTML).toBe(html);
220
+ expect(within(container).getByRole("button", { name: "Save" })).toBeDisabled();
221
+ expect(within(container).getByRole("button", { name: "Submit" })).toBeDisabled();
222
+ expect(within(container).getByRole("link", { name: "Open" })).not.toHaveAttribute("href");
223
+ expect(recoverableError).not.toHaveBeenCalled();
224
+ expect(consoleError).not.toHaveBeenCalled();
225
+ } finally {
226
+ await act(async () => { root?.unmount(); });
227
+ consoleError.mockRestore();
228
+ container.remove();
229
+ }
230
+ });