@agent-native/toolkit 0.9.0 → 0.10.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 (190) hide show
  1. package/README.md +12 -3
  2. package/agent-native.eject.json +71 -0
  3. package/dist/chat-history/ChatHistoryRail.d.ts +33 -0
  4. package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -0
  5. package/dist/chat-history/ChatHistoryRail.js +35 -0
  6. package/dist/chat-history/ChatHistoryRail.js.map +1 -0
  7. package/dist/chat-history/ChatHistoryRail.spec.d.ts +2 -0
  8. package/dist/chat-history/ChatHistoryRail.spec.d.ts.map +1 -0
  9. package/dist/chat-history/ChatHistoryRail.spec.js +157 -0
  10. package/dist/chat-history/ChatHistoryRail.spec.js.map +1 -0
  11. package/dist/chat-history/index.d.ts +2 -0
  12. package/dist/chat-history/index.d.ts.map +1 -1
  13. package/dist/chat-history/index.js +2 -0
  14. package/dist/chat-history/index.js.map +1 -1
  15. package/dist/chat-history/useChatHistoryRailController.d.ts +26 -0
  16. package/dist/chat-history/useChatHistoryRailController.d.ts.map +1 -0
  17. package/dist/chat-history/useChatHistoryRailController.js +36 -0
  18. package/dist/chat-history/useChatHistoryRailController.js.map +1 -0
  19. package/dist/chat-history/useChatHistoryRailController.spec.d.ts +2 -0
  20. package/dist/chat-history/useChatHistoryRailController.spec.d.ts.map +1 -0
  21. package/dist/chat-history/useChatHistoryRailController.spec.js +101 -0
  22. package/dist/chat-history/useChatHistoryRailController.spec.js.map +1 -0
  23. package/dist/chat-history.css +61 -0
  24. package/dist/composer/ComposerPlusMenu.js +1 -1
  25. package/dist/composer/ComposerPlusMenu.js.map +1 -1
  26. package/dist/composer/PromptComposer.d.ts.map +1 -1
  27. package/dist/composer/PromptComposer.js +22 -4
  28. package/dist/composer/PromptComposer.js.map +1 -1
  29. package/dist/composer/RealtimeVoiceMode.d.ts +5 -1
  30. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  31. package/dist/composer/RealtimeVoiceMode.js +27 -8
  32. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  33. package/dist/composer/RealtimeVoiceMode.spec.js +51 -11
  34. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
  35. package/dist/composer/VoiceButton.d.ts +2 -0
  36. package/dist/composer/VoiceButton.d.ts.map +1 -1
  37. package/dist/composer/VoiceButton.js +33 -1
  38. package/dist/composer/VoiceButton.js.map +1 -1
  39. package/dist/composer/VoiceButton.spec.js +9 -1
  40. package/dist/composer/VoiceButton.spec.js.map +1 -1
  41. package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -1
  42. package/dist/composer/realtime-voice-audio-level.js +2 -1
  43. package/dist/composer/realtime-voice-audio-level.js.map +1 -1
  44. package/dist/composer/realtime-voice-audio-level.spec.js +7 -0
  45. package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -1
  46. package/dist/composer/useRealtimeVoiceMode.d.ts +7 -0
  47. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  48. package/dist/composer/useRealtimeVoiceMode.js +95 -23
  49. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  50. package/dist/composer/useRealtimeVoiceMode.spec.js +58 -9
  51. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -1
  52. package/dist/conformance/__fixtures__/css-in-js-adapter.d.ts +7 -0
  53. package/dist/conformance/__fixtures__/css-in-js-adapter.d.ts.map +1 -0
  54. package/dist/conformance/__fixtures__/css-in-js-adapter.js +84 -0
  55. package/dist/conformance/__fixtures__/css-in-js-adapter.js.map +1 -0
  56. package/dist/conformance/conformance.spec.d.ts +2 -0
  57. package/dist/conformance/conformance.spec.d.ts.map +1 -0
  58. package/dist/conformance/conformance.spec.js +58 -0
  59. package/dist/conformance/conformance.spec.js.map +1 -0
  60. package/dist/conformance/index.d.ts +4 -0
  61. package/dist/conformance/index.d.ts.map +1 -0
  62. package/dist/conformance/index.js +4 -0
  63. package/dist/conformance/index.js.map +1 -0
  64. package/dist/conformance/runner.d.ts +12 -0
  65. package/dist/conformance/runner.d.ts.map +1 -0
  66. package/dist/conformance/runner.js +441 -0
  67. package/dist/conformance/runner.js.map +1 -0
  68. package/dist/conformance/types.d.ts +26 -0
  69. package/dist/conformance/types.d.ts.map +1 -0
  70. package/dist/conformance/types.js +13 -0
  71. package/dist/conformance/types.js.map +1 -0
  72. package/dist/conformance/version.d.ts +6 -0
  73. package/dist/conformance/version.d.ts.map +1 -0
  74. package/dist/conformance/version.js +14 -0
  75. package/dist/conformance/version.js.map +1 -0
  76. package/dist/design-system/components.d.ts +64 -0
  77. package/dist/design-system/components.d.ts.map +1 -0
  78. package/dist/design-system/components.js +50 -0
  79. package/dist/design-system/components.js.map +1 -0
  80. package/dist/design-system/context.d.ts +11 -0
  81. package/dist/design-system/context.d.ts.map +1 -0
  82. package/dist/design-system/context.js +17 -0
  83. package/dist/design-system/context.js.map +1 -0
  84. package/dist/design-system/default-adapter.d.ts +3 -0
  85. package/dist/design-system/default-adapter.d.ts.map +1 -0
  86. package/dist/design-system/default-adapter.js +166 -0
  87. package/dist/design-system/default-adapter.js.map +1 -0
  88. package/dist/design-system/default-adapter.spec.d.ts +2 -0
  89. package/dist/design-system/default-adapter.spec.d.ts.map +1 -0
  90. package/dist/design-system/default-adapter.spec.js +96 -0
  91. package/dist/design-system/default-adapter.spec.js.map +1 -0
  92. package/dist/design-system/definition.d.ts +9 -0
  93. package/dist/design-system/definition.d.ts.map +1 -0
  94. package/dist/design-system/definition.js +4 -0
  95. package/dist/design-system/definition.js.map +1 -0
  96. package/dist/design-system/design-system.spec.d.ts +2 -0
  97. package/dist/design-system/design-system.spec.d.ts.map +1 -0
  98. package/dist/design-system/design-system.spec.js +121 -0
  99. package/dist/design-system/design-system.spec.js.map +1 -0
  100. package/dist/design-system/error-boundary.d.ts +18 -0
  101. package/dist/design-system/error-boundary.d.ts.map +1 -0
  102. package/dist/design-system/error-boundary.js +16 -0
  103. package/dist/design-system/error-boundary.js.map +1 -0
  104. package/dist/design-system/index.d.ts +8 -0
  105. package/dist/design-system/index.d.ts.map +1 -0
  106. package/dist/design-system/index.js +8 -0
  107. package/dist/design-system/index.js.map +1 -0
  108. package/dist/design-system/theme.d.ts +30 -0
  109. package/dist/design-system/theme.d.ts.map +1 -0
  110. package/dist/design-system/theme.js +95 -0
  111. package/dist/design-system/theme.js.map +1 -0
  112. package/dist/design-system/theme.spec.d.ts +2 -0
  113. package/dist/design-system/theme.spec.d.ts.map +1 -0
  114. package/dist/design-system/theme.spec.js +43 -0
  115. package/dist/design-system/theme.spec.js.map +1 -0
  116. package/dist/design-system/types.d.ts +288 -0
  117. package/dist/design-system/types.d.ts.map +1 -0
  118. package/dist/design-system/types.js +2 -0
  119. package/dist/design-system/types.js.map +1 -0
  120. package/dist/provider.d.ts +4 -3
  121. package/dist/provider.d.ts.map +1 -1
  122. package/dist/provider.js +46 -4
  123. package/dist/provider.js.map +1 -1
  124. package/dist/styles.css +35 -0
  125. package/dist/ui/button.d.ts +5 -0
  126. package/dist/ui/button.d.ts.map +1 -1
  127. package/dist/ui/button.js +25 -4
  128. package/dist/ui/button.js.map +1 -1
  129. package/dist/ui/dialog.d.ts +4 -0
  130. package/dist/ui/dialog.d.ts.map +1 -1
  131. package/dist/ui/dialog.js +2 -2
  132. package/dist/ui/dialog.js.map +1 -1
  133. package/dist/ui/popover.d.ts +1 -0
  134. package/dist/ui/popover.d.ts.map +1 -1
  135. package/dist/ui/popover.js +2 -2
  136. package/dist/ui/popover.js.map +1 -1
  137. package/dist/ui/select.d.ts +3 -1
  138. package/dist/ui/select.d.ts.map +1 -1
  139. package/dist/ui/select.js +1 -1
  140. package/dist/ui/select.js.map +1 -1
  141. package/dist/ui/sidebar.d.ts +6 -2
  142. package/dist/ui/sidebar.d.ts.map +1 -1
  143. package/dist/ui/sidebar.js +12 -15
  144. package/dist/ui/sidebar.js.map +1 -1
  145. package/dist/ui/tooltip.d.ts +3 -1
  146. package/dist/ui/tooltip.d.ts.map +1 -1
  147. package/dist/ui/tooltip.js +2 -2
  148. package/dist/ui/tooltip.js.map +1 -1
  149. package/package.json +31 -1
  150. package/src/chat-history/ChatHistoryRail.spec.tsx +312 -0
  151. package/src/chat-history/ChatHistoryRail.tsx +150 -0
  152. package/src/chat-history/index.ts +14 -0
  153. package/src/chat-history/useChatHistoryRailController.spec.tsx +131 -0
  154. package/src/chat-history/useChatHistoryRailController.ts +76 -0
  155. package/src/chat-history.css +61 -0
  156. package/src/composer/ComposerPlusMenu.tsx +11 -8
  157. package/src/composer/PromptComposer.tsx +27 -6
  158. package/src/composer/RealtimeVoiceMode.spec.tsx +116 -11
  159. package/src/composer/RealtimeVoiceMode.tsx +106 -36
  160. package/src/composer/VoiceButton.spec.ts +17 -1
  161. package/src/composer/VoiceButton.tsx +49 -0
  162. package/src/composer/realtime-voice-audio-level.spec.ts +15 -0
  163. package/src/composer/realtime-voice-audio-level.ts +2 -1
  164. package/src/composer/useRealtimeVoiceMode.spec.ts +82 -8
  165. package/src/composer/useRealtimeVoiceMode.tsx +112 -25
  166. package/src/conformance/__fixtures__/css-in-js-adapter.tsx +401 -0
  167. package/src/conformance/conformance.spec.tsx +83 -0
  168. package/src/conformance/index.ts +3 -0
  169. package/src/conformance/runner.tsx +710 -0
  170. package/src/conformance/types.ts +45 -0
  171. package/src/conformance/version.ts +22 -0
  172. package/src/design-system/components.tsx +146 -0
  173. package/src/design-system/context.tsx +31 -0
  174. package/src/design-system/default-adapter.spec.tsx +143 -0
  175. package/src/design-system/default-adapter.tsx +915 -0
  176. package/src/design-system/definition.ts +14 -0
  177. package/src/design-system/design-system.spec.tsx +183 -0
  178. package/src/design-system/error-boundary.tsx +37 -0
  179. package/src/design-system/index.ts +7 -0
  180. package/src/design-system/theme.spec.ts +61 -0
  181. package/src/design-system/theme.ts +145 -0
  182. package/src/design-system/types.ts +363 -0
  183. package/src/provider.tsx +98 -5
  184. package/src/styles.css +35 -0
  185. package/src/ui/button.tsx +95 -6
  186. package/src/ui/dialog.tsx +48 -23
  187. package/src/ui/popover.tsx +16 -2
  188. package/src/ui/select.tsx +7 -3
  189. package/src/ui/sidebar.tsx +47 -48
  190. package/src/ui/tooltip.tsx +7 -3
