@linto-ai/transcript-ui-core 0.9.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 (101) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +46 -0
  3. package/package.json +46 -0
  4. package/src/adapters/apiAdapter.ts +44 -0
  5. package/src/adapters/mapApiTurns.ts +32 -0
  6. package/src/adapters/test/mapApiTurns.test.ts +73 -0
  7. package/src/adapters/whisperXAdapter.ts +73 -0
  8. package/src/components/ChannelSelector.vue +31 -0
  9. package/src/components/EditorErrorOverlay.vue +55 -0
  10. package/src/components/EditorLoadingOverlay.vue +52 -0
  11. package/src/components/Header.vue +138 -0
  12. package/src/components/Layout.vue +221 -0
  13. package/src/components/MicrophoneIllustration.vue +28 -0
  14. package/src/components/SelectionActionBar.vue +86 -0
  15. package/src/components/SidebarDrawer.vue +32 -0
  16. package/src/components/SpeakerLabel.vue +105 -0
  17. package/src/components/SpeakerSidebar.vue +455 -0
  18. package/src/components/TabBar.constants.ts +2 -0
  19. package/src/components/TabBar.vue +52 -0
  20. package/src/components/TranscriptUI.vue +68 -0
  21. package/src/components/TranscriptionEmpty.vue +39 -0
  22. package/src/components/TranscriptionPanel.vue +275 -0
  23. package/src/components/TranscriptionTurn.vue +407 -0
  24. package/src/components/TranslationSelector.vue +39 -0
  25. package/src/components/VerbatimPanel.vue +160 -0
  26. package/src/components/molecules/MergeDialog.vue +150 -0
  27. package/src/components/molecules/MergeTurnsButton.vue +57 -0
  28. package/src/components/molecules/SpeakerPopover.vue +146 -0
  29. package/src/composables/useEditorReady.ts +62 -0
  30. package/src/composables/useFollowPlayback.ts +124 -0
  31. package/src/composables/useIsMobile.ts +24 -0
  32. package/src/composables/useTurnSelection.ts +169 -0
  33. package/src/constants/speakers.ts +14 -0
  34. package/src/core/createCore.ts +162 -0
  35. package/src/core/helpers/addSpeaker.ts +11 -0
  36. package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
  37. package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
  38. package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
  39. package/src/core/helpers/ensureSpeaker.ts +12 -0
  40. package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
  41. package/src/core/helpers/findTurnIndex.ts +5 -0
  42. package/src/core/helpers/index.ts +15 -0
  43. package/src/core/helpers/insertTurn.ts +5 -0
  44. package/src/core/helpers/mergeSpeakers.ts +29 -0
  45. package/src/core/helpers/patchTurn.ts +15 -0
  46. package/src/core/helpers/prependTurns.ts +5 -0
  47. package/src/core/helpers/removeTurn.ts +8 -0
  48. package/src/core/helpers/renameSpeaker.ts +12 -0
  49. package/src/core/helpers/speakerEquals.ts +6 -0
  50. package/src/core/helpers/switchTurnSpeaker.ts +17 -0
  51. package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
  52. package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
  53. package/src/core/helpers/test/makeTestCore.ts +40 -0
  54. package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
  55. package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
  56. package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
  57. package/src/core/helpers/updateTurnWords.ts +22 -0
  58. package/src/core/index.ts +47 -0
  59. package/src/core/modules/eventBus.ts +43 -0
  60. package/src/core/stores/channelStore.ts +100 -0
  61. package/src/core/stores/crossTranslationStore.ts +141 -0
  62. package/src/core/stores/index.ts +7 -0
  63. package/src/core/stores/speakersStore.ts +53 -0
  64. package/src/core/stores/translationStore.ts +133 -0
  65. package/src/core/types.ts +602 -0
  66. package/src/core/useCore.ts +16 -0
  67. package/src/index.ts +88 -0
  68. package/src/types/api.ts +52 -0
  69. package/src/types/editor.ts +65 -0
  70. package/src/types/whisperx.ts +22 -0
  71. package/src/utils/color.ts +7 -0
  72. package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
  73. package/src/utils/computeTurnPlainText.ts +9 -0
  74. package/src/utils/extractLangCode.ts +7 -0
  75. package/src/utils/findWordAtOffset.ts +16 -0
  76. package/src/utils/index.ts +19 -0
  77. package/src/utils/intl.ts +47 -0
  78. package/src/utils/isSameLanguage.ts +13 -0
  79. package/src/utils/test/computeTurnPlainText.test.ts +34 -0
  80. package/src/utils/test/findWordAtOffset.test.ts +25 -0
  81. package/src/utils/throttle.ts +23 -0
  82. package/src/utils/time.ts +95 -0
  83. package/src/utils/tokenize.ts +34 -0
  84. package/src/utils/tts.ts +49 -0
  85. package/src/utils/turnWords/carryWordTimes.ts +37 -0
  86. package/src/utils/turnWords/index.ts +6 -0
  87. package/src/utils/turnWords/layoutWords.ts +41 -0
  88. package/src/utils/turnWords/parseWordId.ts +13 -0
  89. package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
  90. package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
  91. package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
  92. package/src/utils/turnWords/test/wordId.test.ts +9 -0
  93. package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
  94. package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
  95. package/src/utils/turnWords/wordId.ts +9 -0
  96. package/src/utils/turnWords/wordsFromApi.ts +16 -0
  97. package/src/utils/turnWords/wordsFromText.ts +14 -0
  98. package/src/utils/validateDocument.ts +80 -0
  99. package/src/utils/waveform.ts +64 -0
  100. package/src/utils/wordRange.ts +67 -0
  101. package/src/utils/words.ts +45 -0
