@ephia/dova-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,489 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { EphiaProvider } from "../../react/provider/EphiaProvider";
5
+ import { useOptionalEphiaContext } from "../../react/provider/EphiaContext";
6
+ import { useEphia, useEphiaAudioLevel } from "../../react/hooks/useEphia";
7
+ import type { EphiaStatus } from "../../react/store/types";
8
+ import type { EphiaSessionOptions } from "../../shared/types/session";
9
+ import type { Transport } from "../../core/transport/Transport";
10
+ import {
11
+ EphiaLogo,
12
+ EPHIA_BRAND_PURPLE,
13
+ logoWidthFromHeight,
14
+ } from "../components/EphiaLogo";
15
+ import {
16
+ EPHIA_FLOATING_BUTTON_RADIUS_CSS,
17
+ type EphiaFloatingButtonBorderRadius,
18
+ } from "./appearance";
19
+ import { MinimalProcessingBars } from "./MinimalProcessingBars";
20
+ import { StandardIntensityVisualizer } from "./StandardIntensityVisualizer";
21
+
22
+ export type { EphiaFloatingButtonBorderRadius } from "./appearance";
23
+
24
+ const EPHIA_APP_URL = "https://ephia.app";
25
+
26
+ function EphiaExternalLinkIcon() {
27
+ return (
28
+ <svg
29
+ className="ephia-transcribe-poweredby-icon"
30
+ viewBox="0 0 24 24"
31
+ aria-hidden
32
+ focusable="false"
33
+ >
34
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
35
+ <path d="M15 3h6v6" />
36
+ <path d="M10 14 21 3" />
37
+ </svg>
38
+ );
39
+ }
40
+
41
+ /* ── Types ───────────────────────────────────────────────────────── */
42
+
43
+ export type EphiaFloatingButtonPosition =
44
+ | "bottom-right"
45
+ | "bottom-left"
46
+ | "bottom-center"
47
+ | "top-right"
48
+ | "top-left"
49
+ | "top-center";
50
+ export type EphiaFloatingButtonTheme = "light" | "dark";
51
+ export type EphiaFloatingButtonSize = "S" | "M" | "L";
52
+ export type EphiaFloatingButtonVariant = "minimal" | "standard";
53
+ export type EphiaFloatingButtonInteractionMode = "toggle" | "push-to-talk";
54
+ export type EphiaFloatingButtonCustomColors = {
55
+ /** Ink color: logo, visualiseur Standard, texte. Any valid CSS color. */
56
+ primary: string;
57
+ /** Background color. Any valid CSS color. */
58
+ secondary: string;
59
+ };
60
+
61
+ export interface EphiaFloatingButtonProps {
62
+ /**
63
+ * API URL. Optional override (ex. http://localhost:8000 en dev).
64
+ * Sans override : EPHIA_SDK_ENDPOINT, sinon https://api.ephia.app.
65
+ */
66
+ apiUrl?: string;
67
+ apiKey?: string;
68
+ bearerToken?: string;
69
+ clientType?: string;
70
+ transport?: Transport;
71
+ position?: EphiaFloatingButtonPosition;
72
+ theme?: EphiaFloatingButtonTheme;
73
+ size?: EphiaFloatingButtonSize;
74
+ variant?: EphiaFloatingButtonVariant;
75
+ colors?: EphiaFloatingButtonCustomColors;
76
+ /** Corner radius of the dictation control. Default `md`. */
77
+ borderRadius?: EphiaFloatingButtonBorderRadius;
78
+ /** Interaction model. `"toggle"` (default): click to start, click to stop. `"push-to-talk"`: hold to record, release to stop. */
79
+ interactionMode?: EphiaFloatingButtonInteractionMode;
80
+ className?: string;
81
+ style?: React.CSSProperties;
82
+ sessionOptions?: EphiaSessionOptions;
83
+ onError?: (error: { code: string; message: string }) => void;
84
+ onStatusChange?: (status: EphiaStatus) => void;
85
+ }
86
+
87
+ const SIZE_CONFIG: Record<EphiaFloatingButtonSize, { logoHeightPx: number }> = {
88
+ S: { logoHeightPx: 24 },
89
+ M: { logoHeightPx: 32 },
90
+ L: { logoHeightPx: 44 },
91
+ };
92
+
93
+ /** Logo légèrement réduit en pilule (full) pour l’équilibre visuel. */
94
+ const LOGO_SCALE_WHEN_RADIUS_FULL = 0.92;
95
+
96
+ const POSITION_STYLE: Record<EphiaFloatingButtonPosition, React.CSSProperties> = {
97
+ "bottom-right": { bottom: 24, right: 24 },
98
+ "bottom-left": { bottom: 24, left: 24 },
99
+ "bottom-center": { bottom: 24, left: "50%", transform: "translateX(-50%)" },
100
+ "top-right": { top: 24, right: 24 },
101
+ "top-left": { top: 24, left: 24 },
102
+ "top-center": { top: 24, left: "50%", transform: "translateX(-50%)" },
103
+ };
104
+
105
+ /* ── Inner button — requires Provider in scope ───────────────────── */
106
+
107
+ interface InnerProps {
108
+ position: EphiaFloatingButtonPosition;
109
+ theme: EphiaFloatingButtonTheme;
110
+ size: EphiaFloatingButtonSize;
111
+ variant: EphiaFloatingButtonVariant;
112
+ borderRadius: EphiaFloatingButtonBorderRadius;
113
+ interactionMode: EphiaFloatingButtonInteractionMode;
114
+ colors?: EphiaFloatingButtonCustomColors;
115
+ className?: string;
116
+ style?: React.CSSProperties;
117
+ onError?: (error: { code: string; message: string }) => void;
118
+ onStatusChange?: (status: EphiaStatus) => void;
119
+ }
120
+
121
+ function EphiaFloatingButtonInner({
122
+ position,
123
+ theme,
124
+ size,
125
+ variant,
126
+ borderRadius,
127
+ interactionMode,
128
+ colors,
129
+ className,
130
+ style,
131
+ onError,
132
+ onStatusChange,
133
+ }: InnerProps) {
134
+ const { status, isRecording, isProcessing, start, stop, error, activeTargetId } = useEphia();
135
+ const audio = useEphiaAudioLevel();
136
+
137
+ // Traitement sans micro ouvert : bouton en attente.
138
+ const isArming = isProcessing;
139
+ const voiceLevel = Math.min(1, Math.max(0, audio?.level ?? audio?.rms ?? 0));
140
+ const isLiveSession =
141
+ isRecording || (!!audio?.localAudioPublished && !!audio?.micReady);
142
+
143
+ // Callbacks — compare by value, not reference, to catch same-code re-errors
144
+ const lastErrorRef = useRef<{ code: string; message: string } | null>(null);
145
+ useEffect(() => {
146
+ if (error && (lastErrorRef.current?.code !== error.code || lastErrorRef.current?.message !== error.message)) {
147
+ lastErrorRef.current = error;
148
+ onError?.(error);
149
+ }
150
+ }, [error, onError]);
151
+
152
+ const lastStatusRef = useRef(status);
153
+ useEffect(() => {
154
+ if (status !== lastStatusRef.current) {
155
+ lastStatusRef.current = status;
156
+ onStatusChange?.(status);
157
+ }
158
+ }, [status, onStatusChange]);
159
+
160
+ // Screen reader status
161
+ const statusAnnouncement =
162
+ isLiveSession ? "Dictée en cours" :
163
+ status === "processing" ? "Traitement" :
164
+ status === "error" ? "Erreur de dictée" : "";
165
+
166
+ // Colors — validate non-empty strings to avoid silent CSS failures
167
+ const isLight = theme === "light";
168
+ const isValidColor = (c: string | undefined): boolean => Boolean(c?.trim());
169
+ const hasCustomColors = isValidColor(colors?.primary) && isValidColor(colors?.secondary);
170
+ const swapForLight = hasCustomColors && isLight;
171
+ const effectivePrimary = hasCustomColors
172
+ ? (swapForLight ? colors!.secondary.trim() : colors!.primary.trim())
173
+ : (isLight ? "#ffffff" : "#262626");
174
+ const effectiveSecondary = hasCustomColors
175
+ ? (swapForLight ? colors!.primary.trim() : colors!.secondary.trim())
176
+ : null;
177
+
178
+ const safeSize = (size && size in SIZE_CONFIG) ? size : "M";
179
+ const { logoHeightPx: baseLogoHeightPx } = SIZE_CONFIG[safeSize];
180
+ const logoHeightPx =
181
+ borderRadius === "full"
182
+ ? Math.round(baseLogoHeightPx * LOGO_SCALE_WHEN_RADIUS_FULL)
183
+ : baseLogoHeightPx;
184
+ const logoWidthPx = logoWidthFromHeight(logoHeightPx);
185
+
186
+ const isStandardVariant = variant === "standard";
187
+ const showTimer = isStandardVariant && isRecording;
188
+ const showProcessing = isStandardVariant && isArming && !isRecording;
189
+ const showStandardPrompt = isStandardVariant && !isRecording && !isArming;
190
+ const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
191
+ const sessionStartRef = useRef<number | null>(null);
192
+ const timerMeasureRef = useRef<HTMLSpanElement | null>(null);
193
+ const [logoSlotWidthPx, setLogoSlotWidthPx] = useState(logoHeightPx);
194
+
195
+ useEffect(() => {
196
+ if (showTimer) {
197
+ sessionStartRef.current = Date.now();
198
+ setSessionElapsedMs(0);
199
+ const id = setInterval(() => {
200
+ if (sessionStartRef.current != null) setSessionElapsedMs(Date.now() - sessionStartRef.current);
201
+ }, 200);
202
+ return () => clearInterval(id);
203
+ }
204
+ sessionStartRef.current = null;
205
+ }, [showTimer]);
206
+
207
+ const timerLabel = useMemo(() => {
208
+ const s = Math.max(0, Math.floor(sessionElapsedMs / 1000));
209
+ return `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
210
+ }, [sessionElapsedMs]);
211
+
212
+ // useLayoutEffect is intentional: measures DOM before paint to avoid width flicker.
213
+ // Only runs when recording (showTimer=true), so SSR hydration mismatch is not a concern.
214
+ useLayoutEffect(() => {
215
+ if (!showTimer) { setLogoSlotWidthPx(logoHeightPx); return; }
216
+ const el = timerMeasureRef.current;
217
+ const measured = el ? Math.ceil(el.getBoundingClientRect().width) : 0;
218
+ setLogoSlotWidthPx(Math.max(logoHeightPx, measured + 12));
219
+ }, [showTimer, timerLabel, logoHeightPx]);
220
+
221
+ const logoVariant = hasCustomColors ? "mono" : "color";
222
+ const waveformColor = hasCustomColors ? effectivePrimary : EPHIA_BRAND_PURPLE;
223
+ const radiusCss =
224
+ EPHIA_FLOATING_BUTTON_RADIUS_CSS[borderRadius] ?? EPHIA_FLOATING_BUTTON_RADIUS_CSS.md;
225
+
226
+ // useState initializer so SSR gives false without crashing, client gets real value on mount
227
+ const [reducedMotion] = useState(
228
+ () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
229
+ );
230
+
231
+ return (
232
+ <div
233
+ className="ephia-transcribe-floating-root"
234
+ data-ephia-control="true"
235
+ style={{ ...POSITION_STYLE[position], ...style }}
236
+ >
237
+ {/* Screen-reader live region */}
238
+ <div
239
+ role="status"
240
+ aria-live="polite"
241
+ aria-atomic="true"
242
+ style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap" }}
243
+ >
244
+ {statusAnnouncement}
245
+ </div>
246
+
247
+ {/* Inner Ephia button — same markup as the original TranscriptionButton */}
248
+ <div
249
+ className={`ephia-transcribe-root ephia-transcribe-root--${theme} ephia-transcribe-size--${size} ephia-transcribe-radius--${borderRadius}${className ? ` ${className}` : ""}`}
250
+ style={
251
+ {
252
+ ["--ephia-transcribe-radius" as string]: radiusCss,
253
+ ...(hasCustomColors
254
+ ? {
255
+ backgroundColor: effectiveSecondary ?? undefined,
256
+ ["--ephia-transcribe-ink" as string]: effectivePrimary,
257
+ }
258
+ : {}),
259
+ } as React.CSSProperties
260
+ }
261
+ >
262
+ {/* Lien externe ephia.app */}
263
+ <div className={`ephia-transcribe-poweredby ephia-transcribe-poweredby--${theme}`}>
264
+ <a
265
+ href={EPHIA_APP_URL}
266
+ target="_blank"
267
+ rel="noopener noreferrer"
268
+ className="ephia-transcribe-poweredby-btn"
269
+ aria-label="En savoir plus sur Ephia — ouvre ephia.app dans un nouvel onglet"
270
+ onClick={(e) => e.stopPropagation()}
271
+ onPointerDown={(e) => e.stopPropagation()}
272
+ >
273
+ <EphiaExternalLinkIcon />
274
+ </a>
275
+ <div className="ephia-transcribe-poweredby-tooltip">ephia.app</div>
276
+ </div>
277
+
278
+ {/* Recording glow ring */}
279
+ {isLiveSession && !reducedMotion && (
280
+ <div
281
+ className="ephia-transcribe-ring"
282
+ style={
283
+ hasCustomColors
284
+ ? {
285
+ boxShadow: `0 0 20px color-mix(in srgb, ${effectivePrimary} 55%, transparent)`,
286
+ }
287
+ : {
288
+ boxShadow:
289
+ "0 0 24px rgba(114, 58, 222, 0.55), 0 0 14px rgba(0, 235, 208, 0.45)",
290
+ }
291
+ }
292
+ />
293
+ )}
294
+
295
+ <button
296
+ type="button"
297
+ {...(interactionMode === "push-to-talk"
298
+ ? {
299
+ onPointerDown: (e: React.PointerEvent) => {
300
+ e.preventDefault();
301
+ if (typeof window !== "undefined") {
302
+ (window as any).__ephia_record_click_ts = Date.now();
303
+ window.dispatchEvent(
304
+ new CustomEvent("ephia:sdk-debug", {
305
+ detail: {
306
+ type: "sdk.record.click",
307
+ sessionId: null,
308
+ payload: { message: "Push-to-talk press", ts: Date.now() },
309
+ },
310
+ })
311
+ );
312
+ }
313
+ if (!isLiveSession && !isArming) void start(activeTargetId ?? undefined, { startupBufferMs: 5000 });
314
+ },
315
+ onPointerUp: () => { if (isLiveSession) void stop(); },
316
+ onPointerLeave: () => { if (isLiveSession) void stop(); },
317
+ }
318
+ : {
319
+ onClick: () => {
320
+ if (isArming) return;
321
+ if (typeof window !== "undefined") {
322
+ (window as any).__ephia_record_click_ts = Date.now();
323
+ window.dispatchEvent(
324
+ new CustomEvent("ephia:sdk-debug", {
325
+ detail: {
326
+ type: "sdk.record.click",
327
+ sessionId: null,
328
+ payload: { message: "Clic sur Record", ts: Date.now() },
329
+ },
330
+ })
331
+ );
332
+ }
333
+ if (isLiveSession) { void stop(); } else { void start(activeTargetId ?? undefined, { startupBufferMs: 5000 }); }
334
+ },
335
+ })}
336
+ disabled={isArming}
337
+ className="ephia-transcribe-btn"
338
+ aria-pressed={isLiveSession}
339
+ aria-busy={isArming}
340
+ aria-label={
341
+ isLiveSession ? "Arrêter la dictée" :
342
+ isArming ? "Traitement en cours…" :
343
+ "Démarrer la dictée"
344
+ }
345
+ >
346
+ <span
347
+ className={`ephia-transcribe-btn-inner${isStandardVariant ? " ephia-transcribe-btn-inner--standard" : ""}${showProcessing ? " ephia-transcribe-btn-inner--processing" : ""}`}
348
+ >
349
+ {variant === "minimal" ? (
350
+ <span
351
+ className={`ephia-transcribe-minimal-swap${isRecording || isArming ? " ephia-transcribe-minimal-swap--recording" : ""}`}
352
+ style={{ width: logoWidthPx, height: logoHeightPx }}
353
+ >
354
+ <span className="ephia-transcribe-minimal-swap-layer ephia-transcribe-minimal-swap-layer--logo" aria-hidden>
355
+ <span className="ephia-transcribe-logo-clip ephia-transcribe-logo-slot" style={{ width: logoWidthPx, height: logoHeightPx }}>
356
+ <span className="ephia-transcribe-logo-slot-layer ephia-transcribe-logo-slot-layer--logo" aria-hidden>
357
+ <EphiaLogo
358
+ height={logoHeightPx}
359
+ width={logoWidthPx}
360
+ variant={logoVariant}
361
+ fill={effectivePrimary}
362
+ isRecording={isLiveSession}
363
+ />
364
+ </span>
365
+ </span>
366
+ </span>
367
+ <span className="ephia-transcribe-minimal-swap-layer ephia-transcribe-minimal-swap-layer--activity" aria-hidden>
368
+ <MinimalProcessingBars
369
+ active={isRecording || isArming}
370
+ size={safeSize}
371
+ color={waveformColor}
372
+ />
373
+ </span>
374
+ </span>
375
+ ) : (
376
+ // standard variant
377
+ <span
378
+ className={`ephia-transcribe-logo-clip ephia-transcribe-logo-slot${showTimer ? " ephia-transcribe-logo-slot--recording" : ""}`}
379
+ style={{ width: logoSlotWidthPx, height: logoHeightPx }}
380
+ >
381
+ <span className="ephia-transcribe-logo-slot-layer ephia-transcribe-logo-slot-layer--logo" aria-hidden>
382
+ <EphiaLogo
383
+ height={logoHeightPx}
384
+ width={logoWidthPx}
385
+ variant={logoVariant}
386
+ fill={effectivePrimary}
387
+ isRecording={isLiveSession}
388
+ />
389
+ </span>
390
+ {showTimer && (
391
+ <>
392
+ <span className="ephia-transcribe-logo-slot-layer ephia-transcribe-logo-slot-layer--timer" aria-hidden>
393
+ <span className="ephia-transcribe-session-timer">{timerLabel}</span>
394
+ </span>
395
+ <span className="ephia-transcribe-session-timer ephia-transcribe-session-timer--measure" ref={timerMeasureRef}>
396
+ {timerLabel}
397
+ </span>
398
+ </>
399
+ )}
400
+ </span>
401
+ )}
402
+
403
+ {isStandardVariant && (
404
+ <span
405
+ className={`ephia-transcribe-standard-right-swap${showStandardPrompt ? " ephia-transcribe-standard-right-swap--prompt" : showProcessing ? " ephia-transcribe-standard-right-swap--processing" : " ephia-transcribe-standard-right-swap--waveform"}`}
406
+ aria-hidden
407
+ >
408
+ <span className="ephia-transcribe-standard-right-layer ephia-transcribe-standard-right-layer--prompt">
409
+ Commencer à dicter
410
+ </span>
411
+ <span className="ephia-transcribe-standard-right-layer ephia-transcribe-standard-right-layer--processing">
412
+ Traitement…
413
+ </span>
414
+ {!showProcessing ? (
415
+ <span className="ephia-transcribe-standard-right-layer ephia-transcribe-standard-right-layer--waveform">
416
+ <StandardIntensityVisualizer
417
+ enabled={isRecording}
418
+ level={voiceLevel}
419
+ />
420
+ </span>
421
+ ) : null}
422
+ </span>
423
+ )}
424
+ </span>
425
+ </button>
426
+ </div>
427
+ </div>
428
+ );
429
+ }
430
+
431
+ /* ── Public component ────────────────────────────────────────────── */
432
+
433
+ export function EphiaFloatingButton({
434
+ apiUrl,
435
+ apiKey,
436
+ bearerToken,
437
+ clientType,
438
+ transport,
439
+ position = "bottom-center",
440
+ theme = "light",
441
+ size = "M",
442
+ variant = "standard",
443
+ borderRadius = "lg",
444
+ interactionMode = "toggle",
445
+ colors,
446
+ className,
447
+ style,
448
+ sessionOptions,
449
+ onError,
450
+ onStatusChange,
451
+ }: EphiaFloatingButtonProps) {
452
+ const ctx = useOptionalEphiaContext();
453
+ const safeRadius: EphiaFloatingButtonBorderRadius =
454
+ borderRadius && borderRadius in EPHIA_FLOATING_BUTTON_RADIUS_CSS ? borderRadius : "lg";
455
+
456
+ const innerProps = useMemo<InnerProps>(() => ({
457
+ position,
458
+ theme,
459
+ size,
460
+ variant,
461
+ borderRadius: safeRadius,
462
+ interactionMode,
463
+ colors,
464
+ className,
465
+ style,
466
+ onError,
467
+ onStatusChange,
468
+ }), [position, theme, size, variant, safeRadius, interactionMode, colors, className, style, onError, onStatusChange]);
469
+
470
+ if (ctx) {
471
+ return <EphiaFloatingButtonInner {...innerProps} />;
472
+ }
473
+
474
+ return (
475
+ <EphiaProvider
476
+ apiUrl={apiUrl}
477
+ apiKey={apiKey}
478
+ bearerToken={bearerToken}
479
+ clientType={clientType}
480
+ transport={transport}
481
+ options={{
482
+ language: sessionOptions?.language as string | undefined,
483
+ sessionOptions: sessionOptions,
484
+ }}
485
+ >
486
+ <EphiaFloatingButtonInner {...innerProps} />
487
+ </EphiaProvider>
488
+ );
489
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ type MinimalBarsSize = "S" | "M" | "L";
6
+
7
+ const BAR_LAYOUT: Record<MinimalBarsSize, { barWidthPx: number; gapPx: number }> = {
8
+ S: { barWidthPx: 3, gapPx: 2.5 },
9
+ M: { barWidthPx: 3.5, gapPx: 3 },
10
+ L: { barWidthPx: 4, gapPx: 3.5 },
11
+ };
12
+
13
+ const TAU = Math.PI * 2;
14
+
15
+ /** Chaque barre : fréquences différentes + petite amplitude → pas de cycle min/max synchronisé. */
16
+ const BAR_MOTION = [
17
+ { base: 0.62, a1: 0.14, a2: 0.09, f1: 1.25, f2: 2.05, p1: 0, p2: 0.7 },
18
+ { base: 0.64, a1: 0.15, a2: 0.07, f1: 1.65, f2: 2.35, p1: 1.4, p2: 2.2 },
19
+ { base: 0.6, a1: 0.12, a2: 0.1, f1: 1.05, f2: 1.85, p1: 2.5, p2: 0.4 },
20
+ ] as const;
21
+
22
+ const LERP = 0.14;
23
+
24
+ function clamp01(x: number): number {
25
+ return Math.max(0.36, Math.min(0.9, x));
26
+ }
27
+
28
+ function targetHeight(phaseSec: number, spec: (typeof BAR_MOTION)[number]): number {
29
+ const t = phaseSec;
30
+ const v =
31
+ spec.base +
32
+ spec.a1 * Math.sin(t * spec.f1 * TAU + spec.p1) +
33
+ spec.a2 * Math.sin(t * spec.f2 * TAU + spec.p2);
34
+ return clamp01(v);
35
+ }
36
+
37
+ /**
38
+ * Indicateur minimal : 3 barres, même couleur, respiration douce (pas de balayage min→max).
39
+ */
40
+ export function MinimalProcessingBars({
41
+ active,
42
+ size = "M",
43
+ color,
44
+ }: {
45
+ active: boolean;
46
+ size?: MinimalBarsSize;
47
+ color: string;
48
+ }) {
49
+ const rootRef = useRef<HTMLSpanElement>(null);
50
+ const heightsRef = useRef([0.55, 0.58, 0.54]);
51
+ const layout = BAR_LAYOUT[size] ?? BAR_LAYOUT.M;
52
+
53
+ useEffect(() => {
54
+ const root = rootRef.current;
55
+ if (!root) return;
56
+
57
+ const applyHeights = (h: number[]) => {
58
+ root.style.setProperty("--ephia-bar-0", String(h[0]));
59
+ root.style.setProperty("--ephia-bar-1", String(h[1]));
60
+ root.style.setProperty("--ephia-bar-2", String(h[2]));
61
+ };
62
+
63
+ if (!active) {
64
+ heightsRef.current = [0.52, 0.56, 0.54];
65
+ applyHeights(heightsRef.current);
66
+ return;
67
+ }
68
+
69
+ const reducedMotion =
70
+ typeof window !== "undefined" &&
71
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
72
+
73
+ if (reducedMotion) {
74
+ applyHeights([0.55, 0.58, 0.54]);
75
+ return;
76
+ }
77
+
78
+ let raf = 0;
79
+ const start = performance.now();
80
+
81
+ const tick = (now: number) => {
82
+ const phase = (now - start) / 1000;
83
+ const current = heightsRef.current;
84
+
85
+ for (let i = 0; i < 3; i++) {
86
+ const target = targetHeight(phase, BAR_MOTION[i]);
87
+ current[i] += (target - current[i]) * LERP;
88
+ }
89
+
90
+ applyHeights(current);
91
+ raf = requestAnimationFrame(tick);
92
+ };
93
+
94
+ raf = requestAnimationFrame(tick);
95
+ return () => cancelAnimationFrame(raf);
96
+ }, [active]);
97
+
98
+ return (
99
+ <span
100
+ ref={rootRef}
101
+ className="ephia-transcribe-minimal-bars"
102
+ style={
103
+ {
104
+ ["--ephia-minimal-bar-gap" as string]: `${layout.gapPx}px`,
105
+ } as React.CSSProperties
106
+ }
107
+ aria-hidden
108
+ >
109
+ {BAR_MOTION.map((_, i) => (
110
+ <span
111
+ key={i}
112
+ className="ephia-transcribe-minimal-bar"
113
+ style={{
114
+ width: layout.barWidthPx,
115
+ backgroundColor: color,
116
+ ["--ephia-bar-scale" as string]: `var(--ephia-bar-${i}, 0.5)`,
117
+ }}
118
+ />
119
+ ))}
120
+ </span>
121
+ );
122
+ }