@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,99 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Conversation, ConversationContent, ConversationScrollButton } from "./conversation";
4
+ import { Message, MessageContent, MessageResponse } from "./message";
5
+ import { Composer } from "./composer";
6
+ import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning";
7
+ import { Source, Sources, SourcesContent, SourcesTrigger } from "./sources";
8
+
9
+ interface Msg {
10
+ id: string;
11
+ role: "user" | "assistant";
12
+ text: string;
13
+ reasoning?: string;
14
+ }
15
+
16
+ const initial: Msg[] = [
17
+ { id: "1", role: "user", text: "What changed in last week's deploys?" },
18
+ {
19
+ id: "2",
20
+ role: "assistant",
21
+ text: "Three services shipped this week. **Billing** is currently degraded — elevated p95 latency after the last rollout.",
22
+ reasoning:
23
+ "Queried CI for the last 7 days, grouped by service, then cross-referenced the rollback log to flag regressions.",
24
+ },
25
+ ];
26
+
27
+ function ChatExample() {
28
+ const [messages, setMessages] = useState<Msg[]>(initial);
29
+ const [status, setStatus] = useState<"ready" | "submitted">("ready");
30
+
31
+ const send = (text: string) => {
32
+ setMessages((m) => [...m, { id: String(m.length + 1), role: "user", text }]);
33
+ setStatus("submitted");
34
+ setTimeout(() => {
35
+ setMessages((m) => [
36
+ ...m,
37
+ {
38
+ id: String(m.length + 1),
39
+ role: "assistant",
40
+ text: "Three services shipped; billing is degraded.",
41
+ },
42
+ ]);
43
+ setStatus("ready");
44
+ }, 600);
45
+ };
46
+
47
+ return (
48
+ <div className="mx-auto flex h-[640px] max-w-2xl flex-col overflow-hidden rounded-xl border">
49
+ <Conversation className="flex-1">
50
+ <ConversationContent>
51
+ {messages.map((m) => (
52
+ <Message from={m.role} key={m.id}>
53
+ <MessageContent>
54
+ {m.reasoning ? (
55
+ <Reasoning>
56
+ <ReasoningTrigger />
57
+ <ReasoningContent>{m.reasoning}</ReasoningContent>
58
+ </Reasoning>
59
+ ) : null}
60
+ <MessageResponse>{m.text}</MessageResponse>
61
+ {m.role === "assistant" ? (
62
+ <Sources>
63
+ <SourcesTrigger count={2} />
64
+ <SourcesContent>
65
+ <Source href="https://example.com" title="Deploy log — wk 23" />
66
+ <Source href="https://example.com" title="Billing runbook" />
67
+ </SourcesContent>
68
+ </Sources>
69
+ ) : null}
70
+ </MessageContent>
71
+ </Message>
72
+ ))}
73
+ </ConversationContent>
74
+ <ConversationScrollButton />
75
+ </Conversation>
76
+
77
+ <Composer
78
+ className="m-3 mt-0"
79
+ placeholder="Ask about deploys…"
80
+ status={status === "submitted" ? "Generating…" : "Awaiting your input"}
81
+ sendStatus={status}
82
+ onSubmit={(message) => {
83
+ const text = message.text?.trim();
84
+ if (text) send(text);
85
+ }}
86
+ />
87
+ </div>
88
+ );
89
+ }
90
+
91
+ const meta = {
92
+ title: "AI/Chat",
93
+ component: ChatExample,
94
+ parameters: { layout: "fullscreen" },
95
+ } satisfies Meta<typeof ChatExample>;
96
+ export default meta;
97
+ type Story = StoryObj<typeof meta>;
98
+
99
+ export const Default: Story = {};
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { Button } from "@elabs-ai/components-ui";
4
+ import { Separator } from "@elabs-ai/components-ui";
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@elabs-ai/components-ui";
6
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
7
+ import type { LucideProps } from "lucide-react";
8
+ import { BookmarkIcon } from "lucide-react";
9
+ import type { ComponentProps, HTMLAttributes } from "react";
10
+
11
+ export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
12
+
13
+ export const Checkpoint = ({ className, children, ...props }: CheckpointProps) => (
14
+ <div
15
+ className={cn("flex items-center gap-0.5 overflow-hidden text-muted-foreground", className)}
16
+ {...props}
17
+ >
18
+ {children}
19
+ <Separator />
20
+ </div>
21
+ );
22
+
23
+ export type CheckpointIconProps = LucideProps;
24
+
25
+ export const CheckpointIcon = ({ className, children, ...props }: CheckpointIconProps) =>
26
+ children ?? <BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />;
27
+
28
+ export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
29
+ tooltip?: string;
30
+ };
31
+
32
+ export const CheckpointTrigger = ({
33
+ children,
34
+ variant = "ghost",
35
+ size = "sm",
36
+ tooltip,
37
+ ...props
38
+ }: CheckpointTriggerProps) =>
39
+ tooltip ? (
40
+ <Tooltip>
41
+ <TooltipTrigger asChild>
42
+ <Button size={size} type="button" variant={variant} {...props}>
43
+ {children}
44
+ </Button>
45
+ </TooltipTrigger>
46
+ <TooltipContent align="start" side="bottom">
47
+ {tooltip}
48
+ </TooltipContent>
49
+ </Tooltip>
50
+ ) : (
51
+ <Button size={size} type="button" variant={variant} {...props}>
52
+ {children}
53
+ </Button>
54
+ );
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CodeBlock } from "./code-block";
3
+ const meta = {
4
+ title: "AI/CodeBlock",
5
+ component: CodeBlock,
6
+ parameters: { layout: "padded" },
7
+ tags: ["autodocs"],
8
+ } satisfies Meta<typeof CodeBlock>;
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+ // A representative mix of comment/keyword/type/string/number/function scopes
12
+ // (#315) — so the derived-from-tokens Shiki theme is visually checkable across
13
+ // both themes (light, dark) via the toolbar's global
14
+ // theme switcher, not just a single flat color.
15
+ const SAMPLE_CODE = [
16
+ "// Adds two numbers together",
17
+ "export function add(a: number, b: number): number {",
18
+ ' const label = "sum";',
19
+ " return a + b; // 42",
20
+ "}",
21
+ ].join("\n");
22
+
23
+ export const Default: Story = {
24
+ render: () => (
25
+ <div className="max-w-lg">
26
+ <CodeBlock code={SAMPLE_CODE} language="tsx" />
27
+ </div>
28
+ ),
29
+ };
30
+
31
+ // Dark theme — verifies the derived-from-tokens Shiki theme in dark
32
+ // (#315). Also proves the region-scoped fix: a `<div data-theme="dark">`
33
+ // decorator (the same pattern as `message-table.stories.tsx`'s `DarkTheme`)
34
+ // resolves ITS OWN theme rather than always the document root's.
35
+ export const DarkTheme: Story = {
36
+ render: () => (
37
+ <div className="max-w-lg">
38
+ <CodeBlock code={SAMPLE_CODE} language="tsx" />
39
+ </div>
40
+ ),
41
+ decorators: [
42
+ (Story) => (
43
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
44
+ <Story />
45
+ </div>
46
+ ),
47
+ ],
48
+ };
49
+
50
+ // Markup (JSX tags) — exercises the `--code-tag` role (#315 follow-up). A
51
+ // valid <div>/<span> tag must read as its OWN color, never the same as
52
+ // destructive/warning text — the collision a prior revision had (`--code-tag`
53
+ // aliased to `--calc-warning`, so a normal tag looked identical to an error).
54
+ const MARKUP_CODE = [
55
+ "export function Card({ label, count }: CardProps) {",
56
+ " return (",
57
+ ' <div className="card" data-count={count}>',
58
+ " <span>{label}</span>",
59
+ " </div>",
60
+ " );",
61
+ "}",
62
+ ].join("\n");
63
+
64
+ export const MarkupTags: Story = {
65
+ render: () => (
66
+ <div className="max-w-lg">
67
+ <CodeBlock code={MARKUP_CODE} language="tsx" />
68
+ </div>
69
+ ),
70
+ };
71
+ // STREAMING — code is arriving incrementally (loading-states.md `isStreaming`).
72
+ // The partial code keeps rendering (build-up, NOT a skeleton — it isn't
73
+ // fetch-then-show); a `Shimmer` "Generating…" cue is the in-progress affordance.
74
+ export const Streaming: Story = {
75
+ name: "isStreaming",
76
+ render: () => (
77
+ <div className="max-w-lg">
78
+ <CodeBlock code={"export function add(a: number, b"} language="tsx" isStreaming />
79
+ </div>
80
+ ),
81
+ };
@@ -0,0 +1,191 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { CSSProperties } from "react";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { render, waitFor } from "@testing-library/react";
7
+ import { CodeBlock } from "./code-block";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+
11
+ afterEach(() => {
12
+ document.documentElement.removeAttribute("data-theme");
13
+ document.documentElement.style.removeProperty("--code-background");
14
+ });
15
+
16
+ describe("CodeBlock", () => {
17
+ // #315 — locks the fix: the highlighter theme must be derived from brand
18
+ // `--code-*` tokens, never Shiki's bundled `github-light`/`github-dark`.
19
+ it("never hardcodes shiki's github-light/github-dark theme (#315)", () => {
20
+ const source = readFileSync(join(__dirname, "code-block.tsx"), "utf8");
21
+ expect(source).not.toMatch(/github-light|github-dark/);
22
+ });
23
+
24
+ it("resolves the highlighted <pre> background from the active theme's --code-background token (#315)", async () => {
25
+ document.documentElement.setAttribute("data-theme", "light");
26
+ document.documentElement.style.setProperty("--code-background", "oklch(1 0 0)");
27
+
28
+ const { container } = render(<CodeBlock code="const a = 1;" language="tsx" />);
29
+
30
+ await waitFor(() => {
31
+ const pre = container.querySelector("pre");
32
+ expect(pre?.style.backgroundColor).toBe("rgb(255, 255, 255)");
33
+ });
34
+ });
35
+
36
+ // #315 follow-up — a CodeBlock nested inside a region-scoped
37
+ // `<div data-theme="…">` (a supported ThemeProvider/decorator pattern, see
38
+ // @.claude/rules/theming.md and 6 other @…-ai story files) must resolve
39
+ // THAT region's `--code-*` tokens, not always the document root's.
40
+ it("resolves --code-* tokens from a region-scoped data-theme ancestor, not the document root's", async () => {
41
+ document.documentElement.setAttribute("data-theme", "light");
42
+ document.documentElement.style.setProperty("--code-background", "oklch(1 0 0)");
43
+
44
+ const { container } = render(
45
+ <div
46
+ data-theme="dark"
47
+ style={{ "--code-background": "oklch(0.25 0.005 75)" } as CSSProperties}
48
+ >
49
+ <CodeBlock code="const a = 1;" language="tsx" />
50
+ </div>,
51
+ );
52
+
53
+ await waitFor(() => {
54
+ const pre = container.querySelector("pre");
55
+ // rgb(35, 33, 31) is oklch(0.25 0.005 75) converted to sRGB — the
56
+ // SCOPED dark region's color, never the document root's white.
57
+ expect(pre?.style.backgroundColor).toBe("rgb(35, 33, 31)");
58
+ });
59
+ });
60
+
61
+ // #315 regression lock — `highlightCode` returns the tokenized result
62
+ // SYNCHRONOUSLY on a cache hit, WITHOUT invoking the subscribed callback.
63
+ // Once the "tsx" highlighter is warm (e.g. after the FIRST theme's
64
+ // highlight), a theme switch re-tokenizes fast enough to resolve as a cache
65
+ // hit before `CodeBlockContent`'s effect re-subscribes — a component that
66
+ // only reacted to the callback would get stuck showing the un-highlighted
67
+ // raw fallback forever after every theme change after the first.
68
+ it("re-highlights with the new theme's colors after data-theme changes at runtime (#315)", async () => {
69
+ document.documentElement.setAttribute("data-theme", "light");
70
+ document.documentElement.style.setProperty("--code-background", "oklch(1 0 0)");
71
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.38 0.16 264)");
72
+
73
+ const { container } = render(<CodeBlock code="const value = 1;" language="tsx" />);
74
+
75
+ // Initial highlight settles on the light colors.
76
+ await waitFor(() => {
77
+ const pre = container.querySelector("pre");
78
+ expect(pre?.style.backgroundColor).toBe("rgb(255, 255, 255)");
79
+ });
80
+ const spansBefore = container.querySelectorAll("code > span > span");
81
+ expect(spansBefore.length).toBeGreaterThan(1); // real per-token spans, not the 1-span raw fallback
82
+
83
+ // Switch to dark at runtime (ThemeProvider / Storybook's theme
84
+ // decorator both just flip `data-theme`) with a DIFFERENT resolved value.
85
+ document.documentElement.setAttribute("data-theme", "dark");
86
+ document.documentElement.style.setProperty("--code-background", "oklch(0.25 0.005 75)");
87
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.7 0.16 264)");
88
+
89
+ await waitFor(() => {
90
+ const pre = container.querySelector("pre");
91
+ // rgb(35, 33, 31) is oklch(0.25 0.005 75) converted to sRGB.
92
+ expect(pre?.style.backgroundColor).toBe("rgb(35, 33, 31)");
93
+ });
94
+
95
+ // The re-highlight actually re-tokenized (still real per-token spans, not
96
+ // stuck on the single-span raw fallback the bug would strand it on).
97
+ const spansAfter = container.querySelectorAll("code > span > span");
98
+ expect(spansAfter.length).toBeGreaterThan(1);
99
+ });
100
+
101
+ // #315 regression lock — the blocker: narrowing the attribute to a validated
102
+ // theme name collapses "no `data-theme` attribute yet" (the
103
+ // pre-`ThemeProvider`-mount render) and an EXPLICIT `data-theme="light"` into
104
+ // the SAME value (both fall back to `DEFAULT_THEME`). `:root`'s `--code-*`
105
+ // fallback values are their own
106
+ // distinct placeholder palette, not a copy of `light`'s — so if the
107
+ // highlight cache were keyed on the validated name, a code block that first
108
+ // tokenizes before `data-theme` is set would cache under the SAME key
109
+ // `ThemeProvider` later writes explicitly, and the (correct) light
110
+ // colors would never take effect. This locks the fix: the cache is keyed on
111
+ // the RAW attribute, so the mutation is a genuine re-tokenize.
112
+ it("re-tokenizes when data-theme transitions from unset to an explicit value resolving to the same theme name (#315)", async () => {
113
+ // No data-theme attribute set — the pre-mount state. Simulates :root's
114
+ // fallback --code-background differing from light's own. Uses a code
115
+ // string unique to this test — the highlight cache is module-level and
116
+ // keyed (in part) on the code content, so reusing another test's snippet
117
+ // here would collide with an ALREADY-cached "light" entry from that
118
+ // other test and mask this exact regression.
119
+ document.documentElement.style.setProperty("--code-background", "oklch(0.5 0 0)");
120
+
121
+ const { container } = render(
122
+ <CodeBlock code="const scopeKeyRegressionMarker = 315;" language="tsx" />,
123
+ );
124
+
125
+ await waitFor(() => {
126
+ const pre = container.querySelector("pre");
127
+ expect(pre?.style.backgroundColor).toBe("rgb(99, 99, 99)");
128
+ });
129
+
130
+ // ThemeProvider mounts and writes data-theme="light" explicitly, with
131
+ // a DIFFERENT --code-background than the unset-attribute render resolved.
132
+ document.documentElement.setAttribute("data-theme", "light");
133
+ document.documentElement.style.setProperty("--code-background", "oklch(0.1 0 0)");
134
+
135
+ await waitFor(() => {
136
+ const pre = container.querySelector("pre");
137
+ // Must pick up the NEW color — never stuck on the unset-attribute cache entry.
138
+ expect(pre?.style.backgroundColor).toBe("rgb(3, 3, 3)");
139
+ });
140
+ });
141
+
142
+ it("renders the code content", () => {
143
+ const { container } = render(<CodeBlock code="const a = 1;" language="tsx" />);
144
+ expect(container.textContent).toContain("const a = 1;");
145
+ });
146
+
147
+ it("soft-wraps long lines when `wrap` is set (#5)", () => {
148
+ const { container } = render(
149
+ <CodeBlock code="a very long single line of code" language="tsx" wrap />,
150
+ );
151
+ const pre = container.querySelector("pre");
152
+ expect(pre?.className).toContain("whitespace-pre-wrap");
153
+ expect(pre?.className).toContain("break-words");
154
+ // No horizontal-scroll affordance when wrapping (there's nothing to scroll).
155
+ const scroller = container.querySelector(".overflow-auto");
156
+ expect(scroller?.className ?? "").not.toContain("scrollbar-width:thin");
157
+ });
158
+
159
+ it("does not wrap by default, and shows a scroll affordance (#5)", () => {
160
+ const { container } = render(
161
+ <CodeBlock code="a very long single line of code" language="tsx" />,
162
+ );
163
+ const pre = container.querySelector("pre");
164
+ expect(pre?.className).not.toContain("whitespace-pre-wrap");
165
+ // Default mode keeps horizontal scroll with a discoverable thin scrollbar.
166
+ const scroller = container.querySelector(".overflow-auto");
167
+ expect(scroller).not.toBeNull();
168
+ expect(scroller?.className).toContain("scrollbar-width:thin");
169
+ });
170
+ });
171
+
172
+ describe("CodeBlock isStreaming (#269, loading-states.md)", () => {
173
+ it("keeps rendering the partial code (build-up), not a skeleton", () => {
174
+ const { container } = render(<CodeBlock code="const partial = " language="tsx" isStreaming />);
175
+ expect(container.textContent).toContain("const partial =");
176
+ expect(container.querySelector(".animate-pulse")).toBeNull();
177
+ });
178
+
179
+ it("shows exactly one in-progress live region while streaming", () => {
180
+ const { container } = render(<CodeBlock code="const a = 1;" language="tsx" isStreaming />);
181
+ const statuses = container.querySelectorAll('[role="status"]');
182
+ expect(statuses).toHaveLength(1);
183
+ expect(statuses[0]).toHaveAttribute("aria-live", "polite");
184
+ expect(statuses[0]).toHaveTextContent("Generating…");
185
+ });
186
+
187
+ it("shows no in-progress cue when not streaming", () => {
188
+ const { container } = render(<CodeBlock code="const a = 1;" language="tsx" />);
189
+ expect(container.querySelector('[role="status"]')).toBeNull();
190
+ });
191
+ });