@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,243 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Testimonial component - customer quote with avatar
4
+ *
5
+ * Usage:
6
+ * <Testimonial quote="Great product!" author="John Doe" />
7
+ * <Testimonial
8
+ * quote="This changed everything for us."
9
+ * author="Jane Smith"
10
+ * role="CTO"
11
+ * company="Acme Inc"
12
+ * avatar="/avatars/jane.jpg"
13
+ * :rating="5"
14
+ * />
15
+ * <Testimonial quote="Amazing!" author="Bob" variant="featured" />
16
+ */
17
+
18
+ import { computed } from 'vue'
19
+
20
+ const props = withDefaults(defineProps<{
21
+ quote: string
22
+ author: string
23
+ role?: string
24
+ company?: string
25
+ avatar?: string
26
+ rating?: number // 0-5 stars
27
+ variant?: 'card' | 'minimal' | 'featured'
28
+ }>(), {
29
+ rating: 0,
30
+ variant: 'card'
31
+ })
32
+
33
+ const authorInitial = computed(() => {
34
+ return props.author.charAt(0).toUpperCase()
35
+ })
36
+
37
+ const stars = computed(() => {
38
+ return Array.from({ length: 5 }, (_, i) => i < props.rating)
39
+ })
40
+
41
+ const attribution = computed(() => {
42
+ const parts = []
43
+ if (props.role) parts.push(props.role)
44
+ if (props.company) parts.push(props.company)
45
+ return parts.join(' at ')
46
+ })
47
+ </script>
48
+
49
+ <template>
50
+ <div class="testimonial" :class="`testimonial-${variant}`">
51
+ <!-- Quote icon for featured variant -->
52
+ <div v-if="variant === 'featured'" class="testimonial-quote-icon">
53
+ <svg viewBox="0 0 24 24" fill="currentColor">
54
+ <path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/>
55
+ </svg>
56
+ </div>
57
+
58
+ <!-- Quote text -->
59
+ <blockquote class="testimonial-quote">
60
+ <p>{{ quote }}</p>
61
+ </blockquote>
62
+
63
+ <!-- Rating stars -->
64
+ <div v-if="rating > 0" class="testimonial-rating">
65
+ <span
66
+ v-for="(filled, idx) in stars"
67
+ :key="idx"
68
+ class="testimonial-star"
69
+ :class="{ 'testimonial-star-filled': filled }"
70
+ >★</span>
71
+ </div>
72
+
73
+ <!-- Author info -->
74
+ <div class="testimonial-author">
75
+ <div class="testimonial-avatar">
76
+ <img v-if="avatar" :src="avatar" :alt="author" />
77
+ <span v-else class="testimonial-initial">{{ authorInitial }}</span>
78
+ </div>
79
+ <div class="testimonial-info">
80
+ <div class="testimonial-name">{{ author }}</div>
81
+ <div v-if="attribution" class="testimonial-role">{{ attribution }}</div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </template>
86
+
87
+ <style>
88
+ .testimonial {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: var(--space-md);
92
+ }
93
+
94
+ /* Card variant (default) */
95
+ .testimonial-card {
96
+ background-color: var(--color-bg-soft);
97
+ border-radius: 0.5rem;
98
+ padding: var(--space-lg);
99
+ border: 1px solid var(--color-border);
100
+ }
101
+
102
+ /* Minimal variant */
103
+ .testimonial-minimal {
104
+ padding: var(--space-md) 0;
105
+ border-left: 4px solid var(--color-primary);
106
+ padding-left: var(--space-md);
107
+ }
108
+
109
+ /* Featured variant */
110
+ .testimonial-featured {
111
+ background: var(--gradient-primary);
112
+ color: var(--color-primary-foreground);
113
+ border-radius: 0.75rem;
114
+ padding: var(--space-xl);
115
+ text-align: center;
116
+ align-items: center;
117
+ }
118
+
119
+ .testimonial-featured .testimonial-quote p {
120
+ color: inherit;
121
+ }
122
+
123
+ .testimonial-featured .testimonial-role {
124
+ opacity: 0.8;
125
+ }
126
+
127
+ .testimonial-featured .testimonial-initial {
128
+ background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
129
+ color: inherit;
130
+ }
131
+
132
+ /* Quote icon */
133
+ .testimonial-quote-icon {
134
+ width: 48px;
135
+ height: 48px;
136
+ opacity: 0.3;
137
+ margin-bottom: var(--space-sm);
138
+ }
139
+
140
+ .testimonial-quote-icon svg {
141
+ width: 100%;
142
+ height: 100%;
143
+ }
144
+
145
+ /* Quote — override global blockquote border */
146
+ .testimonial .testimonial-quote {
147
+ margin: 0;
148
+ padding: 0;
149
+ border: none;
150
+ max-width: none;
151
+ color: inherit;
152
+ }
153
+
154
+ .testimonial-quote p {
155
+ margin: 0;
156
+ font-size: var(--font-size-base);
157
+ line-height: var(--line-height-body);
158
+ color: var(--color-text);
159
+ font-style: normal;
160
+ }
161
+
162
+ .testimonial-featured .testimonial-quote p {
163
+ font-size: var(--font-size-h2);
164
+ line-height: 1.3;
165
+ }
166
+
167
+ /* Rating */
168
+ .testimonial-rating {
169
+ display: flex;
170
+ gap: 0.25em;
171
+ }
172
+
173
+ .testimonial-star {
174
+ font-size: 1.25rem;
175
+ color: var(--color-border);
176
+ }
177
+
178
+ .testimonial-star-filled {
179
+ color: var(--color-warning);
180
+ }
181
+
182
+ /* Author */
183
+ .testimonial-author {
184
+ display: flex;
185
+ align-items: center;
186
+ gap: var(--space-sm);
187
+ }
188
+
189
+ .testimonial-featured .testimonial-author {
190
+ flex-direction: column;
191
+ text-align: center;
192
+ }
193
+
194
+ .testimonial-avatar {
195
+ width: 48px;
196
+ height: 48px;
197
+ border-radius: 50%;
198
+ overflow: hidden;
199
+ flex-shrink: 0;
200
+ }
201
+
202
+ .testimonial-avatar img {
203
+ width: 100%;
204
+ height: 100%;
205
+ object-fit: cover;
206
+ }
207
+
208
+ .testimonial-initial {
209
+ width: 100%;
210
+ height: 100%;
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: center;
214
+ font-size: 1.25rem;
215
+ font-weight: var(--font-weight-semibold);
216
+ background: var(--gradient-primary);
217
+ color: var(--color-primary-foreground);
218
+ }
219
+
220
+ .testimonial-card .testimonial-initial,
221
+ .testimonial-minimal .testimonial-initial {
222
+ background: var(--gradient-primary);
223
+ }
224
+
225
+ .testimonial-info {
226
+ display: flex;
227
+ flex-direction: column;
228
+ }
229
+
230
+ .testimonial-name {
231
+ font-weight: var(--font-weight-semibold);
232
+ color: var(--color-text);
233
+ }
234
+
235
+ .testimonial-featured .testimonial-name {
236
+ color: inherit;
237
+ }
238
+
239
+ .testimonial-role {
240
+ font-size: var(--font-size-small);
241
+ color: var(--color-text-secondary);
242
+ }
243
+ </style>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Typewriter component - text typing animation effect
4
+ *
5
+ * Usage:
6
+ * <Typewriter text="Hello, World!" />
7
+ * <Typewriter :text="['First line', 'Second line']" loop />
8
+ * <Typewriter text="Fast typing" :speed="30" />
9
+ * <Typewriter text="With cursor" cursor="|" />
10
+ */
11
+
12
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
13
+ import type { ColorWithInherit } from '../composables/useColors'
14
+ import { textColorVar } from '../composables/useColors'
15
+
16
+ const props = withDefaults(defineProps<{
17
+ text: string | string[]
18
+ speed?: number // ms per character
19
+ deleteSpeed?: number // ms per character when deleting
20
+ delay?: number // initial delay before typing
21
+ pauseBetween?: number // pause between strings (for arrays)
22
+ cursor?: boolean | string
23
+ loop?: boolean
24
+ autostart?: boolean
25
+ color?: ColorWithInherit
26
+ }>(), {
27
+ speed: 50,
28
+ deleteSpeed: 30,
29
+ delay: 0,
30
+ pauseBetween: 1500,
31
+ cursor: true,
32
+ loop: false,
33
+ autostart: true,
34
+ color: 'inherit',
35
+ })
36
+
37
+ const emit = defineEmits<{
38
+ (e: 'complete'): void
39
+ (e: 'typed', text: string): void
40
+ }>()
41
+
42
+ const displayText = ref('')
43
+ const isTyping = ref(false)
44
+ const cursorVisible = ref(true)
45
+
46
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
47
+ let cursorIntervalId: ReturnType<typeof setInterval> | null = null
48
+
49
+ const texts = computed(() => {
50
+ return Array.isArray(props.text) ? props.text : [props.text]
51
+ })
52
+
53
+ const cursorChar = computed(() => {
54
+ if (props.cursor === false) return ''
55
+ if (typeof props.cursor === 'string') return props.cursor
56
+ return '|'
57
+ })
58
+
59
+ async function sleep(ms: number) {
60
+ return new Promise(resolve => {
61
+ timeoutId = setTimeout(resolve, ms)
62
+ })
63
+ }
64
+
65
+ async function typeText(text: string) {
66
+ for (let i = 0; i <= text.length; i++) {
67
+ displayText.value = text.slice(0, i)
68
+ await sleep(props.speed)
69
+ }
70
+ emit('typed', text)
71
+ }
72
+
73
+ async function deleteText() {
74
+ const currentLength = displayText.value.length
75
+ for (let i = currentLength; i >= 0; i--) {
76
+ displayText.value = displayText.value.slice(0, i)
77
+ await sleep(props.deleteSpeed)
78
+ }
79
+ }
80
+
81
+ async function runAnimation() {
82
+ isTyping.value = true
83
+
84
+ if (props.delay > 0) {
85
+ await sleep(props.delay)
86
+ }
87
+
88
+ do {
89
+ for (let i = 0; i < texts.value.length; i++) {
90
+ await typeText(texts.value[i])
91
+
92
+ // If not the last text or looping, delete and continue
93
+ if (i < texts.value.length - 1 || props.loop) {
94
+ await sleep(props.pauseBetween)
95
+ await deleteText()
96
+ await sleep(props.speed * 5)
97
+ }
98
+ }
99
+ } while (props.loop)
100
+
101
+ isTyping.value = false
102
+ emit('complete')
103
+ }
104
+
105
+ function start() {
106
+ stop()
107
+ displayText.value = ''
108
+ runAnimation()
109
+ }
110
+
111
+ function stop() {
112
+ if (timeoutId) {
113
+ clearTimeout(timeoutId)
114
+ timeoutId = null
115
+ }
116
+ isTyping.value = false
117
+ }
118
+
119
+ // Cursor blink effect
120
+ function startCursorBlink() {
121
+ if (props.cursor) {
122
+ cursorIntervalId = setInterval(() => {
123
+ cursorVisible.value = !cursorVisible.value
124
+ }, 530)
125
+ }
126
+ }
127
+
128
+ onMounted(() => {
129
+ startCursorBlink()
130
+ if (props.autostart) {
131
+ start()
132
+ }
133
+ })
134
+
135
+ onUnmounted(() => {
136
+ stop()
137
+ if (cursorIntervalId) {
138
+ clearInterval(cursorIntervalId)
139
+ }
140
+ })
141
+
142
+ // Watch for text changes
143
+ watch(() => props.text, () => {
144
+ if (props.autostart) {
145
+ start()
146
+ }
147
+ })
148
+
149
+ // Expose for external control
150
+ defineExpose({ start, stop })
151
+ </script>
152
+
153
+ <template>
154
+ <span class="typewriter" :style="{ color: color !== 'inherit' ? textColorVar[color] : undefined }">
155
+ <span class="typewriter-text">{{ displayText }}</span>
156
+ <span
157
+ v-if="cursor"
158
+ class="typewriter-cursor"
159
+ :class="{ 'typewriter-cursor-visible': cursorVisible }"
160
+ >{{ cursorChar }}</span>
161
+ </span>
162
+ </template>
163
+
164
+ <style>
165
+ .typewriter {
166
+ display: inline;
167
+ }
168
+
169
+ .typewriter-text {
170
+ white-space: pre-wrap;
171
+ }
172
+
173
+ .typewriter-cursor {
174
+ opacity: 0;
175
+ transition: opacity 0.1s;
176
+ }
177
+
178
+ .typewriter-cursor-visible {
179
+ opacity: 1;
180
+ }
181
+ </style>
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * AnimatedCounter component - animated number counting up
4
+ *
5
+ * Smart v-click integration:
6
+ * - No `at` prop (default): auto-registers one click slot, animates on click
7
+ * - `at="0"`: animates on slide enter (no click registration)
8
+ * - `at="-1"`: manual only (call animate()/resetToStart() via ref)
9
+ *
10
+ * Usage:
11
+ * <AnimatedCounter :value="1000" /> <!-- auto v-click -->
12
+ * <AnimatedCounter :value="50000" prefix="$" separator="," />
13
+ * <AnimatedCounter :value="3.14" :decimals="2" :at="0" /> <!-- on slide enter -->
14
+ */
15
+
16
+ import { ref, unref, computed, watch, watchEffect, inject, 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
+ value: number
24
+ from?: number
25
+ duration?: number // ms
26
+ prefix?: string
27
+ suffix?: string
28
+ decimals?: number
29
+ separator?: string // thousands separator
30
+ easing?: 'linear' | 'easeOut' | 'easeInOut'
31
+ at?: number // undefined = auto-register click, 0 = on slide enter, -1 = manual
32
+ }>(), {
33
+ from: 0,
34
+ duration: 2000,
35
+ prefix: '',
36
+ suffix: '',
37
+ decimals: 0,
38
+ separator: '',
39
+ easing: 'easeOut',
40
+ })
41
+
42
+ const clicksContext = inject<Ref<{ current: number }>>('$$slidev-clicks-context', ref(defaultClicksContext))
43
+ const slidevContext = inject<{ nav: { currentSlideNo: number } }>('$$slidev-context', { nav: defaultNav })
44
+ const slidePage = inject<Ref<number>>('$$slidev-page', ref(-1))
45
+
46
+ const rootEl = ref<HTMLElement | null>(null)
47
+ const currentValue = ref(props.from)
48
+ const isAutoRegistered = ref(false)
49
+ let animationFrameId: number | null = null
50
+ let hasAnimated = false
51
+
52
+ const formattedValue = computed(() => {
53
+ let num = currentValue.value.toFixed(props.decimals)
54
+
55
+ if (props.separator) {
56
+ const parts = num.split('.')
57
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, props.separator)
58
+ num = parts.join('.')
59
+ }
60
+
61
+ return `${props.prefix}${num}${props.suffix}`
62
+ })
63
+
64
+ const easingFunctions = {
65
+ linear: (t: number) => t,
66
+ easeOut: (t: number) => 1 - Math.pow(1 - t, 3),
67
+ easeInOut: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
68
+ }
69
+
70
+ function cancelAnimation() {
71
+ if (animationFrameId) {
72
+ cancelAnimationFrame(animationFrameId)
73
+ animationFrameId = null
74
+ }
75
+ }
76
+
77
+ function animate() {
78
+ cancelAnimation()
79
+
80
+ const startValue = props.from
81
+ const endValue = props.value
82
+ const startTime = performance.now()
83
+ const easingFn = easingFunctions[props.easing]
84
+
85
+ function step(currentTime: number) {
86
+ const elapsed = currentTime - startTime
87
+ const progress = Math.min(elapsed / props.duration, 1)
88
+ const easedProgress = easingFn(progress)
89
+
90
+ currentValue.value = startValue + (endValue - startValue) * easedProgress
91
+
92
+ if (progress < 1) {
93
+ animationFrameId = requestAnimationFrame(step)
94
+ } else {
95
+ currentValue.value = endValue
96
+ animationFrameId = null
97
+ }
98
+ }
99
+
100
+ requestAnimationFrame(step)
101
+ }
102
+
103
+ function resetToStart() {
104
+ cancelAnimation()
105
+ currentValue.value = props.from
106
+ }
107
+
108
+ // Slidev keeps all slides in DOM (no KeepAlive). Detect slide activation
109
+ // by watching nav.currentSlideNo and comparing to this slide's page number.
110
+ onMounted(() => {
111
+ // Auto v-click registration: when at is undefined (default), register with click system
112
+ if (props.at === undefined) {
113
+ const ctx = clicksContext.value
114
+ if (ctx?.calculate && rootEl.value) {
115
+ const info = ctx.calculate('+1')
116
+ if (info) {
117
+ ctx.register(rootEl.value, info)
118
+ isAutoRegistered.value = true
119
+
120
+ const isActiveRef = info.isActive
121
+
122
+ watchEffect(() => {
123
+ const active = isActiveRef.value
124
+ if (active && !hasAnimated) {
125
+ hasAnimated = true
126
+ animate()
127
+ } else if (!active && hasAnimated) {
128
+ hasAnimated = false
129
+ resetToStart()
130
+ }
131
+ })
132
+ }
133
+ }
134
+ }
135
+
136
+ // Fallback for non-Slidev contexts
137
+ if (unref(slidePage) < 0) {
138
+ if (props.at === undefined || props.at === 0) {
139
+ hasAnimated = true
140
+ animate()
141
+ }
142
+ }
143
+ })
144
+
145
+ onUnmounted(() => {
146
+ cancelAnimation()
147
+ if (isAutoRegistered.value && clicksContext.value?.unregister && rootEl.value) {
148
+ clicksContext.value.unregister(rootEl.value)
149
+ }
150
+ })
151
+
152
+ watch(() => slidevContext.nav?.currentSlideNo, (currentNo, prevNo) => {
153
+ const myPage = unref(slidePage)
154
+ if (myPage < 0) return // non-Slidev context
155
+
156
+ const isActive = currentNo + 1 === myPage
157
+ const wasActive = prevNo !== undefined && prevNo + 1 === myPage
158
+
159
+ if (isActive && !wasActive) {
160
+ // Slide just became active: reset and trigger
161
+ hasAnimated = false
162
+ resetToStart()
163
+ if (props.at === 0) {
164
+ hasAnimated = true
165
+ animate()
166
+ }
167
+ } else if (!isActive && wasActive) {
168
+ // Slide deactivated: cancel animation
169
+ cancelAnimation()
170
+ }
171
+ }, { immediate: true })
172
+
173
+ // Watch clicks: passive fallback for explicit at > 0 (rare)
174
+ watch(() => clicksContext.value?.current, (current) => {
175
+ if (isAutoRegistered.value || props.at === undefined || props.at <= 0) return
176
+ const cur = current ?? 0
177
+
178
+ if (cur >= props.at && !hasAnimated) {
179
+ hasAnimated = true
180
+ animate()
181
+ } else if (cur < props.at && hasAnimated) {
182
+ hasAnimated = false
183
+ resetToStart()
184
+ }
185
+ })
186
+
187
+ // Watch for value changes (re-animate if already triggered)
188
+ watch(() => props.value, () => {
189
+ if (hasAnimated) {
190
+ animate()
191
+ }
192
+ })
193
+
194
+ defineExpose({ animate, resetToStart })
195
+ </script>
196
+
197
+ <template>
198
+ <span ref="rootEl" class="animated-counter">
199
+ {{ formattedValue }}
200
+ </span>
201
+ </template>
202
+
203
+ <style>
204
+ .animated-counter {
205
+ font-variant-numeric: tabular-nums;
206
+ display: inline-block;
207
+ }
208
+ </style>