@@ -0,0 +1,221 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue"
3
+ import Header from "./Header.vue"
4
+ import TabBar from "./TabBar.vue"
5
+ import { TRANSCRIPTION_TAB, VERBATIM_TAB } from "./TabBar.constants"
6
+ import TranscriptionPanel from "./TranscriptionPanel.vue"
7
+ import VerbatimPanel from "./VerbatimPanel.vue"
8
+ import SpeakerSidebar from "./SpeakerSidebar.vue"
9
+ import SidebarDrawer from "./SidebarDrawer.vue"
10
+ import ChannelSelector from "./ChannelSelector.vue"
11
+ import TranslationSelector from "./TranslationSelector.vue"
12
+ import SelectionActionBar from "./SelectionActionBar.vue"
13
+ import { useIsMobile } from "../composables/useIsMobile"
14
+ import { provideTurnSelection } from "../composables/useTurnSelection"
15
+ import { useCore } from "../core"
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ showHeader?: boolean
20
+ }>(),
21
+ {
22
+ showHeader: true,
23
+ },
24
+ )
25
+
26
+ const core = useCore()
27
+ const { isMobile } = useIsMobile()
28
+ const isSidebarOpen = ref(false)
29
+
30
+ const activeTab = ref<string>(TRANSCRIPTION_TAB)
31
+
32
+ const activeTurns = computed(
33
+ () => core.activeChannel.value?.activeTranslation.value.turns.value ?? [],
34
+ )
35
+ const speakers = core.speakers.all
36
+
37
+ provideTurnSelection(activeTurns, speakers, core)
38
+
39
+ const channels = computed(() => [...core.channels.values()])
40
+ const translations = computed(
41
+ () => core.activeChannel.value?.selectableTranslations ?? [],
42
+ )
43
+ const activeTranslationId = computed(
44
+ () => core.activeChannel.value?.activeTranslation.value.id ?? "",
45
+ )
46
+ const speakerList = computed(() => Array.from(speakers.values()))
47
+
48
+ const showTranscription = computed(() => activeTab.value === TRANSCRIPTION_TAB)
49
+ const showVerbatim = computed(() => activeTab.value === VERBATIM_TAB)
50
+ const activeService = computed(() => {
51
+ if (showTranscription.value || showVerbatim.value) return null
52
+ return core.llmServices?.get(activeTab.value) ?? null
53
+ })
54
+
55
+ watch(activeTab, (id) => {
56
+ if (!core.llmServices) return
57
+ if (id === TRANSCRIPTION_TAB || id === VERBATIM_TAB) {
58
+ core.llmServices.setActive(null)
59
+ } else {
60
+ core.llmServices.setActive(id)
61
+ }
62
+ })
63
+
64
+ watch(
65
+ () => core.llmServices?.list.value.map((s) => s.id).join("|"),
66
+ () => {
67
+ if (
68
+ activeTab.value !== TRANSCRIPTION_TAB &&
69
+ activeTab.value !== VERBATIM_TAB &&
70
+ !core.llmServices?.get(activeTab.value)
71
+ ) {
72
+ activeTab.value = TRANSCRIPTION_TAB
73
+ }
74
+ },
75
+ )
76
+
77
+ watch(
78
+ () => core.activeChannelId.value,
79
+ () => {
80
+ core.audio?.pause()
81
+ if (core.audio) {
82
+ core.audio.currentTime.value = 0
83
+ core.audio.isPlaying.value = false
84
+ }
85
+ isSidebarOpen.value = false
86
+ },
87
+ )
88
+
89
+ watch(showTranscription, (visible) => {
90
+ if (!visible) core.audio?.pause()
91
+ })
92
+
93
+ function onChannelChange(channelId: string) {
94
+ core.setActiveChannel(channelId)
95
+ }
96
+
97
+ function onTranslationChange(translationId: string) {
98
+ core.activeChannel.value?.setActiveTranslation(translationId)
99
+ }
100
+ </script>
101
+
102
+ <template>
103
+ <div class="editor-layout">
104
+ <Header
105
+ v-if="props.showHeader"
106
+ :title="core.title.value"
107
+ :date="core.date.value"
108
+ :duration="core.activeChannel.value?.duration ?? 0"
109
+ :speaker-count="speakers.size"
110
+ :is-mobile="isMobile"
111
+ :can-ask="!!core.chat"
112
+ @toggle-sidebar="isSidebarOpen = !isSidebarOpen"
113
+ @open-chat="core.chat?.setDrawerOpen(true)" />
114
+ <TabBar v-model="activeTab" />
115
+ <SelectionActionBar v-if="showTranscription" />
116
+ <main class="editor-body">
117
+ <TranscriptionPanel
118
+ v-if="showTranscription"
119
+ :turns="activeTurns"
120
+ :speakers="speakers" />
121
+ <VerbatimPanel v-else-if="showVerbatim" />
122
+ <component
123
+ :is="core.components.llmServicePanel"
124
+ v-else-if="activeService"
125
+ :key="activeService.id"
126
+ :service="activeService" />
127
+ <TranscriptionPanel v-else :turns="activeTurns" :speakers="speakers" />
128
+ <SpeakerSidebar
129
+ v-if="!isMobile"
130
+ :speakers="speakerList"
131
+ :channels="channels"
132
+ :selected-channel-id="core.activeChannelId.value"
133
+ :translations="translations"
134
+ :selected-translation-id="activeTranslationId"
135
+ :show-speakers="showTranscription"
136
+ @update:selected-channel-id="onChannelChange"
137
+ @update:selected-translation-id="onTranslationChange" />
138
+
139
+ <SidebarDrawer v-if="isMobile" v-model:open="isSidebarOpen">
140
+ <SpeakerSidebar
141
+ :speakers="speakerList"
142
+ :channels="channels"
143
+ :selected-channel-id="core.activeChannelId.value"
144
+ :translations="translations"
145
+ :selected-translation-id="activeTranslationId"
146
+ :show-speakers="showTranscription"
147
+ @update:selected-channel-id="onChannelChange"
148
+ @update:selected-translation-id="onTranslationChange" />
149
+ </SidebarDrawer>
150
+ </main>
151
+ <component
152
+ :is="core.components.player"
153
+ v-if="core.audio?.src.value"
154
+ v-show="showTranscription"
155
+ :audio-src="core.audio.src.value" />
156
+ <component
157
+ :is="core.components.subtitleBanner"
158
+ v-if="
159
+ core.subtitle?.isVisible.value &&
160
+ !isMobile &&
161
+ !core.subtitle.isFullscreen.value
162
+ " />
163
+ <component
164
+ :is="core.components.subtitleFullscreen"
165
+ v-if="core.subtitle?.isFullscreen.value" />
166
+ <component :is="core.components.chatDrawer" v-if="core.chat" />
167
+ <div
168
+ v-if="isMobile && (channels.length > 1 || translations.length > 1)"
169
+ class="mobile-selectors">
170
+ <ChannelSelector
171
+ v-if="channels.length > 1"
172
+ :channels="channels"
173
+ :selected-channel-id="core.activeChannelId.value"
174
+ @update:selected-channel-id="onChannelChange" />
175
+ <TranslationSelector
176
+ v-if="translations.length > 1"
177
+ :translations="translations"
178
+ :selected-translation-id="activeTranslationId"
179
+ @update:selected-translation-id="onTranslationChange" />
180
+ </div>
181
+ </div>
182
+ </template>
183
+
184
+ <style scoped>
185
+ .editor-layout {
186
+ display: flex;
187
+ flex-direction: column;
188
+ height: 100%;
189
+ overflow: hidden;
190
+ background-color: var(--color-background);
191
+ }
192
+
193
+ .editor-body {
194
+ display: grid;
195
+ grid-template-columns: 1fr var(--sidebar-width);
196
+ flex: 1;
197
+ min-height: 0;
198
+ }
199
+
200
+ .mobile-selectors {
201
+ display: flex;
202
+ gap: var(--spacing-sm);
203
+ padding: var(--spacing-sm) var(--spacing-md);
204
+ border-top: 1px solid var(--color-border);
205
+ background-color: var(--color-surface);
206
+ flex-shrink: 0;
207
+ box-shadow: var(--shadow-md);
208
+ align-items: end;
209
+ }
210
+
211
+ .mobile-selectors > * {
212
+ flex: 1;
213
+ min-width: 0;
214
+ }
215
+
216
+ @media (max-width: 767px) {
217
+ .editor-body {
218
+ grid-template-columns: 1fr;
219
+ }
220
+ }
221
+ </style>
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <svg
3
+ viewBox="0 0 938 604"
4
+ fill="none"
5
+ xmlns="http://www.w3.org/2000/svg">
6
+ <polygon
7
+ points="331.5,533.5 331.5,520.5 702.5,428.5 705.5,443.5"
8
+ fill="#3f3d56"
9
+ transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
10
+ <polygon
11
+ points="564.5,469.5 555.5,452.5 544.5,455.5 542.5,472.5"
12
+ fill="#3f3d56"
13
+ transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
14
+ <path
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"
17
+ stroke-miterlimit="10"
18
+ stroke-width="9.08808" />
19
+ <path
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
+ fill="currentColor"
22
+ style="fill: #999999" />
23
+ <polygon
24
+ points="691.5,439.5 364.5,521.5 377.5,602.5 666.5,602.5"
25
+ fill="#3f3d56"
26
+ transform="matrix(1.8176168,0,0,1.8176168,-456.32371,-492.51252)" />
27
+ </svg>
28
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ import { Button, CopyButton } from "@linto-ai/transcript-ui-ui"
3
+ import { useTurnSelection } from "../composables/useTurnSelection"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+
6
+ const selection = useTurnSelection()
7
+ const { t } = useI18n()
8
+ </script>
9
+
10
+ <template>
11
+ <div
12
+ v-if="selection.hasSelection.value"
13
+ class="selection-bar"
14
+ role="toolbar"
15
+ :aria-label="t('selection.count')">
16
+ <span class="selection-count">
17
+ {{ selection.count.value }} {{ t("selection.count") }}
18
+ </span>
19
+ <div class="selection-actions">
20
+ <CopyButton
21
+ icon="clipboard-type"
22
+ :copy-fn="selection.copyText"
23
+ variant="secondary">
24
+ {{ t("selection.copyText") }}
25
+ </CopyButton>
26
+ <CopyButton icon="clipboard-list" :copy-fn="selection.copyWithMetadata">
27
+ {{ t("selection.copyWithMetadata") }}
28
+ </CopyButton>
29
+ <Button variant="transparent" icon="x" @click="selection.clear()">
30
+ {{ t("selection.cancel") }}
31
+ </Button>
32
+ </div>
33
+ </div>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .selection-bar {
38
+ flex-shrink: 0;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ padding: var(--spacing-xs) var(--spacing-lg);
43
+ /* No backdrop-filter: it forces a WebRender backdrop render target sized to
44
+ the content behind, a heavy GPU-memory cost on long transcripts. The
45
+ semi-opaque glass background stays legible without the blur. */
46
+ background: var(--glass-background);
47
+ border-bottom: 1px solid var(--color-border);
48
+ animation: bar-slide-down var(--transition-duration) ease;
49
+ }
50
+
51
+ .selection-count {
52
+ font-size: var(--font-size-sm);
53
+ font-weight: 600;
54
+ color: var(--color-primary);
55
+ }
56
+
57
+ .selection-actions {
58
+ display: flex;
59
+ gap: var(--spacing-xs);
60
+ }
61
+
62
+ @keyframes bar-slide-down {
63
+ from {
64
+ opacity: 0;
65
+ translate: 0 -4px;
66
+ }
67
+ to {
68
+ opacity: 1;
69
+ translate: 0 0;
70
+ }
71
+ }
72
+
73
+ @media (prefers-reduced-motion: reduce) {
74
+ .selection-bar {
75
+ animation: none;
76
+ }
77
+ }
78
+
79
+ @media (max-width: 767px) {
80
+ .selection-bar {
81
+ padding: var(--spacing-xs) var(--spacing-md);
82
+ flex-wrap: wrap;
83
+ gap: var(--spacing-xs);
84
+ }
85
+ }
86
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ DialogRoot,
4
+ DialogPortal,
5
+ DialogOverlay,
6
+ DialogContent,
7
+ DialogTitle,
8
+ DialogClose,
9
+ } from "reka-ui"
10
+ import { X } from "lucide-vue-next"
11
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
12
+
13
+ const open = defineModel<boolean>("open", { required: true })
14
+ const { t } = useI18n()
15
+ </script>
16
+
17
+ <template>
18
+ <DialogRoot v-model:open="open">
19
+ <DialogPortal disabled>
20
+ <DialogOverlay class="editor-overlay" />
21
+ <DialogContent class="sidebar-drawer">
22
+ <DialogTitle class="sr-only">{{ t("sidebar.speakers") }}</DialogTitle>
23
+ <DialogClose
24
+ class="sidebar-close"
25
+ :aria-label="t('header.closeSidebar')">
26
+ <X :size="20" />
27
+ </DialogClose>
28
+ <slot />
29
+ </DialogContent>
30
+ </DialogPortal>
31
+ </DialogRoot>
32
+ </template>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { SpeakerIndicator } from "@linto-ai/transcript-ui-ui"
3
+ import { computed } from "vue"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+ import * as utils from "../utils"
6
+ import type { Speaker } from "../types/editor"
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
+ }>()
17
+
18
+ const { t, locale } = useI18n()
19
+
20
+ const languageName = computed(() =>
21
+ utils.getLanguageDisplayName(
22
+ props.language,
23
+ locale.value,
24
+ t("language.wildcard"),
25
+ ),
26
+ )
27
+
28
+ const timestamp = computed<{ text: string; datetime: string } | null>(() => {
29
+ if (props.startTime != null) {
30
+ return {
31
+ text: utils.formatTime(props.startTime),
32
+ datetime: `PT${props.startTime.toFixed(1)}S`,
33
+ }
34
+ }
35
+ if (props.startDate != null) {
36
+ const date = new Date(props.startDate * 1000)
37
+ return {
38
+ text: utils.formatShortDateTime(props.startDate, locale.value),
39
+ datetime: date.toISOString(),
40
+ }
41
+ }
42
+ return null
43
+ })
44
+
45
+ const speakerColor = computed(() => props.speaker?.color ?? "transparent")
46
+
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"))
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ class="speaker-label"
55
+ :class="{ 'speaker-label--interactive': interactive }">
56
+ <SpeakerIndicator v-if="speaker" :color="speakerColor" />
57
+ <span class="speaker-name" :class="{ 'speaker-name--unknown': !speaker }">{{
58
+ displayName
59
+ }}</span>
60
+ <time v-if="timestamp" class="timestamp" :datetime="timestamp.datetime">{{
61
+ timestamp.text
62
+ }}</time>
63
+ <span v-if="languageName" class="lang">{{ languageName }}</span>
64
+ </div>
65
+ </template>
66
+
67
+ <style scoped>
68
+ .speaker-label {
69
+ display: flex;
70
+ border-bottom: 2px solid transparent;
71
+ align-items: center;
72
+ gap: var(--spacing-sm);
73
+ }
74
+
75
+ .speaker-name {
76
+ font-size: var(--font-size-sm);
77
+ font-weight: 600;
78
+ color: var(--color-text-primary);
79
+ }
80
+
81
+ .speaker-name--unknown {
82
+ font-weight: 400;
83
+ font-style: italic;
84
+ color: var(--color-text-muted);
85
+ }
86
+
87
+ .speaker-label--interactive:hover .speaker-name {
88
+ text-decoration: underline;
89
+ }
90
+
91
+ .timestamp {
92
+ font-size: var(--font-size-xs);
93
+ font-family: var(--font-family-mono);
94
+ color: var(--color-text-muted);
95
+ /* not supported on firefox yet */
96
+ text-box: trim-both cap alphabetic;
97
+ }
98
+
99
+ .lang {
100
+ font-size: var(--font-size-xs);
101
+ font-weight: 400;
102
+ /* not supported on firefox yet */
103
+ text-box: trim-both cap alphabetic;
104
+ }
105
+ </style>