@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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- 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
|
+
}
|