@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,81 @@
1
+ <script setup lang="ts">
2
+ import type { VNodeChild } from 'vue'
3
+ import { nextTick, ref, useId } from 'vue'
4
+
5
+ /**
6
+ * UiChartDataReveal
7
+ *
8
+ * The keyboard path to a chart's values. Chart readouts (crosshair, point and
9
+ * band tooltips) open on pointer hover only, and each chart already carries its
10
+ * data as a screen-reader table. This wraps that table: it stays visually
11
+ * hidden, and a "Show data table" button appears on keyboard focus and lays the
12
+ * same table over the chart. Escape or the button hides it again. Pointer users
13
+ * see no change.
14
+ *
15
+ * The host chart root must be `position: relative`; the open table covers it.
16
+ */
17
+
18
+ defineSlots<{ default?: () => VNodeChild }>()
19
+
20
+ const shown = ref(false)
21
+ const panelId = useId()
22
+ const toggle = ref<HTMLButtonElement>()
23
+
24
+ async function hide(): Promise<void> {
25
+ shown.value = false
26
+ await nextTick()
27
+ toggle.value?.focus()
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <button
33
+ ref="toggle"
34
+ type="button"
35
+ data-ui="UiChartDataReveal"
36
+ class="ui-chart-data-reveal__toggle rounded-md bg-default px-2 py-1 focus-visible:px-2 focus-visible:py-1 text-xs font-medium text-default ring-1 ring-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
37
+ :class="shown ? 'absolute top-1 right-1 z-30' : 'sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:top-1 focus-visible:right-1 focus-visible:z-30'"
38
+ :aria-expanded="shown"
39
+ :aria-controls="panelId"
40
+ @click="shown ? hide() : shown = true"
41
+ @keydown.esc="shown && hide()"
42
+ >
43
+ {{ shown ? 'Hide data table' : 'Show data table' }}
44
+ </button>
45
+ <div
46
+ :id="panelId"
47
+ :class="shown ? 'ui-chart-data-reveal__panel absolute inset-0 z-20 overflow-auto rounded-md bg-default p-2 pt-9 ring-1 ring-default' : 'sr-only'"
48
+ @keydown.esc.stop="shown && hide()"
49
+ >
50
+ <slot />
51
+ </div>
52
+ </template>
53
+
54
+ <style scoped>
55
+ .ui-chart-data-reveal__panel :deep(table) {
56
+ width: 100%;
57
+ border-collapse: collapse;
58
+ font-size: 0.75rem;
59
+ font-variant-numeric: tabular-nums;
60
+ }
61
+
62
+ .ui-chart-data-reveal__panel :deep(caption) {
63
+ padding-bottom: 0.25rem;
64
+ text-align: left;
65
+ font-weight: 600;
66
+ color: var(--ui-text-highlighted);
67
+ }
68
+
69
+ .ui-chart-data-reveal__panel :deep(th),
70
+ .ui-chart-data-reveal__panel :deep(td) {
71
+ padding: 0.25rem 0.5rem;
72
+ border-bottom: 1px solid var(--ui-border);
73
+ text-align: left;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .ui-chart-data-reveal__panel :deep(thead th) {
78
+ color: var(--ui-text-muted);
79
+ font-weight: 500;
80
+ }
81
+ </style>
@@ -0,0 +1,123 @@
1
+ <script lang="ts" setup>
2
+ import type { MotionValue } from 'motion-v'
3
+ import type { ChartBrushRange } from '../../composables/useChartBrush'
4
+ import type { ChartAnnotation, ChartAnnotationOptions } from '../../utils/chartAnnotations'
5
+ import { computed, ref } from 'vue'
6
+ import { ClientOnly, UiChartAnnotations, UiChartBrushOverlay, UiChartTooltip } from '#components'
7
+ import { ANNOTATION_TONE_CLASS, annotationsOnDay } from '../../utils/chartAnnotations'
8
+
9
+ // Bundles the chart's hover tooltip + brush selection overlay into one tag, and
10
+ // (opt-in) the annotation marker overlay + same-day annotations inside the
11
+ // tooltip. Card owns the wrap div + ref binding (the brush composable's `wrapRef`
12
+ // doesn't survive being passed through a child); this just consolidates the
13
+ // overlay calls + their prop wiring.
14
+ //
15
+ // Tooltip anchors to the cursor (spring-tracked) — see UiChartTooltip. Markers
16
+ // render via UiChartAnnotations (shared with non-frame charts); both require the
17
+ // host wrap to be `position: relative` with zero horizontal margin (wrap width
18
+ // == plot x-span) so the CSS left-percentage maps to the right screen position.
19
+
20
+ const props = defineProps<{
21
+ // --- brush state (useChartBrush) ---
22
+ dragRange: ChartBrushRange | null
23
+ selectionLeft: number
24
+ selectionWidth: number
25
+ // --- hover state (useChartHover) ---
26
+ tooltipVisible: boolean
27
+ cardSpring: MotionValue<number>
28
+ cursorYSpring: MotionValue<number>
29
+ placement?: 'above' | 'below'
30
+ /** Label shown on the leading row of the tooltip (typically the hovered date). */
31
+ hoverLabel: string
32
+ /**
33
+ * Optional event annotations rendered as thin vertical marker lines + a dot at
34
+ * the bottom of the plot area. Default [] — no change to existing charts (opt-in).
35
+ * Requires `xDomain` to resolve pixel positions. When `xDomain` is absent,
36
+ * annotations are silently skipped.
37
+ */
38
+ annotations?: ChartAnnotation[]
39
+ /** Marker presentation shared by single and stacked annotation popovers. */
40
+ annotationOptions?: ChartAnnotationOptions
41
+ /**
42
+ * The visible x range [min, max] of the chart. Pass Date objects, ISO strings,
43
+ * or epoch-ms numbers. Used to convert each annotation's `x` value to a CSS
44
+ * left-percentage. Required when `annotations` are provided.
45
+ */
46
+ xDomain?: [Date | string | number, Date | string | number]
47
+ /**
48
+ * The currently-hovered x value (the day the crosshair is on). When it lands on
49
+ * a day that carries annotations, those annotations are surfaced inside the
50
+ * hover tooltip (a tone dot + label) so the marker isn't a hover-only native
51
+ * title. Pass the hovered datum's date; omit to keep annotations marker-only.
52
+ */
53
+ hoverX?: Date | string | number | null
54
+ }>()
55
+
56
+ const emit = defineEmits<{
57
+ /** Forwarded from the marker overlay: a user-editable annotation was activated. */
58
+ annotationEdit: [annotation: ChartAnnotation]
59
+ }>()
60
+
61
+ const annotationInteracting = ref(false)
62
+
63
+ /** Annotations on the hovered day — surfaced inside the tooltip (dot + label). */
64
+ const tooltipAnnotations = computed(() =>
65
+ annotationsOnDay(props.annotations ?? [], props.hoverX).map((ann, id) => ({
66
+ id,
67
+ label: ann.label,
68
+ description: ann.description,
69
+ toneClass: ANNOTATION_TONE_CLASS[ann.tone ?? 'neutral'],
70
+ })),
71
+ )
72
+ </script>
73
+
74
+ <template>
75
+ <ClientOnly>
76
+ <UiChartTooltip
77
+ :x="cardSpring"
78
+ :y="cursorYSpring"
79
+ :visible="tooltipVisible && !annotationInteracting"
80
+ :placement="placement"
81
+ :label="hoverLabel"
82
+ >
83
+ <template v-if="$slots['tooltip-header']" #header>
84
+ <slot name="tooltip-header" />
85
+ </template>
86
+ <slot name="tooltip-rows" />
87
+ <!-- Annotations on the hovered day: a tone dot + label, separated from the
88
+ metric rows. Dot carries the semantic tone, label stays neutral text
89
+ (color-budget: colored dot + neutral text). -->
90
+ <div v-if="tooltipAnnotations.length" class="mt-1.5 pt-1.5 border-t border-default space-y-1">
91
+ <div v-for="ann in tooltipAnnotations" :key="ann.id" class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-1.5">
92
+ <span :class="ann.toneClass" class="size-1.5 rounded-full shrink-0 mt-1" aria-hidden="true" />
93
+ <div class="min-w-0">
94
+ <p class="text-sm font-medium text-default truncate">
95
+ {{ ann.label }}
96
+ </p>
97
+ <p v-if="ann.description" class="text-sm text-muted mt-0.5">
98
+ {{ ann.description }}
99
+ </p>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ <template v-if="$slots['tooltip-footer']" #footer>
104
+ <slot name="tooltip-footer" />
105
+ </template>
106
+ </UiChartTooltip>
107
+
108
+ <UiChartBrushOverlay
109
+ :drag-range="dragRange"
110
+ :selection-left="selectionLeft"
111
+ :selection-width="selectionWidth"
112
+ />
113
+
114
+ <!-- Annotation markers (thin vertical lines + dots) — shared overlay. -->
115
+ <UiChartAnnotations
116
+ :annotations="annotations"
117
+ :x-domain="xDomain"
118
+ :options="annotationOptions"
119
+ @edit="emit('annotationEdit', $event)"
120
+ @interaction-change="annotationInteracting = $event"
121
+ />
122
+ </ClientOnly>
123
+ </template>
@@ -0,0 +1,81 @@
1
+ <script lang="ts" setup>
2
+ import type { MotionValue } from 'motion-v'
3
+ import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
4
+ import { computed } from 'vue'
5
+ // Single floating readout for time-series chart hover. Anchored to the cursor
6
+ // position (spring-tracked x + y), sitting either above or below depending on
7
+ // available room. Uses the shared overlay chrome (`.ui-popover-content` +
8
+ // `bg-default`) so it reads as a solid raised card in both light/dark — the
9
+ // metric rows consumers pass in use the standard text tokens (text-muted /
10
+ // text-default / text-dimmed), which only stay legible on a non-inverted
11
+ // surface. No companion crosshair pill, no chart-edge pinning.
12
+ //
13
+ // Position decomposition:
14
+ // - Outer motion.div: spring-tracked translate to cursor (x, y) in chart
15
+ // coordinates. Acts as the anchor point.
16
+ // - Inner div: applies `-translate-x-1/2` (centre horizontally on cursor)
17
+ // plus `-translate-y-full -mt-3` (anchor bottom edge above cursor) or
18
+ // `mt-3` (anchor top edge below cursor) based on `placement`.
19
+ //
20
+ // Slots:
21
+ // #header — optional badge row (e.g. GSC's "Estimated")
22
+ // default — metric rows (each card decides layout)
23
+ // #footer — optional comparison-date strip
24
+
25
+ defineProps<{
26
+ x: MotionValue<number>
27
+ y: MotionValue<number>
28
+ visible: boolean
29
+ /** Date label always rendered on the leading row. */
30
+ label: string
31
+ /** Which side of the cursor the tooltip sits on. */
32
+ placement?: 'above' | 'below'
33
+ }>()
34
+
35
+ // Reduced motion: drop the scale pop + placement-translate easing; the tooltip
36
+ // fades only (sibling chart overlays gate the same way).
37
+ const reduced = useReducedMotion()
38
+ const enter = computed(() => (reduced.value ? { opacity: 1 } : { opacity: 1, scale: 1 }))
39
+ const initial = computed(() => (reduced.value ? { opacity: 0 } : { opacity: 0, scale: 0.96 }))
40
+ </script>
41
+
42
+ <template>
43
+ <AnimatePresence>
44
+ <motion.div
45
+ v-if="visible"
46
+ key="chart-tooltip"
47
+ :initial="initial"
48
+ :animate="enter"
49
+ :exit="initial"
50
+ :transition="{ duration: 0.12, ease: [0.22, 1, 0.36, 1] }"
51
+ :style="{ x, y }"
52
+ class="absolute top-0 left-0 z-10 pointer-events-none"
53
+ >
54
+ <div
55
+ class="ui-popover-content min-w-[120px] max-w-[280px] rounded-md bg-default text-default ring ring-default -translate-x-1/2 transition-transform duration-150 ease-out motion-reduce:transition-none" :class="[
56
+ (placement ?? 'above') === 'above' ? '-translate-y-full -mt-3' : 'mt-3',
57
+ ]"
58
+ >
59
+ <div
60
+ v-if="$slots.header"
61
+ class="px-2 pt-1.5 pb-1 border-b border-muted flex items-center gap-2"
62
+ >
63
+ <slot name="header" />
64
+ </div>
65
+ <div class="px-2 py-1 flex items-center gap-2 whitespace-nowrap">
66
+ <span class="text-mini font-medium tabular-nums text-muted">{{ label }}</span>
67
+ <div v-if="$slots.default" class="h-3 w-px bg-[var(--ui-border)]" />
68
+ <div class="flex-1 flex flex-col gap-px text-mini">
69
+ <slot />
70
+ </div>
71
+ </div>
72
+ <div
73
+ v-if="$slots.footer"
74
+ class="px-2 pt-1 pb-1.5 border-t border-muted flex items-center justify-between gap-2 text-mini"
75
+ >
76
+ <slot name="footer" />
77
+ </div>
78
+ </div>
79
+ </motion.div>
80
+ </AnimatePresence>
81
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { NuxtLink, UiIcon } from '#components'
4
+ /**
5
+ * UiCoverageNote — the quiet "N of M known · View all" evidence-coverage line
6
+ * that sits under a typed evidence browser. Mirrors pro-actions's
7
+ * `collectionCoverageSchema` (`layers/pro/actions/shared/contracts/action-evidence.ts`)
8
+ * structurally, per the `UiActionTitle`/`ActionTitleSegment` "keep in sync"
9
+ * precedent (ADR-0042: design-system MAY know the shape, never the runtime
10
+ * that builds it — importing the pro-layer's zod schema here would invert the
11
+ * layer graph).
12
+ *
13
+ * Color budget: this is a caption, not a status. It never tints its value —
14
+ * only a single fixed neutral dot (the `UiFactsGrid` status-dot rhythm,
15
+ * without the semantic color) marks it as a coverage caption at a glance.
16
+ */
17
+
18
+ /** Keep in sync with pro-actions's `CollectionCoverage` (`action-evidence.ts`). */
19
+ export type CollectionCoverage
20
+ = | { _tag: 'complete', knownTotal: number }
21
+ | { _tag: 'partial', reportedTotal: number, knownTotal: number, limitation: string }
22
+ | { _tag: 'count-only', reportedTotal: number, limitation: string }
23
+ | { _tag: 'unknown', knownTotal: number, limitation: string }
24
+ | { _tag: 'unavailable', limitation: string }
25
+
26
+ const { coverage, to } = defineProps<{
27
+ coverage: CollectionCoverage
28
+ /** Deep link for the "View all" tail. Canonical copy — never re-worded per call site. */
29
+ to?: string
30
+ }>()
31
+
32
+ // Split copy into a leading numeral (numerals-display) + trailing words, so
33
+ // "7 of 9 known" reads with figures set apart from prose the same way every
34
+ // other numeric fact does. `complete`/`unavailable` have no leading count
35
+ // worth isolating (either it's redundant with the check glyph, or there's
36
+ // nothing counted yet) and render as plain text.
37
+ const parts = computed((): { lead?: string, rest: string } => {
38
+ switch (coverage._tag) {
39
+ case 'complete':
40
+ return { rest: `All ${coverage.knownTotal} known` }
41
+ case 'partial':
42
+ return { lead: `${coverage.knownTotal} of ${coverage.reportedTotal}`, rest: 'known' }
43
+ case 'count-only':
44
+ return { lead: `${coverage.reportedTotal}`, rest: 'total, not yet itemized' }
45
+ case 'unknown':
46
+ return { lead: `${coverage.knownTotal}`, rest: 'known, total not yet counted' }
47
+ case 'unavailable':
48
+ return { rest: 'Evidence still compiling' }
49
+ default:
50
+ return { rest: '' }
51
+ }
52
+ })
53
+
54
+ // `partial` is the one state where evidence is actively still growing — the
55
+ // dot earns a single settle-once pulse to say "still counting" without
56
+ // nagging on every re-render. Every other tag is a steady-state read.
57
+ const isGrowing = computed(() => coverage._tag === 'partial')
58
+ const isComplete = computed(() => coverage._tag === 'complete')
59
+ </script>
60
+
61
+ <template>
62
+ <p class="flex items-center gap-1.5 text-xs text-muted" data-ui="UiCoverageNote">
63
+ <UiIcon
64
+ v-if="isComplete"
65
+ name="check"
66
+ class="size-3 shrink-0 text-dimmed"
67
+ aria-hidden="true"
68
+ />
69
+ <span
70
+ v-else
71
+ class="size-1 shrink-0 rounded-full bg-[var(--ui-border)]"
72
+ :class="isGrowing && 'coverage-dot-pulse-once'"
73
+ aria-hidden="true"
74
+ />
75
+ <span>
76
+ <span v-if="parts.lead" class="numerals-display text-default">{{ parts.lead }}</span>
77
+ <template v-if="parts.lead">{{ ' ' }}</template>{{ parts.rest }}
78
+ </span>
79
+ <template v-if="to">
80
+ <span class="text-dimmed" aria-hidden="true"> · </span>
81
+ <component
82
+ :is="NuxtLink"
83
+ :to="to"
84
+ class="text-muted underline-offset-2 hover:text-default hover:underline"
85
+ >
86
+ View all
87
+ </component>
88
+ </template>
89
+ </p>
90
+ </template>
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { motion, stagger, useReducedMotion } from 'motion-v'
3
+ import { computed } from 'vue'
4
+ import { indexingVizColors } from '../../composables/dataVizColors'
5
+
6
+ // Mini, dependency-free echo of CwvMetricChart: faint good/ni/poor stacked bars
7
+ // across time, meant as an ambient background for a KPI tile (the Core Web
8
+ // Vitals card). It is decorative — the exact composition does not need to be
9
+ // precise — so when no per-period histogram is supplied it synthesises a
10
+ // plausible series from the overall `status`. Sibling to UiSparkline.
11
+
12
+ interface Band { good: number, ni: number, poor: number }
13
+
14
+ interface Props {
15
+ /** Per-period composition (good/ni/poor percentages, 0–100). When set, the svg is named with the latest period's shares; when absent, the synthesised bars are hidden from assistive tech. */
16
+ data?: Band[]
17
+ /** Drives the synthesised series + bar emphasis when `data` is absent. */
18
+ status?: 'pass' | 'ni' | 'fail' | null
19
+ width?: number | string
20
+ height?: number | string
21
+ /** Number of bars when synthesising. */
22
+ bars?: number
23
+ /** SVG preserveAspectRatio — "none" stretches to fill a container. */
24
+ preserveAspectRatio?: string
25
+ /** Base opacity for the bars. Kept moderate so the card value stays dominant
26
+ while the composition reads clearly. */
27
+ opacity?: number
28
+ /** Hover state, fed from a parent (e.g. the enclosing UiStat card). On hover
29
+ the bars rise to full height one by one. */
30
+ hovered?: boolean
31
+ }
32
+
33
+ const {
34
+ data,
35
+ status = null,
36
+ width = '100%',
37
+ height = '100%',
38
+ bars = 22,
39
+ preserveAspectRatio = 'none',
40
+ opacity = 0.6,
41
+ hovered = false,
42
+ } = defineProps<Props>()
43
+
44
+ // Respect reduced-motion: skip the staggered hover rise entirely.
45
+ const reduced = useReducedMotion()
46
+
47
+ // On hover the bars settle up to full height in sequence; at rest they sit a
48
+ // touch compressed. scaleY anchored at each bar's base keeps the good/ni/poor
49
+ // proportions intact while the whole column rises.
50
+ const container = {
51
+ rest: {},
52
+ hover: { transition: { delayChildren: stagger(0.05, { ease: 'easeInOut' }) } },
53
+ }
54
+ // A smooth spring with a single gentle overshoot — fluid wave up the bars
55
+ // rather than a jittery bounce.
56
+ const barVariants = {
57
+ rest: { scaleY: 0.72, opacity: 0.85 },
58
+ hover: {
59
+ scaleY: 1,
60
+ opacity: 1,
61
+ transition: { type: 'spring' as const, stiffness: 190, damping: 16, mass: 0.8 },
62
+ },
63
+ }
64
+
65
+ const vbW = 120
66
+ const vbH = 40
67
+
68
+ // Deterministic wobble (no Math.random — keeps SSR + reloads stable).
69
+ function wobble(i: number, amp: number) {
70
+ return (Math.sin(i * 1.3) * 0.5 + Math.sin(i * 0.5) * 0.3 + Math.cos(i * 2.7) * 0.2) * amp
71
+ }
72
+
73
+ const series = computed<Band[]>(() => {
74
+ if (data?.length)
75
+ return data
76
+ // Synthesise a plausible composition trend from the status.
77
+ const base: Band = status === 'pass'
78
+ ? { good: 78, ni: 16, poor: 6 }
79
+ : status === 'fail'
80
+ ? { good: 36, ni: 30, poor: 34 }
81
+ : { good: 56, ni: 31, poor: 13 } // ni / unknown
82
+ return Array.from({ length: bars }, (_, i) => {
83
+ const poor = Math.max(2, base.poor + wobble(i, 8))
84
+ const ni = Math.max(4, base.ni + wobble(i + 7, 6))
85
+ const good = Math.max(0, 100 - poor - ni)
86
+ return { good, ni, poor }
87
+ })
88
+ })
89
+
90
+ // The chart fills, not the raw semantic hexes: light mode steps success and
91
+ // warning down so the bars clear 3:1 on white (dataVizColors.ts).
92
+ function withAlpha(color: string, a: number): string {
93
+ return `color-mix(in srgb, ${color} ${Math.round(a * 10000) / 100}%, transparent)`
94
+ }
95
+
96
+ const colors = computed(() => ({
97
+ good: withAlpha(indexingVizColors.indexed.hex, opacity),
98
+ ni: withAlpha(indexingVizColors.notIndexed.hex, opacity),
99
+ poor: withAlpha(indexingVizColors.error.hex, opacity),
100
+ }))
101
+
102
+ // A synthesised series is decorative: it echoes `status`, not measured data,
103
+ // so it stays out of the accessibility tree. Measured data names the latest
104
+ // period's shares, so the composition does not rest on colour alone.
105
+ const pct = (n: number, total: number) => `${Math.round(n / total * 100)}%`
106
+ const ariaLabel = computed(() => {
107
+ const last = data?.at(-1)
108
+ if (!last)
109
+ return undefined
110
+ const total = last.good + last.ni + last.poor || 1
111
+ return `Core Web Vitals, latest period: ${pct(last.good, total)} good, ${pct(last.ni, total)} needs improvement, ${pct(last.poor, total)} poor`
112
+ })
113
+
114
+ interface Seg { y: number, h: number, fill: string }
115
+ interface Bar { x: number, w: number, segs: Seg[] }
116
+
117
+ const barColumns = computed<Bar[]>(() => {
118
+ const s = series.value
119
+ if (!s.length)
120
+ return []
121
+ const gap = 0.25
122
+ const slot = vbW / s.length
123
+ const w = Math.max(0.5, slot * (1 - gap))
124
+ return s.map((band, i) => {
125
+ const total = band.good + band.ni + band.poor || 1
126
+ const x = i * slot + (slot - w) / 2
127
+ // Stack from the bottom: good → ni → poor (poor sits on top, like CrUX).
128
+ let yTop = vbH
129
+ const stack: [number, string][] = [
130
+ [band.good / total, colors.value.good],
131
+ [band.ni / total, colors.value.ni],
132
+ [band.poor / total, colors.value.poor],
133
+ ]
134
+ const segs: Seg[] = stack.map(([frac, fill]) => {
135
+ const h = frac * vbH
136
+ yTop -= h
137
+ return { y: yTop, h, fill }
138
+ })
139
+ return { x, w, segs }
140
+ })
141
+ })
142
+ </script>
143
+
144
+ <template>
145
+ <svg
146
+ data-ui="UiCwvSparkline"
147
+ :width="width"
148
+ :height="height"
149
+ :viewBox="`0 0 ${vbW} ${vbH}`"
150
+ :preserveAspectRatio="preserveAspectRatio"
151
+ :role="ariaLabel ? 'img' : undefined"
152
+ :aria-label="ariaLabel"
153
+ :aria-hidden="ariaLabel ? undefined : 'true'"
154
+ >
155
+ <!-- tabindex -1: motion-v binds focus listeners for its gesture support,
156
+ and Chromium makes any SVG element with a focus listener a Tab stop.
157
+ These groups carry no name or action. -->
158
+ <motion.g
159
+ :variants="container"
160
+ initial="rest"
161
+ :animate="hovered && !reduced ? 'hover' : 'rest'"
162
+ tabindex="-1"
163
+ >
164
+ <motion.g
165
+ v-for="(bar, i) in barColumns"
166
+ :key="i"
167
+ :variants="barVariants"
168
+ :style="{ transformBox: 'fill-box', transformOrigin: 'bottom' }"
169
+ tabindex="-1"
170
+ >
171
+ <!-- The page-coloured edge splits good, needs improvement and poor,
172
+ so the bands read apart without relying on hue. -->
173
+ <rect
174
+ v-for="(seg, j) in bar.segs"
175
+ :key="j"
176
+ :x="bar.x"
177
+ :y="seg.y"
178
+ :width="bar.w"
179
+ :height="seg.h"
180
+ :fill="seg.fill"
181
+ stroke="var(--ui-bg)"
182
+ stroke-width="0.6"
183
+ vector-effect="non-scaling-stroke"
184
+ />
185
+ </motion.g>
186
+ </motion.g>
187
+ </svg>
188
+ </template>