@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,80 @@
1
+ /**
2
+ * Centralized semantic color system for the Pro dashboard.
3
+ *
4
+ * Three concerns:
5
+ * 1. **Status colors** — success/error/warning/info/neutral for health, connection, validation states
6
+ * 2. **Threshold colors** — good/needs-attention/poor for CWV, indexing %, etc.
7
+ * 3. **Trend colors** — positive (green) / negative (red) / neutral for change values
8
+ *
9
+ * Metric visualization colors (clicks=blue, impressions=purple, etc.) are intentionally
10
+ * NOT included here — those are data-viz concerns, not semantic.
11
+ */
12
+
13
+ export type SemanticStatus = 'success' | 'error' | 'warning' | 'info' | 'neutral'
14
+ export type HealthStatus = 'healthy' | 'attention' | 'issues' | 'unknown'
15
+
16
+ export interface SemanticColorSet {
17
+ text: string
18
+ bg: string
19
+ dot: string
20
+ border: string
21
+ hex: string
22
+ }
23
+
24
+ /** Full class sets for each semantic status */
25
+ export const semanticColors: Record<SemanticStatus, SemanticColorSet> = {
26
+ success: { text: 'text-success', bg: 'bg-success/10', dot: 'bg-success', border: 'border-success/20', hex: '#22c55e' },
27
+ error: { text: 'text-error', bg: 'bg-error/10', dot: 'bg-error', border: 'border-error/20', hex: '#ef4444' },
28
+ warning: { text: 'text-warning', bg: 'bg-warning/10', dot: 'bg-warning', border: 'border-warning/20', hex: '#eab308' },
29
+ info: { text: 'text-info', bg: 'bg-info/10', dot: 'bg-info', border: 'border-info/20', hex: '#3b82f6' },
30
+ // hex defaults to a violet-tinted grey (hue ~292) matching the de-chromatized
31
+ // neutral ramp, not stock cold blue-slate, so chart neutrals sit with the
32
+ // surface greys instead of reading cool against them. A site that extends the
33
+ // layer sets `--ui-viz-neutral`. It is a CSS colour: mix it with `color-mix()`,
34
+ // never parse it as hex. Light mode reads a darker `--_viz-neutral`, so
35
+ // neutral marks clear 3:1 on white (css/global.css).
36
+ neutral: { text: 'text-muted', bg: 'bg-accented', dot: 'bg-[var(--ui-border)]', border: 'border-default', hex: 'var(--ui-viz-neutral, var(--_viz-neutral, #9e9aa6))' },
37
+ }
38
+
39
+ /** Map dashboard health status to semantic status */
40
+ export function healthToSemantic(health: HealthStatus | null): SemanticStatus {
41
+ switch (health) {
42
+ case 'healthy': return 'success'
43
+ case 'attention': return 'warning'
44
+ case 'issues': return 'error'
45
+ default: return 'neutral'
46
+ }
47
+ }
48
+
49
+ /** Map a numeric value against good/poor thresholds to semantic status */
50
+ export function thresholdToSemantic(value: number, good: number, poor: number): SemanticStatus {
51
+ if (value <= good)
52
+ return 'success'
53
+ if (value <= poor)
54
+ return 'warning'
55
+ return 'error'
56
+ }
57
+
58
+ /** Map a trend direction to semantic status — positive=success, negative=error, zero=neutral */
59
+ export function trendToSemantic(value: number): SemanticStatus {
60
+ if (value > 0)
61
+ return 'success'
62
+ if (value < 0)
63
+ return 'error'
64
+ return 'neutral'
65
+ }
66
+
67
+ /** Shorthand: get the full color set for a health status */
68
+ export function healthColors(health: HealthStatus | null): SemanticColorSet {
69
+ return semanticColors[healthToSemantic(health)]
70
+ }
71
+
72
+ /** Shorthand: get the full color set for a threshold value */
73
+ export function thresholdColors(value: number, good: number, poor: number): SemanticColorSet {
74
+ return semanticColors[thresholdToSemantic(value, good, poor)]
75
+ }
76
+
77
+ /** Shorthand: get hex color for a threshold value (for chart rendering) */
78
+ export function thresholdHex(value: number, good: number, poor: number): string {
79
+ return thresholdColors(value, good, poor).hex
80
+ }
@@ -0,0 +1,55 @@
1
+ import { useState } from 'nuxt/app'
2
+
3
+ /**
4
+ * useActivityOverlay — the layer-agnostic trigger for the activity detail
5
+ * overlay (2026-08). Every "Recent Activity" row, on every surface, opens the
6
+ * SAME detail surface through this channel.
7
+ *
8
+ * The defect it exists to kill: an activity row knows exactly which entity it
9
+ * is about (a referring domain, a mention URL, a scanned page), and its
10
+ * destination threw that identity away and dropped the reader on a list. A row
11
+ * about `t128n.dev` landed on the whole backlink profile; a mention row had no
12
+ * destination at all. One overlay, one contract, so the class dies rather than
13
+ * one instance of it.
14
+ *
15
+ * Why it lives in design-system, not in a pro-* layer: ADR-0042 forbids feature
16
+ * layers importing each other's runtime, and the writers span pro-timeline,
17
+ * pro-dataforseo and `apps/pro`. So the trigger is a generic `useState` channel
18
+ * here (which every layer may import) and `apps/pro` owns the host
19
+ * (`ProActivityOverlayHost`) that reads this state and renders the real change
20
+ * brief. Same seam shape as `useIssueOverlay`; the entry travels opaquely
21
+ * (`unknown`) so design-system never learns the ChangeEntry shape.
22
+ *
23
+ * A row must only write here when it holds real evidence. An empty overlay is
24
+ * worse than a row that says nothing.
25
+ */
26
+
27
+ export interface ActivityOverlayRequest {
28
+ /** The activity entry (opaque here; the host casts it to `ChangeEntry`). */
29
+ entry: unknown
30
+ /** The site the activity belongs to — the brief is site-scoped. */
31
+ siteId: string
32
+ /**
33
+ * The owning site record, when the caller already holds one. Opaque for the
34
+ * same reason `entry` is. Omitted callers fall back to the session lookup.
35
+ */
36
+ site?: unknown
37
+ /** When the owning site was last assessed — the brief's "as of" anchor. */
38
+ assessedAt?: string | null
39
+ /** Bumped per open so the host re-triggers even for the same entry. */
40
+ nonce: number
41
+ }
42
+
43
+ /** The shared overlay channel — read by the host, written by `open`. */
44
+ export function useActivityOverlayState() {
45
+ return useState<ActivityOverlayRequest | null>('pro:activity-overlay', () => null)
46
+ }
47
+
48
+ export function useActivityOverlay() {
49
+ const state = useActivityOverlayState()
50
+ return {
51
+ open(req: { entry: unknown, siteId: string, site?: unknown, assessedAt?: string | null }) {
52
+ state.value = { ...req, nonce: (state.value?.nonce ?? 0) + 1 }
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,21 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { inject, provide } from 'vue'
3
+
4
+ // Shell drawer control shared down the tree. UiAppShell provides it; a
5
+ // descendant page header (saas `ProPageHeader`) injects it to render the
6
+ // hamburger inline with the page title below `lg` (shell `inlineMobileNav`).
7
+ // Null outside a shell: consumers hide their hamburger when it is absent,
8
+ // so a header rendered standalone keeps working.
9
+ export interface UiAppShellNav {
10
+ openNav: () => void
11
+ }
12
+
13
+ const key: InjectionKey<UiAppShellNav> = Symbol('ui-app-shell-nav')
14
+
15
+ export function provideUiAppShellNav(nav: UiAppShellNav) {
16
+ provide(key, nav)
17
+ }
18
+
19
+ export function useUiAppShellNav(): UiAppShellNav | null {
20
+ return inject(key, null)
21
+ }
@@ -0,0 +1,221 @@
1
+ import type { MaybeRefOrGetter, Ref } from 'vue'
2
+ import { useResizeObserver } from '@vueuse/core'
3
+ import { computed, onUnmounted, ref, toValue } from 'vue'
4
+
5
+ // Provider-agnostic drag-to-select-range interaction for time-series charts.
6
+ // Extracted from ProCardGsc's pointer-drag logic (the brush half — hover/tooltip
7
+ // springs stay with the consuming card) so the Web Analytics chart reuses the
8
+ // same scrubbing UX without copying ~120 lines. The consumer binds `wrapRef` to
9
+ // the chart wrapper, draws the selection rect from `selectionLeft`/`selectionWidth`,
10
+ // and gets a committed `{ startIdx, endIdx, startDate, endDate }` via `onCommit`.
11
+
12
+ export interface ChartBrushRange {
13
+ startIdx: number
14
+ endIdx: number
15
+ startDate: string
16
+ endDate: string
17
+ }
18
+
19
+ export interface UseChartBrushOptions {
20
+ /** Reactive ISO date strings, one per data point — drives idx ↔ date lookups. */
21
+ dates: MaybeRefOrGetter<string[]>
22
+ /** Called when a drag completes spanning more than one point. */
23
+ onCommit: (range: ChartBrushRange) => void
24
+ }
25
+
26
+ export interface UseChartBrush {
27
+ wrapRef: Ref<HTMLElement | null>
28
+ chartWidth: Ref<number>
29
+ isDragging: Ref<boolean>
30
+ dragRange: Ref<ChartBrushRange | null>
31
+ selectionLeft: Ref<number>
32
+ selectionWidth: Ref<number>
33
+ onPointerDown: (e: PointerEvent) => void
34
+ refreshChartRect: () => void
35
+ cancel: () => void
36
+ }
37
+
38
+ const DRAG_THRESHOLD_PX = 5
39
+
40
+ // Suppress page text-selection for the duration of a brush drag — a fast drag
41
+ // otherwise selects surrounding copy (the "disable text selection during drag"
42
+ // rule). Restored when the drag ends/cancels.
43
+ function setSelectSuppressed(on: boolean) {
44
+ if (typeof document === 'undefined')
45
+ return
46
+ const s = document.documentElement.style
47
+ s.setProperty('user-select', on ? 'none' : '')
48
+ s.setProperty('-webkit-user-select', on ? 'none' : '')
49
+ }
50
+
51
+ export function useChartBrush(opts: UseChartBrushOptions): UseChartBrush {
52
+ const wrapRef = ref<HTMLElement | null>(null)
53
+ // Cached geometry — avoids synchronous layout reads on every pointermove.
54
+ const chartWidth = ref(0)
55
+ let chartRectLeft = 0
56
+
57
+ const isDragging = ref(false)
58
+ const dragRange = ref<ChartBrushRange | null>(null)
59
+
60
+ useResizeObserver(wrapRef, ([entry]) => {
61
+ if (!entry)
62
+ return
63
+ // Width comes straight off contentRect (no layout read). `chartRectLeft` is
64
+ // refreshed via getBoundingClientRect() on pointerdown (refreshChartRect),
65
+ // so reading it here too would just be a redundant synchronous reflow.
66
+ chartWidth.value = entry.contentRect.width
67
+ })
68
+
69
+ function refreshChartRect() {
70
+ const el = wrapRef.value
71
+ if (!el)
72
+ return
73
+ const rect = el.getBoundingClientRect()
74
+ chartRectLeft = rect.left
75
+ chartWidth.value = rect.width
76
+ }
77
+
78
+ function pixelToIdx(px: number): number {
79
+ const len = toValue(opts.dates).length
80
+ const width = chartWidth.value
81
+ if (len < 2 || width <= 0)
82
+ return 0
83
+ return Math.max(0, Math.min(len - 1, Math.round((px / width) * (len - 1))))
84
+ }
85
+
86
+ function idxToPixel(idx: number): number {
87
+ const len = toValue(opts.dates).length
88
+ const width = chartWidth.value
89
+ if (len < 2 || width <= 0)
90
+ return 0
91
+ return (idx / (len - 1)) * width
92
+ }
93
+
94
+ function idxToDate(idx: number): string {
95
+ const rows = toValue(opts.dates)
96
+ const clamped = Math.max(0, Math.min(rows.length - 1, idx))
97
+ return rows[clamped] ?? ''
98
+ }
99
+
100
+ const selectionLeft = computed(() => dragRange.value ? idxToPixel(dragRange.value.startIdx) : 0)
101
+ const selectionWidth = computed(() => {
102
+ if (!dragRange.value)
103
+ return 0
104
+ return Math.max(0, idxToPixel(dragRange.value.endIdx) - idxToPixel(dragRange.value.startIdx))
105
+ })
106
+
107
+ let dragStartX = 0
108
+ let dragStartIdx = 0
109
+ let removeWindowListeners: (() => void) | null = null
110
+
111
+ function cancel() {
112
+ removeWindowListeners?.()
113
+ removeWindowListeners = null
114
+ isDragging.value = false
115
+ dragRange.value = null
116
+ }
117
+
118
+ function onPointerDown(e: PointerEvent) {
119
+ if (e.button !== 0)
120
+ return
121
+ if (!wrapRef.value)
122
+ return
123
+ // A second touch or pointer must replace the active session. Otherwise its
124
+ // cleanup closure overwrites the first one and leaves global listeners alive.
125
+ cancel()
126
+ // Single BCR read on drag start — covers scroll/offset drift since last observer tick.
127
+ refreshChartRect()
128
+ dragStartX = e.clientX - chartRectLeft
129
+ dragStartIdx = pixelToIdx(dragStartX)
130
+ const startY = e.clientY
131
+ const pointerId = e.pointerId
132
+
133
+ // rAF-coalesce moves: a fast drag fires pointermove many times per frame,
134
+ // but we only need one reactive `dragRange` write (and one overlay render)
135
+ // per frame. Listener is passive — we never preventDefault on move.
136
+ let moveRaf: number | null = null
137
+ let pendingX = 0
138
+ const onMove = (ev: PointerEvent) => {
139
+ if (ev.pointerId !== pointerId)
140
+ return
141
+ pendingX = ev.clientX - chartRectLeft
142
+ // Vertical touch intent belongs to page scrolling, even with horizontal drift.
143
+ if (e.pointerType === 'touch' && !isDragging.value && Math.abs(ev.clientY - startY) >= DRAG_THRESHOLD_PX
144
+ && Math.abs(ev.clientY - startY) >= Math.abs(pendingX - dragStartX)) {
145
+ cancel()
146
+ return
147
+ }
148
+ if (moveRaf != null)
149
+ return
150
+ moveRaf = requestAnimationFrame(() => {
151
+ moveRaf = null
152
+ const currentX = pendingX
153
+ if (!isDragging.value) {
154
+ if (Math.abs(currentX - dragStartX) < DRAG_THRESHOLD_PX)
155
+ return
156
+ isDragging.value = true
157
+ setSelectSuppressed(true)
158
+ }
159
+ dragRange.value = rangeTo(currentX)
160
+ })
161
+ }
162
+
163
+ function rangeTo(x: number): ChartBrushRange {
164
+ const currentIdx = pixelToIdx(x)
165
+ const startIdx = Math.min(dragStartIdx, currentIdx)
166
+ const endIdx = Math.max(dragStartIdx, currentIdx)
167
+ return { startIdx, endIdx, startDate: idxToDate(startIdx), endDate: idxToDate(endIdx) }
168
+ }
169
+
170
+ const onUp = (ev: PointerEvent) => {
171
+ if (ev.pointerId !== pointerId)
172
+ return
173
+ removeWindowListeners?.()
174
+ removeWindowListeners = null
175
+ // Read the range from the release point. The rAF that writes
176
+ // `dragRange` is cancelled above, so a flick that ends inside one frame
177
+ // had no range and never committed.
178
+ const upX = ev.clientX - chartRectLeft
179
+ const range = Math.abs(upX - dragStartX) >= DRAG_THRESHOLD_PX ? rangeTo(upX) : null
180
+ isDragging.value = false
181
+ dragRange.value = null
182
+ if (range && range.endIdx > range.startIdx)
183
+ opts.onCommit(range)
184
+ }
185
+
186
+ const onCancel = (ev: PointerEvent) => {
187
+ if (ev.pointerId === pointerId)
188
+ cancel()
189
+ }
190
+
191
+ window.addEventListener('pointermove', onMove, { passive: true })
192
+ window.addEventListener('pointerup', onUp)
193
+ window.addEventListener('pointercancel', onCancel)
194
+ window.addEventListener('wheel', cancel, { passive: true })
195
+ window.addEventListener('scroll', cancel, { passive: true, capture: true })
196
+ removeWindowListeners = () => {
197
+ if (moveRaf != null)
198
+ cancelAnimationFrame(moveRaf)
199
+ setSelectSuppressed(false)
200
+ window.removeEventListener('pointermove', onMove)
201
+ window.removeEventListener('pointerup', onUp)
202
+ window.removeEventListener('pointercancel', onCancel)
203
+ window.removeEventListener('wheel', cancel)
204
+ window.removeEventListener('scroll', cancel, { capture: true })
205
+ }
206
+ }
207
+
208
+ onUnmounted(() => removeWindowListeners?.())
209
+
210
+ return {
211
+ wrapRef,
212
+ chartWidth,
213
+ isDragging,
214
+ dragRange,
215
+ selectionLeft,
216
+ selectionWidth,
217
+ onPointerDown,
218
+ refreshChartRect,
219
+ cancel,
220
+ }
221
+ }
@@ -0,0 +1,153 @@
1
+ import type { MotionValue } from 'motion-v'
2
+ import type { Ref } from 'vue'
3
+ import { useMotionValue, useSpring } from 'motion-v'
4
+ import { onScopeDispose, ref, shallowRef } from 'vue'
5
+
6
+ // Hover/tooltip half of a time-series chart's pointer UX. Sibling to
7
+ // useChartBrush — both cards (Gsc, Analytics) had near-identical tooltip
8
+ // state + spring-tracked card positioning. Pass in the brush's `isDragging`
9
+ // to suppress the tooltip during a drag (the selection overlay owns the
10
+ // visual then).
11
+ //
12
+ // Positioning model: the tooltip anchors to the *cursor* (not the chart edge)
13
+ // via spring-tracked `cardX` + `cursorY`. The `placement` ref says whether
14
+ // the tooltip sits above or below the cursor; consumers translate the
15
+ // rendered element accordingly. Above-cursor is the default; flips to below
16
+ // when the cursor is too close to the chart's top edge to fit the tooltip.
17
+
18
+ export interface UseChartHoverOptions {
19
+ /** Chart wrapper element — used for BCR reads. */
20
+ wrapRef: Ref<HTMLElement | null>
21
+ /** Reactive chart width in px — usually from useChartBrush. */
22
+ chartWidth: Ref<number>
23
+ /** When true, onTooltip clears state instead of setting it. */
24
+ isDragging?: Ref<boolean>
25
+ /** Tooltip card width in px (used to keep the card on-screen). */
26
+ tooltipWidth?: number
27
+ /** Approximate tooltip height — used to decide when to flip above ↔ below. */
28
+ tooltipHeightEst?: number
29
+ /** Padding from the chart edges in px. */
30
+ edgePad?: number
31
+ /** Vertical gap between cursor and tooltip edge. */
32
+ cursorGap?: number
33
+ /** Spring config for the floating card (softer — glides). */
34
+ cardSpring?: { stiffness: number, damping: number, mass: number }
35
+ }
36
+
37
+ export interface UseChartHover<T, E = void> {
38
+ tooltipData: Ref<T | null>
39
+ tooltipPrev: Ref<T | null>
40
+ tooltipExtra: Ref<E | null>
41
+ /** Spring-tracked cursor x (centre of tooltip). */
42
+ cardSpring: MotionValue<number>
43
+ /** Spring-tracked cursor y (anchor point of tooltip). */
44
+ cursorYSpring: MotionValue<number>
45
+ /** Which side of the cursor the tooltip sits on. Reactive. */
46
+ placement: Ref<'above' | 'below'>
47
+ onTooltip: (data: T | null, prev: T | null, extra?: E) => void
48
+ onChartMove: (e: MouseEvent) => void
49
+ clear: () => void
50
+ }
51
+
52
+ const DEFAULT_SPRING = { stiffness: 380, damping: 38, mass: 0.5 }
53
+
54
+ export function useChartHover<T, E = void>(opts: UseChartHoverOptions): UseChartHover<T, E> {
55
+ const tooltipWidth = opts.tooltipWidth ?? 220
56
+ const tooltipHeightEst = opts.tooltipHeightEst ?? 56
57
+ const edgePad = opts.edgePad ?? 8
58
+ const cursorGap = opts.cursorGap ?? 14
59
+ // Approx axis row height; tooltip avoids sitting on top of axis tick labels.
60
+ const AXIS_ROW = 36
61
+
62
+ // shallowRef: these hold whole datum objects swapped wholesale on hover —
63
+ // deep reactivity would be wasted tracking on every pointer move.
64
+ const tooltipData = shallowRef<T | null>(null)
65
+ const tooltipPrev = shallowRef<T | null>(null)
66
+ const tooltipExtra = shallowRef<E | null>(null)
67
+
68
+ function clear() {
69
+ tooltipData.value = null
70
+ tooltipPrev.value = null
71
+ tooltipExtra.value = null
72
+ }
73
+
74
+ function onTooltip(data: T | null, prev: T | null, extra?: E) {
75
+ if (opts.isDragging?.value) {
76
+ clear()
77
+ return
78
+ }
79
+ tooltipData.value = data
80
+ tooltipPrev.value = prev
81
+ tooltipExtra.value = (extra ?? null) as E | null
82
+ }
83
+
84
+ const cardX = useMotionValue(0)
85
+ const cursorY = useMotionValue(0)
86
+ // Honour prefers-reduced-motion: skip the glide spring and track the cursor
87
+ // position directly so the tooltip jumps instead of easing.
88
+ const prefersReduced = import.meta.client
89
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
90
+ const cardSpring = prefersReduced ? cardX : useSpring(cardX, opts.cardSpring ?? DEFAULT_SPRING)
91
+ const cursorYSpring = prefersReduced ? cursorY : useSpring(cursorY, opts.cardSpring ?? DEFAULT_SPRING)
92
+ // Default 'above' (tooltip floats above cursor). Flips to 'below' when the
93
+ // cursor is too close to the top edge to fit the tooltip without clipping.
94
+ const placement = ref<'above' | 'below'>('above')
95
+
96
+ // rAF-batch the mousemove handler so we do at most one motion update per frame.
97
+ let rafId: number | null = null
98
+ let pendingClientX = 0
99
+ let pendingClientY = 0
100
+ function onChartMove(e: MouseEvent) {
101
+ pendingClientX = e.clientX
102
+ pendingClientY = e.clientY
103
+ if (rafId != null)
104
+ return
105
+ rafId = requestAnimationFrame(() => {
106
+ rafId = null
107
+ const width = opts.chartWidth.value
108
+ if (width <= 0)
109
+ return
110
+ const rect = opts.wrapRef.value?.getBoundingClientRect()
111
+ const cursorXpx = pendingClientX - (rect?.left ?? 0)
112
+ const cursorYpx = pendingClientY - (rect?.top ?? 0)
113
+ // Clamp x so the tooltip's left/right edges stay within the chart.
114
+ const halfW = tooltipWidth / 2
115
+ const clampedX = Math.max(halfW + edgePad, Math.min(width - halfW - edgePad, cursorXpx))
116
+ cardX.set(clampedX)
117
+ cursorY.set(cursorYpx)
118
+
119
+ // Decide placement. Prefer above (tooltip floats over the data). Flip
120
+ // to below when too close to the top edge to fit the full tooltip.
121
+ // Also flip back to above when cursor would push the tooltip into the
122
+ // axis-tick row at the bottom.
123
+ const height = rect?.height ?? 0
124
+ const fitsAbove = cursorYpx - cursorGap - tooltipHeightEst >= edgePad
125
+ const fitsBelow = cursorYpx + cursorGap + tooltipHeightEst <= height - AXIS_ROW
126
+ if (placement.value === 'above' && !fitsAbove && fitsBelow)
127
+ placement.value = 'below'
128
+ else if (placement.value === 'below' && fitsAbove && !fitsBelow)
129
+ placement.value = 'above'
130
+ else if (placement.value === 'below' && fitsAbove)
131
+ placement.value = 'above'
132
+ })
133
+ }
134
+
135
+ // Cancel any frame still queued when the consuming component tears down, so
136
+ // the callback never reads a stale `wrapRef` element after unmount.
137
+ onScopeDispose(() => {
138
+ if (rafId != null)
139
+ cancelAnimationFrame(rafId)
140
+ })
141
+
142
+ return {
143
+ tooltipData,
144
+ tooltipPrev,
145
+ tooltipExtra,
146
+ cardSpring,
147
+ cursorYSpring,
148
+ placement,
149
+ onTooltip,
150
+ onChartMove,
151
+ clear,
152
+ }
153
+ }