@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,431 @@
1
+ <script lang="ts">
2
+ export interface LineTooltipRow {
3
+ color: string
4
+ label: string
5
+ value: string
6
+ }
7
+
8
+ export function sharedLineDomain(
9
+ data: Array<Record<string, unknown>>,
10
+ seriesKeys: string[],
11
+ minDomainMaximum = 1,
12
+ ): [number, number] {
13
+ const values = data.flatMap(row => seriesKeys.map((key) => {
14
+ const raw = row[key]
15
+ const value = typeof raw === 'number' ? raw : Number(raw)
16
+ return Number.isFinite(value) ? value : 0
17
+ }))
18
+ // Non-negative data keeps its zero baseline. A signed series (a
19
+ // desktop-minus-mobile gap) follows its values below zero; clipped to the
20
+ // floor, "mobile leads" drew the same line as "no gap".
21
+ const lower = Math.min(0, ...values)
22
+ const upper = Math.max(0, minDomainMaximum, ...values)
23
+ return [lower * 1.1, (lower === upper ? 1 : upper) * 1.1]
24
+ }
25
+
26
+ export function lineTickIndices(length: number, width: number): number[] {
27
+ if (length <= 1)
28
+ return [0]
29
+ const widthBudget = width > 0 ? Math.max(2, Math.floor(width / 96)) : 6
30
+ const count = Math.min(6, widthBudget, length)
31
+ const step = (length - 1) / (count - 1)
32
+ return Array.from({ length: count }, (_, i) => Math.round(i * step))
33
+ }
34
+
35
+ export function escapeChartTooltipHtml(value: unknown): string {
36
+ return String(value)
37
+ .replaceAll('&', '&amp;')
38
+ .replaceAll('<', '&lt;')
39
+ .replaceAll('>', '&gt;')
40
+ .replaceAll('"', '&quot;')
41
+ .replaceAll('\'', '&#39;')
42
+ }
43
+
44
+ export function renderLineTooltip(header: string, rows: LineTooltipRow[]): string {
45
+ const renderedRows = rows.map(row => `
46
+ <div style="display:flex;gap:12px;justify-content:space-between;align-items:center">
47
+ <span style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted)">
48
+ <span style="width:8px;height:8px;border-radius:9999px;background:${escapeChartTooltipHtml(row.color)}"></span>${escapeChartTooltipHtml(row.label)}
49
+ </span>
50
+ <span style="font-variant-numeric:tabular-nums;color:var(--ui-text-highlighted)">${escapeChartTooltipHtml(row.value)}</span>
51
+ </div>`).join('')
52
+ return `<div style="display:flex;flex-direction:column;gap:4px;min-width:140px">
53
+ <div style="font-size:14px;color:var(--ui-text-muted);margin-bottom:2px">${escapeChartTooltipHtml(header)}</div>${renderedRows}
54
+ </div>`
55
+ }
56
+ </script>
57
+
58
+ <script lang="ts" setup generic="T extends Record<string, unknown>">
59
+ import type { ChartAnnotation, ChartAnnotationOptions } from '../../utils/chartAnnotations'
60
+ import { TextAlign } from '@unovis/ts'
61
+ import { VisArea, VisAxis, VisCrosshair, VisLine, VisScatter, VisTooltip, VisXYContainer } from '@unovis/vue'
62
+ import { useElementSize } from '@vueuse/core'
63
+ import { computed, ref, useTemplateRef } from 'vue'
64
+ import { ClientOnly, UiChartAnnotations, UiChartDataReveal } from '#components'
65
+ import { formatGroupedNumber } from '../../composables/formatting'
66
+ import { indexAnnotationsByDay } from '../../utils/chartAnnotations'
67
+
68
+ // Generic interactive line chart. Every series shares one zero-based y scale:
69
+ // callers may compare same-unit series here, while different units belong in
70
+ // separate charts. Crosshair + tooltip make the exact rows available on hover.
71
+
72
+ interface LineSeries {
73
+ /** Row key holding this series' numeric value. */
74
+ key: string
75
+ /** Legend/tooltip label. Defaults to `key`. */
76
+ label?: string
77
+ /** Stroke colour (any CSS colour). Defaults to a rotating palette. */
78
+ color?: string
79
+ /** Draw a translucent area under the line. Default true. */
80
+ area?: boolean
81
+ }
82
+
83
+ const {
84
+ data,
85
+ xKey,
86
+ series,
87
+ height = 220,
88
+ selectedX,
89
+ settledThrough,
90
+ xFormat,
91
+ yFormat,
92
+ minDomainMaximum = 1,
93
+ annotations,
94
+ annotationOptions,
95
+ } = defineProps<{
96
+ data: T[]
97
+ /** Row key for the x dimension (e.g. a date column). */
98
+ xKey: keyof T & string
99
+ series: LineSeries[]
100
+ height?: number | string
101
+ /** Highlight one x value, for example the selected crawl generation. */
102
+ selectedX?: T[keyof T] | null
103
+ /**
104
+ * The last x value whose data is FINAL. Everything after it is still
105
+ * settling — a partial sum that will keep rising — and renders dashed with
106
+ * no area fill instead of as a finished point.
107
+ *
108
+ * Search Console lands two to three days behind, so without this every
109
+ * clicks chart ends in a cliff that reads as a traffic collapse. Omit when
110
+ * the whole window is settled.
111
+ */
112
+ settledThrough?: T[keyof T] | null
113
+ /** Format an x value for axis ticks + the tooltip header. */
114
+ xFormat?: (raw: T[keyof T]) => string
115
+ /** Format a y value for the tooltip, per series key. */
116
+ yFormat?: (value: number, seriesKey: string) => string
117
+ /**
118
+ * The lowest top the y scale may have. The default 1 keeps a count chart of
119
+ * zeros from collapsing. Pass 0 for a small rate (a CTR of 0.02), which the
120
+ * default would flatten against the floor.
121
+ */
122
+ minDomainMaximum?: number
123
+ /**
124
+ * Day-anchored event annotations (thin marker line + dot). Re-anchored onto
125
+ * this chart's index x-scale by matching each annotation's day against the
126
+ * row's `xKey` value — rows whose x isn't a date simply match nothing, so
127
+ * non-date charts stay marker-free without opting out.
128
+ */
129
+ annotations?: ChartAnnotation[]
130
+ /** Marker presentation shared by single and stacked annotation popovers. */
131
+ annotationOptions?: ChartAnnotationOptions
132
+ }>()
133
+
134
+ const emit = defineEmits<{
135
+ annotationEdit: [annotation: ChartAnnotation]
136
+ }>()
137
+
138
+ const annotationInteracting = ref(false)
139
+
140
+ // Semantic chart colours keep default series theme-aware. Callers can still
141
+ // provide a more specific colour when the metric has an established meaning.
142
+ const PALETTE = [
143
+ 'var(--ui-color-primary-500)',
144
+ 'var(--ui-color-success-500)',
145
+ 'var(--ui-color-warning-500)',
146
+ 'var(--ui-color-error-500)',
147
+ 'var(--ui-color-info-500)',
148
+ ]
149
+
150
+ const resolvedSeries = computed(() => series.map((s, i) => ({
151
+ key: s.key,
152
+ label: s.label ?? s.key,
153
+ color: s.color ?? PALETTE[i % PALETTE.length]!,
154
+ area: s.area ?? true,
155
+ })))
156
+
157
+ // No horizontal inset — the line bleeds edge-to-edge (matches ProGraphGsc).
158
+ // Edge tick labels (tickIndices always includes the first and last point) are
159
+ // kept inside via the text-anchor overrides in <style> below.
160
+ const margin = { left: 0, right: 0, top: 8, bottom: 28 }
161
+ const chartHeight = computed(() => Number(height) || 220)
162
+ const chartRoot = useTemplateRef<HTMLElement>('chartRoot')
163
+ const { width: containerWidth } = useElementSize(chartRoot)
164
+ // Unovis measures its parent once when `width` is omitted. Feed the observed
165
+ // container width back as a prop so desktop-to-mobile resizes re-render every
166
+ // point and the selected trailing crawl cannot remain clipped off-canvas.
167
+ const measuredWidth = computed(() => Math.max(0, Math.floor(containerWidth.value)))
168
+
169
+ // A missing point (null, undefined, or not a number) is `undefined`, never 0:
170
+ // Unovis leaves a gap for it, and the tooltip and the table print a dash. Read
171
+ // as 0, a sparse series drew a dip to zero and announced "0" for that day.
172
+ function num(v: unknown): number | undefined {
173
+ if (v == null || v === '')
174
+ return undefined
175
+ const n = typeof v === 'number' ? v : Number(v)
176
+ return Number.isFinite(n) ? n : undefined
177
+ }
178
+
179
+ // x accessor is the row index — keeps spacing even regardless of x type (dates, labels, …).
180
+ const x = (_d: T, i: number) => i
181
+ // Crosshair can render before its container inherits the line accessors.
182
+ // Give it the complete series so snapping also works during data changes.
183
+ const crosshairY = computed(() => resolvedSeries.value.map(s => (d: T) => num(d[s.key])))
184
+
185
+ const yDomain = computed<[number, number]>(() => {
186
+ return sharedLineDomain(data, resolvedSeries.value.map(series => series.key), minDomainMaximum)
187
+ })
188
+
189
+ function selectedSize(d: T): number {
190
+ return selectedX != null && Object.is(d[xKey], selectedX) ? 8 : 0
191
+ }
192
+
193
+ // Index of the last settled row. `null` (the default, and an x value that
194
+ // isn't in `data`) means the whole window is final and the chart draws exactly
195
+ // as it did before this prop existed.
196
+ const settledIndex = computed<number | null>(() => {
197
+ if (settledThrough == null)
198
+ return null
199
+ const i = data.findIndex(row => Object.is(row[xKey], settledThrough))
200
+ return i === -1 ? null : i
201
+ })
202
+
203
+ // The two spans overlap by one point on purpose: the dashed run starts at the
204
+ // last settled row so the line stays visually continuous across the boundary.
205
+ function settledY(d: T, i: number, key: string): number | undefined {
206
+ const cut = settledIndex.value
207
+ return cut != null && i > cut ? undefined : num(d[key])
208
+ }
209
+ function provisionalY(d: T, i: number, key: string): number | undefined {
210
+ const cut = settledIndex.value
211
+ return cut == null || i < cut ? undefined : num(d[key])
212
+ }
213
+
214
+ // Evenly-spaced tick indices (~6), formatted from the row at that index. Index-based rather
215
+ // than calendar-aware so non-date x values work too.
216
+ const tickIndices = computed(() => {
217
+ return lineTickIndices(data.length, measuredWidth.value)
218
+ })
219
+ function tickFormat(i: number): string {
220
+ const row = data[i]
221
+ if (!row)
222
+ return ''
223
+ return xFormat ? xFormat(row[xKey]) : String(row[xKey])
224
+ }
225
+
226
+ // Day-anchored annotations onto the index x-scale (unmatched days drop out).
227
+ const anchoredAnnotations = computed(() =>
228
+ indexAnnotationsByDay(annotations, data.map(row => String(row[xKey]))))
229
+ const annotationXDomain = computed<[number, number] | undefined>(() =>
230
+ data.length > 1 ? [0, data.length - 1] : undefined)
231
+
232
+ // Force a remount when the dataset identity changes so unovis recomputes scales cleanly.
233
+ const chartKey = computed(() => `${data.length}-${String(data[0]?.[xKey] ?? '')}-${String(data.at(-1)?.[xKey] ?? '')}`)
234
+
235
+ function fmtY(value: number | undefined, key: string): string {
236
+ if (value === undefined)
237
+ return '—'
238
+ return yFormat ? yFormat(value, key) : formatGroupedNumber(value)
239
+ }
240
+
241
+ // The drawn lines encode data by position only, so the SVG layers are hidden
242
+ // from assistive tech; a visually-hidden table below carries the same series
243
+ // values as text (mirrors UiScatterPlot). Rendered server-side too (no client
244
+ // deps), so the data is present before hydration.
245
+ const ariaLabel = computed(() => `Line chart of ${resolvedSeries.value.map(s => s.label).join(', ')}`)
246
+ function fmtX(row: T): string {
247
+ return xFormat ? xFormat(row[xKey]) : String(row[xKey])
248
+ }
249
+
250
+ // Crosshair tooltip: x header + one row per series. Portaled to body, so it leans on inherited
251
+ // CSS custom properties for theming rather than scoped classes.
252
+ function tooltipTemplate(d: T): string {
253
+ return renderLineTooltip(
254
+ String(xFormat ? xFormat(d[xKey]) : d[xKey]),
255
+ resolvedSeries.value.map(s => ({
256
+ color: s.color,
257
+ label: s.label,
258
+ value: fmtY(num(d[s.key]), s.key),
259
+ })),
260
+ )
261
+ }
262
+ </script>
263
+
264
+ <template>
265
+ <div
266
+ ref="chartRoot"
267
+ data-ui="UiLineChart"
268
+ class="ui-line-chart"
269
+ :style="{ height: `${chartHeight}px` }"
270
+ >
271
+ <div v-if="data.length < 2" class="flex items-center justify-center h-full text-sm text-muted">
272
+ Not enough data to chart.
273
+ </div>
274
+
275
+ <ClientOnly v-else>
276
+ <VisXYContainer
277
+ :key="chartKey"
278
+ :width="measuredWidth || undefined"
279
+ :height="chartHeight"
280
+ :data="data"
281
+ :margin="margin"
282
+ :auto-margin="false"
283
+ :y-domain="yDomain"
284
+ aria-hidden="true"
285
+ class="chart-layer chart-layer--interactive"
286
+ >
287
+ <template v-for="s in resolvedSeries" :key="s.key">
288
+ <!-- Area stops at the settled boundary — filling under a partial sum
289
+ overstates it more than the line alone does. -->
290
+ <VisArea
291
+ v-if="s.area"
292
+ curve-type="monotoneX"
293
+ :x="x"
294
+ :y="(d: T, i: number) => settledY(d, i, s.key)"
295
+ :color="s.color"
296
+ :opacity="0.12"
297
+ />
298
+ <VisLine
299
+ curve-type="monotoneX"
300
+ :x="x"
301
+ :y="(d: T, i: number) => settledY(d, i, s.key)"
302
+ :color="s.color"
303
+ :line-width="2"
304
+ />
305
+ <VisLine
306
+ v-if="settledIndex != null"
307
+ curve-type="monotoneX"
308
+ :x="x"
309
+ :y="(d: T, i: number) => provisionalY(d, i, s.key)"
310
+ :color="s.color"
311
+ :line-width="2"
312
+ :line-dash-array="[4, 3]"
313
+ />
314
+ </template>
315
+ <VisScatter
316
+ v-if="selectedX != null && resolvedSeries[0]"
317
+ :x="x"
318
+ :y="(d: T) => num(d[resolvedSeries[0]!.key])"
319
+ :size="selectedSize"
320
+ :color="resolvedSeries[0].color"
321
+ stroke-color="var(--ui-bg)"
322
+ :stroke-width="2"
323
+ />
324
+ <VisAxis
325
+ type="x"
326
+ :tick-line="false"
327
+ :grid-line="false"
328
+ :domain-line="false"
329
+ :tick-values="tickIndices"
330
+ :tick-format="tickFormat"
331
+ :tick-text-align="TextAlign.Center"
332
+ tick-text-font-size="14px"
333
+ tick-text-color="var(--ui-text-muted)"
334
+ />
335
+ <!-- A vertical guide plus the tooltip carries the multi-series read;
336
+ the selected crawl marker remains the only persistent point. -->
337
+ <VisCrosshair v-if="!annotationInteracting" :x="x" :y="crosshairY" color="none" :template="tooltipTemplate" />
338
+ <VisTooltip v-if="!annotationInteracting" />
339
+ </VisXYContainer>
340
+
341
+ <!-- Day-anchored annotation markers (shared overlay; root is position:relative). -->
342
+ <UiChartAnnotations
343
+ :annotations="anchoredAnnotations"
344
+ :x-domain="annotationXDomain"
345
+ :options="annotationOptions"
346
+ @edit="emit('annotationEdit', $event)"
347
+ @interaction-change="annotationInteracting = $event"
348
+ />
349
+ </ClientOnly>
350
+
351
+ <!-- Screen-reader equivalent of the position-encoded lines, and the
352
+ keyboard path to the hover readout: UiChartDataReveal keeps it hidden
353
+ until a keyboard user asks for it. -->
354
+ <UiChartDataReveal v-if="data.length >= 2">
355
+ <table>
356
+ <caption>{{ ariaLabel }}</caption>
357
+ <thead>
358
+ <tr>
359
+ <th scope="col">
360
+ {{ xKey }}
361
+ </th>
362
+ <th v-for="s in resolvedSeries" :key="s.key" scope="col">
363
+ {{ s.label }}
364
+ </th>
365
+ </tr>
366
+ </thead>
367
+ <tbody>
368
+ <tr v-for="(row, i) in data" :key="i">
369
+ <th scope="row">
370
+ {{ fmtX(row) }}
371
+ </th>
372
+ <td v-for="s in resolvedSeries" :key="s.key">
373
+ {{ fmtY(num(row[s.key]), s.key) }}
374
+ </td>
375
+ </tr>
376
+ </tbody>
377
+ </table>
378
+ </UiChartDataReveal>
379
+ </div>
380
+ </template>
381
+
382
+ <style scoped>
383
+ .ui-line-chart {
384
+ display: grid;
385
+ /* minmax(0, …): a bare 1fr track floors at the stacked SVGs' width, so the
386
+ chart grew with the window but never shrank back and pushed the page into
387
+ a horizontal scroll. */
388
+ grid-template-columns: minmax(0, 1fr);
389
+ position: relative;
390
+ }
391
+
392
+ .ui-line-chart .chart-layer {
393
+ grid-column-start: 1;
394
+ grid-row-start: 1;
395
+ }
396
+
397
+ /* Visual series layers don't capture pointer events; only the crosshair layer does. */
398
+ .ui-line-chart .chart-layer {
399
+ pointer-events: none;
400
+ }
401
+
402
+ .ui-line-chart .chart-layer--interactive {
403
+ pointer-events: auto;
404
+ --vis-crosshair-line-stroke-color: var(--ui-border-accented);
405
+ --vis-crosshair-line-stroke-opacity: 0.6;
406
+ }
407
+
408
+ .ui-line-chart :deep(.unovis-area-group path) {
409
+ stroke: none;
410
+ }
411
+
412
+ /* Drop the crosshair snap circle entirely. The persistent selected marker and
413
+ vertical guide carry the read without adding a floating point that appears
414
+ to belong to only one series. The crosshair group's class is hashed by
415
+ unovis; the `crosshair-component` suffix is stable. */
416
+ .ui-line-chart :deep([class*="crosshair-component"] circle) {
417
+ display: none;
418
+ }
419
+
420
+ /* Zero horizontal margin: anchor edge tick labels inward so they don't clip at
421
+ the svg boundary. Unovis sets text-anchor as an SVG attribute, which CSS
422
+ outranks; d3-axis keeps tick <g>s DOM-ordered by value, so first/last-of-type
423
+ are the leftmost/rightmost ticks. */
424
+ .ui-line-chart :deep(g.tick:first-of-type > text) {
425
+ text-anchor: start;
426
+ }
427
+
428
+ .ui-line-chart :deep(g.tick:last-of-type > text) {
429
+ text-anchor: end;
430
+ }
431
+ </style>
@@ -0,0 +1,121 @@
1
+ <script lang="ts" setup>
2
+ /**
3
+ * The bar-filled metric cell with a hover popover, shared across every overview
4
+ * "By site" table (Search Console, Indexing, Analytics, DataForSEO, CWV). The
5
+ * trigger is a {@link UiBarCell} carrying the headline value (a `#value` slot,
6
+ * so each tab keeps full control of formatting / threshold colors / share %) and
7
+ * a compact sparkline; the popover panel shows a wide sparkline plus an optional
8
+ * list of `label → value | trend` rows.
9
+ *
10
+ * Replaces the ~60-line block that used to be copy-pasted per metric per tab.
11
+ */
12
+ import { ref, useId } from 'vue'
13
+ import { UiPopover, UiSparkline, UiTrend } from '#components'
14
+ import UiBarCell from './UiBarCell.vue'
15
+
16
+ export interface MetricPanelRow {
17
+ label: string
18
+ /** Plain value (mono). Ignored when `trend` is set. */
19
+ value?: string
20
+ /** Extra classes for the value (e.g. a threshold color). */
21
+ valueClass?: string
22
+ /** Render a UiTrend instead of a plain value. */
23
+ trend?: number
24
+ trendInverted?: boolean
25
+ trendFormat?: 'percent' | 'points'
26
+ /** Draw a hairline divider above this row (section break). */
27
+ divider?: boolean
28
+ }
29
+
30
+ const {
31
+ sparkline = [],
32
+ panelRows = [],
33
+ } = defineProps<{
34
+ /** Bar fill 0–100. */
35
+ percent: number
36
+ /** Tailwind bg class for the bar fill. */
37
+ barClass?: string
38
+ /** Trend/sparkline series. Rendered only when length > 1. */
39
+ sparkline?: number[]
40
+ /** Sparkline color (named viz color or rgb()). */
41
+ sparklineColor?: string
42
+ /** Popover heading + body. */
43
+ title: string
44
+ description: string
45
+ /** Detail rows under the panel sparkline. */
46
+ panelRows?: MetricPanelRow[]
47
+ }>()
48
+
49
+ const panelId = useId()
50
+ // Reka's HoverCard ignores touch, so a tap opens the panel through the model.
51
+ // Outside tap and Escape still dismiss it.
52
+ const open = ref(false)
53
+ </script>
54
+
55
+ <template>
56
+ <UiPopover v-model:open="open" mode="hover" role="tooltip" :panel-id="panelId" :content="{ side: 'bottom' }">
57
+ <!-- Focusable trigger so the hover panel is also reachable by keyboard
58
+ (reka HoverCard opens on focus) and by tap (the click opens it). The
59
+ headline value stays inline for everyone, so the panel breakdown is a
60
+ true enhancement. The accessible name starts with that visible value
61
+ (WCAG 2.5.3), so voice users can say what they see. -->
62
+ <button
63
+ type="button"
64
+ class="block w-full text-left rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
65
+ :aria-describedby="panelId"
66
+ @click="open = true"
67
+ >
68
+ <UiBarCell :percent="percent" :bar-class="barClass">
69
+ <slot name="value" />
70
+ <span class="sr-only">{{ title }}, show breakdown</span>
71
+ <UiSparkline
72
+ v-if="sparkline.length > 1"
73
+ :data="sparkline"
74
+ :width="56"
75
+ :height="18"
76
+ :color="sparklineColor"
77
+ class="shrink-0 relative"
78
+ aria-hidden="true"
79
+ />
80
+ <slot name="trailing" />
81
+ </UiBarCell>
82
+ </button>
83
+ <template #panel>
84
+ <div class="p-3 text-xs space-y-2.5 min-w-[220px] max-w-[250px]">
85
+ <div class="font-semibold text-default">
86
+ {{ title }}
87
+ </div>
88
+ <p class="text-muted leading-relaxed">
89
+ {{ description }}
90
+ </p>
91
+ <UiSparkline
92
+ v-if="sparkline.length > 1"
93
+ :data="sparkline"
94
+ :color="sparklineColor"
95
+ :width="230"
96
+ :height="32"
97
+ />
98
+ <div v-if="panelRows.length" class="border-t border-default pt-2 space-y-1.5">
99
+ <div
100
+ v-for="row in panelRows"
101
+ :key="row.label"
102
+ class="flex justify-between items-center gap-4"
103
+ :class="row.divider ? 'pt-1 border-t border-default' : ''"
104
+ >
105
+ <span class="text-muted">{{ row.label }}</span>
106
+ <UiTrend
107
+ v-if="row.trend != null"
108
+ :value="row.trend"
109
+ :inverted="row.trendInverted"
110
+ colored
111
+ :format="row.trendFormat ?? 'percent'"
112
+ size="xs"
113
+ />
114
+ <span v-else class="font-mono font-medium tabular-nums" :class="row.valueClass ?? 'text-default'">{{ row.value }}</span>
115
+ </div>
116
+ </div>
117
+ <slot name="panel-extra" />
118
+ </div>
119
+ </template>
120
+ </UiPopover>
121
+ </template>
@@ -0,0 +1,30 @@
1
+ <script lang="ts" setup>
2
+ const { size = 'xs', tone = 'dimmed' } = defineProps<{
3
+ label?: string
4
+ /** xs = dense table/metric labels, sm = popover/list section labels */
5
+ size?: 'xs' | 'sm'
6
+ tone?: 'default' | 'muted' | 'dimmed' | 'success' | 'warning' | 'error'
7
+ }>()
8
+
9
+ // xs = the dense .text-label tier (10px); sm = a 12px label for popover/list
10
+ // section headings. (Both were 10px before — the sm tier was a dead no-op.)
11
+ const sizeClass = {
12
+ xs: 'text-label',
13
+ sm: 'text-xs uppercase tracking-wider font-semibold',
14
+ } as const
15
+
16
+ const toneClass = {
17
+ default: 'text-default',
18
+ muted: 'text-muted',
19
+ dimmed: 'text-dimmed',
20
+ success: 'text-success',
21
+ warning: 'text-warning',
22
+ error: 'text-error',
23
+ } as const
24
+ </script>
25
+
26
+ <template>
27
+ <span :class="[sizeClass[size], toneClass[tone]]">
28
+ <slot>{{ label }}</slot>
29
+ </span>
30
+ </template>
@@ -0,0 +1,73 @@
1
+ <script lang="ts" setup>
2
+ import type { SlotTextOptions } from '../../utils/slot-text'
3
+ import { computed } from 'vue'
4
+ import { UiIcon, UiSlotText, UiTrend } from '#components'
5
+ /**
6
+ * Compact direct-label stat for the card-grid chart area: an optional metric
7
+ * icon, the value, an optional unit label, and a trend delta. Used to label each
8
+ * overview card's chart inline (e.g. "⌖ 16.4K clicks +40%") instead of a
9
+ * separate legend. Shared across every overview tab.
10
+ */
11
+ const {
12
+ icon,
13
+ value,
14
+ label,
15
+ trend,
16
+ trendInverted,
17
+ trendFormat = 'percent',
18
+ animatedValue = true,
19
+ } = defineProps<{
20
+ /** Lucide/registry icon name for the metric. */
21
+ icon?: string
22
+ /** Formatted value (e.g. "16.4K", "94%"). Omit to show only the label + trend
23
+ * (e.g. when the value is already displayed elsewhere, like a chart readout). */
24
+ value?: string
25
+ /** Rolls compact numeric values when they change. */
26
+ animatedValue?: boolean | SlotTextOptions
27
+ /** Unit/metric label (e.g. "clicks"). */
28
+ label?: string
29
+ /** Trend delta; rendered as a UiTrend when non-null. */
30
+ trend?: number | null
31
+ /** Invert trend coloring (lower is better — position, errors, CWV). */
32
+ trendInverted?: boolean
33
+ trendFormat?: 'percent' | 'points'
34
+ }>()
35
+
36
+ const animatedValueOptions = computed<SlotTextOptions | null>(() => {
37
+ if (!value || animatedValue === false)
38
+ return null
39
+
40
+ const compactMetricValue = /^[+\-$€£¥]?\s*[\d,.]+(?:\s?[kmbt])?(?:\.\d+)?%?$/i.test(value)
41
+ if (typeof animatedValue === 'object')
42
+ return animatedValue
43
+
44
+ return compactMetricValue && value.length <= 14
45
+ ? { direction: 'up', duration: 300, stagger: 22 }
46
+ : null
47
+ })
48
+ </script>
49
+
50
+ <template>
51
+ <span class="inline-flex items-baseline gap-1.5 min-w-0">
52
+ <UiIcon v-if="icon" :name="icon" class="size-3.5 text-dimmed shrink-0 self-center" aria-hidden="true" />
53
+ <span v-if="value" class="text-sm font-semibold tabular-nums text-default">
54
+ <UiSlotText
55
+ v-if="animatedValueOptions"
56
+ :text="value"
57
+ :options="animatedValueOptions"
58
+ />
59
+ <template v-else>
60
+ {{ value }}
61
+ </template>
62
+ </span>
63
+ <span v-if="label" class="text-xs text-muted truncate" :class="{ 'font-medium text-default': !value }">{{ label }}</span>
64
+ <UiTrend
65
+ v-if="trend != null"
66
+ :value="trend"
67
+ :inverted="trendInverted"
68
+ colored
69
+ :format="trendFormat"
70
+ size="2xs"
71
+ />
72
+ </span>
73
+ </template>