@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
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@elabs-ai/components-ai",
3
+ "version": "4.0.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mreimitz/elabs-components.git",
8
+ "directory": "packages/ai"
9
+ },
10
+ "type": "module",
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "dependencies": {
27
+ "@radix-ui/react-use-controllable-state": "^1.2.2",
28
+ "@rive-app/react-webgl2": "^4.26.1",
29
+ "@streamdown/cjk": "^1.0.2",
30
+ "@streamdown/code": "^1.1.0",
31
+ "@streamdown/math": "^1.0.2",
32
+ "@streamdown/mermaid": "^1.0.2",
33
+ "mermaid": "^11.12.2",
34
+ "@xterm/addon-fit": "^0.11.0",
35
+ "@xterm/xterm": "^6.0.0",
36
+ "ansi-to-react": "^6.2.6",
37
+ "class-variance-authority": "^0.7.1",
38
+ "lucide-react": "^0.577.0",
39
+ "media-chrome": "^4.17.2",
40
+ "motion": "^12.26.2",
41
+ "nanoid": "^5.1.6",
42
+ "react-jsx-parser": "^2.2.0",
43
+ "shiki": "^3.22.0",
44
+ "streamdown": "^2.4.0",
45
+ "tokenlens": "^1.3.1",
46
+ "use-stick-to-bottom": "^1.1.3",
47
+ "zod": "^3.24.1"
48
+ },
49
+ "peerDependencies": {
50
+ "@xyflow/react": "^12.11.1",
51
+ "ai": "^6.0.0",
52
+ "react": "^18.2.0 || ^19.0.0",
53
+ "react-dom": "^18.2.0 || ^19.0.0",
54
+ "@elabs-ai/components-tokens": "4.0.0",
55
+ "@elabs-ai/components-ui": "4.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@testing-library/jest-dom": "^6.6.3",
59
+ "@testing-library/react": "^16.1.0",
60
+ "@testing-library/user-event": "^14.5.2",
61
+ "@types/react": "^19.0.2",
62
+ "@types/react-dom": "^19.0.2",
63
+ "@vitejs/plugin-react": "^4.3.4",
64
+ "@xyflow/react": "^12.11.1",
65
+ "ai": "^6.0.0",
66
+ "eslint": "^9.17.0",
67
+ "jsdom": "^25.0.1",
68
+ "react": "^19.0.0",
69
+ "react-dom": "^19.0.0",
70
+ "tsup": "^8.3.5",
71
+ "typescript": "^5.7.3",
72
+ "vitest": "^3.0.2",
73
+ "@elabs-ai/components-charts": "4.0.0",
74
+ "@elabs-ai/components-eslint-config": "0.1.0",
75
+ "@elabs-ai/components-icons": "4.0.0",
76
+ "@elabs-ai/components-typescript-config": "0.1.0",
77
+ "@elabs-ai/components-tokens": "4.0.0",
78
+ "@elabs-ai/components-ui": "4.0.0"
79
+ },
80
+ "scripts": {
81
+ "build": "tsup",
82
+ "typecheck": "tsc --noEmit",
83
+ "lint": "eslint .",
84
+ "test": "vitest run",
85
+ "clean": "rm -rf dist .turbo coverage"
86
+ }
87
+ }
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The media-chrome half of `AudioPlayer`, split out so it can be `lazy()`-loaded.
5
+ *
6
+ * `media-chrome` declares no `sideEffects`, so a static import from
7
+ * `audio-player.tsx` put the whole custom-element library (and the
8
+ * `media-chrome/react` wrappers that register it) into the entry chunk of every
9
+ * `@elabs-ai/components-ai` consumer — including the vast majority that
10
+ * never render an `AudioPlayer`. Keeping every media-chrome *value* import in
11
+ * this module, reached only through
12
+ * `lazy(() => import("./_audio-player-media-chrome"))`, confines it to its own
13
+ * chunk. `audio-player.tsx` still owns the public prop types and imports the
14
+ * media-chrome element types with `import type` (types erase).
15
+ *
16
+ * See ADR 0019 and `pnpm heavy-deps:check`.
17
+ *
18
+ * @lazy-boundary This module must only ever be reached via `import()`. The gate
19
+ * fails if anything imports it statically, which would put media-chrome back in
20
+ * the entry chunk and make the `lazy()` pointless.
21
+ */
22
+ import { Button, ButtonGroup, ButtonGroupText } from "@elabs-ai/components-ui";
23
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
24
+ import {
25
+ MediaControlBar,
26
+ MediaController,
27
+ MediaDurationDisplay,
28
+ MediaMuteButton,
29
+ MediaPlayButton,
30
+ MediaSeekBackwardButton,
31
+ MediaSeekForwardButton,
32
+ MediaTimeDisplay,
33
+ MediaTimeRange,
34
+ MediaVolumeRange,
35
+ } from "media-chrome/react";
36
+ import type { CSSProperties } from "react";
37
+
38
+ import type {
39
+ AudioPlayerControlBarProps,
40
+ AudioPlayerDurationDisplayProps,
41
+ AudioPlayerMuteButtonProps,
42
+ AudioPlayerPlayButtonProps,
43
+ AudioPlayerProps,
44
+ AudioPlayerSeekBackwardButtonProps,
45
+ AudioPlayerSeekForwardButtonProps,
46
+ AudioPlayerTimeDisplayProps,
47
+ AudioPlayerTimeRangeProps,
48
+ AudioPlayerVolumeRangeProps,
49
+ } from "./audio-player";
50
+
51
+ export const AudioPlayer = ({ children, style, ...props }: AudioPlayerProps) => (
52
+ <MediaController
53
+ audio
54
+ style={
55
+ {
56
+ "--media-background-color": "transparent",
57
+ "--media-button-icon-height": "1rem",
58
+ "--media-button-icon-width": "1rem",
59
+ "--media-control-background": "transparent",
60
+ "--media-control-hover-background": "var(--color-accent)",
61
+ "--media-control-padding": "0",
62
+ "--media-font": "var(--font-sans)",
63
+ "--media-font-size": "10px",
64
+ "--media-icon-color": "currentColor",
65
+ "--media-preview-time-background": "var(--color-background)",
66
+ "--media-preview-time-border-radius": "var(--radius-md)",
67
+ "--media-preview-time-text-shadow": "none",
68
+ "--media-primary-color": "var(--color-primary)",
69
+ "--media-range-bar-color": "var(--color-primary)",
70
+ "--media-range-track-background": "var(--color-secondary)",
71
+ "--media-secondary-color": "var(--color-secondary)",
72
+ "--media-text-color": "var(--color-foreground)",
73
+ "--media-tooltip-arrow-display": "none",
74
+ "--media-tooltip-background": "var(--color-background)",
75
+ "--media-tooltip-border-radius": "var(--radius-md)",
76
+ ...style,
77
+ } as CSSProperties
78
+ }
79
+ {...props}
80
+ >
81
+ {children}
82
+ </MediaController>
83
+ );
84
+
85
+ export const AudioPlayerControlBar = ({ children, ...props }: AudioPlayerControlBarProps) => (
86
+ <MediaControlBar {...props}>
87
+ <ButtonGroup orientation="horizontal">{children}</ButtonGroup>
88
+ </MediaControlBar>
89
+ );
90
+
91
+ export const AudioPlayerPlayButton = ({ className, ...props }: AudioPlayerPlayButtonProps) => (
92
+ <Button asChild size="icon-sm" variant="outline">
93
+ <MediaPlayButton className={cn("bg-transparent", className)} {...props} />
94
+ </Button>
95
+ );
96
+
97
+ export const AudioPlayerSeekBackwardButton = ({
98
+ seekOffset = 10,
99
+ ...props
100
+ }: AudioPlayerSeekBackwardButtonProps) => (
101
+ <Button asChild size="icon-sm" variant="outline">
102
+ <MediaSeekBackwardButton seekOffset={seekOffset} {...props} />
103
+ </Button>
104
+ );
105
+
106
+ export const AudioPlayerSeekForwardButton = ({
107
+ seekOffset = 10,
108
+ ...props
109
+ }: AudioPlayerSeekForwardButtonProps) => (
110
+ <Button asChild size="icon-sm" variant="outline">
111
+ <MediaSeekForwardButton seekOffset={seekOffset} {...props} />
112
+ </Button>
113
+ );
114
+
115
+ export const AudioPlayerTimeDisplay = ({ className, ...props }: AudioPlayerTimeDisplayProps) => (
116
+ <ButtonGroupText asChild className="bg-transparent">
117
+ <MediaTimeDisplay className={cn("tabular-nums", className)} {...props} />
118
+ </ButtonGroupText>
119
+ );
120
+
121
+ export const AudioPlayerTimeRange = ({ className, ...props }: AudioPlayerTimeRangeProps) => (
122
+ <ButtonGroupText asChild className="bg-transparent">
123
+ <MediaTimeRange className={cn("", className)} {...props} />
124
+ </ButtonGroupText>
125
+ );
126
+
127
+ export const AudioPlayerDurationDisplay = ({
128
+ className,
129
+ ...props
130
+ }: AudioPlayerDurationDisplayProps) => (
131
+ <ButtonGroupText asChild className="bg-transparent">
132
+ <MediaDurationDisplay className={cn("tabular-nums", className)} {...props} />
133
+ </ButtonGroupText>
134
+ );
135
+
136
+ export const AudioPlayerMuteButton = ({ className, ...props }: AudioPlayerMuteButtonProps) => (
137
+ <ButtonGroupText asChild className="bg-transparent">
138
+ <MediaMuteButton className={cn("", className)} {...props} />
139
+ </ButtonGroupText>
140
+ );
141
+
142
+ export const AudioPlayerVolumeRange = ({ className, ...props }: AudioPlayerVolumeRangeProps) => (
143
+ <ButtonGroupText asChild className="bg-transparent">
144
+ <MediaVolumeRange className={cn("", className)} {...props} />
145
+ </ButtonGroupText>
146
+ );
@@ -0,0 +1,18 @@
1
+ import { FileText, Sparkles } from "lucide-react";
2
+
3
+ /** Sample context rail for the Chat Shell story. */
4
+ export function ContextPanelRail() {
5
+ return (
6
+ <div className="flex h-full flex-col bg-surface">
7
+ <div className="flex h-12 items-center border-b px-4 text-sm font-semibold">Context</div>
8
+ <div className="space-y-2 p-3 text-sm">
9
+ <div className="flex items-center gap-2 rounded-md border bg-card p-2">
10
+ <FileText className="size-4 text-muted-foreground" /> Deploy log — wk 23
11
+ </div>
12
+ <div className="flex items-center gap-2 rounded-md border bg-card p-2">
13
+ <Sparkles className="size-4 text-muted-foreground" /> Billing runbook
14
+ </div>
15
+ </div>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+
3
+ import { buildCodeBlockTheme, codeBlockThemeId, getThemeScopeKey } from "./_code-block-theme";
4
+
5
+ afterEach(() => {
6
+ document.documentElement.removeAttribute("data-theme");
7
+ document.documentElement.removeAttribute("style");
8
+ });
9
+
10
+ // #315 blocker fix — the highlight-cache scoping key must NOT collapse "no
11
+ // data-theme attribute" and an explicit "data-theme=light" into the same value,
12
+ // or the pre-ThemeProvider-mount render's `:root`-tokenized colors would poison
13
+ // the cache under the key ThemeProvider later writes explicitly.
14
+ describe("getThemeScopeKey", () => {
15
+ it("returns a distinct sentinel when no data-theme attribute is set", () => {
16
+ expect(getThemeScopeKey()).toBe("__root__");
17
+ });
18
+
19
+ it("returns the RAW attribute value when data-theme is set", () => {
20
+ document.documentElement.setAttribute("data-theme", "light");
21
+ expect(getThemeScopeKey()).toBe("light");
22
+ });
23
+
24
+ it("differs between unset and explicit light (the exact collision the fix closes)", () => {
25
+ const unset = getThemeScopeKey();
26
+ document.documentElement.setAttribute("data-theme", "light");
27
+ expect(unset).not.toBe(getThemeScopeKey());
28
+ });
29
+
30
+ // ADR 0029 — theme names are open, so an unrecognized name is a CONSUMER
31
+ // theme, not an error. It must get its own cache bucket (it has its own
32
+ // `--code-*` values); the pre-0029 code narrowed it away to the default,
33
+ // which would have served a consumer theme the light theme's tokens.
34
+ it("gives an unregistered (consumer) theme its own bucket", () => {
35
+ document.documentElement.setAttribute("data-theme", "midnight");
36
+ expect(getThemeScopeKey()).toBe("midnight");
37
+ expect(codeBlockThemeId(getThemeScopeKey())).toBe("brand-code-midnight");
38
+ });
39
+ });
40
+
41
+ describe("codeBlockThemeId", () => {
42
+ it("namespaces the theme name so it can't collide with a bundled Shiki theme", () => {
43
+ expect(codeBlockThemeId("light")).toBe("brand-code-light");
44
+ expect(codeBlockThemeId("dark")).toBe("brand-code-dark");
45
+ });
46
+ });
47
+
48
+ describe("buildCodeBlockTheme", () => {
49
+ it("derives colors from the active theme's --code-* custom properties, not a github-* literal", () => {
50
+ document.documentElement.setAttribute("data-theme", "light");
51
+ document.documentElement.style.setProperty("--code-background", "oklch(1 0 0)");
52
+ document.documentElement.style.setProperty("--code-foreground", "oklch(0.21 0.02 264)");
53
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.38 0.16 264)");
54
+ document.documentElement.style.setProperty("--code-string", "oklch(0.42 0.14 150)");
55
+
56
+ const theme = buildCodeBlockTheme();
57
+
58
+ expect(theme.name).toBe("brand-code-light");
59
+ expect(theme.bg).toBe("#ffffff");
60
+ expect(theme.fg).toMatch(/^#[0-9a-f]{6}$/);
61
+ // Never the hardcoded third-party palette (#315).
62
+ expect(JSON.stringify(theme)).not.toMatch(/github/i);
63
+
64
+ const keywordRule = theme.settings?.find((s) => {
65
+ const scope = s.scope;
66
+ return Array.isArray(scope) ? scope.includes("keyword") : scope === "keyword";
67
+ });
68
+ expect(keywordRule?.settings.foreground).toMatch(/^#[0-9a-f]{6}$/);
69
+ expect(keywordRule?.settings.foreground).not.toBe(theme.fg);
70
+ });
71
+
72
+ // #315 blocker fix — `theme.name` (the highlight-cache key) must differ
73
+ // between "no data-theme attribute" and an explicit "light", even
74
+ // though both resolve to the same light/dark flag.
75
+ it("gives the unset-attribute render a distinct theme name from explicit light", () => {
76
+ const unset = buildCodeBlockTheme();
77
+ expect(unset.name).toBe("brand-code-__root__");
78
+
79
+ document.documentElement.setAttribute("data-theme", "light");
80
+ const explicitBright = buildCodeBlockTheme();
81
+ expect(explicitBright.name).toBe("brand-code-light");
82
+ expect(explicitBright.name).not.toBe(unset.name);
83
+
84
+ // Both are still "light" — the collision was in the CACHE KEY, not the flag.
85
+ expect(unset.type).toBe("light");
86
+ expect(explicitBright.type).toBe("light");
87
+ });
88
+
89
+ it("marks dark as a dark Shiki theme (color-scheme-driven, not guessed)", () => {
90
+ document.documentElement.setAttribute("data-theme", "dark");
91
+ const theme = buildCodeBlockTheme();
92
+ expect(theme.type).toBe("dark");
93
+ });
94
+
95
+ it("marks light as a light Shiki theme", () => {
96
+ document.documentElement.setAttribute("data-theme", "light");
97
+ const theme = buildCodeBlockTheme();
98
+ expect(theme.type).toBe("light");
99
+ });
100
+
101
+ it("re-derives different colors when the active theme changes", () => {
102
+ document.documentElement.setAttribute("data-theme", "light");
103
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.38 0.16 264)");
104
+ const bright = buildCodeBlockTheme();
105
+
106
+ document.documentElement.setAttribute("data-theme", "dark");
107
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.7 0.16 264)");
108
+ const dark = buildCodeBlockTheme();
109
+
110
+ const keywordOf = (theme: typeof bright) =>
111
+ theme.settings?.find(
112
+ (s) => s.scope === "keyword" || (s.scope as string[])?.includes?.("keyword"),
113
+ )?.settings.foreground;
114
+
115
+ expect(keywordOf(bright)).not.toBe(keywordOf(dark));
116
+ });
117
+
118
+ // #315 follow-up — the theme id/type and the resolved colors must ALWAYS
119
+ // come from the SAME element, so a region-scoped `data-theme` (a supported
120
+ // ThemeProvider/decorator pattern, see @.claude/rules/theming.md) resolves
121
+ // ITS OWN theme, never the document root's, and the two can never disagree.
122
+ it("resolves a region-scoped data-theme (not the document root's) when passed that region's element", () => {
123
+ document.documentElement.setAttribute("data-theme", "light");
124
+ const region = document.createElement("div");
125
+ region.setAttribute("data-theme", "dark");
126
+ document.body.appendChild(region);
127
+
128
+ const theme = buildCodeBlockTheme(region);
129
+ expect(theme.name).toBe("brand-code-dark");
130
+ expect(theme.type).toBe("dark"); // the region's theme, not light's "light"
131
+
132
+ document.body.removeChild(region);
133
+ });
134
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * _code-block-theme — derives a Shiki theme from brand-ui's `--code-*` tokens
3
+ * instead of a hardcoded `github-light`/`github-dark` literal (issue #315).
4
+ *
5
+ * Shiki can't read CSS custom properties at tokenize time, so — the same
6
+ * "wrap an engine, theme it from tokens" pattern as `@elabs-ai/components-editor`'s
7
+ * Monaco bridge (`monaco-theme-bridge.ts`), `@elabs-ai/components-maps`' `useTokenColor`,
8
+ * and this package's own `buildInteractiveTerminalTheme` — this module reads the
9
+ * ACTIVE theme's resolved token colors at call time via `resolveTokenColor`
10
+ * (`@elabs-ai/components-tokens`, ADR 0015: oklch → hex, no canvas needed since every
11
+ * `--code-*` token is authored as `oklch()`) and builds a single Shiki
12
+ * `ThemeRegistrationRaw` that matches whatever brand theme is currently active
13
+ * — including a low-chroma monochrome ink palette, never a recolored GitHub
14
+ * theme. Callers re-derive (and re-highlight) whenever `data-theme` changes;
15
+ * see `code-block.tsx`'s `data-theme` MutationObserver.
16
+ *
17
+ * `buildCodeBlockTheme` takes a single `el` (default `<html>`) and derives BOTH
18
+ * the theme id/type AND the resolved colors from that SAME element — never a
19
+ * separately-requested theme name. A CodeBlock nested inside a region-scoped
20
+ * `<div data-theme="dark">` (a supported `ThemeProvider`/decorator pattern,
21
+ * see @.claude/rules/theming.md) resolves ITS region's theme this way: pass a
22
+ * descendant of that region as `el` and the id/type/colors all agree by
23
+ * construction (there is no "themeName" parameter that could disagree with what
24
+ * `el` actually renders).
25
+ *
26
+ * The highlight-cache key (`codeBlockThemeId`, consumed by `code-block.tsx`'s
27
+ * `getTokensCacheKey`) is keyed on `getThemeScopeKey` — the RAW `data-theme`
28
+ * attribute — and must NOT be collapsed to a validated/normalized theme name.
29
+ * This matters: "no `data-theme` attribute yet" (the first render, before
30
+ * `ThemeProvider` mounts and writes the attribute) and an EXPLICIT
31
+ * `data-theme="light"` are DIFFERENT palettes, because `:root`'s fallback
32
+ * `--code-*` values are intentionally their OWN neutral placeholder theme, not
33
+ * a byte-identical copy of `[data-theme="light"]`'s (see `themes.css`'s
34
+ * "`:root` holds the DEFAULT (light) theme" header — a deliberately distinct
35
+ * palette, not an alias). If the cache key normalized "unset" to "light", a
36
+ * code block that first tokenizes before `ThemeProvider` mounts would cache
37
+ * `:root`'s colors under the SAME key `ThemeProvider` later writes explicitly
38
+ * — so the (correct) `light` colors would never take effect; the cache hit
39
+ * would return the stale `:root` colors forever (#315 follow-up). Keying on the
40
+ * raw attribute gives "no attribute" and "light" distinct cache entries, so the
41
+ * mutation from one to the other is a genuine cache miss and re-tokenizes.
42
+ *
43
+ * ADR 0029 removed the `getActiveThemeName` helper this file used to export.
44
+ * Theme names are open now, so "narrow the attribute to a known theme" is not a
45
+ * meaningful operation — a consumer theme is as real as a shipped one. The two
46
+ * things it was used for are both answered off the element directly:
47
+ * `resolveThemeIsDark(el)` for the light/dark `type`, and `resolveTokenColor`
48
+ * for the colors.
49
+ */
50
+
51
+ import { resolveThemeIsDark, resolveTokenColor } from "@elabs-ai/components-tokens";
52
+ import type { ThemeRegistrationRaw } from "shiki";
53
+
54
+ const FALLBACK_BACKGROUND = "#ffffff";
55
+ const FALLBACK_FOREGROUND = "#111111";
56
+ /** Cache-key sentinel for "no `data-theme` attribute present" — see `getThemeScopeKey`. */
57
+ const ROOT_SCOPE_KEY = "__root__";
58
+
59
+ /**
60
+ * Reads the RAW `data-theme` attribute off `el` (default `<html>`) — or the
61
+ * `"__root__"` sentinel when the attribute is absent. It deliberately does NOT
62
+ * collapse "unset" and "explicit light" into one value (see the module comment),
63
+ * and it never validates the name against a registry: a consumer theme gets its
64
+ * own cache bucket, which is exactly right — it has its own `--code-*` values.
65
+ */
66
+ export function getThemeScopeKey(el?: Element | null): string {
67
+ const root = el ?? (typeof document !== "undefined" ? document.documentElement : null);
68
+ return root?.getAttribute("data-theme") || ROOT_SCOPE_KEY;
69
+ }
70
+
71
+ /** Shiki theme `name` for a given theme-scope key — also the highlight-cache key. */
72
+ export function codeBlockThemeId(scopeKey: string): string {
73
+ return `brand-code-${scopeKey}`;
74
+ }
75
+
76
+ /**
77
+ * Builds a Shiki `ThemeRegistrationRaw` from the resolved `--code-*` tokens of
78
+ * whichever brand theme is active on `el` (default `<html>`). The theme id, its
79
+ * light/dark `type` and its colors are ALL derived from THIS SAME element, so
80
+ * they can never disagree. `type` comes from `resolveThemeIsDark` (the theme's
81
+ * own `color-scheme`) rather than a registry lookup, so a consumer-authored
82
+ * theme gets the right Shiki type without registering here (ADR 0029). Scopes
83
+ * follow the common TextMate categories Shiki's bundled grammars emit across
84
+ * languages.
85
+ */
86
+ export function buildCodeBlockTheme(el?: Element | null): ThemeRegistrationRaw {
87
+ const root = el ?? (typeof document !== "undefined" ? document.documentElement : null);
88
+ const read = (name: string, fallback: string) => resolveTokenColor(name, { el: root, fallback });
89
+
90
+ const background = read("--code-background", FALLBACK_BACKGROUND);
91
+ const foreground = read("--code-foreground", FALLBACK_FOREGROUND);
92
+ const comment = read("--code-comment", foreground);
93
+ const string_ = read("--code-string", foreground);
94
+ const number = read("--code-number", foreground);
95
+ const constant = read("--code-constant", foreground);
96
+ const keyword = read("--code-keyword", foreground);
97
+ const func = read("--code-function", foreground);
98
+ const type = read("--code-type", foreground);
99
+ const tag = read("--code-tag", foreground);
100
+ const property = read("--code-property", foreground);
101
+
102
+ return {
103
+ // Keyed on the RAW scope, not the validated name — see the module doc
104
+ // comment + `getThemeScopeKey` for why "unset" and "light" must get
105
+ // different cache buckets.
106
+ name: codeBlockThemeId(getThemeScopeKey(root)),
107
+ type: resolveThemeIsDark(root) ? "dark" : "light",
108
+ bg: background,
109
+ fg: foreground,
110
+ colors: {
111
+ "editor.background": background,
112
+ "editor.foreground": foreground,
113
+ },
114
+ settings: [
115
+ { settings: { foreground } },
116
+ {
117
+ scope: ["comment", "punctuation.definition.comment"],
118
+ settings: { foreground: comment, fontStyle: "italic" },
119
+ },
120
+ {
121
+ scope: ["string", "string.quoted", "string.template", "punctuation.definition.string"],
122
+ settings: { foreground: string_ },
123
+ },
124
+ {
125
+ scope: ["constant.numeric", "constant.character", "constant.character.escape"],
126
+ settings: { foreground: number },
127
+ },
128
+ {
129
+ scope: ["constant.language", "constant.other", "support.constant"],
130
+ settings: { foreground: constant },
131
+ },
132
+ {
133
+ scope: [
134
+ "keyword",
135
+ "keyword.control",
136
+ "keyword.operator",
137
+ "storage.type",
138
+ "storage.modifier",
139
+ ],
140
+ settings: { foreground: keyword },
141
+ },
142
+ {
143
+ scope: [
144
+ "entity.name.function",
145
+ "support.function",
146
+ "meta.function-call",
147
+ "variable.function",
148
+ ],
149
+ settings: { foreground: func },
150
+ },
151
+ {
152
+ scope: [
153
+ "entity.name.type",
154
+ "entity.name.class",
155
+ "support.type",
156
+ "support.class",
157
+ "entity.other.inherited-class",
158
+ ],
159
+ settings: { foreground: type },
160
+ },
161
+ {
162
+ scope: ["entity.name.tag", "punctuation.definition.tag"],
163
+ settings: { foreground: tag },
164
+ },
165
+ {
166
+ scope: [
167
+ "entity.other.attribute-name",
168
+ "variable.other.property",
169
+ "meta.object-literal.key",
170
+ "support.type.property-name",
171
+ ],
172
+ settings: { foreground: property },
173
+ },
174
+ // Deliberately NO `invalid`/`invalid.illegal` rule here (#315 follow-up):
175
+ // it used to alias to the SAME color as `tag`, so a perfectly valid
176
+ // <div>/JSX tag — the most common markup element in the languages this
177
+ // component renders — was indistinguishable from genuinely broken syntax.
178
+ // Leaving the scope unmapped falls through to the base `{ settings:
179
+ // { foreground } }` rule above (Shiki/TextMate cascade), same as most
180
+ // grammars' own default. If a dedicated "this token is invalid" highlight
181
+ // is ever wanted, it needs its OWN `--code-*` token, not a reuse of `tag`.
182
+ ],
183
+ };
184
+ }