@linto-ai/transcript-ui-core 0.9.23 → 0.9.24-unstable.202610151359

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.
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import {
3
+ DEFAULT_SUMMARY_CAPABILITIES,
4
+ computeCanChat,
5
+ computeSummaryCapabilities,
6
+ } from "../capabilities"
7
+
8
+ const TEXT_ONLY = { text: "edit", speakers: "edit" } as const
9
+
10
+ describe("computeSummaryCapabilities", () => {
11
+ it("falls back on the defaults when the host sets none", () => {
12
+ expect(computeSummaryCapabilities(TEXT_ONLY)).toBe(
13
+ DEFAULT_SUMMARY_CAPABILITIES,
14
+ )
15
+ })
16
+
17
+ it("returns the host's permissions", () => {
18
+ const summary = { generate: true, edit: false, forceGenerate: true }
19
+ expect(computeSummaryCapabilities({ ...TEXT_ONLY, summary })).toBe(summary)
20
+ })
21
+ })
22
+
23
+ describe("computeCanChat", () => {
24
+ it("keeps the chat when the host sets nothing", () => {
25
+ expect(computeCanChat(TEXT_ONLY)).toBe(true)
26
+ })
27
+
28
+ it("follows the host's capability", () => {
29
+ expect(computeCanChat({ ...TEXT_ONLY, chat: "disabled" })).toBe(false)
30
+ expect(computeCanChat({ ...TEXT_ONLY, chat: "enabled" })).toBe(true)
31
+ })
32
+ })
@@ -0,0 +1,248 @@
1
+ import { afterEach, describe, expect, it } from "bun:test"
2
+ import { nextTick } from "vue"
3
+ import { createCore } from "../createCore"
4
+
5
+ /** Stands in for the browser's media query so the core can be built at a
6
+ * chosen width and resized — there is no DOM under `bun test`. */
7
+ function installViewport(isMobile: boolean) {
8
+ const listeners = new Set<(event: MediaQueryListEvent) => void>()
9
+ const query = {
10
+ matches: isMobile,
11
+ addEventListener(_: string, listener: (event: MediaQueryListEvent) => void) {
12
+ listeners.add(listener)
13
+ },
14
+ removeEventListener(
15
+ _: string,
16
+ listener: (event: MediaQueryListEvent) => void,
17
+ ) {
18
+ listeners.delete(listener)
19
+ },
20
+ }
21
+ Object.defineProperty(globalThis, "window", {
22
+ value: { matchMedia: () => query },
23
+ configurable: true,
24
+ writable: true,
25
+ })
26
+ return {
27
+ resizeTo(matches: boolean) {
28
+ query.matches = matches
29
+ for (const listener of [...listeners]) {
30
+ listener({ matches } as MediaQueryListEvent)
31
+ }
32
+ },
33
+ listenerCount: () => listeners.size,
34
+ }
35
+ }
36
+
37
+ function uninstallViewport(): void {
38
+ Reflect.deleteProperty(globalThis, "window")
39
+ }
40
+
41
+ afterEach(uninstallViewport)
42
+
43
+ describe("createCore — sidebar drawer", () => {
44
+ it("opens and closes on mobile, reporting every move to the host", () => {
45
+ installViewport(true)
46
+ const core = createCore()
47
+ const seen: boolean[] = []
48
+ core.on("sidebar:open", ({ open }) => seen.push(open))
49
+
50
+ expect(core.sidebarOpen.value).toBe(false)
51
+
52
+ core.setSidebarOpen(true)
53
+ expect(core.sidebarOpen.value).toBe(true)
54
+
55
+ core.setSidebarOpen(false)
56
+ expect(core.sidebarOpen.value).toBe(false)
57
+
58
+ expect(seen).toEqual([true, false])
59
+ })
60
+
61
+ it("stays silent when the state does not actually change", () => {
62
+ installViewport(true)
63
+ const core = createCore()
64
+ let events = 0
65
+ core.on("sidebar:open", () => events++)
66
+
67
+ core.setSidebarOpen(false)
68
+ core.setSidebarOpen(true)
69
+ core.setSidebarOpen(true)
70
+
71
+ expect(events).toBe(1)
72
+ })
73
+
74
+ it("refuses to open above the breakpoint, and keeps no armed state", () => {
75
+ const viewport = installViewport(false)
76
+ const core = createCore()
77
+ let events = 0
78
+ core.on("sidebar:open", () => events++)
79
+
80
+ core.setSidebarOpen(true)
81
+ expect(core.sidebarOpen.value).toBe(false)
82
+ expect(events).toBe(0)
83
+
84
+ // The drawer appears at phone width — closed, not sprung open by the
85
+ // call made while it did not exist.
86
+ viewport.resizeTo(true)
87
+ expect(core.sidebarOpen.value).toBe(false)
88
+ expect(events).toBe(0)
89
+ })
90
+
91
+ it("closes itself when the viewport leaves phone width", () => {
92
+ const viewport = installViewport(true)
93
+ const core = createCore()
94
+ const seen: boolean[] = []
95
+ core.on("sidebar:open", ({ open }) => seen.push(open))
96
+
97
+ core.setSidebarOpen(true)
98
+ viewport.resizeTo(false)
99
+
100
+ expect(core.sidebarOpen.value).toBe(false)
101
+ expect(seen).toEqual([true, false])
102
+ })
103
+ })
104
+
105
+ describe("createCore — viewport", () => {
106
+ it("reports every crossing of the breakpoint to the host", () => {
107
+ const viewport = installViewport(false)
108
+ const core = createCore()
109
+ const seen: boolean[] = []
110
+ core.on("viewport:change", ({ isMobile }) => seen.push(isMobile))
111
+
112
+ expect(core.isMobile.value).toBe(false)
113
+
114
+ viewport.resizeTo(true)
115
+ expect(core.isMobile.value).toBe(true)
116
+
117
+ viewport.resizeTo(false)
118
+ expect(core.isMobile.value).toBe(false)
119
+
120
+ expect(seen).toEqual([true, false])
121
+ })
122
+
123
+ it("releases its listener on destroy", () => {
124
+ const viewport = installViewport(true)
125
+ const core = createCore()
126
+ expect(viewport.listenerCount()).toBe(1)
127
+
128
+ core.destroy()
129
+ expect(viewport.listenerCount()).toBe(0)
130
+ })
131
+
132
+ it("builds and tears down without a DOM to watch", () => {
133
+ uninstallViewport()
134
+ const core = createCore()
135
+
136
+ expect(core.isMobile.value).toBe(false)
137
+ expect(() => core.destroy()).not.toThrow()
138
+ })
139
+ })
140
+
141
+ describe("createCore — transcript font size", () => {
142
+ it("starts at the default and announces every change", async () => {
143
+ const core = createCore()
144
+ const seen: number[] = []
145
+ core.on("transcript:fontSize", ({ fontSize }) => seen.push(fontSize))
146
+
147
+ expect(core.transcriptFontSize.value).toBe(18)
148
+
149
+ core.transcriptFontSize.value = 24
150
+ await nextTick()
151
+ core.transcriptFontSize.value = 14
152
+ await nextTick()
153
+
154
+ expect(seen).toEqual([24, 14])
155
+ })
156
+
157
+ it("stops announcing once destroyed", async () => {
158
+ const core = createCore()
159
+ let events = 0
160
+ core.on("transcript:fontSize", () => events++)
161
+
162
+ core.destroy()
163
+ core.transcriptFontSize.value = 24
164
+ await nextTick()
165
+
166
+ expect(events).toBe(0)
167
+ })
168
+ })
169
+
170
+ describe("createCore — theme", () => {
171
+ it("starts light and announces every switch", async () => {
172
+ const core = createCore()
173
+ const seen: string[] = []
174
+ core.on("theme:change", ({ theme }) => seen.push(theme))
175
+
176
+ expect(core.theme.value).toBe("light")
177
+
178
+ core.theme.value = "dark"
179
+ await nextTick()
180
+ core.theme.value = "light"
181
+ await nextTick()
182
+
183
+ expect(seen).toEqual(["dark", "light"])
184
+ })
185
+
186
+ it("stops announcing once destroyed", async () => {
187
+ const core = createCore()
188
+ let events = 0
189
+ core.on("theme:change", () => events++)
190
+
191
+ core.destroy()
192
+ core.theme.value = "dark"
193
+ await nextTick()
194
+
195
+ expect(events).toBe(0)
196
+ })
197
+ })
198
+
199
+ describe("createCore — active track", () => {
200
+ function track(id: string, isSource: boolean) {
201
+ return { id, languages: ["fr"], isSource, turns: [] }
202
+ }
203
+
204
+ function createDocumentCore() {
205
+ installViewport(false)
206
+ const core = createCore()
207
+ core.setDocument({
208
+ title: "",
209
+ speakers: new Map(),
210
+ channels: [
211
+ {
212
+ id: "ch-1",
213
+ name: "",
214
+ duration: 0,
215
+ translations: [track("src-1", true), track("en-1", false)],
216
+ },
217
+ {
218
+ id: "ch-2",
219
+ name: "",
220
+ duration: 0,
221
+ translations: [track("src-2", true), track("en-2", false)],
222
+ },
223
+ ],
224
+ })
225
+ const seen: Array<{ channelId: string; translationId: string }> = []
226
+ core.on("activeTrack:change", (payload) => seen.push(payload))
227
+ return { core, seen }
228
+ }
229
+
230
+ it("announces a track change of the active channel", () => {
231
+ const { core, seen } = createDocumentCore()
232
+ core.channels.get("ch-1")!.setActiveTranslation("en-1")
233
+ expect(seen).toEqual([{ channelId: "ch-1", translationId: "en-1" }])
234
+ })
235
+
236
+ it("announces a channel change with the track that channel was on", () => {
237
+ const { core, seen } = createDocumentCore()
238
+ core.channels.get("ch-2")!.setActiveTranslation("en-2")
239
+ core.setActiveChannel("ch-2")
240
+ expect(seen).toEqual([{ channelId: "ch-2", translationId: "en-2" }])
241
+ })
242
+
243
+ it("stays silent for a track change of another channel", () => {
244
+ const { core, seen } = createDocumentCore()
245
+ core.channels.get("ch-2")!.setActiveTranslation("en-2")
246
+ expect(seen).toEqual([])
247
+ })
248
+ })
package/src/core/types.ts CHANGED
@@ -11,18 +11,44 @@ import type {
11
11
 
12
12
  // ── Capabilities ────────────────────────────────────────────────────────
13
13
 
14
+ /** What the reader may do with the reports (llm-services plugin). Separate
15
+ * permissions rather than levels: a host can offer forcing a generation
16
+ * without editing (e.g. a phone app). */
17
+ export interface SummaryCapabilities {
18
+ /** Generate a report that is missing or out of date. */
19
+ generate: boolean
20
+ /** Edit and save a report by hand. */
21
+ edit: boolean
22
+ /** Generate again a report that is already up to date. */
23
+ forceGenerate: boolean
24
+ }
25
+
14
26
  export interface CoreCapabilities {
15
27
  text: "edit" | "view"
16
28
  speakers: "edit" | "view"
29
+ /** Left unset: DEFAULT_SUMMARY_CAPABILITIES. */
30
+ summary?: SummaryCapabilities
31
+ /** Whether the reader may chat, once a chat plugin is installed. Left
32
+ * unset: "enabled". */
33
+ chat?: "enabled" | "disabled"
17
34
  }
18
35
 
19
36
  // ── Event Map ──────────────────────────────────────────────────────────
20
37
 
38
+ /** Light is the default; dark redefines the colour tokens on the editor's
39
+ * root, under the same data-theme convention the host app uses. */
40
+ export type EditorTheme = "light" | "dark"
41
+
21
42
  export interface CoreEventMap {
22
43
  /** A new document was loaded via setDocument (channels rebuilt). */
23
44
  "document:change": void
24
45
  "channel:change": { channelId: string }
25
- "translation:change": { translationId: string | null }
46
+ "translation:change": { channelId: string; translationId: string | null }
47
+ /** The displayed track changed: another channel, or another track of the
48
+ * active channel. Follows channel:change / translation:change, so a
49
+ * consumer of "what is on screen" listens to this one alone. Not emitted
50
+ * by setDocument: read core.activeChannel once the document is set. */
51
+ "activeTrack:change": { channelId: string; translationId: string }
26
52
  "turn:add": { turn: Turn; translationId: string }
27
53
  "turn:update": { turn: Turn; translationId: string }
28
54
  "turn:remove": { turnId: string; translationId: string }
@@ -30,9 +56,9 @@ export interface CoreEventMap {
30
56
  "speaker:add": { speaker: Speaker }
31
57
  "speaker:remove": { speakerId: string }
32
58
  "scroll:top": { translationId: string }
33
- /** A seek was requested through core.audio.seekTo (widget scrub or
34
- * click-to-read) — the explicit "take me to this instant" gesture that
35
- * should resume follow-playback scrolling. */
59
+ /** An explicit "take me to this instant" gesture that should resume
60
+ * follow-playback scrolling: core.audio.seekTo (click-to-read) or a
61
+ * click/drag on the player's waveform. */
36
62
  "audio:seek": { time: number }
37
63
  "translation:sync": { translationId: string }
38
64
  "channel:sync": { channelId: string }
@@ -40,6 +66,25 @@ export interface CoreEventMap {
40
66
  "watermark:display": { display: boolean }
41
67
  "watermark:pin": { pinned: boolean }
42
68
  "subtitle:visible": { visible: boolean; height: number }
69
+ /** The reading size of the transcript changed. The editor does not
70
+ * remember it — a host that wants the preference to survive a reload
71
+ * listens here and restores it into core.transcriptFontSize. */
72
+ "transcript:fontSize": { fontSize: number }
73
+ /** The editor switched theme. The editor applies it to itself; a host
74
+ * that wants its own chrome to follow listens here (studio-frontend
75
+ * sets html[data-theme] from it). */
76
+ "theme:change": { theme: EditorTheme }
77
+ /** The sidebar drawer was opened or closed — by the built-in header,
78
+ * by the drawer's own close button, by a channel change, or by the
79
+ * host calling setSidebarOpen. A host rendering its own header (see
80
+ * TranscriptUI's no-header) listens to this to keep its button in
81
+ * sync, since it is not the only thing that moves the drawer. */
82
+ "sidebar:open": { open: boolean }
83
+ /** The viewport crossed the phone-width breakpoint. Same information as
84
+ * core.isMobile, pushed: a host that can't subscribe to a Vue 3 ref (a
85
+ * Vue 2 app, or plain JS around the web component) follows this to show
86
+ * or hide its own sidebar button. */
87
+ "viewport:change": { isMobile: boolean }
43
88
  "llmService:regenerate": { id: string }
44
89
  "llmService:export": { id: string }
45
90
  "llmService:active": { id: string | null }
@@ -53,6 +98,8 @@ export interface CoreEventMap {
53
98
  "chat:deleteSession": { sessionId: string }
54
99
  "chat:renameSession": { sessionId: string; title: string }
55
100
  "chat:send": { content: string }
101
+ /** The action button of an error message (see ChatMessageError) was clicked. */
102
+ "chat:errorAction": { messageId: string; actionId: string }
56
103
  destroy: void
57
104
  }
58
105
 
@@ -81,9 +128,13 @@ export interface ReadableTranslation extends TranslationInfo {
81
128
  export interface TurnStore {
82
129
  readonly id: string
83
130
  readonly turns: Readonly<Ref<Turn[]>>
84
- /** Epoch ms — last time the transcription was modified (host-pushed). */
131
+ /** Epoch ms of the last server-side modification of this track (host- or
132
+ * broadcast-pushed). */
85
133
  readonly lastModifiedAt: Ref<number | null>
86
- setLastModifiedAt(ts: number | null): void
134
+ /** Move lastModifiedAt forward only: null, non-finite, or older-or-equal
135
+ * timestamps are ignored (server reads and broadcasts can arrive out of
136
+ * order; a modification never makes the transcription older). */
137
+ advanceLastModifiedAt(ts: number | null): void
87
138
  addTurn(turn: Turn): void
88
139
  prependTurns(turns: Turn[]): void
89
140
  updateTurn(turnId: string, patch: Partial<Turn>): void
@@ -217,6 +268,8 @@ export interface TurnUpdate {
217
268
  etime?: number
218
269
  /** Per-translation edit version — carried, not consumed yet (resync lot). */
219
270
  version?: number
271
+ /** Server last_update of the track after this mutation. */
272
+ lastUpdate?: string
220
273
  }
221
274
 
222
275
  /** A full turn as carried by structural broadcasts (turn_split, turns_merged). */
@@ -237,6 +290,8 @@ export interface TurnSplit {
237
290
  /** The two halves, in order — the left one keeps the original turn id. */
238
291
  turns: WireTurn[]
239
292
  version?: number
293
+ /** Server last_update of the track after this mutation. */
294
+ lastUpdate?: string
240
295
  }
241
296
 
242
297
  /** Two adjacent turns merged, as broadcast by the server (editor:turns_merged). */
@@ -247,6 +302,8 @@ export interface TurnsMerged {
247
302
  removedTurnId: string
248
303
  turn: WireTurn
249
304
  version?: number
305
+ /** Server last_update of the track after this mutation. */
306
+ lastUpdate?: string
250
307
  }
251
308
 
252
309
  /** A turn removed (the client committed an emptied text), as broadcast by
@@ -256,6 +313,8 @@ export interface TurnDeleted {
256
313
  turnId: string
257
314
  removedSpeakerId?: string
258
315
  version?: number
316
+ /** Server last_update of the track after this mutation. */
317
+ lastUpdate?: string
259
318
  }
260
319
 
261
320
  /** A turn pointed at a (possibly freshly created) speaker, as broadcast by
@@ -267,6 +326,8 @@ export interface TurnSpeakerUpdated {
267
326
  speaker: { id: string; name: string }
268
327
  removedSpeakerId?: string
269
328
  version?: number
329
+ /** Server last_update of the track after this mutation. */
330
+ lastUpdate?: string
270
331
  /** @see SpeakerRenamed.revisionId */
271
332
  revisionId?: string | null
272
333
  /** @see SpeakerRenamed.redoRevisionId */
@@ -278,6 +339,8 @@ export interface SpeakerRenamed {
278
339
  speakerId: string
279
340
  name: string
280
341
  version?: number
342
+ /** Server last_update of the track after this mutation. */
343
+ lastUpdate?: string
281
344
  /** The undo cursor AFTER this broadcast (see onUndo/onRedo server-side) —
282
345
  * a plain rename and an undo/redo replaying one are indistinguishable on
283
346
  * the wire, and don't need to be: both just mean "the cursor is here
@@ -299,6 +362,8 @@ export interface SpeakerReplaced {
299
362
  fromSpeakerId: string
300
363
  toSpeakerId: string
301
364
  version?: number
365
+ /** Server last_update of the track after this mutation. */
366
+ lastUpdate?: string
302
367
  /** @see SpeakerRenamed.revisionId */
303
368
  revisionId?: string | null
304
369
  /** @see SpeakerRenamed.redoRevisionId */
@@ -316,6 +381,8 @@ export interface SpeakerRestored {
316
381
  toSpeakerId: string
317
382
  turnIds: string[]
318
383
  version?: number
384
+ /** Server last_update of the track after this mutation. */
385
+ lastUpdate?: string
319
386
  /** @see SpeakerRenamed.revisionId */
320
387
  revisionId?: string | null
321
388
  /** @see SpeakerRenamed.redoRevisionId */
@@ -422,7 +489,11 @@ export interface SubtitlePluginApi {
422
489
 
423
490
  // ── LLM Services Plugin API ─────────────────────────────────────────────
424
491
 
492
+ /** "loading": the host is fetching the state of the report (opening, or the
493
+ * followed track changed). Nothing to show yet, and no action either: the
494
+ * report may well exist. */
425
495
  export type LLMServiceStatus =
496
+ | "loading"
426
497
  | "idle"
427
498
  | "queued"
428
499
  | "processing"
@@ -552,6 +623,20 @@ export interface LiveTranslationEventData {
552
623
 
553
624
  export interface LivePluginApi {
554
625
  partial: ShallowRef<string | null>
626
+ /** Whether the provisional text of the turn being spoken reaches the
627
+ * screen at all — it feeds both the transcription panel and the live
628
+ * subtitles, so turning it off silences the two of them. */
629
+ partialsVisible: Ref<boolean>
630
+ showPartials(): void
631
+ hidePartials(): void
632
+ /** True while partials keep arriving — the only sign anyone is talking,
633
+ * and the one the UI falls back on once the text itself is hidden. Stays
634
+ * on for silenceDelay after the last partial. */
635
+ isSpeechActive: Ref<boolean>
636
+ /** Milliseconds without a partial before speech counts as over. ASRs
637
+ * differ in how often they emit — one per word, or one every few
638
+ * seconds — so a host tunes this to its backend. */
639
+ silenceDelay: Ref<number>
555
640
  hasLiveUpdate: Ref<boolean>
556
641
  ttsAvailable: boolean
557
642
  ttsEnabled: Ref<boolean>
@@ -577,6 +662,17 @@ export interface ChatMessage {
577
662
  tokenCount?: number
578
663
  /** True only for the virtual in-flight assistant message during streaming. */
579
664
  streaming?: boolean
665
+ /** Set on an assistant message that reports a failure instead of a reply:
666
+ * rendered as an error card, its content is ignored. */
667
+ error?: ChatMessageError
668
+ }
669
+
670
+ /** A failure reported in the thread. The host writes the texts (already
671
+ * translated) and owns the action: clicking it emits "chat:errorAction". */
672
+ export interface ChatMessageError {
673
+ title: string
674
+ description?: string
675
+ action?: { id: string; label: string; icon?: string }
580
676
  }
581
677
 
582
678
  export interface ChatSession {
@@ -639,6 +735,35 @@ export interface Core {
639
735
  readonly channels: Map<string, ChannelStore>
640
736
  readonly activeChannel: ComputedRef<ChannelStore | undefined>
641
737
 
738
+ /** Reading size of the transcript text, in pixels. Chrome (buttons,
739
+ * headers, the sidebar) keeps its own scale. Writable: there is nothing to
740
+ * guard, and a change is announced by "transcript:fontSize". */
741
+ readonly transcriptFontSize: Ref<number>
742
+ /** Colour theme of the editor. Writable: a host can restore a stored
743
+ * preference, and a change is announced by "theme:change". */
744
+ readonly theme: Ref<EditorTheme>
745
+ /** Brand colour, pushed by the host so the editor's accents match the app
746
+ * around it. Null keeps the editor's own. Applied as an inline style on
747
+ * the editor's root, which is the only thing that outranks the token
748
+ * declarations inside the shadow tree. */
749
+ readonly primaryColor: Ref<string | null>
750
+
751
+ // ── Layout chrome ────────────────────────────────────────────────────
752
+ /** True while the viewport is at phone width — below the breakpoint where
753
+ * Layout swaps the permanent sidebar for the drawer. Read it instead of
754
+ * duplicating the media query: a host rendering its own header needs the
755
+ * exact same threshold to know when its sidebar button is worth showing. */
756
+ readonly isMobile: Readonly<Ref<boolean>>
757
+ /** Whether the sidebar drawer is open. Only the mobile, single-panel
758
+ * layout mounts that drawer — on desktop the sidebar is permanent and in
759
+ * split view there is none. */
760
+ readonly sidebarOpen: Readonly<Ref<boolean>>
761
+ /** Opens or closes that drawer. Opening is refused, silently and without
762
+ * an event, while the viewport is wider than the breakpoint: there is no
763
+ * drawer to open, and a stored "open" would spring it on the next resize.
764
+ * Closing always applies. */
765
+ setSidebarOpen(open: boolean): void
766
+
642
767
  // ── Navigation ───────────────────────────────────────────────────────
643
768
  setDocument(doc: EditorDocument): void
644
769
  setActiveChannel(channelId: string): void
package/src/index.ts CHANGED
@@ -4,6 +4,9 @@ export {
4
4
  provideCore,
5
5
  useCore,
6
6
  CROSS_TRANSLATION_ID,
7
+ DEFAULT_SUMMARY_CAPABILITIES,
8
+ computeSummaryCapabilities,
9
+ computeCanChat,
7
10
  } from './core'
8
11
  export type {
9
12
  Core,
@@ -11,6 +14,7 @@ export type {
11
14
  CoreEventMap,
12
15
  CoreOptions,
13
16
  CoreCapabilities,
17
+ SummaryCapabilities,
14
18
  UISlot,
15
19
  TurnEventKey,
16
20
  ReadableTranslation,
@@ -47,6 +51,7 @@ export type {
47
51
  LLMServicesPluginApi,
48
52
  ChatRole,
49
53
  ChatMessage,
54
+ ChatMessageError,
50
55
  ChatSession,
51
56
  ChatPluginApi,
52
57
  } from './core'
@@ -67,7 +72,10 @@ export { default as SpeakerPopover } from './components/molecules/SpeakerPopover
67
72
  export { useEditorReady } from './composables/useEditorReady'
68
73
 
69
74
  // Phone breakpoint shared with plugins, so their panels switch layout at the
70
- // same width as the core Layout.
75
+ // same width as the core Layout. Reads core.isMobile, so it only works under
76
+ // a provideCore() — inside <TranscriptUI>, in other words. A host rendering
77
+ // its own chrome around the editor reads core.isMobile (or follows the
78
+ // "viewport:change" event) instead of calling this.
71
79
  export { useIsMobile } from './composables/useIsMobile'
72
80
 
73
81
  // Adapter API LinTO