@nuxtseo/design-system 0.0.0-stage → 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 (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -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 +232 -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 +125 -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 +55 -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 +68 -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 +29 -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/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,681 @@
1
+ <script setup lang="ts">
2
+ import { usePreferredReducedMotion } from '@vueuse/core'
3
+ import { motion } from 'motion-v'
4
+ import { computed, useId } from 'vue'
5
+ import { vizColorMap } from '../../composables/dataVizColors'
6
+ import { semanticColors } from '../../composables/semanticColors'
7
+
8
+ type Datum = Record<string, number | string | null | undefined>
9
+ type Observation = number | null
10
+ type Series = Array<number | null> | Array<Datum | null>
11
+
12
+ interface Props {
13
+ data: Series
14
+ /** Previous period data, when provided, gradient reflects per-point changes */
15
+ previousData?: Series
16
+ xAxis?: string
17
+ yAxis?: string
18
+ width?: number | string
19
+ height?: number | string
20
+ size?: 'sm' | 'md' | 'lg'
21
+ /** Single color or named color (blue, green, purple, orange, red, neutral) */
22
+ color?: string
23
+ /** Full gradient color array, overrides color & trend defaults */
24
+ colors?: string[]
25
+ /** Colors for comparison mode [improved, declined, neutral] */
26
+ comparisonColors?: [string, string, string]
27
+ strokeWidth?: number
28
+ /** Drawing budget for observed points. Each separate run keeps its endpoints. */
29
+ maxPoints?: number
30
+ /** SVG preserveAspectRatio, set to "none" to stretch fill */
31
+ preserveAspectRatio?: string
32
+ /** Render style. 'line' (smooth curve), 'step' (stepped line for slow integer-valued metrics like DA). Discrete counts and bucketed runs use `UiSparkColumns`. */
33
+ variant?: 'line' | 'step'
34
+ /**
35
+ * Lower-is-better metric (position, CLS, bounce rate).
36
+ *
37
+ * Flips the y-axis so the SMALLEST value sits at the top: a rank of #1 draws
38
+ * high, a slide to #30 draws down. Same convention as `ProGraphGsc`'s position
39
+ * scale, the app's other rank chart — the two used to disagree, and a reader
40
+ * moving between them had to re-learn which direction was bad.
41
+ *
42
+ * With the axis flipped, "line rises" and "metric improved" mean the same
43
+ * thing for every metric, inverted or not. `trend` is negated to match, so it
44
+ * keeps reading as valence (+1 = better) AND as the drawn direction.
45
+ */
46
+ inverted?: boolean
47
+ /** Render the area fill under the line. Defaults to false (Tufte-correct: pure line). Opt in for ambient/background ribbons. */
48
+ area?: boolean
49
+ /** How to render `previousData`. `ghost` (default) draws a thin neutral line behind the current series. `diverging` recolors the current line green/red per-point against the previous value (noisier; opt in when valence-per-point matters). */
50
+ comparisonStyle?: 'ghost' | 'diverging'
51
+ /** Enable the hover tracer — a brighter line that draws along the path. Driven by `hovered`. */
52
+ interactive?: boolean
53
+ /** Hover state, fed from a parent (e.g. the enclosing UiStat card). */
54
+ hovered?: boolean
55
+ /**
56
+ * Row id for an enclosing `UiTooltipGrid`. When set with `interactive`, every
57
+ * observed point gets an invisible pointer band carrying `data-tooltip-row`,
58
+ * `data-tooltip-col` (the point's index in `data`) and `data-tooltip-value`.
59
+ */
60
+ tooltipRow?: number | string
61
+ }
62
+
63
+ const {
64
+ data,
65
+ previousData,
66
+ yAxis,
67
+ width,
68
+ height,
69
+ size = 'md',
70
+ color,
71
+ colors,
72
+ comparisonColors = [semanticColors.success.hex, semanticColors.error.hex, semanticColors.neutral.hex],
73
+ strokeWidth = 1.5,
74
+ maxPoints = 80,
75
+ preserveAspectRatio,
76
+ variant = 'line',
77
+ inverted = false,
78
+ area = false,
79
+ comparisonStyle = 'ghost',
80
+ interactive = false,
81
+ hovered = false,
82
+ tooltipRow,
83
+ } = defineProps<Props>()
84
+
85
+ // Gate the looping hover tracer dot for reduced-motion users (the CSS tracer
86
+ // line is already gated in <style>).
87
+ const reducedMotionPref = usePreferredReducedMotion()
88
+ const reducedMotion = computed(() => reducedMotionPref.value === 'reduce')
89
+
90
+ // Named tokens (e.g. `color="blue"`, `color="clicks"`) resolve through the
91
+ // canonical viz palette so the sparkline shares the same blue as the legend
92
+ // dot, the metric pill, and the chart series. Raw hex / CSS color strings pass
93
+ // through untouched for one-off callers.
94
+ function resolveColor(c: string): string {
95
+ return vizColorMap[c]?.hex ?? c
96
+ }
97
+
98
+ const sizeDefaults: Record<string, { w: number, h: number }> = {
99
+ sm: { w: 96, h: 24 },
100
+ md: { w: 120, h: 32 },
101
+ lg: { w: 160, h: 36 },
102
+ }
103
+
104
+ // Size fallback when `size` isn't one of the named presets. `md` is the
105
+ // documented default; using `!` on the indexed lookup here keeps the
106
+ // compiler from flagging every downstream read as possibly-undefined.
107
+ const defaults = computed(() => sizeDefaults[size] ?? sizeDefaults.md!)
108
+
109
+ // CSS width/height — can be string like "100%" or number
110
+ const cssWidth = computed(() => width ?? defaults.value.w)
111
+ const cssHeight = computed(() => height ?? defaults.value.h)
112
+
113
+ // Internal numeric dimensions for viewBox/path calculations
114
+ const vbW = computed(() => typeof cssWidth.value === 'number' ? cssWidth.value : defaults.value.w)
115
+ const vbH = computed(() => typeof cssHeight.value === 'number' ? cssHeight.value : defaults.value.h)
116
+
117
+ function observed(value: unknown): Observation {
118
+ if (value == null || value === '')
119
+ return null
120
+ const number = Number(value)
121
+ return Number.isFinite(number) ? number : null
122
+ }
123
+
124
+ function normalize(input: Series | undefined, yAxis?: string): Observation[] {
125
+ if (!input?.length)
126
+ return []
127
+ const first = input.find(value => value != null)
128
+ if (first == null)
129
+ return input.map(() => null)
130
+ if (typeof first === 'number')
131
+ return input.map(observed)
132
+ const key = yAxis || guessYKey(first)
133
+ return (input as Array<Datum | null>).map(datum => observed(datum?.[key]))
134
+ }
135
+
136
+ function guessYKey(d: Datum): string {
137
+ for (const key of ['y', 'value', 'clicks', 'impressions', 'count']) {
138
+ if (key in d)
139
+ return key
140
+ }
141
+ const skip = new Set(['x', 'index', 'timestamp', 'date'])
142
+ for (const key of Object.keys(d)) {
143
+ if (!skip.has(key) && typeof d[key] === 'number')
144
+ return key
145
+ }
146
+ return Object.keys(d)[1] ?? Object.keys(d)[0] ?? 'value'
147
+ }
148
+
149
+ /**
150
+ * Largest Triangle Three Buckets — downsamples while preserving visual shape.
151
+ * Keeps first and last points, selects most visually significant point per bucket.
152
+ */
153
+ function lttbIndices(vals: number[], target: number): number[] {
154
+ if (vals.length <= target)
155
+ return vals.map((_, index) => index)
156
+ if (target === 2)
157
+ return [0, vals.length - 1]
158
+
159
+ const result: number[] = [0]
160
+ const bucketSize = (vals.length - 2) / (target - 2)
161
+
162
+ let prevIndex = 0
163
+ for (let i = 1; i < target - 1; i++) {
164
+ const bucketStart = Math.floor((i - 1) * bucketSize) + 1
165
+ const bucketEnd = Math.min(Math.floor(i * bucketSize) + 1, vals.length - 1)
166
+
167
+ // Average of next bucket (for triangle area calc)
168
+ const nextStart = Math.floor(i * bucketSize) + 1
169
+ const nextEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, vals.length - 1)
170
+ let avgX = 0
171
+ let avgY = 0
172
+ const nextLen = nextEnd - nextStart + 1
173
+ for (let j = nextStart; j <= nextEnd; j++) {
174
+ avgX += j
175
+ avgY += vals[j]!
176
+ }
177
+ avgX /= nextLen
178
+ avgY /= nextLen
179
+
180
+ // Pick point in current bucket with largest triangle area
181
+ let maxArea = -1
182
+ let bestIndex = bucketStart
183
+ const px = prevIndex
184
+ const py = vals[prevIndex]!
185
+ for (let j = bucketStart; j <= bucketEnd; j++) {
186
+ const area = Math.abs((px - avgX) * (vals[j]! - py) - (px - j) * (avgY - py))
187
+ if (area > maxArea) {
188
+ maxArea = area
189
+ bestIndex = j
190
+ }
191
+ }
192
+
193
+ result.push(bestIndex)
194
+ prevIndex = bestIndex
195
+ }
196
+
197
+ result.push(vals.length - 1)
198
+ return result
199
+ }
200
+
201
+ // Each contiguous observed run is sampled separately. Missing days remain
202
+ // gaps, while original indices retain their place in the full reporting window.
203
+ function sampleRuns(observations: Observation[], limit: number): number[][] {
204
+ const runs: number[][] = []
205
+ const observedCount = observations.reduce<number>((count, value) => count + Number(value != null), 0)
206
+ let start = 0
207
+ while (start < observations.length) {
208
+ if (observations[start] == null) {
209
+ start++
210
+ continue
211
+ }
212
+ let end = start + 1
213
+ while (end < observations.length && observations[end] != null) end++
214
+ const values = observations.slice(start, end) as number[]
215
+ const budget = Math.max(2, Math.round(limit * values.length / observedCount))
216
+ runs.push(lttbIndices(values, budget).map(index => start + index))
217
+ start = end
218
+ }
219
+ return runs
220
+ }
221
+
222
+ const fullValues = computed(() => normalize(data, yAxis))
223
+ const drawingLimit = computed(() => Number.isFinite(maxPoints) ? Math.max(2, Math.floor(maxPoints)) : 80)
224
+ const sampledRuns = computed(() => sampleRuns(fullValues.value, drawingLimit.value))
225
+ const sampledIndices = computed(() => sampledRuns.value.flat())
226
+ const values = computed(() => sampledIndices.value.map(index => fullValues.value[index] as number))
227
+ const fullPreviousValues = computed(() => normalize(previousData, yAxis))
228
+ const previousRuns = computed(() => sampleRuns(fullPreviousValues.value, drawingLimit.value))
229
+ const prevValues = computed(() => previousRuns.value.flat().map(index => fullPreviousValues.value[index] as number))
230
+ const hasComparison = computed(() => prevValues.value.length > 0)
231
+ // Halves split the full reporting window by time, never the drawn sample.
232
+ // Sampling keeps peaks and drops flat stretches, so a mean over the sample
233
+ // reads one spike as the level of a whole half and can flip the direction.
234
+ const observedHalves = computed(() => {
235
+ const observations = fullValues.value
236
+ const mid = Math.floor(observations.length / 2)
237
+ const mean = (observations: Observation[]) => {
238
+ const values = observations.filter((value): value is number => value != null)
239
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
240
+ }
241
+ return { early: mean(observations.slice(0, mid)), late: mean(observations.slice(mid)) }
242
+ })
243
+
244
+ // Trend from half-window MEANS, not endpoints: daily series are seasonal (a
245
+ // weekend last point read as "downward" against a Wednesday first point) and
246
+ // endpoint comparison also amplifies reporting-lag artifacts. A <2% relative
247
+ // difference reads as flat.
248
+ const trend = computed<-1 | 0 | 1>(() => {
249
+ const { early, late } = observedHalves.value
250
+ if (early == null || late == null)
251
+ return 0
252
+ const base = Math.max(Math.abs(early), Math.abs(late))
253
+ const dir = base === 0 || Math.abs(late - early) / base < 0.02 ? 0 : late > early ? 1 : -1
254
+ // Not a double-negation with the flipped axis — the two cancel by design.
255
+ // `dir` is computed from the raw VALUES, where "up" means a bigger number; on
256
+ // an inverted metric a bigger number is worse AND is drawn lower. Negating
257
+ // here makes `trend` agree with both: +1 = better = the line goes up.
258
+ return inverted ? (-dir as -1 | 0 | 1) : dir
259
+ })
260
+
261
+ // Charts convey data visually only, so the sr-label carries direction AND the
262
+ // early/recent averages (matching how `trend` is computed) rather than the
263
+ // seasonal-noise-prone endpoints.
264
+ //
265
+ // An inverted metric gets valence words, not line words. "Downward trend,
266
+ // averaging 9 early to 16 recently" is a contradiction read aloud — the numbers
267
+ // plainly went up. "Worsening" is what actually happened to the rank, which is
268
+ // the fact the reader wants; the shape of the line is the sighted proxy for it.
269
+ const ariaLabel = computed(() => {
270
+ const { early, late } = observedHalves.value
271
+ if (early == null || late == null)
272
+ return 'Sparkline, trend unavailable'
273
+ const dir = inverted
274
+ ? (trend.value === 1 ? 'improving' : trend.value === -1 ? 'worsening' : 'flat')
275
+ : (trend.value === 1 ? 'upward' : trend.value === -1 ? 'downward' : 'flat')
276
+ const fmt = (n: number) => Math.abs(n) >= 10 || Number.isInteger(n) ? String(Math.round(n)) : n.toFixed(2)
277
+ return `Sparkline, ${dir} trend, averaging ${fmt(early)} early to ${fmt(late)} recently`
278
+ })
279
+
280
+ // Per-point comparison colors: at each data point, compare current vs previous
281
+ const comparisonGradientColors = computed<string[]>(() => {
282
+ const curr = values.value
283
+ const prev = fullPreviousValues.value
284
+ if (!prev.length)
285
+ return []
286
+
287
+ const [up, down, flat] = comparisonColors
288
+ // Compare original time positions. Sampling chooses different days for each
289
+ // history, so sampled array ordinals cannot identify matching observations.
290
+ return curr.map((v, i) => {
291
+ const originalIndex = sampledIndices.value[i]!
292
+ const fraction = fullValues.value.length > 1 ? originalIndex / (fullValues.value.length - 1) : 0
293
+ const previousIndex = Math.round(fraction * (prev.length - 1))
294
+ const previous = prev[previousIndex]
295
+ if (previous == null)
296
+ return flat
297
+ const difference = (v - previous) * (inverted ? -1 : 1)
298
+ return difference > 0 ? up : difference < 0 ? down : flat
299
+ })
300
+ })
301
+
302
+ const gradientColors = computed(() => {
303
+ if (hasComparison.value && comparisonStyle === 'diverging' && comparisonGradientColors.value.length >= 2)
304
+ return comparisonGradientColors.value
305
+
306
+ const seed = semanticColors.neutral.hex
307
+ if (colors)
308
+ return colors.map(resolveColor)
309
+ if (color)
310
+ return [seed, resolveColor(color)]
311
+ return [seed, seed]
312
+ })
313
+
314
+ // Ghost line projection of previousData — drawn behind the current line in a
315
+ // muted color so the reader sees both periods without per-point color churn.
316
+ // Shares `sharedYScale` with the current line for honest comparison.
317
+ const prevPathD = computed(() => {
318
+ if (!hasComparison.value || comparisonStyle !== 'ghost')
319
+ return ''
320
+ return buildSeriesPath(fullPreviousValues.value, previousRuns.value)
321
+ })
322
+
323
+ // Build gradient stops for SVG linearGradient (evenly spaced)
324
+ const gradientStops = computed(() => {
325
+ const colors = gradientColors.value
326
+ if (colors.length <= 1)
327
+ return [{ offset: '0%', color: colors[0] || 'var(--ui-text-dimmed)' }, { offset: '100%', color: colors[0] || 'var(--ui-text-dimmed)' }]
328
+ const diverging = hasComparison.value && comparisonStyle === 'diverging' && comparisonGradientColors.value.length >= 2
329
+ return colors.map((color, i) => ({
330
+ offset: `${(diverging ? sampledIndices.value[i]! / (fullValues.value.length - 1) : i / (colors.length - 1)) * 100}%`,
331
+ color,
332
+ }))
333
+ })
334
+
335
+ // Single-pass min/max — avoids `Math.min(...arr)` spread (which allocates an
336
+ // args array and risks a call-stack overflow on large series).
337
+ function minMax(arr: number[]): { min: number, max: number } {
338
+ let min = Infinity
339
+ let max = -Infinity
340
+ for (const v of arr) {
341
+ if (v < min)
342
+ min = v
343
+ if (v > max)
344
+ max = v
345
+ }
346
+ return { min, max }
347
+ }
348
+
349
+ /**
350
+ * Smallest peak-to-trough spread, as a fraction of the series' own magnitude,
351
+ * that is allowed to fill the chart height.
352
+ *
353
+ * The y-scale auto-fits, so without a floor ANY spread is stretched to the full
354
+ * height: a metric sitting at 100 that jitters by 0.05 draws the same violent
355
+ * zigzag as one that halved. `trend` and the aria-label both correctly said
356
+ * "flat" while the picture screamed movement — the one question a sparkline
357
+ * exists to answer, answered wrong.
358
+ *
359
+ * 8% is picked to sit between the two numbers this component already cares
360
+ * about. Below it is `trend`'s 2% relative-difference flat cut, so a series
361
+ * wobbling inside a couple of percent of its own magnitude now draws as a
362
+ * near-flat line instead of noise. Above it, comfortably, is the smallest move
363
+ * a reader would call real: a CTR going 2.1% → 2.9% is a 28% peak-to-trough
364
+ * spread, 3.5x the floor, so it is untouched and still fills the chart.
365
+ *
366
+ * A floor rather than a flat-series short-circuit, deliberately. A threshold
367
+ * that swaps the curve for a straight line makes a series hovering at the cut
368
+ * flip between "dead flat" and "full height" between refreshes, which is a
369
+ * worse lie than the one being fixed. Scaling by a floored range degrades
370
+ * continuously: amplitude drawn = (relative spread / 0.08) of the chart, so
371
+ * 2% draws at a quarter height, 0.05% draws at sub-pixel, and nothing jumps.
372
+ */
373
+ const FLAT_RANGE_FLOOR = 0.08
374
+
375
+ /**
376
+ * Widen `bounds` to the floor when the series is flatter than FLAT_RANGE_FLOOR,
377
+ * keeping the data centred in the widened band so a truly constant series draws
378
+ * through the middle rather than pinned to the chart's bottom edge.
379
+ *
380
+ * A series at or above the floor is returned byte-identically (same object),
381
+ * so every non-flat sparkline in the product projects to the exact same path.
382
+ */
383
+ function floorRange(bounds: { min: number, max: number }): { min: number, max: number } {
384
+ const spread = bounds.max - bounds.min
385
+ // Magnitude, not spread, sets the floor: "flat" is relative to how big the
386
+ // numbers are. A 0.05 wobble is noise at 100 and a doubling at 0.05.
387
+ const magnitude = Math.max(Math.abs(bounds.max), Math.abs(bounds.min))
388
+ const minRange = magnitude * FLAT_RANGE_FLOOR
389
+ if (!(spread < minRange))
390
+ return bounds
391
+ const mid = (bounds.max + bounds.min) / 2
392
+ return { min: mid - minRange / 2, max: mid + minRange / 2 }
393
+ }
394
+
395
+ function projectPoints(vals: number[], indices: number[], originalLength: number, yScale?: { min: number, max: number }) {
396
+ const padX = strokeWidth
397
+ const padY = strokeWidth
398
+ const chartW = vbW.value - padX * 2
399
+ const chartH = vbH.value - padY * 2
400
+
401
+ const bounds = floorRange(yScale ?? minMax(vals))
402
+ const min = bounds.min
403
+ const max = bounds.max
404
+ const range = max - min || 1
405
+
406
+ return {
407
+ padX,
408
+ padY,
409
+ chartW,
410
+ chartH,
411
+ // The ONE place the y-axis direction is decided, so nothing downstream
412
+ // (line, step, bars, area close, tracer) can disagree about which way is up.
413
+ // Normally the largest value is highest; `inverted` puts the SMALLEST value
414
+ // there instead, because on a lower-is-better metric that is the good end.
415
+ points: vals.map((v, i) => {
416
+ const t = (v - min) / range
417
+ return {
418
+ x: padX + (indices[i]! / (originalLength - 1)) * chartW,
419
+ y: inverted ? padY + t * chartH : padY + chartH - t * chartH,
420
+ }
421
+ }),
422
+ }
423
+ }
424
+
425
+ // Shared y-scale when rendering current + previous as overlay (ghost mode).
426
+ const sharedYScale = computed<{ min: number, max: number } | undefined>(() => {
427
+ const all = hasComparison.value && comparisonStyle === 'ghost'
428
+ ? [...values.value, ...prevValues.value]
429
+ : values.value
430
+ return all.length ? minMax(all) : undefined
431
+ })
432
+
433
+ function buildPath(vals: number[], indices: number[], originalLength: number): string {
434
+ if (!vals.length)
435
+ return ''
436
+
437
+ const { padY, chartH, points } = projectPoints(vals, indices, originalLength, sharedYScale.value)
438
+ // Catmull-Rom control points overshoot local extremes: at an asymmetric peak
439
+ // (small step in, steep drop out) the control point lands above the chart top
440
+ // and the SVG viewport clips the curve flat — the graph's max/min gets "cut
441
+ // off". Clamping control-point Y to the chart area keeps the whole cubic
442
+ // inside it (Bézier convex-hull property) and also stops the curve implying
443
+ // values beyond the data's actual range.
444
+ const clampY = (y: number) => Math.min(padY + chartH, Math.max(padY, y))
445
+
446
+ if (points.length === 1)
447
+ return `M ${points[0]!.x} ${points[0]!.y} L ${points[0]!.x} ${points[0]!.y}`
448
+ const segments: string[] = [`M ${points[0]!.x} ${points[0]!.y}`]
449
+ for (let i = 0; i < points.length - 1; i++) {
450
+ const p0 = points[Math.max(i - 1, 0)]!
451
+ const p1 = points[i]!
452
+ const p2 = points[i + 1]!
453
+ const p3 = points[Math.min(i + 2, points.length - 1)]!
454
+
455
+ const tension = 0.3
456
+ // Sampling leaves uneven time gaps. Keep controls within this interval
457
+ // so a neighboring long gap cannot curl the line backwards in time.
458
+ const clampX = (x: number) => Math.min(p2.x, Math.max(p1.x, x))
459
+ const cp1x = clampX(p1.x + (p2.x - p0.x) * tension)
460
+ const cp1y = clampY(p1.y + (p2.y - p0.y) * tension)
461
+ const cp2x = clampX(p2.x - (p3.x - p1.x) * tension)
462
+ const cp2y = clampY(p2.y - (p3.y - p1.y) * tension)
463
+
464
+ segments.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`)
465
+ }
466
+
467
+ return segments.join(' ')
468
+ }
469
+
470
+ function buildStepPath(vals: number[], indices: number[], originalLength: number): string {
471
+ if (!vals.length)
472
+ return ''
473
+ const { points } = projectPoints(vals, indices, originalLength, sharedYScale.value)
474
+ if (points.length === 1)
475
+ return `M ${points[0]!.x} ${points[0]!.y} L ${points[0]!.x} ${points[0]!.y}`
476
+ const segments: string[] = [`M ${points[0]!.x} ${points[0]!.y}`]
477
+ for (let i = 1; i < points.length; i++) {
478
+ const p = points[i]!
479
+ const prev = points[i - 1]!
480
+ segments.push(`L ${p.x} ${prev.y}`, `L ${p.x} ${p.y}`)
481
+ }
482
+ return segments.join(' ')
483
+ }
484
+
485
+ const strokeGradientId = useId()
486
+ const areaGradientId = `${strokeGradientId}-area`
487
+
488
+ function buildSeriesPath(observations: Observation[], runs: number[][]): string {
489
+ if (observations.length < 2)
490
+ return ''
491
+ return runs.map((indices) => {
492
+ const values = indices.map(index => observations[index] as number)
493
+ return variant === 'step'
494
+ ? buildStepPath(values, indices, observations.length)
495
+ : buildPath(values, indices, observations.length)
496
+ }).join(' ')
497
+ }
498
+
499
+ const pathD = computed(() => buildSeriesPath(fullValues.value, sampledRuns.value))
500
+
501
+ const areaPathD = computed(() => {
502
+ // `inverted` used to be excluded here: with the y-axis unflipped the fill ran
503
+ // from a rising "bad" line down to the floor and read as a ceiling. Now that
504
+ // `projectPoints` flips the axis, the line sits above the floor the same way
505
+ // it does for any other metric and the fill is an ordinary under-curve area,
506
+ // so the carve-out is gone. A caller passing `area` gets an area.
507
+ if (!pathD.value || !area)
508
+ return ''
509
+ const closeY = vbH.value - strokeWidth
510
+ return sampledRuns.value.map((indices) => {
511
+ const values = indices.map(index => fullValues.value[index] as number)
512
+ const runPath = variant === 'step'
513
+ ? buildStepPath(values, indices, fullValues.value.length)
514
+ : buildPath(values, indices, fullValues.value.length)
515
+ const { points } = projectPoints(values, indices, fullValues.value.length)
516
+ return `${runPath} L ${points.at(-1)!.x} ${closeY} L ${points[0]!.x} ${closeY} Z`
517
+ }).join(' ')
518
+ })
519
+
520
+ const endColor = computed(() => gradientColors.value.at(-1) || 'var(--ui-text-dimmed)')
521
+ const areaY1 = computed(() => trend.value === -1 ? '1' : '0')
522
+ const areaY2 = computed(() => trend.value === -1 ? '0' : '1')
523
+
524
+ // Pointer-only hit targets for UiTooltipGrid: one invisible band per observed
525
+ // point, split at the midpoints between neighbours. They stay out of the tab
526
+ // order because the svg already names the series through its role and label.
527
+ const hitTargets = computed(() => {
528
+ if (!interactive || tooltipRow == null || fullValues.value.length < 2)
529
+ return []
530
+ const length = fullValues.value.length
531
+ const xAt = (index: number) => strokeWidth + index / (length - 1) * (vbW.value - strokeWidth * 2)
532
+ return sampledRuns.value.flatMap((indices) => {
533
+ const runValues = indices.map(index => fullValues.value[index] as number)
534
+ const { points } = projectPoints(runValues, indices, length)
535
+ return points.map((point, i) => {
536
+ const index = indices[i]!
537
+ const left = i > 0 ? (points[i - 1]!.x + point.x) / 2 : index === 0 ? 0 : xAt(index - 0.5)
538
+ const right = i < points.length - 1 ? (point.x + points[i + 1]!.x) / 2 : index === length - 1 ? vbW.value : xAt(index + 0.5)
539
+ return { x: left, width: right - left, value: runValues[i]!, col: index }
540
+ })
541
+ })
542
+ })
543
+ </script>
544
+
545
+ <template>
546
+ <!-- overflow-visible: with non-scaling-stroke, a sparkline rendered smaller
547
+ than its viewBox shrinks the padding below the (constant, screen-px)
548
+ stroke half-width; the tracer dot (r=2.2 > padY) also pokes past the
549
+ edge at extremes. Sub-pixel bleed beats clipped peaks. -->
550
+ <svg
551
+ v-if="pathD"
552
+ data-ui="UiSparkline"
553
+ class="overflow-visible"
554
+ :width="cssWidth"
555
+ :height="cssHeight"
556
+ :viewBox="`0 0 ${vbW} ${vbH}`"
557
+ :preserveAspectRatio="preserveAspectRatio"
558
+ role="img"
559
+ :aria-label="ariaLabel"
560
+ >
561
+ <defs>
562
+ <!-- A horizontal path has no bounding-box height. Use the line's SVG
563
+ coordinates so its gradient still paints a constant history. -->
564
+ <linearGradient
565
+ :id="strokeGradientId"
566
+ gradientUnits="userSpaceOnUse"
567
+ :x1="strokeWidth"
568
+ y1="0"
569
+ :x2="vbW - strokeWidth"
570
+ y2="0"
571
+ >
572
+ <stop
573
+ v-for="stop in gradientStops"
574
+ :key="stop.offset"
575
+ :offset="stop.offset"
576
+ :style="{ stopColor: stop.color }"
577
+ />
578
+ </linearGradient>
579
+ <linearGradient :id="areaGradientId" x1="0" :y1="areaY1" x2="0" :y2="areaY2">
580
+ <stop offset="0%" :style="{ stopColor: endColor, stopOpacity: 0.25 }" />
581
+ <stop offset="100%" :style="{ stopColor: endColor, stopOpacity: 0 }" />
582
+ </linearGradient>
583
+ </defs>
584
+ <path :d="areaPathD" :fill="`url(#${areaGradientId})`" stroke="none" />
585
+ <path
586
+ v-if="prevPathD"
587
+ :d="prevPathD"
588
+ :stroke="semanticColors.neutral.hex"
589
+ :stroke-width="Math.max(0.75, strokeWidth - 0.5)"
590
+ stroke-linecap="round"
591
+ stroke-linejoin="round"
592
+ stroke-opacity="0.35"
593
+ fill="none"
594
+ vector-effect="non-scaling-stroke"
595
+ />
596
+ <path
597
+ :d="pathD"
598
+ :stroke="`url(#${strokeGradientId})`"
599
+ :stroke-width="strokeWidth"
600
+ stroke-linecap="round"
601
+ stroke-linejoin="round"
602
+ fill="none"
603
+ vector-effect="non-scaling-stroke"
604
+ />
605
+ <!-- Hover tracer: a brighter copy of the line that draws itself along the
606
+ path on hover (pathLength=1 normalises dasharray regardless of scale). -->
607
+ <path
608
+ v-if="interactive && pathD"
609
+ class="ui-sparkline-tracer"
610
+ :class="{ 'is-hovered': hovered }"
611
+ :d="pathD"
612
+ :stroke="endColor"
613
+ :stroke-width="strokeWidth + 0.5"
614
+ stroke-linecap="round"
615
+ stroke-linejoin="round"
616
+ fill="none"
617
+ pathLength="1"
618
+ vector-effect="non-scaling-stroke"
619
+ />
620
+ <!-- Hover tracer dot: rides the exact smoothed path via offset-path so it
621
+ glides continuously along the curve (not stepping between points).
622
+ tabindex -1: motion-v binds focus listeners, and Chromium makes any
623
+ SVG element with one a Tab stop. The dot carries no name or action. -->
624
+ <motion.circle
625
+ v-if="interactive && pathD"
626
+ tabindex="-1"
627
+ aria-hidden="true"
628
+ :r="2.2"
629
+ cx="0"
630
+ cy="0"
631
+ :fill="endColor"
632
+ :style="{ offsetPath: `path('${pathD}')`, offsetRotate: '0deg', filter: 'drop-shadow(0 0 2px var(--ui-bg))' }"
633
+ :initial="{ opacity: 0, offsetDistance: '0%' }"
634
+ :animate="!hovered
635
+ ? { opacity: 0 }
636
+ : reducedMotion
637
+ ? { offsetDistance: '100%', opacity: 0.9 }
638
+ : { offsetDistance: ['0%', '100%'], opacity: [0, 1, 1, 0] }"
639
+ :transition="!hovered
640
+ ? { duration: 0.25 }
641
+ : reducedMotion
642
+ ? { duration: 0 }
643
+ : { duration: 2.2, ease: 'easeInOut', repeat: Infinity }"
644
+ />
645
+ <rect
646
+ v-for="target in hitTargets"
647
+ :key="`hit-${target.col}`"
648
+ :x="target.x"
649
+ y="0"
650
+ :width="target.width"
651
+ :height="vbH"
652
+ fill="transparent"
653
+ :data-tooltip-row="tooltipRow"
654
+ :data-tooltip-col="target.col"
655
+ :data-tooltip-value="target.value"
656
+ />
657
+ </svg>
658
+ </template>
659
+
660
+ <style scoped>
661
+ /* Tracer rests fully retracted (offset = full length) and draws to 0 on hover,
662
+ so a brighter line sweeps left→right along the data. */
663
+ .ui-sparkline-tracer {
664
+ stroke-dasharray: 1;
665
+ stroke-dashoffset: 1;
666
+ opacity: 0;
667
+ transition:
668
+ stroke-dashoffset 700ms cubic-bezier(0.22, 1, 0.36, 1),
669
+ opacity 200ms ease-out;
670
+ }
671
+ .ui-sparkline-tracer.is-hovered {
672
+ stroke-dashoffset: 0;
673
+ opacity: 0.9;
674
+ }
675
+ @media (prefers-reduced-motion: reduce) {
676
+ .ui-sparkline-tracer {
677
+ transition: opacity 150ms ease-out;
678
+ stroke-dashoffset: 0;
679
+ }
680
+ }
681
+ </style>