@@ -484,6 +484,75 @@ export function createRealtimeVoiceGreetingStarter(
484
484
  };
485
485
  }
486
486
 
487
+ export interface RealtimeVoiceResponseCoordinator {
488
+ request: (event?: Record<string, unknown>) => void;
489
+ handleEvent: (event: RealtimeServerEvent) => void;
490
+ handleError: (message?: string) => boolean;
491
+ reset: () => void;
492
+ }
493
+
494
+ export function createRealtimeVoiceResponseCoordinator(
495
+ send: (event: Record<string, unknown>) => void,
496
+ ): RealtimeVoiceResponseCoordinator {
497
+ let active = false;
498
+ let requestInFlight = false;
499
+ let pending: Record<string, unknown> | undefined;
500
+
501
+ const flush = () => {
502
+ if (active || requestInFlight || !pending) return;
503
+ const event = pending;
504
+ pending = undefined;
505
+ requestInFlight = true;
506
+ send(event);
507
+ };
508
+
509
+ return {
510
+ request(event = { type: "response.create" }) {
511
+ pending = event;
512
+ flush();
513
+ },
514
+ handleEvent(event) {
515
+ if (event.type === "response.created") {
516
+ active = true;
517
+ requestInFlight = false;
518
+ return;
519
+ }
520
+ if (
521
+ event.type === "response.done" ||
522
+ event.type === "response.cancelled" ||
523
+ event.type === "response.failed"
524
+ ) {
525
+ active = false;
526
+ requestInFlight = false;
527
+ const response = event.response;
528
+ const status =
529
+ response && typeof response === "object"
530
+ ? (response as { status?: unknown }).status
531
+ : undefined;
532
+ if (event.type === "response.failed" || status === "failed") {
533
+ pending = undefined;
534
+ return;
535
+ }
536
+ flush();
537
+ }
538
+ },
539
+ handleError(message) {
540
+ if (!message || !/active response.*in progress/i.test(message)) {
541
+ return false;
542
+ }
543
+ pending ??= { type: "response.create" };
544
+ requestInFlight = false;
545
+ active = true;
546
+ return true;
547
+ },
548
+ reset() {
549
+ active = false;
550
+ requestInFlight = false;
551
+ pending = undefined;
552
+ },
553
+ };
554
+ }
555
+
487
556
  /**
488
557
  * Voice mode owns the chat only temporarily. Restore the captured transcript
489
558
  * when it is still the user's active thread (or the chat has no active thread)
@@ -936,22 +1005,11 @@ export function createRealtimeVoiceToolManifestCoordinator(
936
1005
  export function extractRealtimeVoiceFunctionCalls(
937
1006
  event: RealtimeServerEvent,
938
1007
  ): Array<{ name: string; callId: string; argumentsText: string }> {
939
- if (event.type === "response.function_call_arguments.done") {
940
- const name = typeof event.name === "string" ? event.name : "";
941
- const callId = typeof event.call_id === "string" ? event.call_id : "";
942
- if (!name || !callId) return [];
943
- return [
944
- {
945
- name,
946
- callId,
947
- argumentsText:
948
- typeof event.arguments === "string" ? event.arguments : "{}",
949
- },
950
- ];
951
- }
952
1008
  if (event.type !== "response.done") return [];
953
1009
  const response = event.response;
954
1010
  if (!response || typeof response !== "object") return [];
1011
+ const status = (response as { status?: unknown }).status;
1012
+ if (status !== undefined && status !== "completed") return [];
955
1013
  const output = (response as { output?: unknown }).output;
956
1014
  if (!Array.isArray(output)) return [];
957
1015
  return output.flatMap((item) => {
@@ -1018,6 +1076,7 @@ function voiceCopy(t: (key: string) => string): RealtimeVoiceModeCopy {
1018
1076
  startWithOpenAiKey: t("agentPanel.voiceMode.startWithOpenAiKey"),
1019
1077
  startVoiceMode: t("agentPanel.voiceMode.start"),
1020
1078
  keepDictating: t("agentPanel.voiceMode.keepDictating"),
1079
+ rememberPreference: t("agentPanel.voiceMode.rememberPreference"),
1021
1080
  showChat: t("agentPanel.voiceMode.showChat"),
1022
1081
  hideChat: t("agentPanel.voiceMode.hideChat"),
1023
1082
  endVoiceMode: t("agentPanel.voiceMode.end"),
@@ -1140,13 +1199,24 @@ function useRealtimeVoiceModeController(
1140
1199
  const preferencesHydratedRef = useRef(false);
1141
1200
  const preferencesEditedRef = useRef(false);
1142
1201
  const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
1143
- const toolManifestCoordinator = useMemo(
1202
+ const responseCoordinator = useMemo(
1144
1203
  () =>
1145
- createRealtimeVoiceToolManifestCoordinator((event) => {
1204
+ createRealtimeVoiceResponseCoordinator((event) => {
1146
1205
  sendDataChannelEvent(channelRef.current, event);
1147
1206
  }),
1148
1207
  [],
1149
1208
  );
1209
+ const toolManifestCoordinator = useMemo(
1210
+ () =>
1211
+ createRealtimeVoiceToolManifestCoordinator((event) => {
1212
+ if (event.type === "response.create") {
1213
+ responseCoordinator.request(event);
1214
+ } else {
1215
+ sendDataChannelEvent(channelRef.current, event);
1216
+ }
1217
+ }),
1218
+ [responseCoordinator],
1219
+ );
1150
1220
  const switchMicrophoneRef = useRef<
1151
1221
  (deviceId: string, force?: boolean) => Promise<void>
1152
1222
  >(async () => undefined);
@@ -1353,7 +1423,7 @@ function useRealtimeVoiceModeController(
1353
1423
  analyser.fftSize = 512;
1354
1424
  const source = context.createMediaStreamSource(stream);
1355
1425
  source.connect(analyser);
1356
- const buffer = new Uint8Array(analyser.frequencyBinCount);
1426
+ const buffer = new Uint8Array(analyser.fftSize);
1357
1427
  if (channel === "input") {
1358
1428
  inputSourceRef.current?.disconnect();
1359
1429
  inputSourceRef.current = source;
@@ -1460,8 +1530,9 @@ function useRealtimeVoiceModeController(
1460
1530
  if (audioContext) void audioContext.close().catch(() => undefined);
1461
1531
  audioLevels.reset();
1462
1532
  handledCallsRef.current.clear();
1533
+ responseCoordinator.reset();
1463
1534
  toolManifestCoordinator.reset();
1464
- }, [audioLevels, toolManifestCoordinator]);
1535
+ }, [audioLevels, responseCoordinator, toolManifestCoordinator]);
1465
1536
 
1466
1537
  const fail = useCallback(
1467
1538
  (message: string, options?: { openKeySettings?: boolean }) => {
@@ -1536,13 +1607,14 @@ function useRealtimeVoiceModeController(
1536
1607
  const greetingStarter = useMemo(
1537
1608
  () =>
1538
1609
  createRealtimeVoiceGreetingStarter((event) => {
1539
- sendDataChannelEvent(channelRef.current, event);
1610
+ responseCoordinator.request(event);
1540
1611
  }),
1541
- [],
1612
+ [responseCoordinator],
1542
1613
  );
1543
1614
 
1544
1615
  const handleServerEvent = useCallback(
1545
1616
  (event: RealtimeServerEvent) => {
1617
+ responseCoordinator.handleEvent(event);
1546
1618
  transcriptSequencer.handle(event);
1547
1619
  const sessionTools = extractRealtimeVoiceSessionTools(event);
1548
1620
  if (sessionTools) toolManifestCoordinator.setSessionTools(sessionTools);
@@ -1561,6 +1633,7 @@ function useRealtimeVoiceModeController(
1561
1633
  transition("listening");
1562
1634
  } else if (event.type === "input_audio_buffer.speech_stopped") {
1563
1635
  transition("working");
1636
+ responseCoordinator.request();
1564
1637
  } else if (event.type === "response.created") {
1565
1638
  // From this point onward the response is committed to audio output, so
1566
1639
  // changing voices risks violating Realtime's per-session voice lock.
@@ -1592,12 +1665,20 @@ function useRealtimeVoiceModeController(
1592
1665
  ? (response as { status?: unknown }).status
1593
1666
  : undefined;
1594
1667
  if (status === "failed") {
1595
- fail(
1596
- copy?.errors.responseFailed ??
1597
- "OpenAI could not complete the voice response.",
1598
- );
1668
+ transportGenerationRef.current += 1;
1669
+ handledCallsRef.current.clear();
1670
+ responseCoordinator.reset();
1671
+ toolManifestCoordinator.reset();
1672
+ transition("listening");
1599
1673
  return;
1600
1674
  }
1675
+ } else if (event.type === "response.failed") {
1676
+ transportGenerationRef.current += 1;
1677
+ handledCallsRef.current.clear();
1678
+ responseCoordinator.reset();
1679
+ toolManifestCoordinator.reset();
1680
+ transition("listening");
1681
+ return;
1601
1682
  } else if (event.type === "error") {
1602
1683
  const detail = event.error;
1603
1684
  const message =
@@ -1618,6 +1699,10 @@ function useRealtimeVoiceModeController(
1618
1699
  ) {
1619
1700
  return;
1620
1701
  }
1702
+ if (responseCoordinator.handleError(message)) {
1703
+ transition("working");
1704
+ return;
1705
+ }
1621
1706
  fail(
1622
1707
  message ||
1623
1708
  copy?.errors.sessionFailed ||
@@ -1640,6 +1725,7 @@ function useRealtimeVoiceModeController(
1640
1725
  syncAppState,
1641
1726
  transcriptSequencer,
1642
1727
  transition,
1728
+ responseCoordinator,
1643
1729
  toolManifestCoordinator,
1644
1730
  updateLivePreferences,
1645
1731
  ],
@@ -1728,9 +1814,10 @@ function useRealtimeVoiceModeController(
1728
1814
  audio.setAttribute("playsinline", "");
1729
1815
  audioRef.current = audio;
1730
1816
  peer.ontrack = (trackEvent) => {
1731
- const remoteStream = trackEvent.streams[0] ?? null;
1817
+ const remoteStream =
1818
+ trackEvent.streams[0] ?? new MediaStream([trackEvent.track]);
1732
1819
  audio.srcObject = remoteStream;
1733
- if (remoteStream) attachAudioMeter(remoteStream, "output");
1820
+ attachAudioMeter(remoteStream, "output");
1734
1821
  void audio.play().catch(() => undefined);
1735
1822
  };
1736
1823
 
@@ -0,0 +1,401 @@
1
+ import {
2
+ cloneElement,
3
+ createElement,
4
+ useEffect,
5
+ type CSSProperties,
6
+ type ElementType,
7
+ type KeyboardEvent,
8
+ type ReactElement,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+
12
+ import type { DesignSystemComponents } from "../../design-system/types.js";
13
+
14
+ const css = {
15
+ control: {
16
+ border: "1px solid rgb(80 80 90)",
17
+ borderRadius: 6,
18
+ background: "rgb(250 250 252)",
19
+ color: "rgb(20 20 24)",
20
+ padding: "6px 10px",
21
+ font: "inherit",
22
+ } satisfies CSSProperties,
23
+ overlay: {
24
+ position: "fixed",
25
+ inset: "20% auto auto 20%",
26
+ zIndex: 120,
27
+ border: "1px solid rgb(80 80 90)",
28
+ borderRadius: 8,
29
+ background: "white",
30
+ color: "black",
31
+ padding: 12,
32
+ } satisfies CSSProperties,
33
+ stack: {
34
+ display: "grid",
35
+ gap: 6,
36
+ } satisfies CSSProperties,
37
+ };
38
+
39
+ function portal(content: ReactElement, container?: Element | null) {
40
+ return container
41
+ ? createPortal(content, container)
42
+ : createPortal(content, document.body);
43
+ }
44
+
45
+ export const cssInJsFixtureAdapter: DesignSystemComponents = {
46
+ ActionButton: ({
47
+ children,
48
+ leadingIcon,
49
+ trailingIcon,
50
+ pending,
51
+ disabled,
52
+ onPress,
53
+ elementRef,
54
+ style,
55
+ ...props
56
+ }) => (
57
+ <button
58
+ {...props}
59
+ ref={elementRef}
60
+ disabled={disabled || pending}
61
+ onClick={(event) => onPress?.(event)}
62
+ style={{ ...css.control, ...style }}
63
+ >
64
+ {pending ? "Waiting" : leadingIcon}
65
+ {children}
66
+ {trailingIcon}
67
+ </button>
68
+ ),
69
+ IconButton: ({
70
+ label,
71
+ icon,
72
+ pending,
73
+ disabled,
74
+ onPress,
75
+ elementRef,
76
+ style,
77
+ ...props
78
+ }) => (
79
+ <button
80
+ {...props}
81
+ ref={elementRef}
82
+ aria-label={label}
83
+ disabled={disabled || pending}
84
+ onClick={(event) => onPress?.(event)}
85
+ style={{ ...css.control, ...style }}
86
+ >
87
+ {pending ? "Waiting" : icon}
88
+ </button>
89
+ ),
90
+ TextField: ({
91
+ value,
92
+ onChange,
93
+ label,
94
+ description,
95
+ errorMessage,
96
+ inputRef,
97
+ leadingContent,
98
+ trailingContent,
99
+ style,
100
+ onKeyDown,
101
+ ...props
102
+ }) => (
103
+ <label style={{ ...css.stack, ...style }}>
104
+ {label}
105
+ <span>
106
+ {leadingContent}
107
+ <input
108
+ {...props}
109
+ ref={inputRef}
110
+ value={value}
111
+ onChange={(event) => onChange(event.currentTarget.value)}
112
+ onKeyDown={onKeyDown}
113
+ style={css.control}
114
+ />
115
+ {trailingContent}
116
+ </span>
117
+ {errorMessage ?? description}
118
+ </label>
119
+ ),
120
+ TextArea: ({
121
+ value,
122
+ onChange,
123
+ label,
124
+ description,
125
+ errorMessage,
126
+ textAreaRef,
127
+ style,
128
+ onKeyDown,
129
+ ...props
130
+ }) => (
131
+ <label style={{ ...css.stack, ...style }}>
132
+ {label}
133
+ <textarea
134
+ {...props}
135
+ ref={textAreaRef}
136
+ value={value}
137
+ onChange={(event) => onChange(event.currentTarget.value)}
138
+ onKeyDown={onKeyDown}
139
+ style={css.control}
140
+ />
141
+ {errorMessage ?? description}
142
+ </label>
143
+ ),
144
+ Spinner: ({ label, size: _size, ...props }) => (
145
+ <span {...props} role={label ? "status" : undefined} aria-label={label}>
146
+ Loading
147
+ </span>
148
+ ),
149
+ Skeleton: ({ width, height, shape: _shape, style, ...props }) => (
150
+ <span
151
+ {...props}
152
+ aria-hidden="true"
153
+ style={{
154
+ display: "block",
155
+ width,
156
+ height,
157
+ background: "silver",
158
+ ...style,
159
+ }}
160
+ />
161
+ ),
162
+ Status: ({ children, icon, tone: _tone, size: _size, ...props }) => (
163
+ <span {...props}>
164
+ {icon}
165
+ {children}
166
+ </span>
167
+ ),
168
+ Surface: ({
169
+ children,
170
+ as = "div",
171
+ interactive,
172
+ onPress,
173
+ elevation: _elevation,
174
+ padding: _padding,
175
+ style,
176
+ ...props
177
+ }) => {
178
+ const Element = as as ElementType;
179
+ return (
180
+ <Element
181
+ {...props}
182
+ role={interactive ? "button" : undefined}
183
+ tabIndex={interactive ? 0 : undefined}
184
+ onClick={onPress}
185
+ onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
186
+ if (interactive && (event.key === "Enter" || event.key === " "))
187
+ onPress?.(event);
188
+ }}
189
+ style={{ border: "1px solid gray", padding: 8, ...style }}
190
+ >
191
+ {children}
192
+ </Element>
193
+ );
194
+ },
195
+ Avatar: ({
196
+ name,
197
+ src,
198
+ fallback,
199
+ imageRef,
200
+ size: _size,
201
+ status: _status,
202
+ ...props
203
+ }) => (
204
+ <span {...props}>
205
+ {src ? (
206
+ <img ref={imageRef} src={src} alt={name} />
207
+ ) : (
208
+ (fallback ?? name.slice(0, 2))
209
+ )}
210
+ </span>
211
+ ),
212
+ Tooltip: ({
213
+ trigger,
214
+ content,
215
+ open,
216
+ defaultOpen,
217
+ disabled,
218
+ portalContainer,
219
+ }) => (
220
+ <>
221
+ {trigger}
222
+ {!disabled && (open ?? defaultOpen)
223
+ ? portal(
224
+ <div role="tooltip" style={css.overlay}>
225
+ {content}
226
+ </div>,
227
+ portalContainer,
228
+ )
229
+ : null}
230
+ </>
231
+ ),
232
+ Menu: ({
233
+ trigger,
234
+ items = [],
235
+ sections = [],
236
+ open,
237
+ defaultOpen,
238
+ onAction,
239
+ portalContainer,
240
+ }) => (
241
+ <>
242
+ {trigger}
243
+ {(open ?? defaultOpen)
244
+ ? portal(
245
+ <div role="menu" style={css.overlay}>
246
+ {[...items, ...sections.flatMap((section) => section.items)].map(
247
+ (item) => (
248
+ <button
249
+ key={item.id}
250
+ role="menuitem"
251
+ disabled={item.disabled}
252
+ onClick={() => onAction(item.id)}
253
+ >
254
+ {item.label}
255
+ </button>
256
+ ),
257
+ )}
258
+ </div>,
259
+ portalContainer,
260
+ )
261
+ : null}
262
+ </>
263
+ ),
264
+ Popover: ({ trigger, children, open, defaultOpen, portalContainer }) => (
265
+ <>
266
+ {trigger}
267
+ {(open ?? defaultOpen)
268
+ ? portal(
269
+ <div role="dialog" style={css.overlay}>
270
+ {children}
271
+ </div>,
272
+ portalContainer,
273
+ )
274
+ : null}
275
+ </>
276
+ ),
277
+ Dialog: ({
278
+ open,
279
+ title,
280
+ description,
281
+ children,
282
+ footer,
283
+ trigger,
284
+ portalContainer,
285
+ initialFocusRef,
286
+ restoreFocusRef,
287
+ }) => {
288
+ useEffect(() => {
289
+ if (open) initialFocusRef?.current?.focus();
290
+ return () => restoreFocusRef?.current?.focus();
291
+ }, [initialFocusRef, open, restoreFocusRef]);
292
+ return (
293
+ <>
294
+ {trigger}
295
+ {open
296
+ ? portal(
297
+ <div role="dialog" aria-modal="true" style={css.overlay}>
298
+ <h2>{title}</h2>
299
+ {description ? <p>{description}</p> : null}
300
+ {children}
301
+ {footer}
302
+ </div>,
303
+ portalContainer,
304
+ )
305
+ : null}
306
+ </>
307
+ );
308
+ },
309
+ Picker: ({
310
+ options,
311
+ value,
312
+ onChange,
313
+ label,
314
+ open,
315
+ portalContainer,
316
+ ...props
317
+ }) => (
318
+ <label>
319
+ {label}
320
+ <button
321
+ {...props}
322
+ role="combobox"
323
+ aria-expanded={open}
324
+ style={css.control}
325
+ >
326
+ {options.find((option) => option.value === value)?.label ?? "Choose"}
327
+ </button>
328
+ {open
329
+ ? portal(
330
+ <div role="listbox" style={css.overlay}>
331
+ {options.map((option) => (
332
+ <button
333
+ key={option.value}
334
+ role="option"
335
+ aria-selected={option.value === value}
336
+ disabled={option.disabled}
337
+ onClick={() => onChange(option.value)}
338
+ >
339
+ {option.label}
340
+ </button>
341
+ ))}
342
+ </div>,
343
+ portalContainer,
344
+ )
345
+ : null}
346
+ </label>
347
+ ),
348
+ Checkbox: ({ checked, onChange, label, inputRef, ...props }) => (
349
+ <button
350
+ {...props}
351
+ ref={inputRef}
352
+ role="checkbox"
353
+ aria-checked={checked}
354
+ onClick={() => onChange(!checked)}
355
+ style={css.control}
356
+ >
357
+ {label}
358
+ </button>
359
+ ),
360
+ Switch: ({ checked, onChange, label, inputRef, ...props }) => (
361
+ <button
362
+ {...props}
363
+ ref={inputRef}
364
+ role="switch"
365
+ aria-checked={checked}
366
+ onClick={() => onChange(!checked)}
367
+ style={css.control}
368
+ >
369
+ {label}
370
+ </button>
371
+ ),
372
+ Tabs: ({ items, value, onChange, orientation }) => (
373
+ <div>
374
+ <div role="tablist" aria-orientation={orientation}>
375
+ {items.map((item) => (
376
+ <button
377
+ key={item.value}
378
+ role="tab"
379
+ aria-selected={item.value === value}
380
+ disabled={item.disabled}
381
+ onClick={() => onChange(item.value)}
382
+ >
383
+ {item.icon}
384
+ {item.label}
385
+ </button>
386
+ ))}
387
+ </div>
388
+ {items.map((item) =>
389
+ item.value === value ? (
390
+ <div key={item.value} role="tabpanel">
391
+ {item.content}
392
+ </div>
393
+ ) : null,
394
+ )}
395
+ </div>
396
+ ),
397
+ };
398
+
399
+ export function CloneTrigger({ trigger }: { trigger: ReactElement }) {
400
+ return cloneElement(trigger);
401
+ }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { defaultDesignSystemComponents } from "../design-system/default-adapter.js";
6
+ import { DESIGN_SYSTEM_CONTRACT_VERSION } from "../design-system/types.js";
7
+ import { cssInJsFixtureAdapter } from "./__fixtures__/css-in-js-adapter.js";
8
+ import {
9
+ assertDesignSystemConformance,
10
+ DESIGN_SYSTEM_CONFORMANCE_CHECKS,
11
+ runDesignSystemConformance,
12
+ } from "./runner.js";
13
+ import {
14
+ assertDesignSystemContractVersion,
15
+ DESIGN_SYSTEM_CONTRACT_EVOLUTION_POLICY,
16
+ } from "./version.js";
17
+
18
+ class ResizeObserverStub {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ }
23
+
24
+ describe("design-system conformance kit", () => {
25
+ beforeEach(() => {
26
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", false);
27
+ vi.stubGlobal("ResizeObserver", ResizeObserverStub);
28
+ HTMLElement.prototype.scrollIntoView = vi.fn();
29
+ HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
30
+ HTMLElement.prototype.setPointerCapture = vi.fn();
31
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
32
+ });
33
+
34
+ afterEach(() => {
35
+ document.body.replaceChildren();
36
+ vi.restoreAllMocks();
37
+ vi.unstubAllGlobals();
38
+ });
39
+
40
+ it("publishes named coverage for all seventeen components and overlay concerns", () => {
41
+ const covered = new Set(
42
+ DESIGN_SYSTEM_CONFORMANCE_CHECKS.flatMap((check) => check.components),
43
+ );
44
+ expect(covered.size).toBe(17);
45
+ expect(DESIGN_SYSTEM_CONFORMANCE_CHECKS.map((check) => check.id)).toEqual(
46
+ expect.arrayContaining([
47
+ "overlay.portal-and-z-index-stacking",
48
+ "overlay.focus-interoperability",
49
+ ]),
50
+ );
51
+ });
52
+
53
+ it("enforces the published contract evolution policy", () => {
54
+ expect(DESIGN_SYSTEM_CONTRACT_EVOLUTION_POLICY.minor).toContain(
55
+ "optional props",
56
+ );
57
+ expect(DESIGN_SYSTEM_CONTRACT_EVOLUTION_POLICY.major).toContain(
58
+ "behavioral",
59
+ );
60
+ expect(() =>
61
+ assertDesignSystemContractVersion(DESIGN_SYSTEM_CONTRACT_VERSION),
62
+ ).not.toThrow();
63
+ expect(() => assertDesignSystemContractVersion(2)).toThrow(/incompatible/);
64
+ });
65
+
66
+ it("passes the styling-independent CSS-in-JS fixture", async () => {
67
+ const report = await assertDesignSystemConformance({
68
+ adapterName: "CSS-in-JS fixture",
69
+ components: cssInJsFixtureAdapter,
70
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
71
+ });
72
+ expect(report.passed).toBe(true);
73
+ });
74
+
75
+ it("passes the default shadcn and Radix adapter", async () => {
76
+ const report = await runDesignSystemConformance({
77
+ adapterName: "Toolkit default",
78
+ components: defaultDesignSystemComponents,
79
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
80
+ });
81
+ expect(report.results.filter((result) => !result.passed)).toEqual([]);
82
+ });
83
+ });