@elabs-ai/components-ai 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +62 -12
  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 +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for PromptInputMode (`packages/ai/src/prompt-input-mode.tsx`), derived from its
4
+ * `Default` story (packages/ai/src/prompt-input-mode.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../prompt-input-mode.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `ai-composer-promptinputmode--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("PromptInputMode contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="prompt-input-mode" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="prompt-input-mode"]')).not.toBeNull();
48
+ });
49
+ });
@@ -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
+ >;
@@ -4,8 +4,8 @@ import { FileText, Sparkles } from "lucide-react";
4
4
  export function ContextPanelRail() {
5
5
  return (
6
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">
7
+ <div className="flex h-12 items-center border-b px-4 text-body font-semibold">Context</div>
8
+ <div className="space-y-2 p-3 text-body">
9
9
  <div className="flex items-center gap-2 rounded-md border bg-card p-2">
10
10
  <FileText className="size-4 text-muted-foreground" /> Deploy log — wk 23
11
11
  </div>
@@ -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,43 @@
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { needsCjkPlugin, preloadCjk, useLazyCjkPlugin } from "./_lazy-cjk";
5
+
6
+ const fakeCjkPlugin = {
7
+ name: "cjk",
8
+ remarkPlugins: [],
9
+ remarkPluginsAfter: [],
10
+ remarkPluginsBefore: [],
11
+ type: "cjk",
12
+ };
13
+
14
+ vi.mock("@streamdown/cjk", () => ({ cjk: fakeCjkPlugin }));
15
+
16
+ describe("needsCjkPlugin", () => {
17
+ it("returns false for plain ASCII text", () => {
18
+ expect(needsCjkPlugin("plain text")).toBe(false);
19
+ });
20
+
21
+ it("matches CJK Unified Ideographs, Hiragana, Katakana and Hangul", () => {
22
+ expect(needsCjkPlugin("你好")).toBe(true);
23
+ expect(needsCjkPlugin("こんにちは")).toBe(true);
24
+ expect(needsCjkPlugin("コンニチハ")).toBe(true);
25
+ expect(needsCjkPlugin("안녕하세요")).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("useLazyCjkPlugin", () => {
30
+ it("returns undefined when the text has no CJK codepoints", () => {
31
+ const { result } = renderHook(() => useLazyCjkPlugin("plain text"));
32
+ expect(result.current).toBeUndefined();
33
+ });
34
+
35
+ it("loads and returns the plugin once the text needs CJK handling", async () => {
36
+ const { result } = renderHook(() => useLazyCjkPlugin("你好,世界"));
37
+ await waitFor(() => expect(result.current).toBe(fakeCjkPlugin));
38
+ });
39
+
40
+ it("preloadCjk resolves without throwing", () => {
41
+ expect(() => preloadCjk()).not.toThrow();
42
+ });
43
+ });
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Lazy-loaded replacement for `@streamdown/cjk`'s eager `cjk` export.
5
+ *
6
+ * Same rationale and mechanism as `_lazy-math.ts` — `remark-cjk-friendly` +
7
+ * `remark-cjk-friendly-gfm-strikethrough` load only once `needsCjkPlugin(text)`
8
+ * sees a CJK codepoint, instead of shipping in every consumer's entry chunk.
9
+ * See `_lazy-math.ts` for why a remark `Pluggable` cannot be deferred the way
10
+ * `DiagramPlugin` defers Mermaid's render, and for the one-paint literal-text
11
+ * cost this trades for it.
12
+ */
13
+ import type { CjkPlugin } from "@streamdown/cjk";
14
+ import { useEffect, useState } from "react";
15
+
16
+ /**
17
+ * Hiragana, Katakana, CJK Unified Ideographs (+ Extension A), Hangul Syllables,
18
+ * CJK punctuation and halfwidth Katakana — the ranges `remark-cjk-friendly`
19
+ * exists to handle (emphasis/strikethrough boundary rules around CJK text).
20
+ */
21
+ // The range's first codepoint is the ideographic full-width space, not
22
+ // accidental whitespace — a deliberate boundary character, not noise.
23
+ // eslint-disable-next-line no-irregular-whitespace -- see comment above
24
+ const CJK_RANGE_RE = /[ -〿぀-ヿ㐀-䶿一-鿿가-힣・-゚]/u;
25
+
26
+ /** Does `text` contain a CJK codepoint? */
27
+ export function needsCjkPlugin(text: string): boolean {
28
+ return CJK_RANGE_RE.test(text);
29
+ }
30
+
31
+ /** Module-level singleton: the plugin loads at most once. */
32
+ let cachedPlugin: CjkPlugin | undefined;
33
+ let loadPromise: Promise<CjkPlugin> | undefined;
34
+
35
+ function loadCjkPlugin(): Promise<CjkPlugin> {
36
+ loadPromise ??= import("@streamdown/cjk").then((mod) => {
37
+ cachedPlugin = mod.cjk;
38
+ return cachedPlugin;
39
+ });
40
+ return loadPromise;
41
+ }
42
+
43
+ /**
44
+ * Start fetching the CJK plugin ahead of time (e.g. once a conversation's
45
+ * locale is known to be CJK). Optional; rendering CJK text loads it either way.
46
+ */
47
+ export const preloadCjk = (): void => {
48
+ void loadCjkPlugin();
49
+ };
50
+
51
+ /**
52
+ * Returns the loaded `CjkPlugin` once `text` needs one, else `undefined`
53
+ * (Streamdown treats an absent `plugins.cjk` slot as "no CJK handling" — plain
54
+ * remark-gfm boundary rules apply until the plugin arrives).
55
+ */
56
+ export function useLazyCjkPlugin(text: string): CjkPlugin | undefined {
57
+ const needed = needsCjkPlugin(text);
58
+ const [plugin, setPlugin] = useState<CjkPlugin | undefined>(cachedPlugin);
59
+
60
+ useEffect(() => {
61
+ if (!needed || cachedPlugin) return;
62
+ let cancelled = false;
63
+ loadCjkPlugin().then((loaded) => {
64
+ if (!cancelled) setPlugin(loaded);
65
+ });
66
+ return () => {
67
+ cancelled = true;
68
+ };
69
+ }, [needed]);
70
+
71
+ if (!needed) return undefined;
72
+ return plugin ?? cachedPlugin;
73
+ }
@@ -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,63 @@
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { needsMathPlugin, preloadMath, useLazyMathPlugin } from "./_lazy-math";
5
+
6
+ /**
7
+ * The KaTeX-carrying `@streamdown/math` module is mocked so this file tests
8
+ * the lazy wrapper's *behaviour*. That the real dependency is genuinely
9
+ * absent from the entry chunk is a property of the BUILD, not of jsdom — see
10
+ * `pnpm check --rule eager-heavy-deps`, which
11
+ * asserts no `packages/ai/src` module statically imports `@streamdown/math`
12
+ * or `katex`.
13
+ */
14
+ const fakeMathPlugin = {
15
+ name: "katex",
16
+ rehypePlugin: vi.fn(),
17
+ remarkPlugin: vi.fn(),
18
+ type: "math",
19
+ };
20
+
21
+ vi.mock("@streamdown/math", () => ({ math: fakeMathPlugin }));
22
+
23
+ describe("needsMathPlugin", () => {
24
+ it("matches $$block$$ and $inline$ delimiters", () => {
25
+ expect(needsMathPlugin("plain text")).toBe(false);
26
+ expect(needsMathPlugin("$$x^2$$")).toBe(true);
27
+ expect(needsMathPlugin("the answer is $x + 1$ today")).toBe(true);
28
+ });
29
+
30
+ it("matches \\( \\) and \\[ \\] delimiters", () => {
31
+ expect(needsMathPlugin("\\(x^2\\)")).toBe(true);
32
+ expect(needsMathPlugin("\\[x^2\\]")).toBe(true);
33
+ });
34
+
35
+ it("does not false-positive on a bare dollar amount", () => {
36
+ // A single, unpaired `$` (no closing delimiter on the same line) is not math.
37
+ expect(needsMathPlugin("that costs $5")).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe("useLazyMathPlugin", () => {
42
+ it("returns undefined when the text has no math delimiters", () => {
43
+ const { result } = renderHook(() => useLazyMathPlugin("plain text"));
44
+ expect(result.current).toBeUndefined();
45
+ });
46
+
47
+ it("loads and returns the plugin once the text needs math", async () => {
48
+ const { result } = renderHook(() => useLazyMathPlugin("$$x^2$$"));
49
+ await waitFor(() => expect(result.current).toBe(fakeMathPlugin));
50
+ });
51
+
52
+ it("preloadMath resolves without throwing", () => {
53
+ expect(() => preloadMath()).not.toThrow();
54
+ });
55
+
56
+ it("serves a second, simultaneous consumer off the same cached load", async () => {
57
+ const first = renderHook(() => useLazyMathPlugin("$$x^2$$"));
58
+ const second = renderHook(() => useLazyMathPlugin("$y^2$"));
59
+
60
+ await waitFor(() => expect(first.result.current).toBe(fakeMathPlugin));
61
+ await waitFor(() => expect(second.result.current).toBe(fakeMathPlugin));
62
+ });
63
+ });