@troshab/slidev-theme-troshab 0.1.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 (168) hide show
  1. package/CLAUDE.md +537 -0
  2. package/LICENSE +134 -0
  3. package/README.md +168 -0
  4. package/SKILL.md +414 -0
  5. package/components/AnimatedCounter.vue +35 -0
  6. package/components/Background.vue +204 -0
  7. package/components/Callout.vue +135 -0
  8. package/components/Card.vue +75 -0
  9. package/components/CardGrid.vue +67 -0
  10. package/components/CaseStudy.vue +66 -0
  11. package/components/CodeDiff.vue +229 -0
  12. package/components/CodeHighlight.vue +337 -0
  13. package/components/ColorSwatch.vue +114 -0
  14. package/components/Confetti.vue +292 -0
  15. package/components/Conversation.vue +405 -0
  16. package/components/Countdown.vue +476 -0
  17. package/components/Definition.vue +59 -0
  18. package/components/DeviceMockup.vue +392 -0
  19. package/components/Funnel.vue +87 -0
  20. package/components/Icon.vue +73 -0
  21. package/components/Iframe.vue +38 -0
  22. package/components/Image.vue +69 -0
  23. package/components/ImageCompare.vue +436 -0
  24. package/components/MatrixGrid.vue +85 -0
  25. package/components/MermaidChart.vue +299 -0
  26. package/components/Metric.vue +161 -0
  27. package/components/PersonCard.vue +165 -0
  28. package/components/PricingTable.vue +144 -0
  29. package/components/Progress.vue +100 -0
  30. package/components/Pyramid.vue +81 -0
  31. package/components/QRCode.vue +137 -0
  32. package/components/QuoteBlock.vue +101 -0
  33. package/components/SpeechBubble.vue +169 -0
  34. package/components/Stepper.vue +542 -0
  35. package/components/StyledList.vue +156 -0
  36. package/components/StyledText.vue +275 -0
  37. package/components/SwotGrid.vue +99 -0
  38. package/components/Tags.vue +20 -0
  39. package/components/Testimonial.vue +243 -0
  40. package/components/Typewriter.vue +181 -0
  41. package/components_base/AnimatedCounter.vue +208 -0
  42. package/components_base/CodeHighlight.vue +364 -0
  43. package/composables/useColors.ts +101 -0
  44. package/composables/useShiki.ts +81 -0
  45. package/example_content.md +371 -0
  46. package/example_dark.md +10 -0
  47. package/example_slides/001-cover.md +15 -0
  48. package/example_slides/002-agenda.md +25 -0
  49. package/example_slides/003-section-layouts.md +14 -0
  50. package/example_slides/004-fullscreen-centered.md +7 -0
  51. package/example_slides/005-fullscreen-align-bottom.md +14 -0
  52. package/example_slides/006-fullscreen-no-padding.md +14 -0
  53. package/example_slides/007-fullscreen-bg-image-dark.md +13 -0
  54. package/example_slides/008-fullscreen-bg-image-light.md +13 -0
  55. package/example_slides/009-fullscreen-bg-gradient.md +15 -0
  56. package/example_slides/010-fullscreen-bg-color.md +13 -0
  57. package/example_slides/011-split-basic.md +17 -0
  58. package/example_slides/012-split-image-text.md +18 -0
  59. package/example_slides/013-split-contrast.md +22 -0
  60. package/example_slides/014-columns-basic.md +13 -0
  61. package/example_slides/015-columns-two.md +26 -0
  62. package/example_slides/016-columns-ratios.md +22 -0
  63. package/example_slides/017-columns-three.md +31 -0
  64. package/example_slides/018-columns-four.md +22 -0
  65. package/example_slides/019-columns-alignment.md +23 -0
  66. package/example_slides/020-columns-styled.md +21 -0
  67. package/example_slides/021-footnote-prop.md +16 -0
  68. package/example_slides/022-iframe-fullscreen.md +8 -0
  69. package/example_slides/023-iframe-split.md +18 -0
  70. package/example_slides/024-section-components.md +14 -0
  71. package/example_slides/025-styled-text.md +9 -0
  72. package/example_slides/026-styled-text.md +15 -0
  73. package/example_slides/027-text-formatting.md +28 -0
  74. package/example_slides/028-text-spoiler.md +15 -0
  75. package/example_slides/029-icon-component.md +47 -0
  76. package/example_slides/030-metric-component.md +29 -0
  77. package/example_slides/031-person-card.md +33 -0
  78. package/example_slides/032-styled-list.md +50 -0
  79. package/example_slides/033-color-swatch.md +35 -0
  80. package/example_slides/034-code-highlight.md +9 -0
  81. package/example_slides/035-iframe-component.md +9 -0
  82. package/example_slides/036-callout.md +15 -0
  83. package/example_slides/037-card-grid.md +27 -0
  84. package/example_slides/038-stepper-variants.md +18 -0
  85. package/example_slides/039-stepper-clicks.md +49 -0
  86. package/example_slides/040-stepper-interactive.md +28 -0
  87. package/example_slides/041-tags-progress.md +21 -0
  88. package/example_slides/042-speech-bubble.md +30 -0
  89. package/example_slides/043-conversation.md +13 -0
  90. package/example_slides/044-device-iphone.md +26 -0
  91. package/example_slides/045-device-browser.md +7 -0
  92. package/example_slides/046-qrcode.md +26 -0
  93. package/example_slides/047-countdown.md +14 -0
  94. package/example_slides/048-typewriter.md +8 -0
  95. package/example_slides/049-confetti.md +16 -0
  96. package/example_slides/050-image-compare.md +13 -0
  97. package/example_slides/051-code-diff.md +24 -0
  98. package/example_slides/052-quote-block.md +8 -0
  99. package/example_slides/053-testimonial.md +26 -0
  100. package/example_slides/054-testimonial-featured.md +16 -0
  101. package/example_slides/055-funnel.md +12 -0
  102. package/example_slides/056-pyramid.md +13 -0
  103. package/example_slides/057-pricing-table.md +9 -0
  104. package/example_slides/058-swot-grid.md +12 -0
  105. package/example_slides/059-matrix-grid.md +12 -0
  106. package/example_slides/060-case-study.md +11 -0
  107. package/example_slides/061-definition.md +15 -0
  108. package/example_slides/062-mermaid-intro.md +34 -0
  109. package/example_slides/063-mermaid-flowchart.md +19 -0
  110. package/example_slides/064-mermaid-sequence.md +17 -0
  111. package/example_slides/065-mermaid-xy-chart.md +16 -0
  112. package/example_slides/066-mermaid-pie.md +17 -0
  113. package/example_slides/067-mermaid-class.md +19 -0
  114. package/example_slides/068-mermaid-state.md +19 -0
  115. package/example_slides/069-mermaid-er.md +22 -0
  116. package/example_slides/070-mermaid-gantt.md +24 -0
  117. package/example_slides/071-mermaid-timeline.md +17 -0
  118. package/example_slides/072-mermaid-mindmap.md +21 -0
  119. package/example_slides/073-mermaid-gitgraph.md +20 -0
  120. package/example_slides/074-mermaid-split.md +30 -0
  121. package/example_slides/075-mermaid-columns.md +32 -0
  122. package/example_slides/076-section-addons.md +14 -0
  123. package/example_slides/077-asciinema.md +27 -0
  124. package/example_slides/078-fancyarrow.md +31 -0
  125. package/example_slides/079-fancyarrow-demo.md +23 -0
  126. package/example_slides/080-section-theme.md +14 -0
  127. package/example_slides/081-color-architecture.md +22 -0
  128. package/example_slides/082-semantic-text-colors.md +25 -0
  129. package/example_slides/083-typography.md +16 -0
  130. package/example_slides/084-typography-rationale.md +22 -0
  131. package/example_slides/085-icons.md +24 -0
  132. package/example_slides/086-tables.md +14 -0
  133. package/example_slides/087-code-blocks.md +18 -0
  134. package/example_slides/088-motion-modes.md +35 -0
  135. package/example_slides/089-slide-transitions.md +31 -0
  136. package/example_slides/090-v-click-reveals.md +40 -0
  137. package/example_slides/091-accessibility.md +27 -0
  138. package/example_slides/092-safe-zone.md +17 -0
  139. package/example_slides/093-questions.md +8 -0
  140. package/example_white.md +10 -0
  141. package/fonts/IBMPlexMono-Medium.woff2 +1449 -0
  142. package/fonts/IBMPlexMono-Regular.woff2 +1449 -0
  143. package/fonts/IBMPlexSans-Bold.woff2 +1449 -0
  144. package/fonts/IBMPlexSans-Medium.woff2 +1449 -0
  145. package/fonts/IBMPlexSans-Regular.woff2 +1449 -0
  146. package/fonts/IBMPlexSans-SemiBold.woff2 +1449 -0
  147. package/fonts/LICENSE.txt +93 -0
  148. package/layouts/slide.vue +251 -0
  149. package/package.json +62 -0
  150. package/public/avatars/alice.png +0 -0
  151. package/public/avatars/bob.png +0 -0
  152. package/public/avatars/carol.png +0 -0
  153. package/scripts/chart-audit.mjs +216 -0
  154. package/scripts/contrast-audit.mjs +299 -0
  155. package/scripts/generate-palette.mjs +395 -0
  156. package/scripts/integrity-audit.mjs +357 -0
  157. package/scripts/shared/css-utils.mjs +216 -0
  158. package/scripts/shiki-audit.mjs +300 -0
  159. package/scripts/typography-audit.mjs +300 -0
  160. package/setup/main.ts +107 -0
  161. package/setup/mermaid.ts +237 -0
  162. package/setup/shiki.ts +40 -0
  163. package/snippets/demo.ts +26 -0
  164. package/styles/base.css +1053 -0
  165. package/styles/colors.css +422 -0
  166. package/styles/index.css +12 -0
  167. package/styles/motion.css +486 -0
  168. package/uno.config.ts +67 -0
