@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,299 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * MermaidChart — semantic wrapper for Mermaid diagrams.
4
+ *
5
+ * Provides LLMs with typed props and constraints for correct diagram usage.
6
+ * The actual diagram is passed as slot content containing a Mermaid code block.
7
+ * Renders via Slidev's built-in Mermaid support.
8
+ *
9
+ * In development mode, validates diagram complexity against recommended limits
10
+ * and shows a warning banner when exceeded.
11
+ *
12
+ * Usage:
13
+ * <MermaidChart type="flowchart" direction="LR">
14
+ *
15
+ * ```mermaid
16
+ * graph LR
17
+ * A[App] --> B[Server]
18
+ * ```
19
+ *
20
+ * </MermaidChart>
21
+ */
22
+ import { computed, onMounted, ref, useSlots, type VNode } from 'vue'
23
+
24
+ const props = defineProps<{
25
+ /** Diagram type — determines rendering constraints */
26
+ type: 'flowchart' | 'xychart' | 'sequence' | 'pie' | 'class' | 'state' | 'er' | 'gantt' | 'timeline' | 'mindmap' | 'gitgraph'
27
+ /** Flow direction for flowcharts */
28
+ direction?: 'LR' | 'TD' | 'BT' | 'RL'
29
+ /** Caption text below the diagram */
30
+ caption?: string
31
+ }>()
32
+
33
+ const isDev = import.meta.env.DEV
34
+ const slots = useSlots()
35
+
36
+ /** Limits per diagram type */
37
+ const limits: Record<string, { max: number, label: string }> = {
38
+ pie: { max: 5, label: 'segments' },
39
+ flowchart: { max: 8, label: 'nodes' },
40
+ xychart: { max: 8, label: 'x-axis labels' },
41
+ sequence: { max: 4, label: 'participants' },
42
+ class: { max: 5, label: 'classes' },
43
+ state: { max: 8, label: 'states' },
44
+ er: { max: 4, label: 'entities' },
45
+ gantt: { max: 6, label: 'tasks' },
46
+ timeline: { max: 6, label: 'events' },
47
+ mindmap: { max: 4, label: 'branches' },
48
+ gitgraph: { max: 8, label: 'commits' },
49
+ }
50
+
51
+ /**
52
+ * Walk slot VNodes to find mermaid source code.
53
+ * Slidev compiles ```mermaid blocks into components with a `code` prop.
54
+ */
55
+ function extractCode(): string | null {
56
+ const vnodes = slots.default?.()
57
+ if (!vnodes) return null
58
+
59
+ function walk(nodes: VNode[]): string | null {
60
+ for (const vn of nodes) {
61
+ if (typeof vn.props?.code === 'string') return vn.props.code
62
+ if (Array.isArray(vn.children)) {
63
+ const found = walk(vn.children as VNode[])
64
+ if (found) return found
65
+ }
66
+ if (vn.component?.props?.code) {
67
+ return vn.component.props.code as string
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ return walk(vnodes)
74
+ }
75
+
76
+ /** Count items in mermaid code based on diagram type */
77
+ function countItems(type: string, code: string): number {
78
+ switch (type) {
79
+ case 'pie':
80
+ return (code.match(/".+?"\s*:/g) || []).length
81
+
82
+ case 'flowchart': {
83
+ // Match node IDs before shape brackets: A[text], B(text), C{text}, D((text)), etc.
84
+ const matches = code.match(/(?:^|\s|-->|---)([A-Za-z]\w*)[\[({]/gm) || []
85
+ const ids = new Set(matches.map(m => {
86
+ const id = m.replace(/^[\s\->]+/, '').replace(/[\[({]$/, '')
87
+ return id
88
+ }))
89
+ return ids.size
90
+ }
91
+
92
+ case 'sequence': {
93
+ const actors = new Set<string>()
94
+ for (const m of code.matchAll(/(\S+)\s*->>?\+?\s*(\S+)/g)) {
95
+ actors.add(m[1].replace(/:$/, ''))
96
+ actors.add(m[2].replace(/:$/, ''))
97
+ }
98
+ return actors.size
99
+ }
100
+
101
+ case 'xychart': {
102
+ // x-axis [...labels...] or x-axis "title" [...labels...]
103
+ const xMatch = code.match(/x-axis\s+(?:"[^"]*"\s+)?\[([^\]]+)\]/)
104
+ return xMatch ? xMatch[1].split(',').length : 0
105
+ }
106
+
107
+ case 'class':
108
+ return (code.match(/^\s*class\s+\w+/gm) || []).length
109
+
110
+ case 'state':
111
+ // Count state definitions (lines with "state" keyword or [*] transitions)
112
+ return new Set(
113
+ (code.match(/(?:state\s+"[^"]*"\s+as\s+(\w+)|\b(\w+)\s*-->)/gm) || [])
114
+ .map(m => m.replace(/\s*-->.*/, '').replace(/^state\s+"[^"]*"\s+as\s+/, '').trim())
115
+ .filter(Boolean)
116
+ ).size
117
+
118
+ case 'er':
119
+ // Entity names before { blocks
120
+ return (code.match(/^\s*\w[\w-]*\s*\{/gm) || []).length
121
+
122
+ case 'gantt':
123
+ // Task lines: indented lines that are not section/title/dateFormat/axisFormat/etc.
124
+ return (code.match(/^\s{2,}\w[^:]*:[^,\n]*/gm) || []).length
125
+
126
+ case 'timeline': {
127
+ // Period entries: lines with content : detail
128
+ return (code.match(/^\s+.+\s*:\s*.+$/gm) || []).length
129
+ }
130
+
131
+ case 'mindmap': {
132
+ // First-level branches: lines with exactly one level of indentation
133
+ const lines = code.split('\n')
134
+ let rootIndent = -1
135
+ let branches = 0
136
+ for (const line of lines) {
137
+ if (!line.trim() || line.trim().startsWith('mindmap')) continue
138
+ const indent = line.search(/\S/)
139
+ if (rootIndent === -1) { rootIndent = indent; continue }
140
+ if (indent === rootIndent + 2 || indent === rootIndent + 4) branches++
141
+ }
142
+ return branches
143
+ }
144
+
145
+ case 'gitgraph':
146
+ return (code.match(/^\s*commit/gm) || []).length
147
+
148
+ default:
149
+ return 0
150
+ }
151
+ }
152
+
153
+ const diagramRef = ref<HTMLElement | null>(null)
154
+
155
+ /**
156
+ * Mermaid renders SVG inside a shadow DOM with an inline max-width
157
+ * that caps the diagram at its intrinsic size. We observe the shadow root
158
+ * and remove that constraint so the SVG scales to fill the container.
159
+ */
160
+ onMounted(() => {
161
+ const el = diagramRef.value
162
+ if (!el) return
163
+
164
+ function unlockSvg(mermaidEl: Element) {
165
+ const sr = (mermaidEl as any).shadowRoot
166
+ if (!sr) return
167
+ const svg = sr.querySelector('svg')
168
+ if (svg) {
169
+ svg.removeAttribute('height')
170
+ svg.style.maxWidth = 'none'
171
+ svg.style.width = '100%'
172
+ svg.style.height = 'auto'
173
+ svg.style.maxHeight = '100%'
174
+ // Mermaid renders text as foreignObject with overflow:hidden — unclip
175
+ sr.querySelectorAll('foreignObject').forEach((fo: Element) => {
176
+ ;(fo as HTMLElement).style.overflow = 'visible'
177
+ })
178
+ // Mindmap nodes use dark cScale chart backgrounds — Mermaid's textColor
179
+ // applies globally and has no mindmap-specific override, so fix via CSS var
180
+ if (props.type === 'mindmap') {
181
+ sr.querySelectorAll('foreignObject div').forEach((el: Element) => {
182
+ ;(el as HTMLElement).style.color = 'var(--color-bg)'
183
+ })
184
+ }
185
+ // Gantt critText: Mermaid has no themeVariable for critText (only
186
+ // taskTextColor applies, which can't differ from regular task text).
187
+ // Avoid using `crit` tasks in demos, or accept the contrast limitation.
188
+ // done/active text is handled by taskTextDarkColor in mermaid.ts.
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Observe shadow root for SVG changes (initial render + re-renders).
194
+ * Mermaid may re-render after initial mount (e.g. theme config applied),
195
+ * so we must re-apply fixes each time a new SVG appears.
196
+ */
197
+ function observeShadowRoot(mermaidEl: Element) {
198
+ const sr = (mermaidEl as any).shadowRoot
199
+ if (!sr) return
200
+
201
+ // Apply immediately if SVG already exists
202
+ if (sr.querySelector('svg')) unlockSvg(mermaidEl)
203
+
204
+ // Watch for SVG additions/replacements in shadow root
205
+ const srObserver = new MutationObserver(() => {
206
+ if (sr.querySelector('svg')) unlockSvg(mermaidEl)
207
+ })
208
+ srObserver.observe(sr, { childList: true, subtree: true })
209
+ }
210
+
211
+ /** Poll until .mermaid element has a shadow root, then observe it. */
212
+ function pollForShadowRoot(mermaidEl: Element) {
213
+ if ((mermaidEl as any).shadowRoot) {
214
+ observeShadowRoot(mermaidEl)
215
+ } else {
216
+ requestAnimationFrame(() => pollForShadowRoot(mermaidEl))
217
+ }
218
+ }
219
+
220
+ // If .mermaid already in DOM (Slidev pre-render), start observing
221
+ const existing = el.querySelector('.mermaid')
222
+ if (existing) pollForShadowRoot(existing)
223
+
224
+ // Watch for .mermaid being added later (lazy rendering)
225
+ const observer = new MutationObserver(() => {
226
+ const mermaidEl = el.querySelector('.mermaid')
227
+ if (mermaidEl) pollForShadowRoot(mermaidEl)
228
+ })
229
+ observer.observe(el, { childList: true, subtree: true })
230
+ })
231
+
232
+ const warning = computed<string | null>(() => {
233
+ if (!isDev) return null
234
+ const code = extractCode()
235
+ if (!code) return null
236
+ const limit = limits[props.type]
237
+ if (!limit) return null
238
+ const count = countItems(props.type, code)
239
+ if (count > limit.max) {
240
+ return `${props.type}: ${count} ${limit.label} (max ${limit.max})`
241
+ }
242
+ return null
243
+ })
244
+ </script>
245
+
246
+ <template>
247
+ <div class="mermaid-chart">
248
+ <div v-if="warning" class="mermaid-chart-warning">{{ warning }}</div>
249
+ <div ref="diagramRef" class="mermaid-chart-diagram">
250
+ <slot />
251
+ </div>
252
+ <p v-if="caption" class="mermaid-chart-caption">{{ caption }}</p>
253
+ </div>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .mermaid-chart {
258
+ display: flex;
259
+ flex-direction: column;
260
+ align-items: center;
261
+ justify-content: center;
262
+ gap: var(--space-sm);
263
+ width: 100%;
264
+ flex: 1;
265
+ min-height: 0;
266
+ }
267
+
268
+ .mermaid-chart-diagram {
269
+ width: 100%;
270
+ flex: 0 1 auto;
271
+ min-height: 0;
272
+ display: flex;
273
+ justify-content: center;
274
+ align-items: center;
275
+ overflow: hidden;
276
+ }
277
+
278
+ .mermaid-chart-diagram :deep(.mermaid) {
279
+ width: 100%;
280
+ height: 100%;
281
+ }
282
+
283
+ .mermaid-chart-warning {
284
+ align-self: stretch;
285
+ padding: 0.375rem 0.75rem;
286
+ border-left: 3px solid var(--color-danger);
287
+ background: var(--color-danger-tint);
288
+ color: var(--color-danger);
289
+ font-size: var(--font-size-small);
290
+ line-height: 1.4;
291
+ }
292
+
293
+ .mermaid-chart-caption {
294
+ font-size: var(--font-size-small);
295
+ color: var(--color-text-secondary);
296
+ text-align: center;
297
+ margin: 0;
298
+ }
299
+ </style>
@@ -0,0 +1,161 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Metric primitive — KPI/stat display with optional animation.
4
+ *
5
+ * Static: <Metric value="99.9%" label="Uptime" color="primary" />
6
+ * Animated: <Metric :value="10000" label="Users" color="success" animated separator="," />
7
+ * Full: <Metric :value="2.5" prefix="$" suffix="M" label="Revenue" color="info"
8
+ * animated :from="0" :duration="2500" :decimals="1" />
9
+ *
10
+ * When `animated` is set and `value` is a number, uses AnimatedCounter internally.
11
+ * When `value` is a string (or animated is false), displays statically.
12
+ */
13
+
14
+ import { computed } from 'vue'
15
+ import type { SemanticColor } from '../composables/useColors'
16
+ import { semanticColorVar, gradientVar } from '../composables/useColors'
17
+ import AnimatedCounter from '../components_base/AnimatedCounter.vue'
18
+
19
+ const props = withDefaults(defineProps<{
20
+ value: string | number
21
+ label: string
22
+ color?: SemanticColor
23
+ prefix?: string
24
+ suffix?: string
25
+ icon?: string
26
+ size?: 'sm' | 'md' | 'lg'
27
+ variant?: 'card' | 'bare'
28
+ gradient?: boolean
29
+ // Animation props (only when value is number)
30
+ animated?: boolean
31
+ from?: number
32
+ duration?: number
33
+ decimals?: number
34
+ separator?: string
35
+ easing?: 'linear' | 'easeOut' | 'easeInOut'
36
+ at?: number // undefined = auto-register click, 0 = on slide enter, -1 = manual
37
+ }>(), {
38
+ color: 'primary',
39
+ prefix: '',
40
+ suffix: '',
41
+ size: 'md',
42
+ variant: 'card',
43
+ gradient: false,
44
+ animated: false,
45
+ from: 0,
46
+ duration: 2000,
47
+ decimals: 0,
48
+ separator: '',
49
+ easing: 'easeOut',
50
+ })
51
+
52
+ const isNumeric = computed(() => typeof props.value === 'number')
53
+ const shouldAnimate = computed(() => props.animated && isNumeric.value)
54
+
55
+ const staticDisplay = computed(() => {
56
+ if (isNumeric.value && !shouldAnimate.value) {
57
+ let num = (props.value as number).toFixed(props.decimals)
58
+ if (props.separator) {
59
+ const parts = num.split('.')
60
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, props.separator)
61
+ num = parts.join('.')
62
+ }
63
+ return `${props.prefix}${num}${props.suffix}`
64
+ }
65
+ return `${props.prefix}${props.value}${props.suffix}`
66
+ })
67
+
68
+ const sizeClasses: Record<string, string> = {
69
+ sm: 'metric-sm',
70
+ md: 'metric-md',
71
+ lg: 'metric-lg',
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <div class="metric" :class="[sizeClasses[size], { 'metric-bare': variant === 'bare' }]">
77
+ <div v-if="$slots.icon" class="metric-icon" :style="{ color: semanticColorVar[color] }">
78
+ <slot name="icon" />
79
+ </div>
80
+ <div class="metric-value" :class="{ 'metric-gradient': gradient }" :style="gradient ? { '--metric-gradient': gradientVar[color] } : { color: semanticColorVar[color] }">
81
+ <template v-if="shouldAnimate">
82
+ <span v-if="prefix" class="metric-affix">{{ prefix }}</span>
83
+ <AnimatedCounter
84
+ :value="(value as number)"
85
+ :from="from"
86
+ :duration="duration"
87
+ :decimals="decimals"
88
+ :separator="separator"
89
+ :easing="easing"
90
+ :at="at"
91
+ />
92
+ <span v-if="suffix" class="metric-affix">{{ suffix }}</span>
93
+ </template>
94
+ <template v-else>{{ staticDisplay }}</template>
95
+ </div>
96
+ <div class="metric-label">{{ label }}</div>
97
+ <div v-if="$slots.default" class="metric-extra">
98
+ <slot />
99
+ </div>
100
+ </div>
101
+ </template>
102
+
103
+ <style scoped>
104
+ .metric {
105
+ text-align: center;
106
+ padding: var(--space-md);
107
+ background: var(--color-bg-soft);
108
+ border-radius: 0.5rem;
109
+ border: 1px solid var(--color-border);
110
+ }
111
+
112
+ .metric-bare {
113
+ background: none;
114
+ border: none;
115
+ padding: 0;
116
+ }
117
+
118
+ .metric-icon {
119
+ font-size: 1.5em;
120
+ margin-bottom: var(--space-xs);
121
+ line-height: 1;
122
+ }
123
+
124
+ .metric-value {
125
+ font-weight: var(--font-weight-bold);
126
+ line-height: 1.1;
127
+ font-variant-numeric: tabular-nums;
128
+ white-space: nowrap;
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ }
132
+
133
+ .metric-affix {
134
+ font-weight: inherit;
135
+ }
136
+
137
+ .metric-label {
138
+ font-size: var(--font-size-small);
139
+ color: var(--color-text-secondary);
140
+ margin-top: var(--space-xs);
141
+ }
142
+
143
+ .metric-extra {
144
+ margin-top: var(--space-xs);
145
+ font-size: var(--font-size-small);
146
+ color: var(--color-text-tertiary);
147
+ }
148
+
149
+ /* Sizes */
150
+ .metric-sm .metric-value { font-size: var(--font-size-h2); }
151
+ .metric-md .metric-value { font-size: calc(var(--font-size-h1) * 1.1); }
152
+ .metric-lg .metric-value { font-size: calc(var(--font-size-h1) * 1.5); }
153
+
154
+ /* Gradient text effect */
155
+ .metric-gradient {
156
+ background: var(--metric-gradient, var(--gradient-primary));
157
+ -webkit-background-clip: text;
158
+ -webkit-text-fill-color: transparent;
159
+ background-clip: text;
160
+ }
161
+ </style>
@@ -0,0 +1,165 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PersonCard primitive — speaker/team member introduction.
4
+ *
5
+ * <PersonCard name="John Doe" role="CTO" initials="JD" />
6
+ * <PersonCard name="Jane" role="Engineer" company="Acme" avatar="/photo.jpg" />
7
+ * <PersonCard name="Alex" role="Designer" bio="10 years experience" horizontal />
8
+ * <PersonCard name="Sam" role="Lead" size="sm" />
9
+ *
10
+ * Auto-generates initials from name if not provided.
11
+ * Supports avatar image, initials circle, or no avatar.
12
+ * Default slot for extra content (links, badges, etc).
13
+ */
14
+
15
+ import { computed } from 'vue'
16
+ import type { ExtendedColor } from '../composables/useColors'
17
+ import { gradientVar } from '../composables/useColors'
18
+
19
+ const props = withDefaults(defineProps<{
20
+ name: string
21
+ role?: string
22
+ company?: string
23
+ avatar?: string
24
+ initials?: string
25
+ bio?: string
26
+ size?: 'sm' | 'md' | 'lg'
27
+ align?: 'left' | 'center'
28
+ horizontal?: boolean
29
+ color?: ExtendedColor
30
+ }>(), {
31
+ size: 'md',
32
+ align: 'center',
33
+ horizontal: false,
34
+ color: 'primary',
35
+ })
36
+
37
+ const autoInitials = computed(() => {
38
+ if (props.initials) return props.initials
39
+ return props.name
40
+ .split(/\s+/)
41
+ .map(w => w[0])
42
+ .slice(0, 2)
43
+ .join('')
44
+ .toUpperCase()
45
+ })
46
+
47
+ const hasAvatar = computed(() => !!props.avatar || !!props.initials || true)
48
+
49
+ const avatarBg = computed(() => gradientVar[props.color])
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ class="person-card"
55
+ :class="[
56
+ `person-${size}`,
57
+ `person-align-${align}`,
58
+ horizontal ? 'person-horizontal' : '',
59
+ ]"
60
+ >
61
+ <!-- Avatar -->
62
+ <div class="person-avatar-wrap">
63
+ <img
64
+ v-if="avatar"
65
+ :src="avatar"
66
+ :alt="name"
67
+ class="person-avatar person-avatar-img"
68
+ />
69
+ <div
70
+ v-else
71
+ class="person-avatar person-avatar-initials"
72
+ :style="{ background: avatarBg }"
73
+ >
74
+ {{ autoInitials }}
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Info -->
79
+ <div class="person-info">
80
+ <div class="person-name">{{ name }}</div>
81
+ <div v-if="role || company" class="person-role">
82
+ {{ role }}<template v-if="role && company"> @ </template>{{ company }}
83
+ </div>
84
+ <div v-if="bio" class="person-bio">{{ bio }}</div>
85
+ <div v-if="$slots.default" class="person-extra">
86
+ <slot />
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </template>
91
+
92
+ <style scoped>
93
+ .person-card {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: var(--space-xs);
97
+ }
98
+
99
+ .person-align-center { align-items: center; text-align: center; }
100
+ .person-align-left { align-items: flex-start; text-align: left; }
101
+
102
+ .person-horizontal {
103
+ flex-direction: row;
104
+ align-items: center;
105
+ gap: var(--space-md);
106
+ }
107
+
108
+ .person-horizontal .person-info {
109
+ text-align: left;
110
+ }
111
+
112
+ /* Avatar */
113
+ .person-avatar {
114
+ border-radius: 50%;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ overflow: hidden;
119
+ flex-shrink: 0;
120
+ }
121
+
122
+ .person-avatar-img {
123
+ object-fit: cover;
124
+ }
125
+
126
+ .person-avatar-initials {
127
+ color: var(--color-primary-foreground);
128
+ font-weight: var(--font-weight-bold);
129
+ }
130
+
131
+ /* Sizes */
132
+ .person-sm .person-avatar { width: 3rem; height: 3rem; font-size: 1.2rem; }
133
+ .person-md .person-avatar { width: 5rem; height: 5rem; font-size: 2rem; }
134
+ .person-lg .person-avatar { width: 7rem; height: 7rem; font-size: 2.8rem; }
135
+
136
+ .person-sm .person-name { font-size: var(--font-size-small); }
137
+ .person-md .person-name { font-size: var(--font-size-base); }
138
+ .person-lg .person-name { font-size: var(--font-size-h2); }
139
+
140
+ /* Info */
141
+ .person-name {
142
+ font-weight: var(--font-weight-semibold);
143
+ margin-top: var(--space-xs);
144
+ }
145
+
146
+ .person-horizontal .person-name {
147
+ margin-top: 0;
148
+ }
149
+
150
+ .person-role {
151
+ font-size: var(--font-size-small);
152
+ color: var(--color-text-secondary);
153
+ }
154
+
155
+ .person-bio {
156
+ font-size: var(--font-size-small);
157
+ color: var(--color-text-tertiary);
158
+ margin-top: var(--space-xs);
159
+ max-width: 35ch;
160
+ }
161
+
162
+ .person-extra {
163
+ margin-top: var(--space-xs);
164
+ }
165
+ </style>