@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,448 @@
1
+ import { createRef, type ReactElement } from "react";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { oklchToHex } from "@elabs-ai/components-tokens";
8
+
9
+ // xterm.js needs a real canvas + layout to render, so it cannot mount in
10
+ // jsdom — mock the engine and assert the wrapper's lifecycle + theming
11
+ // contract. Real rendering + a11y come from the Storybook interaction tests.
12
+ // Mirrors the Monaco mocking pattern in @elabs-ai/components-editor's code-editor.test.tsx.
13
+ const h = vi.hoisted(() => {
14
+ const state = {
15
+ dataHandler: undefined as undefined | ((data: string) => void),
16
+ resizeHandler: undefined as undefined | ((size: { cols: number; rows: number }) => void),
17
+ };
18
+ const textarea = { setAttribute: vi.fn(), getAttribute: vi.fn() };
19
+
20
+ function makeInstance() {
21
+ return {
22
+ options: { fontSize: 13, disableStdin: false } as Record<string, unknown>,
23
+ textarea,
24
+ loadAddon: vi.fn(),
25
+ open: vi.fn(),
26
+ write: vi.fn(),
27
+ clear: vi.fn(),
28
+ focus: vi.fn(),
29
+ dispose: vi.fn(),
30
+ attachCustomKeyEventHandler: vi.fn(),
31
+ onData: vi.fn((cb: (data: string) => void) => {
32
+ state.dataHandler = cb;
33
+ return { dispose: vi.fn() };
34
+ }),
35
+ onResize: vi.fn((cb: (size: { cols: number; rows: number }) => void) => {
36
+ state.resizeHandler = cb;
37
+ return { dispose: vi.fn() };
38
+ }),
39
+ };
40
+ }
41
+
42
+ const instances: Array<ReturnType<typeof makeInstance>> = [];
43
+ const Terminal = vi.fn(() => {
44
+ const instance = makeInstance();
45
+ instances.push(instance);
46
+ return instance;
47
+ });
48
+ const FitAddon = vi.fn(() => ({ fit: vi.fn(), dispose: vi.fn() }));
49
+
50
+ return { state, textarea, instances, Terminal, FitAddon };
51
+ });
52
+
53
+ vi.mock("@xterm/xterm", () => ({
54
+ Terminal: h.Terminal,
55
+ }));
56
+ vi.mock("@xterm/addon-fit", () => ({
57
+ FitAddon: h.FitAddon,
58
+ }));
59
+ // The real stylesheet import is a side-effect-only CSS import (already a
60
+ // no-op under this package's `css: false` vitest config); mock it explicitly
61
+ // so the module graph doesn't need to touch the filesystem in this test.
62
+ vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
63
+
64
+ import {
65
+ buildInteractiveTerminalTheme,
66
+ InteractiveTerminal,
67
+ type InteractiveTerminalHandle,
68
+ } from "./interactive-terminal";
69
+
70
+ beforeEach(() => {
71
+ h.state.dataHandler = undefined;
72
+ h.state.resizeHandler = undefined;
73
+ h.instances.length = 0;
74
+ vi.clearAllMocks();
75
+ });
76
+ afterEach(cleanup);
77
+
78
+ /** Lets the pending dynamic `import()` + its `.then` settle inside `act`. */
79
+ const flush = () => act(async () => void (await new Promise((resolve) => setTimeout(resolve, 0))));
80
+
81
+ /**
82
+ * xterm is reached through a dynamic `import()` inside the mount effect (ADR
83
+ * 0019 — the engine must not sit in every consumer's entry chunk), so nothing
84
+ * is constructed synchronously with `render()`. Every lifecycle assertion waits
85
+ * for the engine chunk to resolve first.
86
+ */
87
+ async function renderTerminal(ui: ReactElement) {
88
+ const result = render(ui);
89
+ await waitFor(() => expect(h.instances.length).toBeGreaterThan(0));
90
+ return result;
91
+ }
92
+
93
+ describe("InteractiveTerminal", () => {
94
+ it("creates an xterm instance and opens it into the container on mount", async () => {
95
+ await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
96
+ expect(h.Terminal).toHaveBeenCalledTimes(1);
97
+ expect(h.instances[0]?.open).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it("does not touch the engine during render — it is a lazy chunk (#313)", async () => {
101
+ render(<InteractiveTerminal aria-label="Agent shell" />);
102
+ expect(h.Terminal).not.toHaveBeenCalled();
103
+ await waitFor(() => expect(h.Terminal).toHaveBeenCalledTimes(1));
104
+ });
105
+
106
+ it("forwards the aria-label onto xterm's real (focusable) textarea", async () => {
107
+ await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
108
+ expect(h.textarea.setAttribute).toHaveBeenCalledWith("aria-label", "Agent shell");
109
+ });
110
+
111
+ it("handle.write() delegates to the xterm instance", async () => {
112
+ const ref = createRef<InteractiveTerminalHandle>();
113
+ await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
114
+ act(() => ref.current?.write("hello\r\n"));
115
+ expect(h.instances[0]?.write).toHaveBeenCalledWith("hello\r\n");
116
+ });
117
+
118
+ it("handle.clear() / handle.focus() delegate to the xterm instance", async () => {
119
+ const ref = createRef<InteractiveTerminalHandle>();
120
+ await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
121
+ act(() => ref.current?.clear());
122
+ act(() => ref.current?.focus());
123
+ expect(h.instances[0]?.clear).toHaveBeenCalledTimes(1);
124
+ expect(h.instances[0]?.focus).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("handle.fit() delegates to the FitAddon instance", async () => {
128
+ const ref = createRef<InteractiveTerminalHandle>();
129
+ await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
130
+ const fitAddon = h.FitAddon.mock.results[0]?.value as { fit: ReturnType<typeof vi.fn> };
131
+ act(() => ref.current?.fit());
132
+ expect(fitAddon.fit).toHaveBeenCalled();
133
+ });
134
+
135
+ it("wires onData to the onData prop (keystrokes + paste)", async () => {
136
+ const onData = vi.fn();
137
+ await renderTerminal(<InteractiveTerminal aria-label="Agent shell" onData={onData} />);
138
+ await waitFor(() => expect(h.instances[0]?.onData).toHaveBeenCalledTimes(1));
139
+ act(() => h.state.dataHandler?.("ls\n"));
140
+ expect(onData).toHaveBeenCalledWith("ls\n");
141
+ });
142
+
143
+ it("reports container resize as cols/rows via onResize", async () => {
144
+ const onResize = vi.fn();
145
+ await renderTerminal(<InteractiveTerminal aria-label="Agent shell" onResize={onResize} />);
146
+ await waitFor(() => expect(h.instances[0]?.onResize).toHaveBeenCalledTimes(1));
147
+ act(() => h.state.resizeHandler?.({ cols: 80, rows: 24 }));
148
+ expect(onResize).toHaveBeenCalledWith({ cols: 80, rows: 24 });
149
+ });
150
+
151
+ describe("readOnly", () => {
152
+ it("constructs xterm with disableStdin: true", async () => {
153
+ await renderTerminal(<InteractiveTerminal aria-label="Agent log" readOnly />);
154
+ expect(h.instances[0]?.options.disableStdin).toBe(true);
155
+ });
156
+
157
+ it("does not wire onData", async () => {
158
+ const onData = vi.fn();
159
+ await renderTerminal(<InteractiveTerminal aria-label="Agent log" onData={onData} readOnly />);
160
+ await flush();
161
+ expect(h.instances[0]?.onData).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("still exposes write() so consumers can render output", async () => {
165
+ const ref = createRef<InteractiveTerminalHandle>();
166
+ await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent log" readOnly />);
167
+ act(() => ref.current?.write("log line\r\n"));
168
+ expect(h.instances[0]?.write).toHaveBeenCalledWith("log line\r\n");
169
+ });
170
+ });
171
+
172
+ it("disposes the xterm instance on unmount", async () => {
173
+ const { unmount } = await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
174
+ unmount();
175
+ expect(h.instances[0]?.dispose).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ it("replays writes issued before the engine chunk resolved (#313)", async () => {
179
+ const ref = createRef<InteractiveTerminalHandle>();
180
+ render(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
181
+
182
+ // A consumer's own mount effect runs before the lazy chunk lands — the
183
+ // banner it writes must not be silently dropped.
184
+ act(() => ref.current?.write("banner\r\n"));
185
+ expect(h.instances).toHaveLength(0);
186
+
187
+ await waitFor(() => expect(h.instances[0]?.write).toHaveBeenCalledWith("banner\r\n"));
188
+ });
189
+
190
+ it("unmounting before the engine chunk resolves never opens a terminal (#313)", async () => {
191
+ // Warm the module cache first, so the second render's dynamic import really
192
+ // does resolve on this tick — otherwise the assertion would pass vacuously.
193
+ const warm = await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
194
+ warm.unmount();
195
+ h.instances.length = 0;
196
+
197
+ const { unmount } = render(<InteractiveTerminal aria-label="Agent shell" />);
198
+ unmount();
199
+ await flush();
200
+ expect(h.instances).toHaveLength(0);
201
+ });
202
+ });
203
+
204
+ describe("buildInteractiveTerminalTheme", () => {
205
+ afterEach(() => {
206
+ document.documentElement.removeAttribute("style");
207
+ });
208
+
209
+ it("maps ANSI red to the resolved --destructive-text token", () => {
210
+ document.documentElement.style.setProperty("--destructive-text", "#dc2626");
211
+ expect(buildInteractiveTerminalTheme().red).toBe("#dc2626");
212
+ });
213
+
214
+ // Fixture values are real AA-clearing `-text` rungs (≥4.5:1 on the default
215
+ // `#ffffff` background this test leaves in place). A `-text` token that does
216
+ // NOT clear AA is not a valid fixture for this mapping — the readable-ink
217
+ // floor below would (correctly) darken it and the identity assertion would
218
+ // stop describing the mapping.
219
+ it("maps ANSI green/yellow/blue to their -text tokens", () => {
220
+ document.documentElement.style.setProperty("--success-text", "#15803d");
221
+ document.documentElement.style.setProperty("--warning-text", "#854d0e");
222
+ document.documentElement.style.setProperty("--info-text", "#1d4ed8");
223
+ const theme = buildInteractiveTerminalTheme();
224
+ expect(theme.green).toBe("#15803d");
225
+ expect(theme.yellow).toBe("#854d0e");
226
+ expect(theme.blue).toBe("#1d4ed8");
227
+ });
228
+
229
+ it("resolves an oklch() token value to hex, not passed through raw", () => {
230
+ document.documentElement.style.setProperty("--destructive-text", "oklch(0.5 0.2 27)");
231
+ expect(buildInteractiveTerminalTheme().red).toMatch(/^#[0-9a-f]{6}$/);
232
+ });
233
+
234
+ it("derives background/foreground/cursor from --card/--foreground/--primary", () => {
235
+ document.documentElement.style.setProperty("--card", "#111827");
236
+ document.documentElement.style.setProperty("--foreground", "#f9fafb");
237
+ document.documentElement.style.setProperty("--primary", "#6366f1");
238
+ const theme = buildInteractiveTerminalTheme();
239
+ expect(theme.background).toBe("#111827");
240
+ expect(theme.foreground).toBe("#f9fafb");
241
+ expect(theme.cursor).toBe("#6366f1");
242
+ // cursorAccent tracks the background so glyphs stay legible under a block cursor.
243
+ expect(theme.cursorAccent).toBe("#111827");
244
+ });
245
+
246
+ it("keeps black/white as background/foreground rungs, not the same value", () => {
247
+ document.documentElement.style.setProperty("--card", "#0b1220");
248
+ document.documentElement.style.setProperty("--foreground", "#e5e7eb");
249
+ const theme = buildInteractiveTerminalTheme();
250
+ expect(theme.black).toBe("#0b1220");
251
+ expect(theme.brightWhite).toBe("#e5e7eb");
252
+ expect(theme.black).not.toBe(theme.brightWhite);
253
+ });
254
+ });
255
+
256
+ // --- #386: every ANSI slot is TEXT, so every one of them must clear AA ------
257
+ // xterm paints ANSI colour codes as real text on the terminal background. The
258
+ // original mapping reached for MARK-rung tokens (`--chart-2`/`--chart-4`,
259
+ // `--border-strong`) and FILL-rung tokens (`--success`/`--info`/… for the
260
+ // `bright*` siblings), which are only guaranteed ≥3:1 (see
261
+ // .claude/rules/styling-and-tokens.md, "which status rung a graphical MARK
262
+ // reaches for"), so slots came out below 4.5:1 in EVERY palette this repo ships.
263
+ //
264
+ // The fixtures below are PARSED FROM `packages/tokens/src/themes.css` at test
265
+ // time and resolved with the same `oklchToHex` the component itself uses —
266
+ // deliberately not hand-copied hexes. An earlier revision of this file DID
267
+ // hand-copy them and got them wrong while claiming they were measured; a parsed
268
+ // fixture cannot drift from the shipped themes and cannot be fabricated.
269
+ // `resolveBlock` THROWS on a token it cannot resolve, so a broken parse fails
270
+ // loudly instead of silently substituting a fallback that happens to pass.
271
+
272
+ /** WCAG 2.1 relative luminance of a `#rrggbb` — the reference implementation
273
+ * this test checks the component's own clamp against. */
274
+ function relativeLuminance(hex: string): number {
275
+ const channel = (i: number) => {
276
+ const c = (parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) || 0) / 255;
277
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
278
+ };
279
+ return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
280
+ }
281
+
282
+ function contrastRatio(a: string, b: string): number {
283
+ const la = relativeLuminance(a);
284
+ const lb = relativeLuminance(b);
285
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
286
+ }
287
+
288
+ // ADR 0029 split the reference themes out of `themes.css` into their own opt-in
289
+ // files, so a reader that opens ONLY `themes.css` sees `:root` and the paused
290
+ // engine block and nothing else — it keeps parsing, just less. Read the SET,
291
+ // and throw rather than return an incomplete one.
292
+ const TOKENS_SRC = join(dirname(fileURLToPath(import.meta.url)), "../../tokens/src");
293
+
294
+ function readThemeCssSet(): string {
295
+ const engine = join(TOKENS_SRC, "themes.css");
296
+ const themesDir = join(TOKENS_SRC, "themes");
297
+ const files = readdirSync(themesDir).filter((f) => f.endsWith(".css"));
298
+ if (files.length === 0) {
299
+ throw new Error(`no theme stylesheets found in ${themesDir} — the fixture would be vacuous`);
300
+ }
301
+ return [engine, ...files.map((f) => join(themesDir, f))]
302
+ .map((p) => readFileSync(p, "utf8"))
303
+ .join("\n");
304
+ }
305
+
306
+ /** Every custom property declared per `:root` / `[data-theme="…"]` block.
307
+ * Comments are stripped FIRST — themes.css documents its tokens heavily, and a
308
+ * `--foo: bar;` written inside a `/* … *\/` prose block otherwise parses as a
309
+ * declaration and poisons the fixture. */
310
+ function parseThemeBlocks(source: string): Record<string, Record<string, string>> {
311
+ const css = source.replace(/\/\*[\s\S]*?\*\//g, "");
312
+ const out: Record<string, Record<string, string>> = {};
313
+ const opener = /(^|\n)(:root|\[data-theme="([a-z0-9-]+)"\])\s*\{/g;
314
+ let match: RegExpExecArray | null;
315
+ while ((match = opener.exec(css))) {
316
+ const name = match[3] ?? ":root";
317
+ let depth = 1;
318
+ let i = opener.lastIndex;
319
+ while (i < css.length && depth > 0) {
320
+ if (css[i] === "{") depth += 1;
321
+ else if (css[i] === "}") depth -= 1;
322
+ i += 1;
323
+ }
324
+ const decls = out[name] ?? {};
325
+ for (const d of css
326
+ .slice(opener.lastIndex, i - 1)
327
+ .matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/g)) {
328
+ decls[d[1]!] = d[2]!.trim();
329
+ }
330
+ out[name] = decls;
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /** The tokens `buildInteractiveTerminalTheme` reads. Every one must resolve. */
336
+ const READ_TOKENS = [
337
+ "--card",
338
+ "--foreground",
339
+ "--primary",
340
+ "--muted-foreground",
341
+ "--border-strong",
342
+ "--destructive-text",
343
+ "--destructive",
344
+ "--success-text",
345
+ "--success",
346
+ "--warning-text",
347
+ "--warning",
348
+ "--info-text",
349
+ "--info",
350
+ "--chart-2",
351
+ "--chart-4",
352
+ ] as const;
353
+
354
+ /** Resolve a block's declarations (following one `var()` hop) to concrete hex.
355
+ * Throws rather than falling back — a silent fallback of `#000000` on a light
356
+ * card reads as 21:1 and would hide exactly the failure this test locks. */
357
+ function resolveBlock(decls: Record<string, string>, blockName: string): Record<string, string> {
358
+ const out: Record<string, string> = {};
359
+ for (const token of READ_TOKENS) {
360
+ let raw = decls[token];
361
+ for (let hop = 0; raw?.startsWith("var(") && hop < 4; hop += 1) {
362
+ raw = decls[raw.slice(4, raw.indexOf(")")).trim()];
363
+ }
364
+ if (!raw) throw new Error(`themes.css block "${blockName}" does not declare ${token}`);
365
+ const hex = raw.startsWith("oklch") ? oklchToHex(raw) : raw;
366
+ if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) {
367
+ throw new Error(
368
+ `themes.css block "${blockName}": ${token} = "${raw}" did not resolve to hex`,
369
+ );
370
+ }
371
+ out[token] = hex;
372
+ }
373
+ return out;
374
+ }
375
+
376
+ /** Every slot xterm paints as INK. `black` is deliberately excluded: it is the
377
+ * background RUNG by ANSI convention (documented in interactive-terminal.tsx),
378
+ * not ink chosen for legibility. */
379
+ const ANSI_INK_SLOTS = [
380
+ "red",
381
+ "green",
382
+ "yellow",
383
+ "blue",
384
+ "magenta",
385
+ "cyan",
386
+ "white",
387
+ "brightBlack",
388
+ "brightRed",
389
+ "brightGreen",
390
+ "brightYellow",
391
+ "brightBlue",
392
+ "brightMagenta",
393
+ "brightCyan",
394
+ "brightWhite",
395
+ ] as const;
396
+
397
+ const THEME_BLOCKS = parseThemeBlocks(readThemeCssSet());
398
+
399
+ describe("buildInteractiveTerminalTheme readable-ink floor (#386)", () => {
400
+ afterEach(() => {
401
+ document.documentElement.removeAttribute("style");
402
+ });
403
+
404
+ // Guards the parse itself: if the regex ever stops matching, the per-block
405
+ // loop below would silently run zero assertions and still go green.
406
+ it("parses every palette the repo ships out of the theme stylesheet SET", () => {
407
+ expect(Object.keys(THEME_BLOCKS).sort()).toEqual(
408
+ expect.arrayContaining([":root", "light", "dark"]),
409
+ );
410
+ });
411
+
412
+ for (const blockName of [":root", "light", "dark"]) {
413
+ const apply = () => {
414
+ const palette = resolveBlock(THEME_BLOCKS[blockName] ?? {}, blockName);
415
+ for (const [name, value] of Object.entries(palette)) {
416
+ document.documentElement.style.setProperty(name, value);
417
+ }
418
+ };
419
+
420
+ it(`clears WCAG AA (4.5:1) on every ANSI ink slot in ${blockName}`, () => {
421
+ apply();
422
+ const theme = buildInteractiveTerminalTheme();
423
+ const background = theme.background as string;
424
+ const failures = ANSI_INK_SLOTS.filter(
425
+ (slot) => contrastRatio(theme[slot] as string, background) < 4.5,
426
+ ).map(
427
+ (slot) =>
428
+ `${slot}=${theme[slot]} (${contrastRatio(theme[slot] as string, background).toFixed(2)}:1)`,
429
+ );
430
+ expect(failures).toEqual([]);
431
+ });
432
+
433
+ it(`keeps each bright ANSI sibling distinguishable from its base in ${blockName}`, () => {
434
+ apply();
435
+ const theme = buildInteractiveTerminalTheme();
436
+ // A bright/base pair that collapses to one hex makes `\x1b[1;3Xm` output
437
+ // indistinguishable from `\x1b[3Xm` — the clamp must not flatten the palette.
438
+ expect(theme.brightMagenta).not.toBe(theme.magenta);
439
+ expect(theme.brightCyan).not.toBe(theme.cyan);
440
+ });
441
+ }
442
+
443
+ it("leaves an already-AA ink untouched (the clamp is a floor, not a filter)", () => {
444
+ document.documentElement.style.setProperty("--card", "#ffffff");
445
+ document.documentElement.style.setProperty("--destructive-text", "#b3261e");
446
+ expect(buildInteractiveTerminalTheme().red).toBe("#b3261e");
447
+ });
448
+ });