@@ -0,0 +1,292 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Confetti — celebration effect for background slots.
4
+ *
5
+ * Smart v-click integration:
6
+ * - No `at` prop (default): auto-registers one click slot, fires on click
7
+ * - `at="0"`: fires on slide enter (no click registration)
8
+ * - `at="-1"`: manual only (call fire() via ref)
9
+ *
10
+ * ::bg::
11
+ * <Confetti /> <!-- auto v-click -->
12
+ * <Confetti :at="0" /> <!-- fires on slide enter -->
13
+ * <Confetti :at="0" :particleCount="100" :spread="90" />
14
+ */
15
+
16
+ import { ref, unref, computed, inject, watch, watchEffect, onMounted, onUnmounted } from 'vue'
17
+ import type { Ref } from 'vue'
18
+
19
+ const defaultClicksContext = { current: 0 }
20
+ const defaultNav = { currentSlideNo: -1 }
21
+
22
+ const props = withDefaults(defineProps<{
23
+ at?: number // undefined = auto-register click, 0 = on slide enter, -1 = manual
24
+ colors?: string[]
25
+ particleCount?: number
26
+ spread?: number
27
+ duration?: number
28
+ origin?: { x: number; y: number }
29
+ }>(), {
30
+ particleCount: 50,
31
+ spread: 70,
32
+ duration: 3000,
33
+ origin: () => ({ x: 0.5, y: 0.5 })
34
+ })
35
+
36
+ function getThemeColors(): string[] {
37
+ const style = getComputedStyle(document.documentElement)
38
+ const get = (v: string) => style.getPropertyValue(v).trim()
39
+ return [
40
+ get('--color-primary'),
41
+ get('--color-success'),
42
+ get('--color-warning'),
43
+ get('--color-danger'),
44
+ get('--color-accent'),
45
+ get('--chart-6'),
46
+ ]
47
+ }
48
+
49
+ const resolvedColors = computed(() => props.colors ?? getThemeColors())
50
+
51
+ const emit = defineEmits<{
52
+ (e: 'complete'): void
53
+ }>()
54
+
55
+ const clicksContext = inject<Ref<{ current: number }>>('$$slidev-clicks-context', ref(defaultClicksContext))
56
+ const slidevContext = inject<{ nav: { currentSlideNo: number } }>('$$slidev-context', { nav: defaultNav })
57
+ const slidePage = inject<Ref<number>>('$$slidev-page', ref(-1))
58
+
59
+ const containerRef = ref<HTMLElement | null>(null)
60
+ const particles = ref<Array<{
61
+ id: number
62
+ x: number
63
+ y: number
64
+ color: string
65
+ rotation: number
66
+ scale: number
67
+ vx: number
68
+ vy: number
69
+ vr: number
70
+ shape: 'square' | 'circle' | 'strip'
71
+ }>>([])
72
+
73
+ const isAutoRegistered = ref(false)
74
+ let animationId: number | null = null
75
+ let particleId = 0
76
+ let hasFired = false
77
+
78
+ function randomBetween(min: number, max: number) {
79
+ return Math.random() * (max - min) + min
80
+ }
81
+
82
+ function fire() {
83
+ if (!containerRef.value) return
84
+
85
+ const rect = containerRef.value.getBoundingClientRect()
86
+ const originX = rect.width * props.origin.x
87
+ const originY = rect.height * props.origin.y
88
+
89
+ const newParticles = []
90
+
91
+ for (let i = 0; i < props.particleCount; i++) {
92
+ const angle = randomBetween(-props.spread / 2, props.spread / 2) * (Math.PI / 180)
93
+ const velocity = randomBetween(5, 15)
94
+ const shapes: Array<'square' | 'circle' | 'strip'> = ['square', 'circle', 'strip']
95
+
96
+ newParticles.push({
97
+ id: particleId++,
98
+ x: originX,
99
+ y: originY,
100
+ color: resolvedColors.value[Math.floor(Math.random() * resolvedColors.value.length)],
101
+ rotation: randomBetween(0, 360),
102
+ scale: randomBetween(0.5, 1),
103
+ vx: Math.sin(angle) * velocity,
104
+ vy: -Math.cos(angle) * velocity - randomBetween(2, 8),
105
+ vr: randomBetween(-10, 10),
106
+ shape: shapes[Math.floor(Math.random() * shapes.length)]
107
+ })
108
+ }
109
+
110
+ particles.value = [...particles.value, ...newParticles]
111
+
112
+ if (!animationId) {
113
+ animateParticles()
114
+ }
115
+
116
+ setTimeout(() => {
117
+ particles.value = particles.value.filter(p => !newParticles.find(np => np.id === p.id))
118
+ if (particles.value.length === 0) {
119
+ stopAnimation()
120
+ emit('complete')
121
+ }
122
+ }, props.duration)
123
+ }
124
+
125
+ function animateParticles() {
126
+ const gravity = 0.3
127
+ const friction = 0.99
128
+
129
+ particles.value = particles.value.map(p => ({
130
+ ...p,
131
+ x: p.x + p.vx,
132
+ y: p.y + p.vy,
133
+ rotation: p.rotation + p.vr,
134
+ vx: p.vx * friction,
135
+ vy: p.vy + gravity,
136
+ scale: Math.max(0, p.scale - 0.005)
137
+ })).filter(p => p.scale > 0)
138
+
139
+ if (particles.value.length > 0) {
140
+ animationId = requestAnimationFrame(animateParticles)
141
+ } else {
142
+ animationId = null
143
+ }
144
+ }
145
+
146
+ function stopAnimation() {
147
+ if (animationId) {
148
+ cancelAnimationFrame(animationId)
149
+ animationId = null
150
+ }
151
+ }
152
+
153
+ // Slidev keeps all slides in DOM. Detect slide activation via nav watcher.
154
+ onMounted(() => {
155
+ // Auto v-click registration: when at is undefined (default), register with click system
156
+ if (props.at === undefined) {
157
+ const ctx = clicksContext.value
158
+ if (ctx?.calculate && containerRef.value) {
159
+ const info = ctx.calculate('+1')
160
+ if (info) {
161
+ ctx.register(containerRef.value, info)
162
+ isAutoRegistered.value = true
163
+
164
+ watchEffect(() => {
165
+ const active = info.isActive.value
166
+ if (active && !hasFired) {
167
+ hasFired = true
168
+ fire()
169
+ } else if (!active && hasFired) {
170
+ hasFired = false
171
+ particles.value = []
172
+ stopAnimation()
173
+ }
174
+ })
175
+ }
176
+ }
177
+ }
178
+
179
+ if (unref(slidePage) < 0) {
180
+ // Non-Slidev context fallback
181
+ if (props.at === undefined || props.at === 0) {
182
+ setTimeout(fire, 100)
183
+ hasFired = true
184
+ }
185
+ }
186
+ })
187
+
188
+ watch(() => slidevContext.nav?.currentSlideNo, (currentNo, prevNo) => {
189
+ const myPage = unref(slidePage)
190
+ if (myPage < 0) return
191
+
192
+ const isActive = currentNo + 1 === myPage
193
+ const wasActive = prevNo !== undefined && prevNo + 1 === myPage
194
+
195
+ if (isActive && !wasActive) {
196
+ // Reset state on slide re-entry so confetti can fire again
197
+ hasFired = false
198
+ particles.value = []
199
+ stopAnimation()
200
+ if (props.at === 0) {
201
+ setTimeout(fire, 100)
202
+ hasFired = true
203
+ }
204
+ } else if (!isActive && wasActive) {
205
+ hasFired = false
206
+ particles.value = []
207
+ stopAnimation()
208
+ }
209
+ }, { immediate: true })
210
+
211
+ // Watch clicks: fire when reaching explicit `at` value (non-auto-registered)
212
+ watch(() => clicksContext.value?.current, (current) => {
213
+ if (isAutoRegistered.value || props.at === undefined || props.at <= 0) return
214
+ const cur = current ?? 0
215
+
216
+ if (cur >= props.at && !hasFired) {
217
+ hasFired = true
218
+ fire()
219
+ } else if (cur < props.at && hasFired) {
220
+ hasFired = false
221
+ particles.value = []
222
+ stopAnimation()
223
+ }
224
+ })
225
+
226
+ onUnmounted(() => {
227
+ stopAnimation()
228
+ if (isAutoRegistered.value && clicksContext.value?.unregister && containerRef.value) {
229
+ clicksContext.value.unregister(containerRef.value)
230
+ }
231
+ })
232
+
233
+ defineExpose({ fire })
234
+ </script>
235
+
236
+ <template>
237
+ <div ref="containerRef" class="confetti-bg">
238
+ <div class="confetti-particles">
239
+ <div
240
+ v-for="particle in particles"
241
+ :key="particle.id"
242
+ class="confetti-particle"
243
+ :class="`confetti-${particle.shape}`"
244
+ :style="{
245
+ left: `${particle.x}px`,
246
+ top: `${particle.y}px`,
247
+ backgroundColor: particle.color,
248
+ transform: `rotate(${particle.rotation}deg) scale(${particle.scale})`,
249
+ opacity: particle.scale
250
+ }"
251
+ />
252
+ </div>
253
+ </div>
254
+ </template>
255
+
256
+ <style>
257
+ .confetti-bg {
258
+ position: absolute;
259
+ inset: 0;
260
+ pointer-events: none;
261
+ overflow: hidden;
262
+ }
263
+
264
+ .confetti-particles {
265
+ position: absolute;
266
+ inset: 0;
267
+ overflow: visible;
268
+ z-index: 10;
269
+ }
270
+
271
+ .confetti-particle {
272
+ position: absolute;
273
+ will-change: transform, opacity;
274
+ }
275
+
276
+ .confetti-square {
277
+ width: 10px;
278
+ height: 10px;
279
+ }
280
+
281
+ .confetti-circle {
282
+ width: 8px;
283
+ height: 8px;
284
+ border-radius: 50%;
285
+ }
286
+
287
+ .confetti-strip {
288
+ width: 3px;
289
+ height: 12px;
290
+ border-radius: 1px;
291
+ }
292
+ </style>
@@ -0,0 +1,405 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Conversation widget — iMessage-style chat dialog.
4
+ *
5
+ * <Conversation :messages="[
6
+ * { from: 'Alice', text: 'Hey!' },
7
+ * { from: 'Bob', text: 'Hello!' },
8
+ * ]" />
9
+ *
10
+ * In clicks mode (default), messages reveal one per click.
11
+ * Auto-detects left/right side from sender names.
12
+ */
13
+
14
+ import { ref, unref, inject, watch, computed, onMounted, onUnmounted, watchEffect } from 'vue'
15
+ import type { Ref } from 'vue'
16
+
17
+ interface Message {
18
+ from: string
19
+ text: string
20
+ side?: 'left' | 'right'
21
+ avatar?: string
22
+ color?: 'default' | 'primary' | 'info' | 'success' | 'warning' | 'danger'
23
+ }
24
+
25
+ const props = withDefaults(defineProps<{
26
+ messages: Message[]
27
+ mode?: 'static' | 'clicks'
28
+ showNames?: boolean
29
+ groupConsecutive?: boolean
30
+ }>(), {
31
+ mode: 'clicks',
32
+ showNames: true,
33
+ groupConsecutive: true,
34
+ })
35
+
36
+ const defaultClicksContext = { current: 0 }
37
+ const defaultNav = { currentSlideNo: -1 }
38
+
39
+ const clicksContext = inject<Ref<{ current: number }>>('$$slidev-clicks-context', ref(defaultClicksContext))
40
+ const slidevContext = inject<{ nav: { currentSlideNo: number } }>('$$slidev-context', { nav: defaultNav })
41
+ const slidePage = inject<Ref<number>>('$$slidev-page', ref(-1))
42
+
43
+ const slideActive = ref(false)
44
+
45
+ // Detect slide activation
46
+ watch(() => slidevContext.nav?.currentSlideNo, (currentNo, prevNo) => {
47
+ const myPage = unref(slidePage)
48
+ if (myPage < 0) {
49
+ slideActive.value = true
50
+ return
51
+ }
52
+ slideActive.value = currentNo + 1 === myPage
53
+ }, { immediate: true })
54
+
55
+ // Auto-side detection: first unique sender = left, second = right
56
+ const senderSides = computed(() => {
57
+ const map = new Map<string, 'left' | 'right'>()
58
+ for (const msg of props.messages) {
59
+ if (msg.side) {
60
+ map.set(msg.from, msg.side)
61
+ } else if (!map.has(msg.from)) {
62
+ map.set(msg.from, map.size === 0 ? 'left' : 'right')
63
+ }
64
+ }
65
+ return map
66
+ })
67
+
68
+ // Resolved messages with computed side and grouping info
69
+ const resolvedMessages = computed(() => {
70
+ return props.messages.map((msg, i) => {
71
+ const side = msg.side || senderSides.value.get(msg.from) || 'left'
72
+ const prev = i > 0 ? props.messages[i - 1] : null
73
+ const next = i < props.messages.length - 1 ? props.messages[i + 1] : null
74
+ const prevSame = props.groupConsecutive && prev?.from === msg.from
75
+ const nextSame = props.groupConsecutive && next?.from === msg.from
76
+ return {
77
+ ...msg,
78
+ side,
79
+ isFirstInGroup: !prevSame,
80
+ isLastInGroup: !nextSame,
81
+ }
82
+ })
83
+ })
84
+
85
+ // --- Auto v-click registration (clicks mode) ---
86
+ const rootEl = ref<HTMLElement | null>(null)
87
+ const autoVisibleCount = ref(1)
88
+ const isAutoRegistered = ref(false)
89
+
90
+ onMounted(() => {
91
+ if (props.mode !== 'clicks') return
92
+ const ctx = clicksContext.value
93
+ if (!ctx?.calculateSince || !rootEl.value) return
94
+
95
+ const size = props.messages.length - 1
96
+ if (size <= 0) return
97
+
98
+ const info = ctx.calculateSince('+1', size)
99
+ if (!info) return
100
+
101
+ ctx.register(rootEl.value, info)
102
+ isAutoRegistered.value = true
103
+
104
+ const isActiveRef = info.isActive
105
+ const offsetRef = info.currentOffset
106
+
107
+ watchEffect(() => {
108
+ const active = isActiveRef.value
109
+ const offset = offsetRef.value
110
+ autoVisibleCount.value = active ? Math.min(offset + 2, props.messages.length) : 1
111
+ })
112
+ })
113
+
114
+ onUnmounted(() => {
115
+ if (isAutoRegistered.value && clicksContext.value?.unregister && rootEl.value) {
116
+ clicksContext.value.unregister(rootEl.value)
117
+ }
118
+ })
119
+
120
+ // Visible count based on mode
121
+ const visibleCount = computed(() => {
122
+ if (props.mode === 'static') return props.messages.length
123
+ if (isAutoRegistered.value) return autoVisibleCount.value
124
+ // Fallback: direct click count (non-Slidev contexts)
125
+ const clicks = clicksContext.value?.current ?? 0
126
+ return clicks + 1
127
+ })
128
+
129
+ // Generate initials from name
130
+ function getInitials(name: string): string {
131
+ return name
132
+ .split(/\s+/)
133
+ .map(w => w[0])
134
+ .slice(0, 2)
135
+ .join('')
136
+ .toUpperCase()
137
+ }
138
+ </script>
139
+
140
+ <template>
141
+ <div ref="rootEl" class="conversation">
142
+ <TransitionGroup name="conv-msg">
143
+ <div
144
+ v-for="(msg, i) in resolvedMessages"
145
+ v-show="i < visibleCount"
146
+ :key="i"
147
+ class="conv-row"
148
+ :class="[
149
+ `conv-${msg.side}`,
150
+ msg.isFirstInGroup ? 'conv-group-first' : '',
151
+ msg.isLastInGroup ? 'conv-group-last' : '',
152
+ !msg.isFirstInGroup ? 'conv-group-continuation' : '',
153
+ ]"
154
+ >
155
+ <!-- Avatar (left side only, first in group) -->
156
+ <div class="conv-avatar-col">
157
+ <div
158
+ v-if="msg.side === 'left' && msg.isFirstInGroup"
159
+ class="conv-avatar"
160
+ >
161
+ <img v-if="msg.avatar" :src="msg.avatar" :alt="msg.from" class="conv-avatar-img" />
162
+ <span v-else class="conv-avatar-initials">{{ getInitials(msg.from) }}</span>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- Bubble -->
167
+ <div class="conv-bubble-col">
168
+ <div
169
+ v-if="showNames && msg.isFirstInGroup"
170
+ class="conv-sender"
171
+ >
172
+ {{ msg.from }}
173
+ </div>
174
+ <div
175
+ class="conv-bubble"
176
+ :class="[
177
+ `conv-bubble-${msg.side}`,
178
+ msg.color ? `conv-color-${msg.color}` : '',
179
+ msg.isFirstInGroup && msg.isLastInGroup ? 'conv-bubble-single' : '',
180
+ msg.isFirstInGroup && !msg.isLastInGroup ? 'conv-bubble-first' : '',
181
+ !msg.isFirstInGroup && !msg.isLastInGroup ? 'conv-bubble-middle' : '',
182
+ !msg.isFirstInGroup && msg.isLastInGroup ? 'conv-bubble-last' : '',
183
+ ]"
184
+ >
185
+ {{ msg.text }}
186
+ </div>
187
+ </div>
188
+
189
+ <!-- Avatar (right side only, first in group) -->
190
+ <div class="conv-avatar-col conv-avatar-right">
191
+ <div
192
+ v-if="msg.side === 'right' && msg.isFirstInGroup"
193
+ class="conv-avatar"
194
+ >
195
+ <img v-if="msg.avatar" :src="msg.avatar" :alt="msg.from" class="conv-avatar-img" />
196
+ <span v-else class="conv-avatar-initials">{{ getInitials(msg.from) }}</span>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </TransitionGroup>
201
+ </div>
202
+ </template>
203
+
204
+ <style>
205
+ .conversation {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: var(--space-xs, 8px);
209
+ max-width: 100%;
210
+ padding: var(--space-sm, 16px) 0;
211
+ }
212
+
213
+ /* Row layout */
214
+ .conv-row {
215
+ display: flex;
216
+ align-items: flex-end;
217
+ gap: var(--space-xs, 8px);
218
+ }
219
+
220
+ .conv-group-continuation {
221
+ margin-top: calc(-1 * var(--space-xs, 8px) + 2px);
222
+ }
223
+
224
+ .conv-left {
225
+ flex-direction: row;
226
+ }
227
+
228
+ .conv-right {
229
+ flex-direction: row-reverse;
230
+ }
231
+
232
+ .conv-right .conv-bubble-col {
233
+ align-items: flex-end;
234
+ }
235
+
236
+ /* Avatar */
237
+ .conv-avatar-col {
238
+ width: 2rem;
239
+ flex-shrink: 0;
240
+ }
241
+
242
+ .conv-avatar {
243
+ width: 2rem;
244
+ height: 2rem;
245
+ border-radius: 50%;
246
+ overflow: hidden;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ background: var(--gradient-primary);
251
+ }
252
+
253
+ .conv-avatar-img {
254
+ width: 100%;
255
+ height: 100%;
256
+ object-fit: cover;
257
+ }
258
+
259
+ .conv-avatar-initials {
260
+ color: var(--color-primary-foreground);
261
+ font-size: 0.75rem;
262
+ font-weight: var(--font-weight-semibold, 600);
263
+ }
264
+
265
+ /* Bubble column */
266
+ .conv-bubble-col {
267
+ display: flex;
268
+ flex-direction: column;
269
+ max-width: 75%;
270
+ }
271
+
272
+ /* Sender name */
273
+ .conv-sender {
274
+ font-size: 0.75rem;
275
+ color: var(--color-text-tertiary);
276
+ margin-bottom: 2px;
277
+ padding: 0 0.75rem;
278
+ }
279
+
280
+ .conv-right .conv-sender {
281
+ text-align: right;
282
+ }
283
+
284
+ /* Bubble */
285
+ .conv-bubble {
286
+ padding: 0.5rem 0.75rem;
287
+ font-size: var(--font-size-small, 18px);
288
+ line-height: var(--line-height-body, 1.35);
289
+ word-wrap: break-word;
290
+ }
291
+
292
+ /* Left bubbles */
293
+ .conv-bubble-left {
294
+ background-color: var(--color-bg-soft);
295
+ color: var(--color-text);
296
+ border: 1px solid var(--color-border);
297
+ }
298
+
299
+ .conv-bubble-left.conv-bubble-single {
300
+ border-radius: 1rem 1rem 1rem 0.25rem;
301
+ }
302
+
303
+ .conv-bubble-left.conv-bubble-first {
304
+ border-radius: 1rem 1rem 1rem 0.25rem;
305
+ }
306
+
307
+ .conv-bubble-left.conv-bubble-middle {
308
+ border-radius: 0.25rem 1rem 1rem 0.25rem;
309
+ }
310
+
311
+ .conv-bubble-left.conv-bubble-last {
312
+ border-radius: 0.25rem 1rem 1rem 1rem;
313
+ }
314
+
315
+ /* Right bubbles */
316
+ .conv-bubble-right {
317
+ background-color: var(--color-primary);
318
+ color: var(--color-primary-foreground);
319
+ border: 1px solid transparent;
320
+ }
321
+
322
+ .conv-bubble-right.conv-bubble-single {
323
+ border-radius: 1rem 1rem 0.25rem 1rem;
324
+ }
325
+
326
+ .conv-bubble-right.conv-bubble-first {
327
+ border-radius: 1rem 1rem 0.25rem 1rem;
328
+ }
329
+
330
+ .conv-bubble-right.conv-bubble-middle {
331
+ border-radius: 1rem 0.25rem 0.25rem 1rem;
332
+ }
333
+
334
+ .conv-bubble-right.conv-bubble-last {
335
+ border-radius: 1rem 0.25rem 1rem 1rem;
336
+ }
337
+
338
+ /* Color variants (override right-side default) */
339
+ .conv-color-primary.conv-bubble-right {
340
+ background-color: var(--color-primary);
341
+ }
342
+
343
+ .conv-color-info.conv-bubble-right {
344
+ background-color: var(--color-info);
345
+ }
346
+
347
+ .conv-color-success.conv-bubble-right {
348
+ background-color: var(--color-success);
349
+ }
350
+
351
+ .conv-color-warning.conv-bubble-right {
352
+ background-color: var(--color-warning);
353
+ }
354
+
355
+ .conv-color-danger.conv-bubble-right {
356
+ background-color: var(--color-danger);
357
+ }
358
+
359
+ /* Color variants for left bubbles — color-mix auto-adapts to light/dark */
360
+ .conv-color-primary.conv-bubble-left {
361
+ background-color: var(--color-primary-soft);
362
+ border-color: var(--color-primary);
363
+ }
364
+
365
+ .conv-color-info.conv-bubble-left {
366
+ background-color: var(--color-info-tint);
367
+ border-color: var(--color-info);
368
+ }
369
+
370
+ .conv-color-success.conv-bubble-left {
371
+ background-color: var(--color-success-tint);
372
+ border-color: var(--color-success);
373
+ }
374
+
375
+ .conv-color-warning.conv-bubble-left {
376
+ background-color: var(--color-warning-tint);
377
+ border-color: var(--color-warning);
378
+ }
379
+
380
+ .conv-color-danger.conv-bubble-left {
381
+ background-color: var(--color-danger-tint);
382
+ border-color: var(--color-danger);
383
+ }
384
+
385
+ /* Transition */
386
+ .conv-msg-enter-active {
387
+ transition: opacity var(--dur-moderate-02, 240ms) var(--ease-enter, ease-out),
388
+ transform var(--dur-moderate-02, 240ms) var(--ease-enter, ease-out);
389
+ }
390
+
391
+ .conv-msg-leave-active {
392
+ transition: opacity var(--dur-fast-02, 110ms) var(--ease-exit, ease-in),
393
+ transform var(--dur-fast-02, 110ms) var(--ease-exit, ease-in);
394
+ }
395
+
396
+ .conv-msg-enter-from {
397
+ opacity: 0;
398
+ transform: translateY(var(--shift-sm, 12px));
399
+ }
400
+
401
+ .conv-msg-leave-to {
402
+ opacity: 0;
403
+ transform: translateY(calc(-1 * var(--shift-xs, 6px)));
404
+ }
405
+ </style>