@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.
package/README.md CHANGED
@@ -22,6 +22,38 @@ import { TranscriptUI, mapApiDocument } from "@linto-ai/transcript-ui-core"
22
22
 
23
23
  If you don't want `TranscriptUI`'s default screen, `createCore`/`provideCore` and `Layout` are the two pieces it uses internally — use them directly to build your own.
24
24
 
25
+ ## Driving the layout from the host
26
+
27
+ Rendering your own header (`<TranscriptUI no-header>`) removes the button that
28
+ opens the sidebar on phones — the sidebar's only entry point at that width.
29
+ Two things on `core` let your own header take that job over:
30
+
31
+ - `core.isMobile` — readonly ref, true at the width where the layout swaps its
32
+ permanent sidebar for a drawer. Read it instead of repeating the media query,
33
+ so your button shows up exactly when the drawer exists.
34
+ - `core.setSidebarOpen(open)` / `core.sidebarOpen` — open or close that drawer.
35
+
36
+ Opening is **refused** above the breakpoint, silently and without an event:
37
+ only the mobile, single-panel layout mounts a drawer, and an "open" stored
38
+ while none exists would spring it on the user at the next resize. Closing
39
+ always applies.
40
+
41
+ Two events keep a host in sync without watching a Vue ref — which matters when
42
+ the host isn't a Vue 3 app (a Vue 2 app, or plain JS around the web component,
43
+ can read `core.isMobile.value` but can't subscribe to it):
44
+
45
+ ```js
46
+ core.on("viewport:change", ({ isMobile }) => {
47
+ sidebarButton.hidden = !isMobile
48
+ })
49
+
50
+ // Your button is not the only thing that moves the drawer — its own close
51
+ // button, a channel change and leaving phone width also shut it.
52
+ core.on("sidebar:open", ({ open }) => {
53
+ sidebarButton.setAttribute("aria-expanded", String(open))
54
+ })
55
+ ```
56
+
25
57
  ## Turning a transcript into something this package understands
26
58
 
27
59
  - `mapApiDocument` — for a transcript from the LinTO Studio API.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linto-ai/transcript-ui-core",
3
- "version": "0.9.23",
3
+ "version": "0.9.24-unstable.202610151359",
4
4
  "description": "State layer and UI shell for @linto-ai/transcript-ui — core, stores, format adapters, and the TranscriptUI component",
5
5
  "keywords": [
6
6
  "vue",
@@ -39,8 +39,8 @@
39
39
  "vue": "^3.5.0"
40
40
  },
