@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.
Files changed (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. 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.0.0",
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-tokens": "4.0.0",
55
- "@elabs-ai/components-ui": "4.0.0"
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": "^6.0.0",
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.0.0",
88
+ "@elabs-ai/components-charts": "4.1.0",
74
89
  "@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"
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` still owns the public prop types and imports the
14
- * media-chrome element types with `import type` (types erase).
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
- 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";
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
+ >;
@@ -119,65 +119,84 @@ export const EdgeTemporary = ({
119
119
  );
120
120
  };
121
121
 
122
- const getHandleCoordsByPosition = (node: InternalNode<FlowNode>, handlePosition: Position) => {
123
- // Choose the handle type based on position - Left is for target, Right is for source
124
- const handleType = handlePosition === Position.Left ? "target" : "source";
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
- const handle = node.internals.handleBounds?.[handleType]?.find(
127
- (h) => h.position === handlePosition,
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
- if (!handle) {
131
- return [0, 0] as const;
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
- let offsetX = handle.width / 2;
135
- let offsetY = handle.height / 2;
136
-
137
- // this is a tiny detail to make the markerEnd of an edge visible.
138
- // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
139
- // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
140
- switch (handlePosition) {
141
- case Position.Left: {
142
- offsetX = 0;
143
- break;
144
- }
145
- case Position.Right: {
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 sourcePos = Position.Right;
170
- const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
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
  });
@@ -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) => m.default as unknown as MermaidModule);
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
+ });