@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,393 @@
1
+ <script lang="ts" setup>
2
+ import type { ChartAnnotation, ChartAnnotationOptions } from '../../utils/chartAnnotations'
3
+ import type { TopEntityStackBucket, TopEntityStackRow, TopEntityStackSeries } from '../../utils/topEntityStack'
4
+ import { StackedBar, TextAlign } from '@unovis/ts'
5
+ import { VisAxis, VisCrosshair, VisLine, VisStackedBar, VisTooltip, VisXYContainer } from '@unovis/vue'
6
+ import { useElementSize } from '@vueuse/core'
7
+ import { computed, useTemplateRef } from 'vue'
8
+ import { ClientOnly, UiChartAnnotations, UiChartDataReveal, UiEmptyState, UiSkeleton } from '#components'
9
+ import { gscTopEntityColors } from '../../composables/dataVizColors'
10
+ import { DISPLAY_LOCALE, formatGroupedNumber } from '../../composables/formatting'
11
+ import { useChartTickPlan } from '../../composables/useChartTickPlan'
12
+ import { anchorAnnotationsToBuckets, topEntityStackRows, topEntityStackTimeline } from '../../utils/topEntityStack'
13
+
14
+ // Shared "top N + Other" stacked-bar trend chart (manual-review-2026-08:
15
+ // search-console Queries / Pages / Countries). Dumb + presentational — the
16
+ // top-N-and-Other bucketing is `bucketTopEntities` (pure, unit-tested); this
17
+ // component only draws whatever `buckets`/`series` it's handed. One component,
18
+ // three call sites (queries, pages-on-queries-table-view, countries).
19
+
20
+ const {
21
+ buckets,
22
+ series,
23
+ loading = false,
24
+ height = 220,
25
+ scale = 'share',
26
+ legendSlots = 0,
27
+ format = formatGroupedNumber,
28
+ xFormat,
29
+ emptyTitle = 'Not enough data to chart',
30
+ emptyDescription = 'This trend fills in once there is more than one day of data.',
31
+ annotations,
32
+ annotationOptions,
33
+ } = defineProps<{
34
+ buckets: TopEntityStackBucket[]
35
+ series: TopEntityStackSeries[]
36
+ loading?: boolean
37
+ height?: number | string
38
+ /** `share` fills each bar to 100%. `absolute` draws real levels, so growth stays visible. */
39
+ scale?: 'share' | 'absolute'
40
+ /** Reserve at least this many single-line legend cells, including while loading. */
41
+ legendSlots?: number
42
+ /** Format a metric value for the legend + tooltip. */
43
+ format?: (value: number) => string
44
+ /** Format bucket labels for details. The axis uses the shared calendar tick planner. */
45
+ xFormat?: (bucket: TopEntityStackBucket) => string
46
+ emptyTitle?: string
47
+ emptyDescription?: string
48
+ annotations?: ChartAnnotation[]
49
+ annotationOptions?: ChartAnnotationOptions
50
+ }>()
51
+
52
+ const emit = defineEmits<{
53
+ annotationEdit: [annotation: ChartAnnotation]
54
+ }>()
55
+
56
+ const chartHeight = computed(() => Number(height) || 220)
57
+ // `absolute` draws a value axis, because bar height alone hides the magnitude.
58
+ const margin = computed(() => ({ left: scale === 'absolute' ? 44 : 0, right: 0, top: 4, bottom: 28 }))
59
+
60
+ // gscTopEntityColors is a fixed 11-slot palette (10 identity hues + 1 neutral),
61
+ // wider than any current caller's topN (5) and already Tailwind-safelisted
62
+ // (dataVizColors.ts), so no new runtime class needs adding here. Other always takes the neutral slot regardless of its position in
63
+ // `series`, ranked entities keep their identity hue by rank order. `.hex` feeds
64
+ // the SVG marks + the HTML-string crosshair tooltip (neither can resolve a
65
+ // Tailwind/CSS-var class); `.dot` (the SOLID identity colour, vs. `.bg`'s
66
+ // translucent fill) feeds the real DOM legend swatches.
67
+ function colorFor(index: number) {
68
+ return series[index]?.isOther ? gscTopEntityColors.at(-1)! : (gscTopEntityColors[index] ?? gscTopEntityColors.at(-1)!)
69
+ }
70
+ const resolvedColors = computed(() => series.map((s, i) => s.isOther
71
+ ? 'color-mix(in oklab, var(--ui-primary) 22%, transparent)'
72
+ : colorFor(i).hex))
73
+ const resolvedDotClasses = computed(() => series.map((_, i) => colorFor(i).dot))
74
+ const legend = computed(() => Array.from({ length: Math.max(legendSlots, series.length) }, (_, index) => series[index]))
75
+
76
+ const rows = computed(() => topEntityStackRows({ buckets, series }))
77
+ const y = computed(() => series.map((_, si) => (d: TopEntityStackRow) => (scale === 'absolute' ? d.values[si] : d.shares[si]) ?? 0))
78
+ const yDomain = computed<[number, number]>(() => [0, scale === 'absolute' ? Math.max(1, ...rows.value.map(row => row.total)) : 100])
79
+ const percentFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'percent', maximumFractionDigits: 1 })
80
+ function shareLabel(share: number): string {
81
+ return percentFormat.format(share / 100)
82
+ }
83
+
84
+ const x = (_d: TopEntityStackRow, i: number) => i
85
+ const totalY = (d: TopEntityStackRow) => scale === 'absolute' ? d.total : d.total > 0 ? 100 : 0
86
+
87
+ // The plot box is `overflow-hidden` at a fixed height, and the crosshair
88
+ // tooltip lists one row per series plus a header. Rendered inside the plot it
89
+ // was clipped to the bars, so the caller saw a fragment of the figures. Mount it
90
+ // on `document.body` instead (Unovis switches to fixed positioning and
91
+ // constrains to the viewport). Client-only: the chart renders inside ClientOnly.
92
+ const tooltipContainer = import.meta.client ? document.body : undefined
93
+ // On `document.body` the tooltip no longer inherits the chart's dark vars, and
94
+ // Unovis gives it only generated class names. The dark `.unovis-tooltip` rule
95
+ // in global.css matches this class, so dark mode keeps a dark tooltip.
96
+ const TOOLTIP_CLASS = 'unovis-tooltip'
97
+ const chartKey = computed(() => `${buckets.length}-${series.length}`)
98
+
99
+ // Both layers share bucket centres. Adjacent normalized buckets form one
100
+ // continuous field, with no gaps between dates or segments.
101
+ const plotEl = useTemplateRef<HTMLElement>('plotEl')
102
+ const { width: plotWidth } = useElementSize(plotEl)
103
+ const barWidth = computed(() => {
104
+ const count = buckets.length
105
+ if (!count || plotWidth.value <= 0)
106
+ return undefined
107
+ return (plotWidth.value - margin.value.left) / count
108
+ })
109
+ const interactiveXDomain = computed<[number, number]>(() => [-0.5, Math.max(buckets.length - 1, 0) + 0.5])
110
+
111
+ const defaultDateFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', timeZone: 'UTC' })
112
+ function bucketLabel(bucket: TopEntityStackBucket): string {
113
+ if (xFormat)
114
+ return xFormat(bucket)
115
+ const d = new Date(`${bucket.start}T00:00:00Z`)
116
+ return Number.isNaN(d.getTime()) ? bucket.start : defaultDateFmt.format(d)
117
+ }
118
+
119
+ const timeline = computed(() => topEntityStackTimeline(buckets))
120
+ const { tickPlan, tickFormat: calendarTickFormat } = useChartTickPlan({ dates: () => timeline.value.dates })
121
+ const tickValues = computed(() => {
122
+ const values = tickPlan.value.indices.map(i => timeline.value.positions[i] ?? 0)
123
+ const pixelsPerBucket = (plotWidth.value - margin.value.left) / Math.max(buckets.length, 1)
124
+ const last = values.at(-1) ?? 0
125
+ return values.reduce<number[]>((kept, value, i) => {
126
+ if (i === 0 || i === values.length - 1
127
+ || ((value - kept.at(-1)!) * pixelsPerBucket >= 56 && (last - value) * pixelsPerBucket >= 56)) {
128
+ kept.push(value)
129
+ }
130
+ return kept
131
+ }, [])
132
+ })
133
+ function tickFormat(position: number): string {
134
+ const index = timeline.value.positions.indexOf(position)
135
+ return index < 0 ? '' : calendarTickFormat(index)
136
+ }
137
+ function tickTextAlign(position: number): TextAlign {
138
+ if (position === -0.5)
139
+ return TextAlign.Left
140
+ if (position === buckets.length - 0.5)
141
+ return TextAlign.Right
142
+ return TextAlign.Center
143
+ }
144
+
145
+ // Unovis inserts this template as HTML. Keep every dynamic text value literal,
146
+ // including caller-supplied formatters and stored entity names.
147
+ function escapeTooltipText(value: string): string {
148
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
149
+ }
150
+
151
+ function crosshairTemplate(d: TopEntityStackRow): string {
152
+ const bucket = buckets[d.i]
153
+ if (!bucket)
154
+ return ''
155
+ const total = d.total
156
+ const rowsHtml = series
157
+ .map((s, i) => ({ label: s.label, value: d.values[i] ?? 0, share: d.shares[i] ?? 0, color: resolvedColors.value[i] }))
158
+ .filter(r => r.value > 0)
159
+ .map(r => `<div style="display:flex;align-items:center;gap:6px">
160
+ <span style="width:8px;height:8px;border-radius:9999px;background:${r.color};flex-shrink:0"></span>
161
+ <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;color:var(--ui-text-muted)">${escapeTooltipText(r.label)}</span>
162
+ <span style="font-variant-numeric:tabular-nums;color:var(--ui-text-highlighted);margin-left:auto">${escapeTooltipText(format(r.value))} <span style="color:var(--ui-text-muted)">(${escapeTooltipText(shareLabel(r.share))})</span></span>
163
+ </div>`)
164
+ .join('')
165
+ // Rows pin to the header's 12px. On the body they inherit the page's base
166
+ // size, and a large one made the tooltip swamp the chart under it.
167
+ return `<div style="display:flex;flex-direction:column;gap:4px;min-width:180px;padding:2px;font-size:12px">
168
+ <div style="display:flex;justify-content:space-between;align-items:center;color:var(--ui-text-dimmed);margin-bottom:2px;font-size:12px">
169
+ <span>${escapeTooltipText(bucketLabel(bucket))}</span>
170
+ <span style="font-weight:600;color:var(--ui-text-highlighted)">${escapeTooltipText(format(total))}</span>
171
+ </div>
172
+ ${rowsHtml}
173
+ </div>`
174
+ }
175
+
176
+ // Day-anchored annotation markers re-anchor onto the bucket index axis: each
177
+ // lands on the bucket whose day range holds it. They share the bars' domain,
178
+ // so a marker sits on its bar's centre, and the overlay box below starts after
179
+ // the value-axis margin, as the plot does.
180
+ const anchoredAnnotations = computed(() => anchorAnnotationsToBuckets(annotations, buckets))
181
+
182
+ const ariaLabel = computed(() => `${scale === 'absolute' ? 'Stacked' : '100% stacked'} bar chart of ${series.map(s => s.label).join(', ')} over time`)
183
+ </script>
184
+
185
+ <template>
186
+ <div data-ui="UiTopEntityStackChart" class="ui-top-entity-stack-chart relative min-w-0">
187
+ <!-- Plot area: fixed height, layers grid-stacked. Never grows past `height`
188
+ regardless of series/bucket count — the axis + legend live OUTSIDE this
189
+ box in normal flow so nothing can push the plot taller than its box or
190
+ bleed past the container edge. -->
191
+ <div
192
+ ref="plotEl"
193
+ class="ui-top-entity-stack-chart__plot min-w-0 overflow-hidden"
194
+ role="img"
195
+ :aria-label="ariaLabel"
196
+ :style="{ height: `${chartHeight}px` }"
197
+ >
198
+ <div v-if="loading" class="loading-skeleton">
199
+ <div class="flex-1 flex items-end gap-1">
200
+ <UiSkeleton v-for="i in 16" :key="i" type="bar" :index="i" />
201
+ </div>
202
+ </div>
203
+
204
+ <!-- Empty payload is not zero traffic: no buckets/series means we never
205
+ got data, so this must read as "not enough data", never a chart of
206
+ zeros. -->
207
+ <UiEmptyState
208
+ v-else-if="!buckets.length || !series.length"
209
+ compact
210
+ icon="chart-bar"
211
+ :title="emptyTitle"
212
+ :description="emptyDescription"
213
+ />
214
+
215
+ <ClientOnly v-else>
216
+ <VisXYContainer
217
+ :key="`bars-${chartKey}`"
218
+ :height="chartHeight"
219
+ :data="rows"
220
+ :margin="margin"
221
+ :auto-margin="false"
222
+ :y-domain="yDomain"
223
+ :x-domain="interactiveXDomain"
224
+ aria-hidden="true"
225
+ class="chart-layer chart-layer--bars"
226
+ >
227
+ <VisStackedBar :attributes="{ [StackedBar.selectors.bar]: { 'shape-rendering': 'crispEdges' } }" :x="x" :y="y" :color="resolvedColors" :bar-padding="0" :bar-width="barWidth" :rounded-corners="0" />
228
+ </VisXYContainer>
229
+
230
+ <VisXYContainer
231
+ :key="`interactive-${chartKey}`"
232
+ :height="chartHeight"
233
+ :data="rows"
234
+ :margin="margin"
235
+ :auto-margin="false"
236
+ :y-domain="yDomain"
237
+ :x-domain="interactiveXDomain"
238
+ class="chart-layer chart-layer--interactive"
239
+ >
240
+ <VisLine :x="x" :y="totalY" color="transparent" :line-width="0" />
241
+ <VisAxis
242
+ type="x"
243
+ :tick-line="false"
244
+ :grid-line="false"
245
+ :domain-line="false"
246
+ :tick-values="tickValues"
247
+ :tick-format="tickFormat"
248
+ :tick-text-align="tickTextAlign"
249
+ tick-text-font-size="11px"
250
+ tick-text-color="var(--ui-text-dimmed)"
251
+ />
252
+ <!-- No grid lines: this layer sits above the bars, so they would cut
253
+ through every bar. The labels alone carry the scale. -->
254
+ <VisAxis
255
+ v-if="scale === 'absolute'"
256
+ type="y"
257
+ :tick-line="false"
258
+ :grid-line="false"
259
+ :domain-line="false"
260
+ :num-ticks="3"
261
+ :tick-format="format"
262
+ tick-text-font-size="11px"
263
+ tick-text-color="var(--ui-text-dimmed)"
264
+ />
265
+ <VisTooltip :container="tooltipContainer" :class-name="TOOLTIP_CLASS" :follow-cursor="false" horizontal-placement="right" />
266
+ <VisCrosshair :x="x" :y="totalY" color="none" :template="crosshairTemplate" />
267
+ </VisXYContainer>
268
+
269
+ <!-- Day-anchored annotation markers (shared overlay). The box spans the
270
+ plot's x range only, so it starts after the value-axis margin. -->
271
+ <div class="pointer-events-none absolute inset-y-0 right-0" :style="{ left: `${margin.left}px` }">
272
+ <UiChartAnnotations
273
+ :annotations="anchoredAnnotations"
274
+ :x-domain="interactiveXDomain"
275
+ :options="annotationOptions"
276
+ @edit="emit('annotationEdit', $event)"
277
+ @interaction-change="() => {}"
278
+ />
279
+ </div>
280
+
281
+ <template #fallback>
282
+ <div class="loading-skeleton">
283
+ <div class="flex-1 flex items-end gap-1">
284
+ <UiSkeleton v-for="i in 16" :key="i" type="bar" :index="i" />
285
+ </div>
286
+ </div>
287
+ </template>
288
+ </ClientOnly>
289
+ </div>
290
+
291
+ <!-- Legend — normal flow BELOW the plot box, never overlaid on it, so it
292
+ can never crowd or overflow the bars. Always present for 2+ series so
293
+ identity is never color-alone. Opt-in slots also reserve loading and
294
+ unused cells so the surrounding panel keeps its height. -->
295
+ <ul
296
+ v-if="legendSlots > 0 || (series.length > 1 && !loading && buckets.length)"
297
+ class="mt-2 gap-x-3 gap-y-1"
298
+ :class="legendSlots ? 'ui-top-entity-stack-chart__legend--slots text-mini' : 'flex flex-wrap'"
299
+ :aria-label="loading ? undefined : `Legend: ${series.map(s => s.label).join(', ')}`"
300
+ :aria-busy="loading || undefined"
301
+ >
302
+ <li
303
+ v-for="(s, i) in legend"
304
+ :key="s?.key ?? i"
305
+ class="flex items-center gap-1.5 min-w-0 max-w-full"
306
+ :class="{ invisible: !loading && !s }"
307
+ :aria-hidden="loading || !s || undefined"
308
+ >
309
+ <UiSkeleton v-if="loading" type="block" class="h-3 w-24 max-w-full" />
310
+ <template v-else-if="s">
311
+ <span class="size-2 rounded-full shrink-0" :class="s.isOther ? 'bg-primary/25' : resolvedDotClasses[i]" aria-hidden="true" />
312
+ <span class="text-mini text-muted truncate" :title="legendSlots ? s.label : undefined">{{ s.label }}</span>
313
+ </template>
314
+ </li>
315
+ </ul>
316
+
317
+ <!-- Screen-reader equivalent of the position-encoded stack, and the
318
+ keyboard path to the hover readout (mirrors UiLineChart). -->
319
+ <UiChartDataReveal v-if="buckets.length">
320
+ <table>
321
+ <caption>{{ ariaLabel }}</caption>
322
+ <thead>
323
+ <tr>
324
+ <th scope="col">
325
+ Period
326
+ </th>
327
+ <th v-for="s in series" :key="s.key" scope="col">
328
+ {{ s.label }}
329
+ </th>
330
+ </tr>
331
+ </thead>
332
+ <tbody>
333
+ <tr v-for="(b, i) in buckets" :key="`${b.start}-${b.end}`">
334
+ <th scope="row">
335
+ {{ bucketLabel(b) }}
336
+ </th>
337
+ <td v-for="(s, si) in series" :key="s.key">
338
+ {{ format(s.values[i] ?? 0) }} ({{ shareLabel(rows[i]?.shares[si] ?? 0) }})
339
+ </td>
340
+ </tr>
341
+ </tbody>
342
+ </table>
343
+ </UiChartDataReveal>
344
+ </div>
345
+ </template>
346
+
347
+ <style scoped>
348
+ .ui-top-entity-stack-chart__legend--slots {
349
+ display: grid;
350
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
351
+ grid-auto-rows: 1.5em;
352
+ }
353
+
354
+ .ui-top-entity-stack-chart__plot {
355
+ display: grid;
356
+ /* minmax(0, …): a bare 1fr track floors at the stacked SVGs' width, so the
357
+ chart grew with its container but never shrank back. */
358
+ grid-template-columns: minmax(0, 1fr);
359
+ position: relative;
360
+ /* Shared edges preserve one continuous 100% field across dates. */
361
+ --vis-stacked-bar-stroke-width: 0;
362
+ }
363
+
364
+ .ui-top-entity-stack-chart__plot .chart-layer,
365
+ .ui-top-entity-stack-chart__plot .loading-skeleton {
366
+ grid-column-start: 1;
367
+ grid-row-start: 1;
368
+ min-width: 0;
369
+ }
370
+
371
+ .ui-top-entity-stack-chart__plot .chart-layer {
372
+ pointer-events: none;
373
+ }
374
+
375
+ .ui-top-entity-stack-chart__plot .chart-layer--bars {
376
+ clip-path: inset(4px 0 28px round calc(var(--ui-radius) * 2));
377
+ }
378
+
379
+ .ui-top-entity-stack-chart__plot .chart-layer--interactive {
380
+ pointer-events: auto;
381
+ --vis-crosshair-line-stroke-color: var(--ui-border-accented);
382
+ --vis-crosshair-line-stroke-opacity: 0.6;
383
+ }
384
+
385
+ .loading-skeleton {
386
+ display: flex;
387
+ flex-direction: column;
388
+ justify-content: end;
389
+ padding-bottom: 1.5rem;
390
+ padding-left: 0.25rem;
391
+ padding-right: 0.25rem;
392
+ }
393
+ </style>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <span class="text-dimmed" aria-label="No data">{{ '—' }}</span>
3
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts" generic="T extends object">
2
+ import type { Header } from '@tanstack/vue-table'
3
+ import type { UiTableColumn, UiTableFeatures } from '../../../shared/table'
4
+ import { FlexRender } from '@tanstack/vue-table'
5
+ import { computed } from 'vue'
6
+ import { UiIcon, UiTooltip } from '#components'
7
+
8
+ const { header, sortDirection } = defineProps<{
9
+ header: Header<UiTableFeatures, T, unknown>
10
+ sortDirection: 'asc' | 'desc' | false
11
+ }>()
12
+
13
+ const emit = defineEmits<{ sort: [columnId: string] }>()
14
+
15
+ const def = computed(() => header.column.columnDef as UiTableColumn<T>)
16
+ const sortable = computed(() => header.column.getCanSort())
17
+
18
+ const justifyClass = computed(() => {
19
+ if (def.value.align === 'center')
20
+ return 'justify-center'
21
+ if (def.value.align === 'right' || def.value.numeric)
22
+ return 'justify-end'
23
+ return 'justify-start'
24
+ })
25
+
26
+ const isHeaderEmpty = computed(() => {
27
+ const v = def.value.header
28
+ return v === '' || v == null
29
+ })
30
+
31
+ const sortIcon = computed(() => {
32
+ if (sortDirection)
33
+ return 'collapse'
34
+ return 'sort'
35
+ })
36
+
37
+ // Chevron rotates 180° between asc/desc — a single icon animates rather than swapping.
38
+ const sortIconRotate = computed(() => sortDirection === 'desc' ? 'rotate-180' : '')
39
+
40
+ const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
41
+ </script>
42
+
43
+ <template>
44
+ <UiTooltip v-if="def.tooltip" :text="def.tooltip">
45
+ <button
46
+ v-if="sortable"
47
+ type="button"
48
+ :class="[buttonClass, justifyClass, sortDirection ? 'text-highlighted' : 'text-muted hover:text-default']"
49
+ :aria-label="isHeaderEmpty ? `Sort by ${header.column.id}` : undefined"
50
+ @click.stop="emit('sort', header.column.id)"
51
+ >
52
+ <FlexRender v-if="!header.isPlaceholder" :header="header" />
53
+ <UiIcon :name="sortIcon" class="size-3 text-dimmed transition-transform duration-150" :class="sortIconRotate" aria-hidden="true" />
54
+ </button>
55
+ <div v-else class="flex items-center gap-1" :class="justifyClass">
56
+ <FlexRender v-if="!header.isPlaceholder" :header="header" />
57
+ </div>
58
+ </UiTooltip>
59
+ <button
60
+ v-else-if="sortable"
61
+ type="button"
62
+ :class="[buttonClass, justifyClass, sortDirection ? 'text-highlighted' : 'text-muted hover:text-default']"
63
+ :aria-label="isHeaderEmpty ? `Sort by ${header.column.id}` : undefined"
64
+ @click.stop="emit('sort', header.column.id)"
65
+ >
66
+ <FlexRender v-if="!header.isPlaceholder" :header="header" />
67
+ <UiIcon :name="sortIcon" class="size-3 text-dimmed transition-transform duration-150" :class="sortIconRotate" aria-hidden="true" />
68
+ </button>
69
+ <div v-else class="flex items-center gap-1" :class="justifyClass">
70
+ <FlexRender v-if="!header.isPlaceholder" :render="def.header" :props="header.getContext()" />
71
+ </div>
72
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { UiTableDash } from '#components'
4
+
5
+ type Status = 'good' | 'ni' | 'poor' | 'neutral'
6
+
7
+ const {
8
+ value,
9
+ display,
10
+ status = 'neutral',
11
+ muted = false,
12
+ align = 'right',
13
+ } = defineProps<{
14
+ value: number | null | undefined
15
+ /** Pre-formatted display string. If omitted, value is rendered as-is via String(). */
16
+ display?: string | null
17
+ /** Optional status colour token (e.g. CWV good/needs-improvement/poor). */
18
+ status?: Status
19
+ /** Render in muted secondary tone (e.g. impressions vs primary clicks). */
20
+ muted?: boolean
21
+ align?: 'left' | 'center' | 'right'
22
+ }>()
23
+
24
+ const isNullish = computed(() => value == null || display === null)
25
+
26
+ // Color rides the failure, never the pass. A table of green "good" values spends
27
+ // the page's color budget on non-events, so the one failing row can't stand out
28
+ // (DESIGN — Color budget). `good` reads as a plain numeral; the aria-label still
29
+ // carries the word for anyone who can't see the hue at all.
30
+ const statusClass: Record<Status, string> = {
31
+ good: '',
32
+ ni: 'text-warning',
33
+ poor: 'text-error',
34
+ neutral: '',
35
+ }
36
+
37
+ const statusLabel: Record<Status, string | undefined> = {
38
+ good: 'Good',
39
+ ni: 'Needs improvement',
40
+ poor: 'Poor',
41
+ neutral: undefined,
42
+ }
43
+
44
+ const alignClass = computed(() => align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start')
45
+
46
+ const ariaLabel = computed(() => {
47
+ const label = statusLabel[status]
48
+ if (!label || !display)
49
+ return undefined
50
+ return `${display}, ${label}`
51
+ })
52
+ </script>
53
+
54
+ <template>
55
+ <div
56
+ class="flex"
57
+ :class="[$slots.trend ? 'flex-col items-stretch gap-1' : 'items-center gap-2', alignClass]"
58
+ >
59
+ <UiTableDash v-if="isNullish" />
60
+ <span
61
+ v-else
62
+ class="numerals-display text-sm"
63
+ :class="[
64
+ statusClass[status],
65
+ muted && status === 'neutral' ? 'text-muted' : 'font-medium',
66
+ ]"
67
+ :aria-label="ariaLabel"
68
+ >{{ display ?? String(value) }}</span>
69
+ <slot v-if="!isNullish" name="trend" />
70
+ <slot v-if="!isNullish" name="after" />
71
+ </div>
72
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { NuxtLink, UiIcon, UiTooltip } from '#components'
4
+ import { safeHttpHref } from '../../../../shared/utils/urls'
5
+
6
+ const {
7
+ url,
8
+ label,
9
+ to,
10
+ maxWidth = '280px',
11
+ external = true,
12
+ } = defineProps<{
13
+ url: string
14
+ /** Display text. Defaults to the pathname portion of url. */
15
+ label?: string
16
+ /** Internal route. When set, renders NuxtLink to this route instead of an external anchor. */
17
+ to?: string
18
+ maxWidth?: string
19
+ /** Show external open icon on hover/focus. Defaults to true when no internal `to` is provided. */
20
+ external?: boolean
21
+ }>()
22
+
23
+ const display = computed(() => {
24
+ if (label)
25
+ return label
26
+ try {
27
+ const u = new URL(url)
28
+ return u.pathname + u.search
29
+ }
30
+ catch {
31
+ return url
32
+ }
33
+ })
34
+
35
+ // `url` is stored data (Search Console pages, sitemap entries). An unfiltered
36
+ // `href` would run a `javascript:` value on click, so only http(s) reaches the
37
+ // anchor and anything else renders as inert text.
38
+ const safeHref = computed(() => safeHttpHref(url))
39
+ </script>
40
+
41
+ <template>
42
+ <UiTooltip :text="url">
43
+ <div class="group/path inline-flex items-center gap-1.5 min-w-0">
44
+ <NuxtLink
45
+ v-if="to"
46
+ :to="to"
47
+ :title="url"
48
+ class="font-sans text-xs text-default hover:text-primary focus-visible:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded transition-colors truncate"
49
+ :style="{ maxWidth }"
50
+ >
51
+ {{ display }}
52
+ </NuxtLink>
53
+ <a
54
+ v-else-if="safeHref"
55
+ :href="safeHref"
56
+ :title="url"
57
+ target="_blank"
58
+ rel="noopener noreferrer"
59
+ class="font-sans text-xs text-default hover:text-primary focus-visible:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded transition-colors truncate"
60
+ :style="{ maxWidth }"
61
+ >{{ display }}</a>
62
+ <span
63
+ v-else
64
+ :title="url"
65
+ class="font-sans text-xs text-default truncate"
66
+ :style="{ maxWidth }"
67
+ >{{ display }}</span>
68
+ <UiIcon
69
+ v-if="external && !to && safeHref"
70
+ name="arrow-up-right"
71
+ class="size-3 text-dimmed opacity-0 group-hover/path:opacity-100 group-focus-within/path:opacity-100 transition-opacity shrink-0"
72
+ aria-hidden="true"
73
+ />
74
+ </div>
75
+ </UiTooltip>
76
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const {
5
+ score,
6
+ label,
7
+ bgClass = '',
8
+ } = defineProps<{
9
+ score: number | null | undefined
10
+ /** Metric name (e.g. "Performance", "LCP"). Used for the aria-label. */
11
+ label?: string
12
+ /** Background class derived from caller's threshold logic (e.g. scoreBgClass()). */
13
+ bgClass?: string
14
+ }>()
15
+
16
+ const status = computed<'good' | 'ni' | 'poor' | 'neutral'>(() => {
17
+ if (score == null)
18
+ return 'neutral'
19
+ if (score >= 90)
20
+ return 'good'
21
+ if (score >= 50)
22
+ return 'ni'
23
+ return 'poor'
24
+ })
25
+
26
+ const statusWord: Record<typeof status['value'], string> = {
27
+ good: 'Good',
28
+ ni: 'Needs improvement',
29
+ poor: 'Poor',
30
+ neutral: 'No data',
31
+ }
32
+
33
+ const ariaLabel = computed(() => {
34
+ const value = score == null ? 'No data' : String(score)
35
+ const word = statusWord[status.value]
36
+ return label ? `${label} ${value}, ${word}` : `${value}, ${word}`
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <div
42
+ class="inline-flex items-center justify-center size-8 rounded-md text-mini font-bold numerals-display"
43
+ :class="bgClass"
44
+ role="img"
45
+ :aria-label="ariaLabel"
46
+ >
47
+ <span aria-hidden="true">{{ score ?? '—' }}</span>
48
+ </div>
49
+ </template>