@elabs-ai/components-ai 4.0.0 → 4.1.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/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-ai",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,18 +25,12 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
|
28
|
-
"@rive-app/react-webgl2": "^4.26.1",
|
|
29
28
|
"@streamdown/cjk": "^1.0.2",
|
|
30
29
|
"@streamdown/code": "^1.1.0",
|
|
31
30
|
"@streamdown/math": "^1.0.2",
|
|
32
31
|
"@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
32
|
"class-variance-authority": "^0.7.1",
|
|
38
33
|
"lucide-react": "^0.577.0",
|
|
39
|
-
"media-chrome": "^4.17.2",
|
|
40
34
|
"motion": "^12.26.2",
|
|
41
35
|
"nanoid": "^5.1.6",
|
|
42
36
|
"react-jsx-parser": "^2.2.0",
|
|
@@ -47,14 +41,33 @@
|
|
|
47
41
|
"zod": "^3.24.1"
|
|
48
42
|
},
|
|
49
43
|
"peerDependencies": {
|
|
44
|
+
"@rive-app/react-webgl2": "^4.26.1",
|
|
50
45
|
"@xyflow/react": "^12.11.1",
|
|
51
|
-
"ai": "^6.0.0",
|
|
46
|
+
"ai": "^6.0.0 || ^7.0.0",
|
|
47
|
+
"media-chrome": "^4.17.2",
|
|
48
|
+
"mermaid": "^11.12.2",
|
|
52
49
|
"react": "^18.2.0 || ^19.0.0",
|
|
53
50
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
54
|
-
"@elabs-ai/components-
|
|
55
|
-
"@elabs-ai/components-ui": "4.
|
|
51
|
+
"@elabs-ai/components-icons": "4.1.0",
|
|
52
|
+
"@elabs-ai/components-ui": "4.1.0",
|
|
53
|
+
"@elabs-ai/components-tokens": "4.1.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@rive-app/react-webgl2": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"ai": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"media-chrome": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"mermaid": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
56
68
|
},
|
|
57
69
|
"devDependencies": {
|
|
70
|
+
"@rive-app/react-webgl2": "^4.26.1",
|
|
58
71
|
"@testing-library/jest-dom": "^6.6.3",
|
|
59
72
|
"@testing-library/react": "^16.1.0",
|
|
60
73
|
"@testing-library/user-event": "^14.5.2",
|
|
@@ -62,20 +75,22 @@
|
|
|
62
75
|
"@types/react-dom": "^19.0.2",
|
|
63
76
|
"@vitejs/plugin-react": "^4.3.4",
|
|
64
77
|
"@xyflow/react": "^12.11.1",
|
|
65
|
-
"ai": "^
|
|
78
|
+
"ai": "^7.0.0",
|
|
66
79
|
"eslint": "^9.17.0",
|
|
67
80
|
"jsdom": "^25.0.1",
|
|
81
|
+
"media-chrome": "^4.17.2",
|
|
82
|
+
"mermaid": "^11.12.2",
|
|
68
83
|
"react": "^19.0.0",
|
|
69
84
|
"react-dom": "^19.0.0",
|
|
70
85
|
"tsup": "^8.3.5",
|
|
71
86
|
"typescript": "^5.7.3",
|
|
72
87
|
"vitest": "^3.0.2",
|
|
73
|
-
"@elabs-ai/components-charts": "4.
|
|
88
|
+
"@elabs-ai/components-charts": "4.1.0",
|
|
74
89
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
75
|
-
"@elabs-ai/components-icons": "4.
|
|
76
|
-
"@elabs-ai/components-
|
|
77
|
-
"@elabs-ai/components-
|
|
78
|
-
"@elabs-ai/components-
|
|
90
|
+
"@elabs-ai/components-icons": "4.1.0",
|
|
91
|
+
"@elabs-ai/components-tokens": "4.1.0",
|
|
92
|
+
"@elabs-ai/components-ui": "4.1.0",
|
|
93
|
+
"@elabs-ai/components-typescript-config": "0.1.0"
|
|
79
94
|
},
|
|
80
95
|
"scripts": {
|
|
81
96
|
"build": "tsup",
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* never render an `AudioPlayer`. Keeping every media-chrome *value* import in
|
|
11
11
|
* this module, reached only through
|
|
12
12
|
* `lazy(() => import("./_audio-player-media-chrome"))`, confines it to its own
|
|
13
|
-
* chunk. `audio-player.tsx`
|
|
14
|
-
*
|
|
13
|
+
* chunk. `audio-player.tsx` owns the public prop types, and — since issue #101
|
|
14
|
+
* — owns them as OWNED types (`AudioPlayerPartProps` and its ten aliases) that
|
|
15
|
+
* never reference `media-chrome/react`'s own types; this module still imports
|
|
16
|
+
* the REAL media-chrome values (below) and, at the bottom of the file, proves
|
|
17
|
+
* every owned type stays assignable to its real counterpart.
|
|
15
18
|
*
|
|
16
19
|
* See ADR 0019 and `pnpm heavy-deps:check`.
|
|
17
20
|
*
|
|
@@ -21,20 +24,23 @@
|
|
|
21
24
|
*/
|
|
22
25
|
import { Button, ButtonGroup, ButtonGroupText } from "@elabs-ai/components-ui";
|
|
23
26
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
// A NAMED import (`import { MediaController } from "media-chrome/react"`) is a
|
|
28
|
+
// static ESM binding a bundler must resolve at build time. Now that
|
|
29
|
+
// `media-chrome` is a genuinely optional peer (issue #33), a consumer who has
|
|
30
|
+
// not installed it hits that resolution at the worst possible time: Vite's own
|
|
31
|
+
// optional-peer-dependency handling swaps in a build-time stub with no
|
|
32
|
+
// exports, and Rollup's static named-export check then fails the WHOLE APP
|
|
33
|
+
// BUILD — not a runtime error any `.catch()`/error boundary could ever see
|
|
34
|
+
// (confirmed against `fixtures/consumer-smoke`'s real Vite build). A namespace
|
|
35
|
+
// import defers every one of these to a plain property lookup, which Rollup
|
|
36
|
+
// does not statically validate, so the build always succeeds; a genuinely
|
|
37
|
+
// missing peer instead resolves the destructured names below to `undefined`,
|
|
38
|
+
// and the guard turns that into a render-phase throw `LazyEngineBoundary`
|
|
39
|
+
// already catches (see `persona.tsx`'s identical pattern).
|
|
40
|
+
import * as MediaChromeReactModule from "media-chrome/react";
|
|
41
|
+
import type { ComponentProps, CSSProperties } from "react";
|
|
37
42
|
|
|
43
|
+
import type { AssertAssignable } from "./_lazy-boundary-conformance";
|
|
38
44
|
import type {
|
|
39
45
|
AudioPlayerControlBarProps,
|
|
40
46
|
AudioPlayerDurationDisplayProps,
|
|
@@ -48,6 +54,28 @@ import type {
|
|
|
48
54
|
AudioPlayerVolumeRangeProps,
|
|
49
55
|
} from "./audio-player";
|
|
50
56
|
|
|
57
|
+
const {
|
|
58
|
+
MediaControlBar,
|
|
59
|
+
MediaController,
|
|
60
|
+
MediaDurationDisplay,
|
|
61
|
+
MediaMuteButton,
|
|
62
|
+
MediaPlayButton,
|
|
63
|
+
MediaSeekBackwardButton,
|
|
64
|
+
MediaSeekForwardButton,
|
|
65
|
+
MediaTimeDisplay,
|
|
66
|
+
MediaTimeRange,
|
|
67
|
+
MediaVolumeRange,
|
|
68
|
+
} = MediaChromeReactModule;
|
|
69
|
+
|
|
70
|
+
// The peer is genuinely absent (Vite's build-time stub, or any other bundler
|
|
71
|
+
// that resolves an optional peer to an empty module) — surface a message
|
|
72
|
+
// `isModuleNotFoundMessage` recognizes, so the caller's `renderMissing` still
|
|
73
|
+
// gets the actionable "install media-chrome" copy rather than a raw
|
|
74
|
+
// "MediaController is not a valid JSX element" crash.
|
|
75
|
+
if (!MediaController) {
|
|
76
|
+
throw new Error("Cannot find module 'media-chrome'");
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
export const AudioPlayer = ({ children, style, ...props }: AudioPlayerProps) => (
|
|
52
80
|
<MediaController
|
|
53
81
|
audio
|
|
@@ -144,3 +172,62 @@ export const AudioPlayerVolumeRange = ({ className, ...props }: AudioPlayerVolum
|
|
|
144
172
|
<MediaVolumeRange className={cn("", className)} {...props} />
|
|
145
173
|
</ButtonGroupText>
|
|
146
174
|
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Conformance assertions (issue #101): compile-time proof that every OWNED
|
|
178
|
+
* type declared in `audio-player.tsx` stays assignable to its REAL
|
|
179
|
+
* `media-chrome/react` counterpart — this module still has the real values
|
|
180
|
+
* (and therefore the real types) in scope, since it is the one place they are
|
|
181
|
+
* allowed to be (see the module doc comment above). `AssertAssignable`'s type
|
|
182
|
+
* parameter is constrained (`TOwned extends TReal`), so if a future
|
|
183
|
+
* media-chrome release narrows an element's props in a way the owned type no
|
|
184
|
+
* longer satisfies, ONE of these ten lines fails to typecheck — caught by
|
|
185
|
+
* `pnpm --filter @elabs-ai/components-ai typecheck` locally, never shipped as
|
|
186
|
+
* a silent mismatch to a consumer using the real component underneath.
|
|
187
|
+
*
|
|
188
|
+
* `AssertAssignable` itself is shared with `_persona-rive.tsx` via
|
|
189
|
+
* `_lazy-boundary-conformance.ts` — see that module's doc comment for what
|
|
190
|
+
* this check can and cannot prove (it is one-directional: it cannot catch the
|
|
191
|
+
* owned type being NARROWER than the real one, which is exactly what these
|
|
192
|
+
* owned types are — see the CHANGELOG's "Breaking (types)" entry).
|
|
193
|
+
*/
|
|
194
|
+
export type _AudioPlayerPropsConformance = AssertAssignable<
|
|
195
|
+
AudioPlayerProps,
|
|
196
|
+
Omit<ComponentProps<typeof MediaController>, "audio">
|
|
197
|
+
>;
|
|
198
|
+
export type _AudioPlayerControlBarPropsConformance = AssertAssignable<
|
|
199
|
+
AudioPlayerControlBarProps,
|
|
200
|
+
ComponentProps<typeof MediaControlBar>
|
|
201
|
+
>;
|
|
202
|
+
export type _AudioPlayerPlayButtonPropsConformance = AssertAssignable<
|
|
203
|
+
AudioPlayerPlayButtonProps,
|
|
204
|
+
ComponentProps<typeof MediaPlayButton>
|
|
205
|
+
>;
|
|
206
|
+
export type _AudioPlayerSeekBackwardButtonPropsConformance = AssertAssignable<
|
|
207
|
+
AudioPlayerSeekBackwardButtonProps,
|
|
208
|
+
ComponentProps<typeof MediaSeekBackwardButton>
|
|
209
|
+
>;
|
|
210
|
+
export type _AudioPlayerSeekForwardButtonPropsConformance = AssertAssignable<
|
|
211
|
+
AudioPlayerSeekForwardButtonProps,
|
|
212
|
+
ComponentProps<typeof MediaSeekForwardButton>
|
|
213
|
+
>;
|
|
214
|
+
export type _AudioPlayerTimeDisplayPropsConformance = AssertAssignable<
|
|
215
|
+
AudioPlayerTimeDisplayProps,
|
|
216
|
+
ComponentProps<typeof MediaTimeDisplay>
|
|
217
|
+
>;
|
|
218
|
+
export type _AudioPlayerTimeRangePropsConformance = AssertAssignable<
|
|
219
|
+
AudioPlayerTimeRangeProps,
|
|
220
|
+
ComponentProps<typeof MediaTimeRange>
|
|
221
|
+
>;
|
|
222
|
+
export type _AudioPlayerDurationDisplayPropsConformance = AssertAssignable<
|
|
223
|
+
AudioPlayerDurationDisplayProps,
|
|
224
|
+
ComponentProps<typeof MediaDurationDisplay>
|
|
225
|
+
>;
|
|
226
|
+
export type _AudioPlayerMuteButtonPropsConformance = AssertAssignable<
|
|
227
|
+
AudioPlayerMuteButtonProps,
|
|
228
|
+
ComponentProps<typeof MediaMuteButton>
|
|
229
|
+
>;
|
|
230
|
+
export type _AudioPlayerVolumeRangePropsConformance = AssertAssignable<
|
|
231
|
+
AudioPlayerVolumeRangeProps,
|
|
232
|
+
ComponentProps<typeof MediaVolumeRange>
|
|
233
|
+
>;
|
package/src/_flow-boundary.tsx
CHANGED
|
@@ -119,65 +119,84 @@ export const EdgeTemporary = ({
|
|
|
119
119
|
);
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The point on `node` where an edge of `handleType` should attach, plus the side
|
|
124
|
+
* it leaves from.
|
|
125
|
+
*
|
|
126
|
+
* Read from React Flow's **measured** `handleBounds` — the DOM box of the
|
|
127
|
+
* painted dot — and taken at the box's OUTER edge, which is React Flow's own
|
|
128
|
+
* convention (the anchor lands on the dot's rim, which keeps a `markerEnd`
|
|
129
|
+
* visible).
|
|
130
|
+
*
|
|
131
|
+
* The two fallbacks matter as much as the happy path. `preferred` is only a
|
|
132
|
+
* preference: `nodeTypes` is an open prop, so a consumer node may legitimately
|
|
133
|
+
* put its handles on the top and bottom, and the shipped `Node`'s left/right
|
|
134
|
+
* pair is a convenience rather than a constraint. And `handleBounds` is empty
|
|
135
|
+
* until React Flow's first measurement pass, which on this canvas is a real
|
|
136
|
+
* window because the engine arrives in a lazy chunk (ADR 0019). Both used to
|
|
137
|
+
* resolve to `[0, 0]` — the CANVAS ORIGIN — so the edge was drawn hundreds of
|
|
138
|
+
* pixels from either node (measured at 498.8px in the `VerticalHandles` story).
|
|
139
|
+
* Falling back to any handle of the right type, and then to the node's own
|
|
140
|
+
* border, keeps the line on the node in every case.
|
|
141
|
+
*/
|
|
142
|
+
const getHandleAnchor = (
|
|
143
|
+
node: InternalNode<FlowNode>,
|
|
144
|
+
handleType: "source" | "target",
|
|
145
|
+
preferred: Position,
|
|
146
|
+
) => {
|
|
147
|
+
const bounds = node.internals.handleBounds?.[handleType] ?? [];
|
|
148
|
+
const handle = bounds.find((h) => h.position === preferred) ?? bounds[0];
|
|
149
|
+
const origin = node.internals.positionAbsolute;
|
|
125
150
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
if (handle) {
|
|
152
|
+
// Offset to the handle box's outer edge on its own side; the other axis is
|
|
153
|
+
// centred.
|
|
154
|
+
const offsetX =
|
|
155
|
+
handle.position === Position.Left
|
|
156
|
+
? 0
|
|
157
|
+
: handle.position === Position.Right
|
|
158
|
+
? handle.width
|
|
159
|
+
: handle.width / 2;
|
|
160
|
+
const offsetY =
|
|
161
|
+
handle.position === Position.Top
|
|
162
|
+
? 0
|
|
163
|
+
: handle.position === Position.Bottom
|
|
164
|
+
? handle.height
|
|
165
|
+
: handle.height / 2;
|
|
129
166
|
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
return {
|
|
168
|
+
position: handle.position,
|
|
169
|
+
x: origin.x + handle.x + offsetX,
|
|
170
|
+
y: origin.y + handle.y + offsetY,
|
|
171
|
+
};
|
|
132
172
|
}
|
|
133
173
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
offsetX = handle.width;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
case Position.Top: {
|
|
150
|
-
offsetY = 0;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
case Position.Bottom: {
|
|
154
|
-
offsetY = handle.height;
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
default: {
|
|
158
|
-
throw new Error(`Invalid handle position: ${handlePosition}`);
|
|
159
|
-
}
|
|
174
|
+
// No measured handle at all — anchor on the node's own border midpoint.
|
|
175
|
+
const width = node.measured.width ?? 0;
|
|
176
|
+
const height = node.measured.height ?? 0;
|
|
177
|
+
switch (preferred) {
|
|
178
|
+
case Position.Left:
|
|
179
|
+
return { position: preferred, x: origin.x, y: origin.y + height / 2 };
|
|
180
|
+
case Position.Right:
|
|
181
|
+
return { position: preferred, x: origin.x + width, y: origin.y + height / 2 };
|
|
182
|
+
case Position.Top:
|
|
183
|
+
return { position: preferred, x: origin.x + width / 2, y: origin.y };
|
|
184
|
+
default:
|
|
185
|
+
return { position: preferred, x: origin.x + width / 2, y: origin.y + height };
|
|
160
186
|
}
|
|
161
|
-
|
|
162
|
-
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
|
163
|
-
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
|
164
|
-
|
|
165
|
-
return [x, y] as const;
|
|
166
187
|
};
|
|
167
188
|
|
|
168
189
|
const getEdgeParams = (source: InternalNode<FlowNode>, target: InternalNode<FlowNode>) => {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const targetPos = Position.Left;
|
|
172
|
-
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
|
190
|
+
const from = getHandleAnchor(source, "source", Position.Right);
|
|
191
|
+
const to = getHandleAnchor(target, "target", Position.Left);
|
|
173
192
|
|
|
174
193
|
return {
|
|
175
|
-
sourcePos,
|
|
176
|
-
sx,
|
|
177
|
-
sy,
|
|
178
|
-
targetPos,
|
|
179
|
-
tx,
|
|
180
|
-
ty,
|
|
194
|
+
sourcePos: from.position,
|
|
195
|
+
sx: from.x,
|
|
196
|
+
sy: from.y,
|
|
197
|
+
targetPos: to.position,
|
|
198
|
+
tx: to.x,
|
|
199
|
+
ty: to.y,
|
|
181
200
|
};
|
|
182
201
|
};
|
|
183
202
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AssertAssignable` — the shared compile-time conformance-check helper for
|
|
3
|
+
* issue #101's "own the type locally" pattern (see `persona.tsx`,
|
|
4
|
+
* `audio-player.tsx`, and the ADR 0019 amendment). Every `@lazy-boundary`
|
|
5
|
+
* sibling module that owns a structurally-compatible mirror of an optional
|
|
6
|
+
* peer's type (`_persona-rive.tsx`, `_audio-player-media-chrome.tsx`) uses
|
|
7
|
+
* THIS declaration rather than redeclaring it locally — previously the same
|
|
8
|
+
* three-line type was declared verbatim in both files (round-1 validator
|
|
9
|
+
* finding F5), which is exactly the kind of drift this repo's "a convention
|
|
10
|
+
* ships with its teeth" rule (@.claude/rules/quality-gates.md) exists to
|
|
11
|
+
* avoid: a future edit to one copy (e.g. relaxing the constraint) could
|
|
12
|
+
* silently diverge from the other with no gate to catch it.
|
|
13
|
+
*
|
|
14
|
+
* Purely a type-level helper — no runtime value, no import of anything heavy
|
|
15
|
+
* or peer-owned — so it is safe for a `@lazy-boundary` module to import it
|
|
16
|
+
* statically without pulling the peer back into the entry chunk, and safe for
|
|
17
|
+
* anything else in the package to import it too.
|
|
18
|
+
*
|
|
19
|
+
* ## What this assertion can, and cannot, prove (round-1 validator finding F4)
|
|
20
|
+
*
|
|
21
|
+
* `AssertAssignable<TOwned extends TReal, TReal>` only typechecks when
|
|
22
|
+
* `TOwned` is assignable TO `TReal` — i.e. it proves the owned mirror is a
|
|
23
|
+
* SUPERTYPE-OR-EQUAL of the real peer type (every value the real type can
|
|
24
|
+
* produce is accepted by the owned type), never the reverse. That is
|
|
25
|
+
* sufficient to prove the mirror is SAFE to use in place of the real type
|
|
26
|
+
* wherever the real type is only ever produced (an event object Rive hands
|
|
27
|
+
* back, a prop object media-chrome reads) — but it is a ONE-DIRECTIONAL
|
|
28
|
+
* check, so it cannot detect the owned type merely being NARROWER than the
|
|
29
|
+
* real one. `PersonaRiveEvent["data"]` (`unknown`) and the `AudioPlayer*`
|
|
30
|
+
* part-prop types (ordinary HTML attributes only, no per-element instance
|
|
31
|
+
* members, no `ref`) are both real, deliberate narrowings versus their real
|
|
32
|
+
* peer counterparts — see the CHANGELOG's "Breaking (types)" entry — and this
|
|
33
|
+
* assertion passes for both, exactly as designed: a narrower type is still a
|
|
34
|
+
* valid supertype-direction match. Don't read a green
|
|
35
|
+
* `_*Conformance` type as "identical shape to the peer"; read it as "safe to
|
|
36
|
+
* substitute here", which is the property issue #101 actually needed.
|
|
37
|
+
*/
|
|
38
|
+
export type AssertAssignable<_TOwned extends TReal, TReal> = true;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export interface LazyEngineBoundaryProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Called once a wrapped `lazy()` import rejects (an optional peer that is
|
|
9
|
+
* not installed, or any other load-time failure). Return what to render in
|
|
10
|
+
* its place — the boundary owns no default visual, since the right
|
|
11
|
+
* stand-in differs by surface (an orb placeholder for `Persona`, `null`
|
|
12
|
+
* for an `AudioPlayer` sub-control).
|
|
13
|
+
*/
|
|
14
|
+
renderMissing: (error: unknown) => ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LazyEngineBoundaryState {
|
|
18
|
+
// Wrapped in an object so `{ value: undefined }` (a boundary that caught
|
|
19
|
+
// an error whose value happens to be `undefined`) is distinguishable from
|
|
20
|
+
// "nothing caught yet" (`error: null`).
|
|
21
|
+
error: { value: unknown } | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Catches a `React.lazy()` load failure — an optional peer dependency that is
|
|
26
|
+
* not installed (issue #33), or any other dynamic-import rejection — and
|
|
27
|
+
* renders a caller-supplied fallback instead of unmounting the tree.
|
|
28
|
+
*
|
|
29
|
+
* `Suspense` alone does not do this: it only covers the PENDING state. A
|
|
30
|
+
* REJECTED lazy import throws during render, and with no boundary above it
|
|
31
|
+
* that throw propagates to the nearest ancestor boundary React DOES find —
|
|
32
|
+
* by default, the whole app. This is the one place in `@elabs-ai/components-ai`
|
|
33
|
+
* that needs a real render-phase error boundary; Mermaid and the interactive
|
|
34
|
+
* terminal fail via an awaited promise instead (Streamdown's `errorComponent`
|
|
35
|
+
* and a `.catch()` on the mount effect, respectively), which don't need one.
|
|
36
|
+
*/
|
|
37
|
+
export class LazyEngineBoundary extends Component<
|
|
38
|
+
LazyEngineBoundaryProps,
|
|
39
|
+
LazyEngineBoundaryState
|
|
40
|
+
> {
|
|
41
|
+
override state: LazyEngineBoundaryState = { error: null };
|
|
42
|
+
|
|
43
|
+
static getDerivedStateFromError(error: unknown): LazyEngineBoundaryState {
|
|
44
|
+
return { error: { value: error } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
48
|
+
console.error(
|
|
49
|
+
"[@elabs-ai/components-ai] a lazy engine failed to load:",
|
|
50
|
+
error,
|
|
51
|
+
info.componentStack,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override render(): ReactNode {
|
|
56
|
+
if (this.state.error) {
|
|
57
|
+
return this.props.renderMissing(this.state.error.value);
|
|
58
|
+
}
|
|
59
|
+
return this.props.children;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `mermaid` is genuinely absent from this file's module registry (deliberately
|
|
5
|
+
* mocked to the shape a bundler hands back for an uninstalled optional peer —
|
|
6
|
+
* see below), so this lives in its OWN file rather than a `describe` inside
|
|
7
|
+
* `_lazy-mermaid.test.ts`. `vi.mock` is hoisted to the top of the file and
|
|
8
|
+
* applies for the file's entire run; a second, conflicting registration for
|
|
9
|
+
* the same specifier inside one file (this shape, vs. `_lazy-mermaid.test.ts`'s
|
|
10
|
+
* `{ initialize, render }` shape) does not reliably scope to one `describe`
|
|
11
|
+
* block — it can leak into sibling tests in the same file that expect the
|
|
12
|
+
* OTHER shape, corrupting them instead of the isolated case this is meant to
|
|
13
|
+
* add. See `.claude/rules/component-api.md` "Regression locks with observable
|
|
14
|
+
* side effects must be verified co-resident" for the general version of this
|
|
15
|
+
* lesson; a global module-mock registration is exactly such a side effect.
|
|
16
|
+
*
|
|
17
|
+
* Vite's production build substitutes an EMPTY module for a genuinely-absent
|
|
18
|
+
* optional peer (`export default {}`) rather than rejecting the import — so
|
|
19
|
+
* `loadEngine()`'s own shape guard in `_lazy-mermaid.ts`, not a `.catch()`, is
|
|
20
|
+
* what has to catch this. `_lazy-mermaid.test.ts`'s own "engine call itself
|
|
21
|
+
* fails" test is circular for this exact case (it rejects with the very
|
|
22
|
+
* string it then asserts on); this one instead makes the RESOLVED module
|
|
23
|
+
* shape wrong, the way an empty stub actually is, and checks `loadEngine`'s
|
|
24
|
+
* guard converts that into the module-not-found-shaped message
|
|
25
|
+
* `isModuleNotFoundMessage` recognizes.
|
|
26
|
+
*
|
|
27
|
+
* **Explicit scope statement (issue #94):** this test proves the LOAD-TIME
|
|
28
|
+
* ERROR PATH — what happens when `import("mermaid")` resolves to an empty
|
|
29
|
+
* stub — and nothing more. It does NOT prove mermaid is absent from an
|
|
30
|
+
* installed consumer tree, and must never be read as though it did. For the
|
|
31
|
+
* other four lazy-loaded optional peers (Rive, xterm, `@xterm/addon-fit`,
|
|
32
|
+
* media-chrome), `fixtures/consumer-smoke` + `pnpm consumer:check` give an
|
|
33
|
+
* end-to-end proof of genuine absence from the installed tarball. That proof
|
|
34
|
+
* is currently UNWRITABLE for mermaid: two of `@elabs-ai/components-ai`'s own
|
|
35
|
+
* plain dependencies (`streamdown`, `@streamdown/mermaid`) each depend on
|
|
36
|
+
* mermaid directly, so mermaid is never actually absent from the installed
|
|
37
|
+
* tree for this fixture to observe (see `scripts/check-optional-peer-transitives.mjs`
|
|
38
|
+
* / `pnpm optional-peers:check`, which proves and tracks that residual
|
|
39
|
+
* instead). Do not "fix" this gap by trying to make this file assert
|
|
40
|
+
* absence — it can't, until upstream `streamdown` declares mermaid an
|
|
41
|
+
* optional peer of its own.
|
|
42
|
+
*/
|
|
43
|
+
vi.mock("mermaid", () => ({ default: {} }));
|
|
44
|
+
|
|
45
|
+
describe("lazy mermaid plugin — genuinely absent optional peer (#33)", () => {
|
|
46
|
+
it("throws a module-not-found-shaped message when the resolved module has no initialize/render", async () => {
|
|
47
|
+
const { createLazyMermaidPlugin } = await import("./_lazy-mermaid");
|
|
48
|
+
|
|
49
|
+
await expect(
|
|
50
|
+
createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;"),
|
|
51
|
+
).rejects.toThrow(/cannot find module 'mermaid'/i);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -98,4 +98,19 @@ describe("lazy mermaid plugin", () => {
|
|
|
98
98
|
expect(() => preloadMermaid()).not.toThrow();
|
|
99
99
|
expect(render).not.toHaveBeenCalled();
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it("surfaces a module-not-found-shaped message when the engine call itself fails — the missing optional `mermaid` peer, #33", async () => {
|
|
103
|
+
// The engine module resolved fine (it is mocked at the top of this file);
|
|
104
|
+
// this simulates the shape a missing peer actually takes downstream — the
|
|
105
|
+
// dynamic `import("mermaid")` rejecting — by rejecting the mocked call the
|
|
106
|
+
// plugin awaits. Streamdown reduces whatever this rejects with to a plain
|
|
107
|
+
// string and hands it to `MermaidErrorPanel`
|
|
108
|
+
// (`_mermaid-error-panel.tsx`), which classifies a message in this exact
|
|
109
|
+
// shape as a capability gap, not a render failure.
|
|
110
|
+
render.mockRejectedValueOnce(new Error("Cannot find module 'mermaid'"));
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;"),
|
|
114
|
+
).rejects.toThrow(/cannot find module/i);
|
|
115
|
+
});
|
|
101
116
|
});
|
package/src/_lazy-mermaid.ts
CHANGED
|
@@ -50,7 +50,30 @@ type MermaidModule = {
|
|
|
50
50
|
let enginePromise: Promise<MermaidModule> | undefined;
|
|
51
51
|
|
|
52
52
|
const loadEngine = (): Promise<MermaidModule> => {
|
|
53
|
-
enginePromise ??= import("mermaid").then((m) =>
|
|
53
|
+
enginePromise ??= import("mermaid").then((m) => {
|
|
54
|
+
const engine = m.default as unknown as MermaidModule;
|
|
55
|
+
// `mermaid` is an optional peer (issue #33), but its bytes are always
|
|
56
|
+
// installed anyway: TWO of this package's own plain dependencies —
|
|
57
|
+
// `streamdown` and `@streamdown/mermaid` — each declare `mermaid` as
|
|
58
|
+
// their own plain, non-optional dependency (issue #94,
|
|
59
|
+
// `pnpm optional-peers:check` proves and tracks this). So a hoisting
|
|
60
|
+
// package manager (pnpm's default `node-linker=isolated` included, via
|
|
61
|
+
// phantom resolution through the virtual store) may still make the real
|
|
62
|
+
// bytes resolve even when a consumer never installed `mermaid`
|
|
63
|
+
// themselves — this guard does not cover that case, only a bundler
|
|
64
|
+
// whose optional-peer handling substitutes an EMPTY module (Vite's
|
|
65
|
+
// production stub is `export default {}`) once the peer is genuinely
|
|
66
|
+
// absent from the resolved tree. Without this guard that empty object
|
|
67
|
+
// resolves successfully and the crash lands one call later, on
|
|
68
|
+
// `engine.initialize is not a function` — a message
|
|
69
|
+
// `isModuleNotFoundMessage` does not recognize, so it reaches the user
|
|
70
|
+
// as "Diagram couldn't be drawn" with a Retry button that can never
|
|
71
|
+
// succeed, instead of the actionable capability-gap panel.
|
|
72
|
+
if (typeof engine?.initialize !== "function" || typeof engine?.render !== "function") {
|
|
73
|
+
throw new Error("Cannot find module 'mermaid'");
|
|
74
|
+
}
|
|
75
|
+
return engine;
|
|
76
|
+
});
|
|
54
77
|
return enginePromise;
|
|
55
78
|
};
|
|
56
79
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #33 — `mermaid` is an OPTIONAL peer of `@elabs-ai/components-ai`,
|
|
3
|
+
* reached only through the lazy plugin in `_lazy-mermaid.ts`. Streamdown hands
|
|
4
|
+
* a failed diagram render to `MermaidErrorPanel` as a plain STRING (never the
|
|
5
|
+
* original `Error`), so this test locks the message-based branch directly:
|
|
6
|
+
* a missing-peer-shaped message renders the neutral "capability gap" panel
|
|
7
|
+
* (`kind="empty"`, `role="status"`, no retry — re-installing a dependency does
|
|
8
|
+
* not happen by clicking a button); any other message keeps the destructive
|
|
9
|
+
* `kind="error"` panel wired to Streamdown's own `retry()`.
|
|
10
|
+
*/
|
|
11
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
import { MermaidErrorPanel } from "./_mermaid-error-panel";
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup);
|
|
16
|
+
|
|
17
|
+
describe("MermaidErrorPanel (#33)", () => {
|
|
18
|
+
it("renders the neutral capability-gap panel for a missing-peer-shaped message, naming mermaid", () => {
|
|
19
|
+
const retry = vi.fn();
|
|
20
|
+
render(
|
|
21
|
+
<MermaidErrorPanel
|
|
22
|
+
chart="graph TD; A-->B;"
|
|
23
|
+
error="Cannot find module 'mermaid'"
|
|
24
|
+
retry={retry}
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText(/mermaid/)).toBeInTheDocument();
|
|
30
|
+
// Re-installing a dependency does not happen by clicking a button.
|
|
31
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps the destructive, retryable panel for a genuine render failure", () => {
|
|
35
|
+
const retry = vi.fn();
|
|
36
|
+
render(
|
|
37
|
+
<MermaidErrorPanel
|
|
38
|
+
chart="graph TD; A--"
|
|
39
|
+
error="Parse error on line 1: Unexpected end of input"
|
|
40
|
+
retry={retry}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText(/Unexpected end of input/)).toBeInTheDocument();
|
|
46
|
+
const retryButton = screen.getByRole("button", { name: /try again/i });
|
|
47
|
+
retryButton.click();
|
|
48
|
+
expect(retry).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
});
|