41
41
  "dependencies": {
42
- "@linto-ai/transcript-ui-i18n": "0.9.23",
43
- "@linto-ai/transcript-ui-ui": "0.9.23",
42
+ "@linto-ai/transcript-ui-i18n": "0.9.24-unstable.202610151359",
43
+ "@linto-ai/transcript-ui-ui": "0.9.24-unstable.202610151359",
44
44
  "lucide-vue-next": "^0.564.0",
45
45
  "reka-ui": "^2.8.0",
46
46
  "vue-stick-to-bottom": "^1.0.0"
@@ -46,49 +46,54 @@ const metaParts = computed(() =>
46
46
 
47
47
  <template>
48
48
  <header class="editor-header">
49
- <div class="header-main">
50
- <h1 class="document-title">{{ formattedTitle }}</h1>
51
- <div v-if="metaParts.length" class="document-meta">
52
- <span
53
- v-for="(part, i) in metaParts"
54
- :key="i"
55
- class="document-meta__part">
56
- {{ part }}
57
- </span>
49
+ <!-- Host-provided controls, e.g. a button reopening the app's menu.
50
+ Outside the padded content: they reach the header's edges. -->
51
+ <slot name="start" />
52
+ <div class="header-content">
53
+ <div class="header-main">
54
+ <h1 class="document-title">{{ formattedTitle }}</h1>
55
+ <div v-if="metaParts.length" class="document-meta">
56
+ <span
57
+ v-for="(part, i) in metaParts"
58
+ :key="i"
59
+ class="document-meta__part">
60
+ {{ part }}
61
+ </span>
62
+ </div>
58
63
  </div>
59
- </div>
60
- <div class="header-right">
61
- <Button
62
- v-if="isMobile"
63
- variant="transparent"
64
- :aria-label="t('header.openSidebar')"
65
- @click="$emit('toggleSidebar')">
66
- <template #icon><EditorIcon name="users" :size="16" /></template>
67
- </Button>
68
- <template v-if="props.canUndo || props.canRedo">
64
+ <div class="header-right">
69
65
  <Button
70
- variant="secondary"
71
- :aria-label="t('header.undo')"
72
- :disabled="!props.canUndo"
73
- @click="$emit('undo')">
74
- <template #icon><EditorIcon name="undo" :size="16" /></template>
66
+ v-if="isMobile"
67
+ variant="transparent"
68
+ :aria-label="t('header.openSidebar')"
69
+ @click="$emit('toggleSidebar')">
70
+ <template #icon><EditorIcon name="users" :size="16" /></template>
75
71
  </Button>
72
+ <template v-if="props.canUndo || props.canRedo">
73
+ <Button
74
+ variant="secondary"
75
+ :aria-label="t('header.undo')"
76
+ :disabled="!props.canUndo"
77
+ @click="$emit('undo')">
78
+ <template #icon><EditorIcon name="undo" :size="16" /></template>
79
+ </Button>
80
+ <Button
81
+ variant="secondary"
82
+ :aria-label="t('header.redo')"
83
+ :disabled="!props.canRedo"
84
+ @click="$emit('redo')">
85
+ <template #icon><EditorIcon name="redo" :size="16" /></template>
86
+ </Button>
87
+ </template>
76
88
  <Button
77
- variant="secondary"
78
- :aria-label="t('header.redo')"
79
- :disabled="!props.canRedo"
80
- @click="$emit('redo')">
81
- <template #icon><EditorIcon name="redo" :size="16" /></template>
89
+ variant="primary"
90
+ :aria-label="t('header.ask')"
91
+ :disabled="!props.canAsk"
92
+ @click="$emit('openChat')">
93
+ <template #icon><EditorIcon name="sparkles" :size="16" /></template>
94
+ <span v-if="!isMobile">{{ t("header.ask") }}</span>
82
95
  </Button>
83
- </template>
84
- <Button
85
- variant="primary"
86
- :aria-label="t('header.ask')"
87
- :disabled="!props.canAsk"
88
- @click="$emit('openChat')">
89
- <template #icon><EditorIcon name="sparkles" :size="16" /></template>
90
- <span v-if="!isMobile">{{ t("header.ask") }}</span>
91
- </Button>
96
+ </div>
92
97
  </div>
93
98
  </header>
94
99
  </template>
@@ -96,16 +101,22 @@ const metaParts = computed(() =>
96
101
  <style scoped>
97
102
  .editor-header {
98
103
  display: flex;
99
- align-items: center;
100
- justify-content: space-between;
101
- gap: var(--spacing-md);
102
- padding: var(--spacing-sm) var(--spacing-lg);
103
104
  min-height: var(--header-height);
104
105
  border-bottom: 1px solid var(--color-border);
105
106
  background-color: var(--color-surface);
106
107
  flex-shrink: 0;
107
108
  }
108
109
 
110
+ .header-content {
111
+ display: flex;
112
+ flex: 1;
113
+ min-width: 0;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ gap: var(--spacing-md);
117
+ padding: var(--spacing-sm) var(--spacing-lg);
118
+ }
119
+
109
120
  .header-main {
110
121
  display: flex;
111
122
  flex-direction: column;
@@ -150,7 +161,7 @@ const metaParts = computed(() =>
150
161
  }
151
162
 
152
163
  @media (max-width: 767px) {
153
- .editor-header {
164
+ .header-content {
154
165
  padding: var(--spacing-xs) var(--spacing-md);
155
166
  }
156
167
 
@@ -12,22 +12,23 @@ import TranslationSelector from "./TranslationSelector.vue"
12
12
  import SelectionActionBar from "./SelectionActionBar.vue"
13
13
  import { useIsMobile } from "../composables/useIsMobile"
14
14
  import { provideTurnSelection } from "../composables/useTurnSelection"
15
- import { useCore, type LLMService } from "../core"
15
+ import { useCore, computeCanChat, type LLMService } from "../core"
16
16
 
17
17
  const props = withDefaults(
18
18
  defineProps<{
19
19
  showHeader?: boolean
20
20
  showVerbatim?: boolean
21
+ showThemeToggle?: boolean
21
22
  }>(),
22
23
  {
23
24
  showHeader: true,
24
25
  showVerbatim: true,
26
+ showThemeToggle: true,
25
27
  },
26
28
  )
27
29
 
28
30
  const core = useCore()
29
31
  const { isMobile } = useIsMobile()
30
- const isSidebarOpen = ref(false)
31
32
 
32
33
  const shownPanels = ref<string[]>([TRANSCRIPTION_TAB])
33
34
  // The ?? never actually triggers — every shownPanels mutation keeps at least one entry — it's just satisfying noUncheckedIndexedAccess.
@@ -87,6 +88,17 @@ const speakerList = computed(() => Array.from(speakers.values()))
87
88
 
88
89
  const showTranscription = computed(() => activeTab.value === TRANSCRIPTION_TAB)
89
90
 
91
+ // Installing the chat plugin offers the chat; the capability says whether
92
+ // this reader may use it. core.chat is a plain field, invisible to Vue: the
93
+ // drawer the plugin registers in the reactive core.components is what makes
94
+ // a late install (the host checks first that its backend offers the chat)
95
+ // show up. core.chat is still read, by the template: it is unset again when
96
+ // the plugin is released, while its component stays registered.
97
+ const chatOffered = computed(
98
+ () =>
99
+ !!core.components.chatDrawer && computeCanChat(core.capabilities.value),
100
+ )
101
+
90
102
  // remove split when shrinking in mobile view
91
103
  watch(isMobile, (mobile) => {
92
104
  if (mobile && isSplit.value) isSplit.value = false
@@ -138,7 +150,7 @@ watch(
138
150
  core.audio.currentTime.value = 0
139
151
  core.audio.isPlaying.value = false
140
152
  }
141
- isSidebarOpen.value = false
153
+ core.setSidebarOpen(false)
142
154
  },
143
155
  )
144
156
 
@@ -164,13 +176,15 @@ function onTranslationChange(translationId: string) {
164
176
  :duration="core.activeChannel.value?.duration ?? 0"
165
177
  :speaker-count="speakers.size"
166
178
  :is-mobile="isMobile"
167
- :can-ask="!!core.chat"
179
+ :can-ask="chatOffered && !!core.chat"
168
180
  :can-undo="core.transcriptionEditor?.canUndo.value ?? false"
169
181
  :can-redo="core.transcriptionEditor?.canRedo.value ?? false"
170
- @toggle-sidebar="isSidebarOpen = !isSidebarOpen"
182
+ @toggle-sidebar="core.setSidebarOpen(!core.sidebarOpen.value)"
171
183
  @open-chat="core.chat?.setDrawerOpen(true)"
172
184
  @undo="core.transcriptionEditor?.undo()"
173
- @redo="core.transcriptionEditor?.redo()" />
185
+ @redo="core.transcriptionEditor?.redo()">
186
+ <template #start><slot name="header-start" /></template>
187
+ </Header>
174
188
  <TabBar
175
189
  :model-value="activeTab"
176
190
  :show-verbatim="props.showVerbatim"
@@ -203,12 +217,14 @@ function onTranslationChange(translationId: string) {
203
217
  :translations="translations"
204
218
  :selected-translation-id="activeTranslationId"
205
219
  :show-speakers="showTranscription"
220
+ :show-theme-toggle="props.showThemeToggle"
206
221
  @update:selected-channel-id="onChannelChange"
207
222
  @update:selected-translation-id="onTranslationChange" />
208
223
 
209
224
  <SidebarDrawer
210
225
  v-if="isMobile && panels.length === 1"
211
- v-model:open="isSidebarOpen">
226
+ :open="core.sidebarOpen.value"
227
+ @update:open="core.setSidebarOpen($event)">
212
228
  <SpeakerSidebar
213
229
  :speakers="speakerList"
214
230
  :channels="channels"
@@ -216,6 +232,7 @@ function onTranslationChange(translationId: string) {
216
232
  :translations="translations"
217
233
  :selected-translation-id="activeTranslationId"
218
234
  :show-speakers="showTranscription"
235
+ :show-theme-toggle="props.showThemeToggle"
219
236
  @update:selected-channel-id="onChannelChange"
220
237
  @update:selected-translation-id="onTranslationChange" />
221
238
  </SidebarDrawer>
@@ -235,7 +252,9 @@ function onTranslationChange(translationId: string) {
235
252
  <component
236
253
  :is="core.components.subtitleFullscreen"
237
254
  v-if="core.subtitle?.isFullscreen.value" />
238
- <component :is="core.components.chatDrawer" v-if="core.chat" />
255
+ <component
256
+ :is="core.components.chatDrawer"
257
+ v-if="chatOffered && !!core.chat" />
239
258
  <div
240
259
  v-if="isMobile && (channels.length > 1 || translations.length > 1)"
241
260
  class="mobile-selectors">
@@ -5,24 +5,24 @@
5
5
  xmlns="http://www.w3.org/2000/svg">
6
6
  <polygon
7
7
  points="331.5,533.5 331.5,520.5 702.5,428.5 705.5,443.5"
8
- fill="#3f3d56"
8
+ fill="currentColor"
9
9
  transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
10
10
  <polygon
11
11
  points="564.5,469.5 555.5,452.5 544.5,455.5 542.5,472.5"
12
- fill="#3f3d56"
12
+ fill="currentColor"
13
13
  transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
14
14
  <path
15
15
  d="m 317.61655,19.99224 c 0,0 79.97514,-5.452851 101.78654,56.34612 21.81141,61.79897 72.70468,172.67359 92.69846,189.03214 19.99379,16.35855 41.80519,59.98136 38.16995,74.52229"
16
- stroke="#3f3d56"
16
+ stroke="currentColor"
17
17
  stroke-miterlimit="10"
18
18
  stroke-width="9.08808" />
19
19
  <path
20
20
  d="m 329.43106,19.083431 c 0,8.532657 -9.0733,15.449743 -23.62902,15.449743 -14.55571,0 -21.8114,-6.917086 -21.8114,-15.449743 0,-8.532657 7.25569,-15.4497427 21.8114,-15.4497427 14.55572,0 23.62902,6.9170857 23.62902,15.4497427 z"
21
21
  fill="currentColor"
22
- style="fill: #999999" />
22
+ style="fill: currentColor; opacity: 0.6" />
23
23
  <polygon
24
24
  points="691.5,439.5 364.5,521.5 377.5,602.5 666.5,602.5"
25
- fill="#3f3d56"
25
+ fill="currentColor"
26
26
  transform="matrix(1.8176168,0,0,1.8176168,-456.32371,-492.51252)" />
27
27
  </svg>
28
28
  </template>
@@ -5,15 +5,22 @@ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
5
  import * as utils from "../utils"
6
6
  import type { Speaker } from "../types/editor"
7
7
 
8
- const props = defineProps<{
9
- speaker?: Speaker
10
- startTime?: number
11
- startDate?: number
12
- language: string
13
- /** The label is wrapped in a clickable trigger (speaker assignment):
14
- * show the hover affordance on the name. */
15
- interactive?: boolean
16
- }>()
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ speaker?: Speaker
11
+ startTime?: number
12
+ startDate?: number
13
+ language: string
14
+ /** The label is wrapped in a clickable trigger (speaker assignment):
15
+ * show the hover affordance on the name, and the placeholder that
16
+ * invites the click when nobody is assigned yet. */
17
+ interactive?: boolean
18
+ /** False on a translation track, where every turn is in the same language
19
+ * and the selector already names it. */
20
+ showLanguage?: boolean
21
+ }>(),
22
+ { showLanguage: true },
23
+ )
17
24
 
18
25
  const { t, locale } = useI18n()
19
26
 
@@ -44,9 +51,12 @@ const timestamp = computed<{ text: string; datetime: string } | null>(() => {
44
51
 
45
52
  const speakerColor = computed(() => props.speaker?.color ?? "transparent")
46
53
 
47
- // A turn always shows a speaker: unnamed ones get an explicit placeholder
48
- // (assignment lands with the speakers iteration).
49
- const displayName = computed(() => props.speaker?.name ?? t("speaker.unknown"))
54
+ // The placeholder for an unnamed speaker is an invitation to assign one, so it
55
+ // belongs where the label is a trigger. Read-only — a live session has no
56
+ // speaker assignment — it would only be noise nobody can act on.
57
+ const displayName = computed(
58
+ () => props.speaker?.name ?? (props.interactive ? t("speaker.unknown") : ""),
59
+ )
50
60
  </script>
51
61
 
52
62
  <template>
@@ -54,13 +64,18 @@ const displayName = computed(() => props.speaker?.name ?? t("speaker.unknown"))
54
64
  class="speaker-label"
55
65
  :class="{ 'speaker-label--interactive': interactive }">
56
66
  <SpeakerIndicator v-if="speaker" :color="speakerColor" />
57
- <span class="speaker-name" :class="{ 'speaker-name--unknown': !speaker }">{{
58
- displayName
59
- }}</span>
67
+ <span
68
+ v-if="displayName"
69
+ class="speaker-name"
70
+ :class="{ 'speaker-name--unknown': !speaker }"
71
+ >{{ displayName }}</span
72
+ >
60
73
  <time v-if="timestamp" class="timestamp" :datetime="timestamp.datetime">{{
61
74
  timestamp.text
62
75
  }}</time>
63
- <span v-if="languageName" class="lang">{{ languageName }}</span>
76
+ <span v-if="showLanguage && languageName" class="lang">{{
77
+ languageName
78
+ }}</span>
64
79
  </div>
65
80
  </template>
66
81
 
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { SpeakerIndicator, SwitchToggle, EditableText, SelectableListItem, EditorIcon, SpeakerMenu } from "@linto-ai/transcript-ui-ui"
3
- import { computed, ref } from "vue"
2
+ import { SpeakerIndicator, SwitchToggle, EditableText, SelectableListItem, EditorIcon, RangeSlider, SpeakerMenu } from "@linto-ai/transcript-ui-ui"
3
+ import { computed, ref, useId } from "vue"
4
4
  import ChannelSelector from "./ChannelSelector.vue"
5
5
  import TranslationSelector from "./TranslationSelector.vue"
6
6
  import MergeDialog from "./molecules/MergeDialog.vue"
7
7
  import { useI18n } from "@linto-ai/transcript-ui-i18n"
8
8
  import { useCore } from "../core"
9
+ import { useIsMobile } from "../composables/useIsMobile"
9
10
  import { renameSpeaker } from "../core/helpers"
10
11
  import type { Speaker } from "../types/editor"
11
12
  import type { LLMServiceGenerationStatus, TranslationInfo } from "../core"
@@ -18,8 +19,9 @@ withDefaults(
18
19
  translations: TranslationInfo[]
19
20
  selectedTranslationId: string
20
21
  showSpeakers?: boolean
22
+ showThemeToggle?: boolean
21
23
  }>(),
22
- { showSpeakers: true },
24
+ { showSpeakers: true, showThemeToggle: true },
23
25
  )
24
26
 
25
27
  defineEmits<{
@@ -29,6 +31,7 @@ defineEmits<{
29
31
 
30
32
  const core = useCore()
31
33
  const { t } = useI18n()
34
+ const { isMobile } = useIsMobile()
32
35
 
33
36
  const canEditSpeakers = computed(
34
37
  () => core.capabilities.value.speakers === "edit",
@@ -47,6 +50,21 @@ function onToggleTts(value: boolean): void {
47
50
  else core.live.disableTTS()
48
51
  }
49
52
 
53
+ // The switch is a bare checkbox with no built-in name, so the visible text
54
+ // has to be a real <label> pointing at it.
55
+ const partialsToggleId = useId()
56
+ const darkThemeToggleId = useId()
57
+
58
+ function onToggleDarkTheme(value: boolean): void {
59
+ core.theme.value = value ? "dark" : "light"
60
+ }
61
+
62
+ function onTogglePartials(value: boolean): void {
63
+ if (!core.live) return
64
+ if (value) core.live.showPartials()
65
+ else core.live.hidePartials()
66
+ }
67
+
50
68
  const mergeOpen = ref(false)
51
69
  const mergeFromId = ref<string | null>(null)
52
70
 
@@ -146,32 +164,45 @@ function onSelectVersion(versionNumber: number): void {
146
164
  $emit('update:selectedTranslationId', $event)
147
165
  " />
148
166
  </section>
149
- <section v-if="core.subtitle" class="sidebar-section">
167
+ <!-- Scoped to live sessions for now: the font size is a core setting, but
168
+ it is only offered where it was asked for. The reading size comes
169
+ first — it stays meaningful the day this section shows outside a live
170
+ session, where the partials toggle would not. -->
171
+ <section v-if="core.live" class="sidebar-section">
172
+ <h2 class="sidebar-title">{{ t("sidebar.transcription") }}</h2>
173
+ <RangeSlider
174
+ v-model="core.transcriptFontSize.value"
175
+ :label="t('transcription.fontSize')"
176
+ :min="14"
177
+ :max="28"
178
+ :step="2" />
179
+ <div class="subtitle-toggle">
180
+ <label class="subtitle-toggle-label" :for="partialsToggleId">
181
+ {{ t("live.showPartials") }}
182
+ </label>
183
+ <SwitchToggle
184
+ :id="partialsToggleId"
185
+ :model-value="core.live.partialsVisible.value"
186
+ @update:model-value="onTogglePartials" />
187
+ </div>
188
+ </section>
189
+ <!-- Subtitles are a desktop surface: Layout only mounts the banner above
190
+ the breakpoint, and the fullscreen view is opened by the host, not
191
+ from here. On a phone the whole section is inert — including the font
192
+ size and watermark controls, which hang off the switch above them. -->
193
+ <section v-if="core.subtitle && !isMobile" class="sidebar-section">
150
194
  <h2 class="sidebar-title">{{ t("sidebar.subtitle") }}</h2>
151
195
  <div class="subtitle-toggle">
152
196
  <span class="subtitle-toggle-label">{{ t("subtitle.show") }}</span>
153
197
  <SwitchToggle v-model="core.subtitle.isVisible.value" />
154
198
  </div>
155
- <label class="subtitle-slider">
156
- <span class="subtitle-slider-label">
157
- {{ t("subtitle.fontSize") }}
158
- <span class="subtitle-slider-value"
159
- >{{ core.subtitle.fontSize.value }}px</span
160
- >
161
- </span>
162
- <input
163
- type="range"
164
- :min="20"
165
- :max="80"
166
- :step="2"
167
- :value="core.subtitle.fontSize.value"
168
- :disabled="!core.subtitle.isVisible.value"
169
- @input="
170
- core.subtitle!.fontSize.value = Number(
171
- ($event.target as HTMLInputElement).value,
172
- )
173
- " />
174
- </label>
199
+ <RangeSlider
200
+ v-model="core.subtitle.fontSize.value"
201
+ :label="t('subtitle.fontSize')"
202
+ :min="20"
203
+ :max="80"
204
+ :step="2"
205
+ :disabled="!core.subtitle.isVisible.value" />
175
206
  <div
176
207
  v-if="core.subtitle.watermark && !core.subtitle.watermark.readonly"
177
208
  class="subtitle-toggle">
@@ -283,6 +314,20 @@ function onSelectVersion(versionNumber: number): void {
283
314
  </li>
284
315
  </ul>
285
316
  </section>
317
+ <!-- Its own section, and never gated: the theme belongs to the reader,
318
+ not to a plugin or to a live session. -->
319
+ <section v-if="showThemeToggle" class="sidebar-section">
320
+ <h2 class="sidebar-title">{{ t("sidebar.appearance") }}</h2>
321
+ <div class="subtitle-toggle">
322
+ <label class="subtitle-toggle-label" :for="darkThemeToggleId">
323
+ {{ t("theme.highContrast") }}
324
+ </label>
325
+ <SwitchToggle
326
+ :id="darkThemeToggleId"
327
+ :model-value="core.theme.value === 'dark'"
328
+ @update:model-value="onToggleDarkTheme" />
329
+ </div>
330
+ </section>
286
331
  <MergeDialog
287
332
  v-if="canEditSpeakers"
288
333
  v-model:open="mergeOpen"
@@ -355,6 +400,10 @@ function onSelectVersion(versionNumber: number): void {
355
400
  color: var(--color-text-primary);
356
401
  }
357
402
 
403
+ label.subtitle-toggle-label {
404
+ cursor: pointer;
405
+ }
406
+
358
407
  .voice-playback-hint {
359
408
  padding: 0 var(--spacing-sm);
360
409
  font-size: var(--font-size-xs);
@@ -365,35 +414,6 @@ function onSelectVersion(versionNumber: number): void {
365
414
  color: var(--color-danger);
366
415
  }
367
416
 
368
- .subtitle-slider {
369
- display: flex;
370
- flex-direction: column;
371
- gap: var(--spacing-xs);
372
- padding: var(--spacing-sm);
373
- }
374
-
375
- .subtitle-slider-label {
376
- display: flex;
377
- justify-content: space-between;
378
- font-size: var(--font-size-sm);
379
- color: var(--color-text-primary);
380
- }
381
-
382
- .subtitle-slider-value {
383
- color: var(--color-text-muted);
384
- font-variant-numeric: tabular-nums;
385
- }
386
-
387
- .subtitle-slider input[type="range"] {
388
- width: 100%;
389
- accent-color: var(--color-primary);
390
- }
391
-
392
- .subtitle-slider input[type="range"]:disabled {
393
- opacity: 0.4;
394
- cursor: not-allowed;
395
- }
396
-
397
417
  /* ── History (LLM generations + versions) ──────────────────────────── */
398
418
 
399
419
  .sidebar-section--busy {
@@ -416,7 +436,7 @@ function onSelectVersion(versionNumber: number): void {
416
436
  }
417
437
 
418
438
  .history-generation__status--completed {
419
- color: var(--color-success, #2e7d32);
439
+ color: var(--color-success);
420
440
  }
421
441
 
422
442
  .history-generation__status--error {