@nuxtseo/design-system 0.0.0-stage → 0.1.1

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 (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,445 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import type { SlotTextOptions } from '../../utils/slot-text'
4
+ import { useElementHover } from '@vueuse/core'
5
+ import { computed, onMounted, ref, useSlots } from 'vue'
6
+ import { ClientOnly, NuxtLink, UiIcon, UiSkeleton, UiSlotText, UiSparkline, UiTooltip } from '#components'
7
+ import { formatGroupedNumber } from '../../composables/formatting'
8
+ import { semanticColors } from '../../composables/semanticColors'
9
+
10
+ const {
11
+ to,
12
+ icon,
13
+ title,
14
+ tooltip,
15
+ tooltipDescription,
16
+ value,
17
+ suffix,
18
+ valueClass,
19
+ format,
20
+ trend,
21
+ trendFormat,
22
+ trendSuffix,
23
+ trendLabel,
24
+ invertTrend,
25
+ trendColored,
26
+ sparkline,
27
+ size = 'md',
28
+ loading,
29
+ status,
30
+ card,
31
+ animatedValue = true,
32
+ readableLabels = false,
33
+ } = defineProps<UiStatProps>()
34
+
35
+ // Stat card primitive. Ported from nuxtseo/core with two swaps:
36
+ // - UiNavIcon → UiIcon (design-system registry)
37
+ // - UiHelpLabel (→ UiPopover + useMarkdown) → UiTooltip (the design-system
38
+ // tooltip primitive — keeps tooltip usage consistent across components)
39
+ // Behavior + slots kept identical so consumers port cleanly.
40
+
41
+ // NuxtLink auto-registers globally; resolve by name so we don't depend on
42
+ // the `#components` virtual (which doesn't narrow nicely in a layer).
43
+
44
+ type Datum = Record<string, number | string>
45
+
46
+ export interface UiStatProps {
47
+ // link
48
+ to?: string
49
+
50
+ // label
51
+ icon?: UiIconName
52
+ title?: string
53
+ tooltip?: string
54
+ tooltipDescription?: string
55
+
56
+ // value
57
+ value?: string | number | null
58
+ suffix?: string
59
+ valueClass?: string
60
+ format?: (n: number) => string
61
+
62
+ // trend
63
+ trend?: number | null
64
+ /**
65
+ * Formats the trend magnitude. The value `format` never applies: the trend is
66
+ * a percent or a delta, so a currency formatter printed "+$12.5%".
67
+ */
68
+ trendFormat?: (n: number) => string
69
+ trendSuffix?: string
70
+ trendLabel?: string
71
+ invertTrend?: boolean
72
+ /**
73
+ * Trend color budget (DESIGN.md Composition): colored success/error trends
74
+ * belong to the hero zone only. Default renders neutral; `UiStats
75
+ * variant="cards"` (the hero pattern) opts its stats in automatically.
76
+ */
77
+ trendColored?: boolean
78
+
79
+ // sparkline
80
+ sparkline?: Datum[]
81
+
82
+ // sizing
83
+ size?: 'sm' | 'md' | 'lg'
84
+ /** Keep report-visible labels and deltas at the 14px readable tier. */
85
+ readableLabels?: boolean
86
+
87
+ // state
88
+ loading?: boolean
89
+
90
+ /**
91
+ * Rolls compact metric values when they change. Defaults on for numeric-ish
92
+ * values; pass false for dense/realtime surfaces or object options to tune.
93
+ */
94
+ animatedValue?: boolean | SlotTextOptions
95
+
96
+ // threshold alerting
97
+ status?: 'crisis' | 'warning' | 'good'
98
+
99
+ // render the value area inside a card, keeping the header outside
100
+ card?: boolean
101
+ }
102
+
103
+ const hydrated = ref(false)
104
+ onMounted(() => {
105
+ hydrated.value = true
106
+ })
107
+ const isLoading = computed(() => hydrated.value && loading)
108
+ const slots = useSlots()
109
+
110
+ // Hover state for the background viz: the card owns the gesture, the chart
111
+ // (default sparkline or #chart slot) reacts to it. Subtle, opt-in via the
112
+ // chart component itself.
113
+ const cardEl = ref<HTMLElement | null>(null)
114
+ const cardHovered = useElementHover(cardEl)
115
+
116
+ const rootTag = computed(() => to ? NuxtLink : 'div')
117
+
118
+ const sizeConfig = computed(() => {
119
+ const map = {
120
+ // `chartH` is the band the chart may draw in; `bgH` minus it is headroom
121
+ // under the value so no peak crosses the numerals.
122
+ sm: { value: 'text-xl', sparkW: 96, sparkH: 32, ribbonH: 30, bgH: 46, chartH: 26 },
123
+ md: { value: 'text-2xl', sparkW: 120, sparkH: 32, ribbonH: 38, bgH: 56, chartH: 30 },
124
+ lg: { value: 'text-3xl', sparkW: 160, sparkH: 36, ribbonH: 46, bgH: 64, chartH: 34 },
125
+ }
126
+ return map[size]
127
+ })
128
+
129
+ // A raw number with no `format` still gets digit grouping (12,345) in the
130
+ // pinned display locale, so the server and the browser render the same string.
131
+
132
+ const displayValue = computed(() => {
133
+ if (value == null)
134
+ return null
135
+ if (typeof value === 'number')
136
+ return format ? format(value) : formatGroupedNumber(value)
137
+ return value
138
+ })
139
+
140
+ const animatedValueOptions = computed<SlotTextOptions | null>(() => {
141
+ if (!displayValue.value || animatedValue === false || slots.default)
142
+ return null
143
+
144
+ const compactMetricValue = /^[+\-$€£¥]?\s*[\d,.]+(?:\s?[kmbt])?(?:\.\d+)?%?$/i.test(displayValue.value)
145
+ if (typeof animatedValue === 'object')
146
+ return animatedValue
147
+
148
+ return compactMetricValue && displayValue.value.length <= 14
149
+ ? { direction: 'up', duration: 320, stagger: 24 }
150
+ : null
151
+ })
152
+
153
+ const trendDirection = computed(() => {
154
+ if (trend == null || trend === 0)
155
+ return 'neutral'
156
+ return trend > 0 ? 'up' : 'down'
157
+ })
158
+
159
+ const isTrendPositive = computed(() => {
160
+ if (trendDirection.value === 'neutral')
161
+ return null
162
+ return invertTrend
163
+ ? trendDirection.value === 'down'
164
+ : trendDirection.value === 'up'
165
+ })
166
+
167
+ const trendColorClass = computed(() => {
168
+ if (!trendColored || isTrendPositive.value === null)
169
+ return 'text-muted'
170
+ return isTrendPositive.value ? 'text-success' : 'text-error'
171
+ })
172
+ const titleTypeClass = computed(() => readableLabels ? 'text-sm font-strong' : 'text-label')
173
+ const trendTypeClass = computed(() => readableLabels ? 'text-sm' : 'text-xs')
174
+ const statusTypeClass = computed(() => readableLabels ? 'text-sm' : 'text-mini')
175
+
176
+ // Sparkline stays neutral by default — trend valence is already encoded in
177
+ // the colored text delta. Avoid double-encoding (Tufte: one channel per signal).
178
+ // Plain const (not computed): it never varies, so a reactive node per instance
179
+ // across a KPI grid is pure overhead.
180
+ const sparklineColor = semanticColors.neutral.hex
181
+
182
+ const statusChip = computed(() => {
183
+ if (!status)
184
+ return null
185
+ const map: Record<string, { label: string, dot: string, bg: string, text: string }> = {
186
+ crisis: { label: 'Critical', dot: 'bg-error', bg: 'bg-error/10', text: 'text-error' },
187
+ warning: { label: 'Needs work', dot: 'bg-warning', bg: 'bg-warning/10', text: 'text-warning' },
188
+ good: { label: 'Healthy', dot: 'bg-success', bg: 'bg-success/10', text: 'text-success' },
189
+ }
190
+ return map[status] ?? null
191
+ })
192
+
193
+ const formattedTrend = computed(() => {
194
+ if (trend == null)
195
+ return ''
196
+ const abs = Math.abs(trend)
197
+ const sign = trend > 0 ? '+' : trend < 0 ? '-' : ''
198
+ const formatted = trendFormat ? trendFormat(abs) : abs % 1 === 0 ? String(abs) : abs.toFixed(1)
199
+ return `${sign}${formatted}${trendSuffix || ''}`
200
+ })
201
+ </script>
202
+
203
+ <template>
204
+ <UiTooltip
205
+ :text="tooltipDescription || tooltip"
206
+ :disabled="!to || !tooltip"
207
+ trigger-as="child"
208
+ >
209
+ <component
210
+ :is="rootTag"
211
+ :to="to || undefined"
212
+ data-ui="UiStat"
213
+ class="relative flex min-w-0 flex-col"
214
+ :class="[
215
+ card ? 'gap-2' : 'gap-1',
216
+ to ? 'cursor-pointer' : '',
217
+ ]"
218
+ >
219
+ <!-- Header: icon + label + trend + status chip.
220
+ `leading-none` collapses .text-label's 1.5 line-box to the cap band so
221
+ items-center aligns the leading icon to the text, not to empty leading. -->
222
+ <!-- `min-h-5` pins the header to the tooltip button's height, so a
223
+ title rendered as a plain span (link mode, or no tooltip) does not
224
+ make its card sit 8px higher than its siblings. -->
225
+ <div
226
+ class="flex min-h-5 items-center gap-1.5 leading-none"
227
+ :class="to && !card ? 'transition-opacity hover:opacity-80' : ''"
228
+ >
229
+ <slot name="icon">
230
+ <UiIcon v-if="icon" :name="icon" class="size-3 text-dimmed shrink-0" />
231
+ </slot>
232
+ <slot name="title">
233
+ <!-- `mx-0.5` cancels the trigger button's `-m-0.5` bleed, so this
234
+ title starts on the same edge as the link-mode title and the value. -->
235
+ <UiTooltip v-if="tooltip && title && !to" :text="tooltipDescription || tooltip" trigger-as="button">
236
+ <span class="mx-0.5 inline-flex items-center gap-1" :class="titleTypeClass">
237
+ {{ title }}
238
+ <UiIcon name="help" class="size-3 opacity-50 shrink-0" aria-hidden="true" />
239
+ </span>
240
+ </UiTooltip>
241
+ <!-- Link mode: the tooltip wraps the whole card (above), so the
242
+ title keeps the same help glyph and the same height as its
243
+ siblings; a card that loses the icon sits higher in the row. -->
244
+ <span v-else-if="title" class="inline-flex items-center gap-1" :class="titleTypeClass">
245
+ {{ title }}
246
+ <UiIcon v-if="tooltip && to" name="help" class="size-3 opacity-50 shrink-0" aria-hidden="true" />
247
+ </span>
248
+ </slot>
249
+ <slot v-if="!card" name="trend">
250
+ <span v-if="trend != null && trend !== 0" class="numerals-display" :class="[trendTypeClass, trendColorClass]">
251
+ {{ formattedTrend }}
252
+ </span>
253
+ <span v-else-if="trend === null || trend === 0" class="numerals-display text-dimmed" :class="trendTypeClass" aria-label="No comparison data">—</span>
254
+ <span v-if="trendLabel" class="text-dimmed" :class="trendTypeClass">{{ trendLabel }}</span>
255
+ </slot>
256
+ <span
257
+ v-if="statusChip"
258
+ class="inline-flex items-center gap-1 px-1.5 py-px rounded-md font-medium leading-tight"
259
+ :class="[statusTypeClass, statusChip.bg, statusChip.text]"
260
+ >
261
+ <span class="size-1 rounded-full" :class="statusChip.dot" />
262
+ {{ statusChip.label }}
263
+ </span>
264
+ </div>
265
+
266
+ <div
267
+ ref="cardEl"
268
+ class="group/card relative flex flex-col gap-1 overflow-hidden"
269
+ :class="[
270
+ card ? 'ui-stat-card rounded-xl border border-default bg-[var(--ui-bg-elevated)]/5 p-4 min-h-[5.5rem]' : '',
271
+ to && card ? 'ui-stat-card--linked' : '',
272
+ to && !card ? 'transition-opacity hover:opacity-80' : '',
273
+ ]"
274
+ >
275
+ <!-- Hover affordance: chevron signals the card is clickable -->
276
+ <UiIcon
277
+ v-if="to && card"
278
+ name="chevron-right"
279
+ class="pointer-events-none absolute top-2.5 right-2.5 size-3.5 text-dimmed opacity-0 -translate-x-1 transition-[opacity,transform] duration-150 group-hover/card:opacity-100 group-hover/card:translate-x-0"
280
+ aria-hidden="true"
281
+ />
282
+
283
+ <!-- Loading skeleton -->
284
+ <template v-if="isLoading">
285
+ <UiSkeleton :base="80" :index="0" />
286
+ </template>
287
+
288
+ <!-- Content -->
289
+ <template v-else>
290
+ <!-- Card mode: viz fills the lower portion of the card as a background
291
+ layer (sparkline by default, or a #chart slot e.g. UiCwvSparkline).
292
+ A left→right bg→transparent fade keeps the value/trend legible where
293
+ they overlap the chart, so the card keeps its compact height. -->
294
+ <div
295
+ v-if="card && (sparkline?.length || $slots.chart)"
296
+ class="pointer-events-none absolute inset-x-0 bottom-0 z-0"
297
+ :style="{ height: `${sizeConfig.bgH}px` }"
298
+ >
299
+ <!-- The chart draws in the LOWER band of the layer only. A series
300
+ pinned at its maximum (a fresh site whose whole backlog opened
301
+ this week) used to run its line straight through the value's
302
+ numerals; text-shadow cannot rescue a stroke crossing the glyphs.
303
+ Headroom above the band keeps every peak under the value's
304
+ baseline while the area fill still bleeds to the card edge. -->
305
+ <div class="absolute inset-x-0 bottom-0" :style="{ height: `${sizeConfig.chartH}px` }">
306
+ <!-- Client-only: the chart (sparkline or a motion-driven #chart slot)
307
+ is decorative and motion components hydrate awkwardly, so skip SSR. -->
308
+ <ClientOnly>
309
+ <slot name="chart" :hovered="cardHovered">
310
+ <UiSparkline
311
+ :data="sparkline ?? []"
312
+ :color="sparklineColor"
313
+ :inverted="invertTrend"
314
+ area
315
+ interactive
316
+ :hovered="cardHovered"
317
+ :max-points="48"
318
+ width="100%"
319
+ height="100%"
320
+ preserve-aspect-ratio="none"
321
+ class="block size-full opacity-90"
322
+ />
323
+ </slot>
324
+ </ClientOnly>
325
+ </div>
326
+ <!-- Legibility wash: a light left→right dark gradient just takes the
327
+ edge off the chart under the value. The heavy lifting is the
328
+ value's text-shadow, so the chart itself stays bright. -->
329
+ <div class="absolute inset-0 bg-gradient-to-r from-[var(--ui-bg)]/70 via-[var(--ui-bg)]/10 via-[50%] to-transparent" />
330
+ </div>
331
+
332
+ <!-- Value + trend row. In card mode it floats above the background viz
333
+ (text-shadow keeps it legible); outside card mode the sparkline
334
+ stays inline on the right. -->
335
+ <div class="relative z-10 flex items-end justify-between gap-3" :class="card && (sparkline?.length || $slots.chart) ? 'ui-stat-legible' : ''">
336
+ <slot>
337
+ <div v-if="value != null" class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
338
+ <span class="numerals-display tracking-tight" :class="[sizeConfig.value, valueClass || 'text-default']">
339
+ <UiSlotText
340
+ v-if="animatedValueOptions && displayValue"
341
+ :text="displayValue"
342
+ :options="animatedValueOptions"
343
+ />
344
+ <template v-else>
345
+ {{ displayValue }}
346
+ </template>
347
+ </span>
348
+ <span v-if="suffix" class="text-sm text-muted">{{ suffix }}</span>
349
+ <slot v-if="card" name="trend">
350
+ <span
351
+ v-if="trend != null && trend !== 0"
352
+ class="numerals-display"
353
+ :class="[trendTypeClass, trendColorClass]"
354
+ >
355
+ {{ formattedTrend }}
356
+ </span>
357
+ <span
358
+ v-else-if="trend === null || trend === 0"
359
+ class="numerals-display text-dimmed"
360
+ :class="trendTypeClass"
361
+ aria-label="No comparison data"
362
+ >—</span>
363
+ <span v-if="trendLabel" class="text-dimmed" :class="trendTypeClass">{{ trendLabel }}</span>
364
+ </slot>
365
+ </div>
366
+ <span v-else class="font-semibold text-muted" :class="sizeConfig.value" role="img" aria-label="No data">&mdash;</span>
367
+ </slot>
368
+ <!-- Compact-strip viz: the same chart the card mode floats as a
369
+ background renders here as a small fixed-size cell on the right
370
+ (a #chart slot, e.g. the CWV composition), so status tiles keep
371
+ their signal in compact mode. Falls back to the inline sparkline
372
+ for metric tiles that supply a `sparkline` array. -->
373
+ <div
374
+ v-if="!card && ($slots.chart || sparkline?.length)"
375
+ class="ui-stat-inline-chart -mb-0.5 min-w-0 flex-1 overflow-hidden"
376
+ :style="{ maxWidth: `${sizeConfig.sparkW}px` }"
377
+ >
378
+ <ClientOnly>
379
+ <div
380
+ v-if="$slots.chart"
381
+ class="w-full"
382
+ :style="{ height: `${sizeConfig.sparkH}px` }"
383
+ >
384
+ <slot name="chart" :hovered="cardHovered" />
385
+ </div>
386
+ <UiSparkline
387
+ v-else
388
+ :data="sparkline ?? []"
389
+ :size="size"
390
+ :color="sparklineColor"
391
+ :inverted="invertTrend"
392
+ :area="false"
393
+ width="100%"
394
+ class="block max-w-full"
395
+ />
396
+ </ClientOnly>
397
+ </div>
398
+ </div>
399
+
400
+ <!-- Info slot for extra content -->
401
+ <slot name="info" />
402
+ </template>
403
+ </div>
404
+ </component>
405
+ </UiTooltip>
406
+ </template>
407
+
408
+ <style scoped>
409
+ /* Value/trend floats over the background chart. A soft halo in the card-bg
410
+ colour (so it adapts to light/dark) buys contrast without a heavy mask,
411
+ letting the chart stay bright. */
412
+ .ui-stat-legible {
413
+ text-shadow:
414
+ 0 0 2px var(--ui-bg),
415
+ 0 1px 10px var(--ui-bg);
416
+ }
417
+
418
+ .ui-stat-card {
419
+ /* Recessed resting depth — carved edge, no lift. */
420
+ box-shadow: var(--elevation-inset);
421
+ letter-spacing: var(--tracking-data);
422
+ -webkit-font-smoothing: antialiased;
423
+ transition:
424
+ box-shadow 200ms ease-out,
425
+ border-color 200ms ease-out,
426
+ transform 220ms var(--ease-spring);
427
+ }
428
+
429
+ /* Linked cards lift to the raised tier on hover + the accent-tinted hover
430
+ halo, with a small spring rise — signals "clickable". */
431
+ .ui-stat-card--linked:hover {
432
+ border-color: var(--ui-border-accented);
433
+ box-shadow: var(--elevation-raised), var(--elevation-hover);
434
+ transform: translateY(-2px);
435
+ }
436
+
437
+ @media (prefers-reduced-motion: reduce) {
438
+ .ui-stat-card {
439
+ transition: border-color 150ms ease-out;
440
+ }
441
+ .ui-stat-card--linked:hover {
442
+ transform: none;
443
+ }
444
+ }
445
+ </style>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import type { UiStatProps } from './UiStat.vue'
3
+ // Port of nuxtseo/core UiStats. Swaps tailwind-merge for a plain
4
+ // filter+join — the component doesn't generate conflicting Tailwind classes,
5
+ // and consumer `class` attrs cascade normally via the class attribute merge.
6
+
7
+ import { computed, useSlots } from 'vue'
8
+ import { UiStat } from '#components'
9
+
10
+ const {
11
+ data,
12
+ variant = 'card',
13
+ layout = 'flex',
14
+ wrap = false,
15
+ } = defineProps<{
16
+ data: UiStatProps[]
17
+ variant?: 'card' | 'cards' | 'inline'
18
+ layout?: 'flex' | 'grid'
19
+ wrap?: boolean
20
+ }>()
21
+
22
+ const gridCols = [
23
+ 'lg:grid-cols-1 md:grid-cols-1',
24
+ 'lg:grid-cols-2 md:grid-cols-2',
25
+ 'lg:grid-cols-3 md:grid-cols-2',
26
+ 'lg:grid-cols-4 md:grid-cols-2',
27
+ 'lg:grid-cols-5 md:grid-cols-3',
28
+ 'lg:grid-cols-6 md:grid-cols-3',
29
+ 'lg:grid-cols-7 md:grid-cols-4',
30
+ 'lg:grid-cols-8 md:grid-cols-4',
31
+ ]
32
+
33
+ function join(...parts: (string | false | null | undefined)[]): string {
34
+ return parts.filter(Boolean).join(' ')
35
+ }
36
+
37
+ const containerClass = computed(() => {
38
+ const length = data.length
39
+ const gridIndex = length > 0 && length < gridCols.length ? length - 1 : 0
40
+ const isGrid = layout === 'grid' || variant === 'cards'
41
+ return join(
42
+ 'justify-around',
43
+ isGrid ? `grid ${length === 1 ? 'grid-cols-1' : 'grid-cols-2'} ${gridCols[gridIndex]}` : 'flex flex-col md:flex-row',
44
+ wrap && 'flex-wrap',
45
+ variant === 'card' && 'divide-y md:divide-y-0 md:divide-x divide-[var(--ui-border-muted)] rounded-lg border border-default py-4',
46
+ variant === 'cards' && 'gap-4',
47
+ // Consumer `class` is merged onto the root automatically by Vue's
48
+ // attribute fallthrough (reactive), so it isn't joined in here.
49
+ )
50
+ })
51
+
52
+ const statClass = computed(() =>
53
+ join(
54
+ variant === 'card' && 'shrink-0 grow',
55
+ variant === 'card' && 'px-5 py-2 md:py-0',
56
+ ),
57
+ )
58
+
59
+ const slots = useSlots()
60
+ </script>
61
+
62
+ <template>
63
+ <div data-ui="UiStats" :class="containerClass">
64
+ <template v-for="(item, index) in data" :key="item.title">
65
+ <slot v-if="slots.default" v-bind="{ item, index }" />
66
+ <template v-else>
67
+ <UiStat
68
+ v-bind="item"
69
+ :card="variant === 'cards'"
70
+ :trend-colored="item.trendColored ?? (variant === 'cards')"
71
+ :class="statClass"
72
+ />
73
+ </template>
74
+ </template>
75
+ </div>
76
+ </template>