@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,100 @@
1
+ /**
2
+ * CSV and JSON exports for data lists and tables (ADR-0076).
3
+ * VISION principle 7 (independence): every data list ships a way out. One
4
+ * serialiser for every surface, so no page re-inlines its own `toCsv`.
5
+ *
6
+ * The serialisers are pure. Only `downloadCsv` and `downloadJson` touch the DOM.
7
+ */
8
+
9
+ /** One exported column: the header a spreadsheet shows, and the cell value. */
10
+ export interface ExportColumn<T> {
11
+ /** The JSON key. Falls back to `header`. */
12
+ readonly key?: string
13
+ readonly header: string
14
+ readonly value: (row: T) => unknown
15
+ }
16
+
17
+ /**
18
+ * A spreadsheet runs a cell that starts with one of these as a formula, so a
19
+ * row value such as `=HYPERLINK(...)` from a crawled page would execute on open.
20
+ * Tab and carriage return start a formula in some spreadsheets too (OWASP CSV
21
+ * injection).
22
+ */
23
+ const FORMULA_PREFIX = /^[=+\-@\t\r]/
24
+
25
+ function cellText(value: unknown): string {
26
+ if (value === null || value === undefined)
27
+ return ''
28
+ if (value instanceof Date)
29
+ return Number.isNaN(value.getTime()) ? '' : value.toISOString()
30
+ if (typeof value === 'object')
31
+ return JSON.stringify(value)
32
+ return String(value)
33
+ }
34
+
35
+ /**
36
+ * One quoted CSV cell. A text cell that starts with a formula character gets a
37
+ * leading apostrophe, so the spreadsheet shows it as text. A finite number
38
+ * stays a number, so `-12` still sorts and sums as a number.
39
+ */
40
+ export function csvCell(value: unknown): string {
41
+ const text = cellText(value)
42
+ const isNumber = typeof value === 'number' && Number.isFinite(value)
43
+ const safe = !isNumber && FORMULA_PREFIX.test(text) ? `'${text}` : text
44
+ return `"${safe.replace(/"/g, '""')}"`
45
+ }
46
+
47
+ /**
48
+ * Serialise rows to CSV over the given ordered columns. The first line holds the
49
+ * headers. Lines end with CRLF (RFC 4180). Pure.
50
+ */
51
+ export function tableToCsv<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
52
+ const lines = [
53
+ columns.map(column => csvCell(column.header)).join(','),
54
+ ...rows.map(row => columns.map(column => csvCell(column.value(row))).join(',')),
55
+ ]
56
+ return `${lines.join('\r\n')}\r\n`
57
+ }
58
+
59
+ /** Serialise rows to records keyed by each column's key, else its header. Pure. */
60
+ export function tableToRecords<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): Record<string, unknown>[] {
61
+ return rows.map(row => Object.fromEntries(columns.map(column => [column.key ?? column.header, column.value(row)])))
62
+ }
63
+
64
+ /** Columns from row keys, for rows whose keys are already their headers. */
65
+ export function keyColumns<T>(keys: readonly (keyof T & string)[]): ExportColumn<T>[] {
66
+ return keys.map(key => ({ header: key, value: row => row[key] }))
67
+ }
68
+
69
+ /** Serialise rows to CSV over the given ordered keys. Pure. */
70
+ export function rowsToCsv<T>(rows: readonly T[], keys: readonly (keyof T & string)[]): string {
71
+ return tableToCsv(rows, keyColumns(keys))
72
+ }
73
+
74
+ function triggerDownload(filename: string, content: string, mime: string): void {
75
+ const blob = new Blob([content], { type: mime })
76
+ const url = URL.createObjectURL(blob)
77
+ const a = document.createElement('a')
78
+ a.href = url
79
+ a.download = filename
80
+ a.click()
81
+ URL.revokeObjectURL(url)
82
+ }
83
+
84
+ /**
85
+ * Download CSV text. The byte order mark makes Excel read the file as UTF-8,
86
+ * so a non-ASCII query or URL keeps its characters.
87
+ */
88
+ export function downloadCsvText(filename: string, csv: string): void {
89
+ triggerDownload(filename, `\uFEFF${csv}`, 'text/csv;charset=utf-8')
90
+ }
91
+
92
+ /** Serialise rows to CSV over the given ordered keys and download it. */
93
+ export function downloadCsv<T>(filename: string, rows: readonly T[], keys: readonly (keyof T & string)[]): void {
94
+ downloadCsvText(filename, rowsToCsv(rows, keys))
95
+ }
96
+
97
+ /** Serialise any value to pretty JSON and download it. */
98
+ export function downloadJson(filename: string, data: unknown): void {
99
+ triggerDownload(filename, JSON.stringify(data, null, 2), 'application/json')
100
+ }
@@ -0,0 +1,12 @@
1
+ export function faviconProxyPath(domain: string): string {
2
+ return `/api/favicon?domain=${encodeURIComponent(domain)}&fallback=blank`
3
+ }
4
+
5
+ export function faviconFallbackInitial(fallbackLabel: string, domain: string): string {
6
+ const source = fallbackLabel.trim() || domain.replace(/^www\./, '')
7
+ return (source[0] ?? '?').toUpperCase()
8
+ }
9
+
10
+ export function isBlankFaviconSize(width: number, height: number): boolean {
11
+ return width === 1 && height === 1
12
+ }
@@ -0,0 +1,105 @@
1
+ import { tryUseNuxtApp, useAppConfig } from 'nuxt/app'
2
+
3
+ export type IconColor
4
+ = | 'neutral'
5
+ | 'primary'
6
+ | 'pro'
7
+ | 'success'
8
+ | 'warning'
9
+ | 'error'
10
+ | 'info'
11
+ | 'red'
12
+ | 'orange'
13
+ | 'amber'
14
+ | 'yellow'
15
+ | 'green'
16
+ | 'emerald'
17
+ | 'teal'
18
+ | 'blue'
19
+ | 'cyan'
20
+ | 'sky'
21
+ | 'indigo'
22
+ | 'violet'
23
+ | 'purple'
24
+ | 'pink'
25
+ | 'rose'
26
+ | 'gray'
27
+ | 'slate'
28
+
29
+ /** The container fill and glyph colour classes one icon colour role paints. */
30
+ export interface IconColorClasses {
31
+ bg: string
32
+ text: string
33
+ }
34
+
35
+ /** A role name to its classes. A site adds or remaps roles in app.config. */
36
+ export type IconColorRoles = Readonly<Record<string, IconColorClasses>>
37
+
38
+ /**
39
+ * The layer's icon colour roles. A site adds a role, or remaps one, under
40
+ * `designSystem.iconColors` in its app.config. Tailwind only emits classes it
41
+ * finds as literals in scanned source, so the site writes them there in full.
42
+ */
43
+ export const ICON_COLOR_ROLES: Readonly<Record<IconColor, IconColorClasses>> = {
44
+ neutral: { bg: 'bg-accented', text: 'text-muted' },
45
+ primary: { bg: 'bg-primary/10', text: 'text-primary' },
46
+ pro: { bg: 'bg-pro/10', text: 'text-pro' },
47
+ success: { bg: 'bg-success/10', text: 'text-success' },
48
+ warning: { bg: 'bg-warning/10', text: 'text-warning' },
49
+ error: { bg: 'bg-error/10', text: 'text-error' },
50
+ info: { bg: 'bg-info/10', text: 'text-info' },
51
+ red: { bg: 'bg-red-500/10', text: 'text-red-500' },
52
+ orange: { bg: 'bg-orange-500/10', text: 'text-orange-500' },
53
+ amber: { bg: 'bg-amber-500/10', text: 'text-amber-500' },
54
+ yellow: { bg: 'bg-yellow-500/10', text: 'text-yellow-500' },
55
+ green: { bg: 'bg-green-500/10', text: 'text-green-500' },
56
+ emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-500' },
57
+ teal: { bg: 'bg-teal-500/10', text: 'text-teal-500' },
58
+ blue: { bg: 'bg-blue-500/10', text: 'text-blue-500' },
59
+ cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-500' },
60
+ sky: { bg: 'bg-sky-500/10', text: 'text-sky-500' },
61
+ indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-500' },
62
+ violet: { bg: 'bg-violet-500/10', text: 'text-violet-500' },
63
+ purple: { bg: 'bg-purple-500/10', text: 'text-purple-500' },
64
+ pink: { bg: 'bg-pink-500/10', text: 'text-pink-500' },
65
+ rose: { bg: 'bg-rose-500/10', text: 'text-rose-500' },
66
+ // 'gray' is a semantic alias for 'neutral': raw `gray-*` Tailwind utilities
67
+ // render Tailwind's stock cold gray, not the app's warm-slate neutral ramp
68
+ // (global.css re-derives `--color-slate-*`, not `--color-gray-*`), so this
69
+ // reuses the same semantic tokens as 'neutral' rather than the raw hue.
70
+ gray: { bg: 'bg-accented', text: 'text-muted' },
71
+ slate: { bg: 'bg-slate-500/10', text: 'text-slate-500' },
72
+ }
73
+
74
+ /**
75
+ * Pure lookup: the classes for `color` in `roles`. An unknown or empty role
76
+ * paints `neutral`.
77
+ */
78
+ export function resolveIconColor(color: IconColor | string | null | undefined, roles: IconColorRoles = ICON_COLOR_ROLES): IconColorClasses {
79
+ const neutral = roles.neutral ?? ICON_COLOR_ROLES.neutral
80
+ if (!color)
81
+ return neutral
82
+ return roles[color] ?? neutral
83
+ }
84
+
85
+ /** The app.config roles, or none outside a Nuxt app. */
86
+ function siteIconColorRoles(): IconColorRoles | undefined {
87
+ if (!tryUseNuxtApp())
88
+ return undefined
89
+ return useAppConfig().designSystem?.iconColors as IconColorRoles | undefined
90
+ }
91
+
92
+ /**
93
+ * The classes for an icon colour role, including roles the site adds in
94
+ * app.config. Call it from component setup or render, where the Nuxt app is
95
+ * available. Each call reads app.config live, site role first, then layer
96
+ * role. A merged copy cached per config object went stale when a role was
97
+ * added to that same object after the first lookup.
98
+ */
99
+ export function getIconColor(color: IconColor | string | null | undefined): IconColorClasses {
100
+ const site = siteIconColorRoles()
101
+ const neutral = site?.neutral ?? ICON_COLOR_ROLES.neutral
102
+ if (!color)
103
+ return neutral
104
+ return site?.[color] ?? (ICON_COLOR_ROLES as IconColorRoles)[color] ?? neutral
105
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Calculate percentage change between two values.
3
+ * Returns 0 if prev is 0 (avoids division by zero).
4
+ *
5
+ * Not clamped: a clamp here read 2 → 60 clicks as "+999%", and it hid the
6
+ * change from UiTrend, whose own clamp shows "10x+" above 999 with the exact
7
+ * value in its title. Clamping is presentation, so it stays in UiTrend.
8
+ */
9
+ export function percentChange(current: number, prev: number): number {
10
+ if (!prev)
11
+ return 0
12
+ // Divide by the size of the base, so a rise from -10 to -5 reads as +50%,
13
+ // not -50%. Signed series (the desktop-minus-mobile gap) have negative bases.
14
+ return Math.round(((current - prev) / Math.abs(prev)) * 100) || 0
15
+ }
16
+
17
+ /**
18
+ * Clamp a number between min and max values:
19
+ *
20
+ * @example clamp(-5, 1, 5) // 1
21
+ * @example clamp(10, 1, 5) // 5
22
+ *
23
+ * Or clamp an index to valid array indices:
24
+ *
25
+ * @example clamp(-5, [1, 2, 3, 4, 5]) // 0
26
+ * @example clamp(10, [1, 2, 3, 4, 5]) // 4
27
+ */
28
+ export function clamp(value: number, arr: readonly unknown[]): number
29
+ export function clamp(value: number, min: number, max: number): number
30
+ export function clamp(value: number, a: number | readonly unknown[], b?: number): number {
31
+ if (isReadonlyArray(a))
32
+ return Math.min(Math.max(value, 0), a.length - 1)
33
+ const min = a
34
+ const max = b ?? min
35
+ return Math.min(Math.max(value, min), max)
36
+ }
37
+
38
+ function isReadonlyArray(value: number | readonly unknown[]): value is readonly unknown[] {
39
+ return Array.isArray(value)
40
+ }
41
+
42
+ /**
43
+ * Whole-number percentages of `values` that sum to exactly 100 (largest
44
+ * remainder). Rounding each share on its own made a column read 99% or 101%.
45
+ * All zero (or empty) gives all zero.
46
+ */
47
+ export function roundedShares(values: readonly number[]): number[] {
48
+ const total = values.reduce((sum, v) => sum + Math.max(0, v), 0)
49
+ if (total <= 0)
50
+ return values.map(() => 0)
51
+ const exact = values.map(v => Math.max(0, v) / total * 100)
52
+ const floors = exact.map(Math.floor)
53
+ let left = 100 - floors.reduce((sum, v) => sum + v, 0)
54
+ const order = exact.map((v, i) => ({ i, frac: v - floors[i]! })).sort((a, b) => b.frac - a.frac || a.i - b.i)
55
+ for (const { i } of order) {
56
+ if (left <= 0)
57
+ break
58
+ floors[i]!++
59
+ left--
60
+ }
61
+ return floors
62
+ }
63
+
64
+ /**
65
+ * A rounded share as a label. A non-zero slice never reads "0%", and a slice
66
+ * beside other non-zero slices never reads "100%".
67
+ */
68
+ export function roundedShareLabel(rounded: number, value: number, othersNonZero: boolean): string {
69
+ if (value > 0 && rounded === 0)
70
+ return '<1%'
71
+ if (rounded === 100 && othersNonZero)
72
+ return '>99%'
73
+ return `${rounded}%`
74
+ }
@@ -0,0 +1,14 @@
1
+ import type { NuxtLinkProps } from 'nuxt/app'
2
+ import type { AnchorHTMLAttributes, VNode } from 'vue'
3
+ import type { NavigationDestination } from '../shared/navigation'
4
+ import { createVNode } from 'vue'
5
+ import { NuxtLink } from '#components'
6
+
7
+ type LinkProps = Omit<NuxtLinkProps, 'to' | 'href'>
8
+ & Omit<AnchorHTMLAttributes, keyof NuxtLinkProps>
9
+ & { to?: NavigationDestination, href?: NavigationDestination }
10
+
11
+ // Check the destination before Vue expands the component's complete prop map.
12
+ export function renderLink(props: LinkProps, children: (() => unknown) | Record<string, () => unknown>): VNode {
13
+ return createVNode(NuxtLink, props, typeof children === 'function' ? { default: children } : children)
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared options for the design-system slot text roll.
3
+ *
4
+ * Kept as a local type so components can expose tuning without depending on the
5
+ * external `slot-text` package or carrying its DOM controller runtime.
6
+ */
7
+ export interface SlotTextOptions {
8
+ direction?: 'up' | 'down'
9
+ stagger?: number
10
+ duration?: number
11
+ exitOffset?: number
12
+ easing?: string
13
+ bounce?: number
14
+ color?: string | ((index: number, total: number) => string)
15
+ colorFade?: number
16
+ skipUnchanged?: boolean
17
+ interrupt?: boolean
18
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Figma-style squircle (smooth-corner) SVG path generation.
3
+ *
4
+ * Vendored and trimmed from Lisse (`@lisse/core`, MIT © Jace), which in turn
5
+ * implements Figma's smoothing algorithm via MartinRGB's approximation:
6
+ * https://www.figma.com/blog/desperately-seeking-squircles/
7
+ * https://github.com/MartinRGB/Figma_Squircles_Approximation
8
+ *
9
+ * We only need the uniform-radius squircle curve (not arc / superellipse /
10
+ * clothoid, nor per-corner config), so this is the squircle path math only.
11
+ * Used as the Safari/Firefox fallback for the native CSS `corner-shape:
12
+ * squircle` progressive enhancement (see global.css + UiCard).
13
+ */
14
+
15
+ export interface SquircleOptions {
16
+ /** Corner radius in px. */
17
+ radius: number
18
+ /** 0 (sharp) → 1 (max). Default 0.6 (Figma's value). */
19
+ smoothing?: number
20
+ /** Keep the smoothing even when the corner is space-constrained. Default true. */
21
+ preserveSmoothing?: boolean
22
+ }
23
+
24
+ interface CornerPathParams {
25
+ a: number
26
+ b: number
27
+ c: number
28
+ d: number
29
+ p: number
30
+ arcSectionLength: number
31
+ cornerRadius: number
32
+ }
33
+
34
+ const toRadians = (deg: number) => (deg * Math.PI) / 180
35
+ const f = (n: number) => n.toFixed(4)
36
+
37
+ /** Tagged template that formats interpolated numbers to 4 decimal places. */
38
+ function rounded(strings: TemplateStringsArray, ...values: number[]): string {
39
+ let out = strings[0] ?? ''
40
+ for (let i = 0; i < values.length; i++) {
41
+ const value = values[i]
42
+ out += `${value === undefined ? '' : value.toFixed(4)}${strings[i + 1] ?? ''}`
43
+ }
44
+ return out
45
+ }
46
+
47
+ const adjacentsByCorner = {
48
+ topLeft: [{ corner: 'topRight', side: 'top' }, { corner: 'bottomLeft', side: 'left' }],
49
+ topRight: [{ corner: 'topLeft', side: 'top' }, { corner: 'bottomRight', side: 'right' }],
50
+ bottomLeft: [{ corner: 'bottomRight', side: 'bottom' }, { corner: 'topLeft', side: 'left' }],
51
+ bottomRight: [{ corner: 'bottomLeft', side: 'bottom' }, { corner: 'topRight', side: 'right' }],
52
+ } as const
53
+
54
+ type Corner = keyof typeof adjacentsByCorner
55
+
56
+ /**
57
+ * Distribute available space among corners, clamping radii so they can't
58
+ * exceed the rectangle. Larger corners get priority. With a uniform radius
59
+ * this only matters on small/thin elements.
60
+ */
61
+ function distributeAndNormalize(radius: number, width: number, height: number) {
62
+ const radii: Record<Corner, number> = { topLeft: radius, topRight: radius, bottomLeft: radius, bottomRight: radius }
63
+ const budgets: Record<Corner, number> = { topLeft: -1, topRight: -1, bottomLeft: -1, bottomRight: -1 }
64
+
65
+ ;(Object.entries(radii) as [Corner, number][])
66
+ .sort(([, r1], [, r2]) => r2 - r1)
67
+ .forEach(([corner, r]) => {
68
+ const budget = Math.min(...adjacentsByCorner[corner].map((adj) => {
69
+ const adjRadius = radii[adj.corner]
70
+ if (r === 0 && adjRadius === 0)
71
+ return 0
72
+ const adjBudget = budgets[adj.corner]
73
+ const sideLength = adj.side === 'top' || adj.side === 'bottom' ? width : height
74
+ return adjBudget >= 0 ? sideLength - adjBudget : (r / (r + adjRadius)) * sideLength
75
+ }))
76
+ budgets[corner] = budget
77
+ radii[corner] = Math.min(r, budget)
78
+ })
79
+
80
+ return { radii, budgets }
81
+ }
82
+
83
+ /** Bézier-curve parameters for one corner (Figma squircle). */
84
+ function getPathParamsForCorner(cornerRadius: number, cornerSmoothing: number, preserveSmoothing: boolean, budget: number): CornerPathParams {
85
+ if (cornerRadius <= 0)
86
+ return { a: 0, b: 0, c: 0, d: 0, p: 0, arcSectionLength: 0, cornerRadius: 0 }
87
+
88
+ let p = (1 + cornerSmoothing) * cornerRadius
89
+ if (!preserveSmoothing) {
90
+ const maxSmoothing = budget / cornerRadius - 1
91
+ cornerSmoothing = Math.min(cornerSmoothing, maxSmoothing)
92
+ p = Math.min(p, budget)
93
+ }
94
+
95
+ const arcMeasure = 90 * (1 - cornerSmoothing)
96
+ const arcSectionLength = Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2)
97
+ const angleAlpha = (90 - arcMeasure) / 2
98
+ const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2))
99
+ const angleBeta = 45 * cornerSmoothing
100
+ const c = p3ToP4Distance * Math.cos(toRadians(angleBeta))
101
+ const d = c * Math.tan(toRadians(angleBeta))
102
+
103
+ let b = (p - arcSectionLength - c - d) / 3
104
+ let a = 2 * b
105
+ if (preserveSmoothing && p > budget) {
106
+ const p1ToP3MaxDistance = budget - d - arcSectionLength - c
107
+ const minA = p1ToP3MaxDistance / 6
108
+ const maxB = p1ToP3MaxDistance - minA
109
+ b = Math.min(b, maxB)
110
+ a = p1ToP3MaxDistance - b
111
+ p = Math.min(p, budget)
112
+ }
113
+
114
+ return { a, b, c, d, p, arcSectionLength, cornerRadius }
115
+ }
116
+
117
+ function drawTopRight({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
118
+ return cornerRadius
119
+ ? rounded`c ${a} 0 ${a + b} 0 ${a + b + c} ${d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength} c ${d} ${c} ${d} ${b + c} ${d} ${a + b + c}`
120
+ : ''
121
+ }
122
+ function drawBottomRight({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
123
+ return cornerRadius
124
+ ? rounded`c 0 ${a} 0 ${a + b} ${-d} ${a + b + c} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${arcSectionLength} c ${-c} ${d} ${-(b + c)} ${d} ${-(a + b + c)} ${d}`
125
+ : ''
126
+ }
127
+ function drawBottomLeft({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
128
+ return cornerRadius
129
+ ? rounded`c ${-a} 0 ${-(a + b)} 0 ${-(a + b + c)} ${-d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${-arcSectionLength} c ${-d} ${-c} ${-d} ${-(b + c)} ${-d} ${-(a + b + c)}`
130
+ : ''
131
+ }
132
+ function drawTopLeft({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
133
+ return cornerRadius
134
+ ? rounded`c 0 ${-a} 0 ${-(a + b)} ${d} ${-(a + b + c)} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${-arcSectionLength} c ${c} ${-d} ${b + c} ${-d} ${a + b + c} ${-d}`
135
+ : ''
136
+ }
137
+
138
+ /** SVG path `d` string for a smooth-cornered rectangle, traversed clockwise from the top-left. */
139
+ export function squircleSvgPath(width: number, height: number, { radius, smoothing = 0.6, preserveSmoothing = true }: SquircleOptions): string {
140
+ if (width <= 0 || height <= 0)
141
+ return ''
142
+ const { radii, budgets } = distributeAndNormalize(radius, width, height)
143
+ const params = (c: Corner) => getPathParamsForCorner(radii[c], smoothing, preserveSmoothing, budgets[c])
144
+ const tl = params('topLeft')
145
+ const tr = params('topRight')
146
+ const br = params('bottomRight')
147
+ const bl = params('bottomLeft')
148
+
149
+ return `M ${f(tl.p)} 0`
150
+ + ` L ${f(width - tr.p)} 0 ${drawTopRight(tr)}`
151
+ + ` L ${f(width)} ${f(br.p)} L ${f(width)} ${f(height - br.p)} ${drawBottomRight(br)}`
152
+ + ` L ${f(width - bl.p)} ${f(height)} L ${f(bl.p)} ${f(height)} ${drawBottomLeft(bl)}`
153
+ + ` L 0 ${f(height - tl.p)} L 0 ${f(tl.p)} ${drawTopLeft(tl)} Z`
154
+ }
@@ -0,0 +1,197 @@
1
+ // Shared "top N + Other" stacked-trend bucketing — pure, dependency-free logic
2
+ // backing `UiTopEntityStackChart`. Used for the search-console Queries / Pages
3
+ // / Countries trend charts (manual-review-2026-08): pick the top N entities by
4
+ // total value over the range, zero-fill each against the full date axis (an
5
+ // entity absent on a given day reads as a zero band, never a gap), roll
6
+ // everything else into a single "Other" band, and coarsen the date axis into
7
+ // a bounded number of buckets so a wide range doesn't render one bar per day.
8
+ //
9
+ // "Other" has two modes:
10
+ // - `totals` supplied (the caller only fetched the top-K candidates, not the
11
+ // full entity universe): Other = total - sum(top N). This is the common
12
+ // case — GSC queries/pages have far more than 5 distinct values.
13
+ // - `totals` omitted (the caller supplied every candidate, e.g. a bounded
14
+ // dimension like country): Other = sum of every candidate beyond top N.
15
+
16
+ import type { ChartAnnotation } from './chartAnnotations'
17
+ import { toChartDayKey } from './chartAnnotations'
18
+
19
+ const EPSILON = 1e-9
20
+
21
+ export interface TopEntityDayRow {
22
+ /** ISO `YYYY-MM-DD` day. */
23
+ date: string
24
+ /** Stable entity identity (query text, page path, country code). */
25
+ key: string
26
+ /** Display label. Defaults to `key` when omitted at the call site. */
27
+ label: string
28
+ /** Metric value for this entity on this day (already the selected metric). */
29
+ value: number
30
+ }
31
+
32
+ export interface TopEntityStackBucket {
33
+ /** First day folded into this bucket. */
34
+ start: string
35
+ /** Last day folded into this bucket. */
36
+ end: string
37
+ }
38
+
39
+ export interface TopEntityStackSeries {
40
+ key: string
41
+ label: string
42
+ /** One value per bucket, aligned to `result.buckets`. */
43
+ values: number[]
44
+ isOther: boolean
45
+ }
46
+
47
+ export interface TopEntityStackResult {
48
+ buckets: TopEntityStackBucket[]
49
+ series: TopEntityStackSeries[]
50
+ }
51
+
52
+ export interface BucketTopEntitiesOptions {
53
+ /** Full canonical date axis, ascending, no gaps — the zero-fill target. */
54
+ dates: string[]
55
+ /** Sparse per-entity-per-day rows. A missing (entity, date) pair is 0. */
56
+ rows: TopEntityDayRow[]
57
+ /** How many entities stay explicit. Default 5. */
58
+ topN?: number
59
+ /** Site/aggregate total per date, aligned to `dates`. Drives Other = total - topN. */
60
+ totals?: number[]
61
+ /** Cap on rendered date buckets (adjacent days are summed together). Default 10. */
62
+ maxBuckets?: number
63
+ otherLabel?: string
64
+ /** Drop a candidate entirely before ranking/rolling it into Other — e.g. `isSearchOperatorQuery`. */
65
+ excludeKey?: (key: string) => boolean
66
+ }
67
+
68
+ export function bucketTopEntities(opts: BucketTopEntitiesOptions): TopEntityStackResult {
69
+ const { dates, rows, topN = 5, totals, maxBuckets = 10, otherLabel = 'All Others', excludeKey } = opts
70
+ if (!dates.length)
71
+ return { buckets: [], series: [] }
72
+
73
+ const dateIndex = new Map(dates.map((d, i) => [d, i]))
74
+ const filteredRows = excludeKey ? rows.filter(r => !excludeKey(r.key)) : rows
75
+
76
+ const byKey = new Map<string, { label: string, values: number[] }>()
77
+ for (const row of filteredRows) {
78
+ const idx = dateIndex.get(row.date)
79
+ if (idx == null)
80
+ continue
81
+ let entry = byKey.get(row.key)
82
+ if (!entry) {
83
+ entry = { label: row.label || row.key, values: Array.from({ length: dates.length }).fill(0) as number[] }
84
+ byKey.set(row.key, entry)
85
+ }
86
+ entry.values[idx] = (entry.values[idx] ?? 0) + row.value
87
+ }
88
+
89
+ const ranked = Array.from(byKey.entries(), ([key, entry]) => ({
90
+ key,
91
+ label: entry.label,
92
+ values: entry.values,
93
+ total: entry.values.reduce((sum, v) => sum + v, 0),
94
+ }))
95
+ .sort((a, b) => b.total - a.total || a.key.localeCompare(b.key))
96
+
97
+ const top = ranked.slice(0, topN)
98
+ const rest = ranked.slice(topN)
99
+
100
+ const otherValues = dates.map((_, i) => {
101
+ if (totals) {
102
+ const topSum = top.reduce((sum, s) => sum + (s.values[i] ?? 0), 0)
103
+ return Math.max(0, (totals[i] ?? 0) - topSum)
104
+ }
105
+ return rest.reduce((sum, s) => sum + (s.values[i] ?? 0), 0)
106
+ })
107
+ const hasOther = otherValues.some(v => v > EPSILON)
108
+
109
+ const daily: TopEntityStackSeries[] = top.map(s => ({
110
+ key: s.key,
111
+ label: s.label,
112
+ values: s.values,
113
+ isOther: false,
114
+ }))
115
+ if (hasOther) {
116
+ daily.push({
117
+ key: '__other__',
118
+ label: otherLabel,
119
+ values: otherValues,
120
+ isOther: true,
121
+ })
122
+ }
123
+
124
+ // Coarsen the date axis: fold contiguous day-groups into one bucket per
125
+ // series (summed), bounded to `maxBuckets`. Buckets align on the newest day,
126
+ // so the OLDEST bucket takes the remainder (as UiSparkColumns does). Aligned
127
+ // on the oldest day, the newest bar held the leftover 1 to 2 days and, in
128
+ // `scale="absolute"`, read as a collapse in the latest period.
129
+ const bucketCount = Math.max(1, Math.min(maxBuckets, dates.length))
130
+ const groupSize = Math.ceil(dates.length / bucketCount)
131
+ const buckets: TopEntityStackBucket[] = []
132
+ const bucketedSeries: TopEntityStackSeries[] = daily.map(s => ({ ...s, values: [] }))
133
+ const remainder = dates.length % groupSize
134
+
135
+ for (let start = 0; start < dates.length; start = start === 0 && remainder ? remainder : start + groupSize) {
136
+ const end = start === 0 && remainder ? remainder : Math.min(start + groupSize, dates.length)
137
+ buckets.push({ start: dates[start]!, end: dates[end - 1]! })
138
+ daily.forEach((s, si) => {
139
+ let sum = 0
140
+ for (let i = start; i < end; i++) sum += s.values[i] ?? 0
141
+ bucketedSeries[si]!.values.push(sum)
142
+ })
143
+ }
144
+
145
+ return { buckets, series: bucketedSeries }
146
+ }
147
+
148
+ export interface TopEntityStackRow {
149
+ i: number
150
+ values: number[]
151
+ shares: number[]
152
+ total: number
153
+ }
154
+
155
+ /** Preserve counts for details and express each plotted bucket as a percentage. */
156
+ export function topEntityStackRows({ buckets, series }: TopEntityStackResult): TopEntityStackRow[] {
157
+ return buckets.map((_, i) => {
158
+ const values = series.map(s => s.values[i] ?? 0)
159
+ const total = values.reduce((sum, value) => sum + value, 0)
160
+ return { i, values, shares: values.map(value => total > 0 ? value / total * 100 : 0), total }
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Anchor day annotations onto the bucket index axis. An annotation lands on
166
+ * the bucket whose `start <= day <= end`; one outside every bucket, or with
167
+ * no day, drops out. Matching only a bucket's START day silently dropped
168
+ * about 8 of every 9 annotations on a 90-day range.
169
+ */
170
+ export function anchorAnnotationsToBuckets(
171
+ annotations: readonly ChartAnnotation[] | undefined,
172
+ buckets: readonly TopEntityStackBucket[],
173
+ ): ChartAnnotation[] {
174
+ if (!annotations?.length || !buckets.length)
175
+ return []
176
+ return annotations.flatMap((annotation) => {
177
+ const day = toChartDayKey(annotation.x)
178
+ if (!day)
179
+ return []
180
+ const i = buckets.findIndex(bucket => bucket.start <= day && day <= bucket.end)
181
+ return i === -1 ? [] : [{ ...annotation, x: i, dayKey: day }]
182
+ })
183
+ }
184
+
185
+ /** Project the full reporting-day axis onto the chart's bucket domain. */
186
+ export function topEntityStackTimeline(buckets: TopEntityStackBucket[]): { dates: string[], positions: number[] } {
187
+ const dates: string[] = []
188
+ for (const bucket of buckets) {
189
+ const start = Date.parse(`${bucket.start}T00:00:00Z`)
190
+ const end = Date.parse(`${bucket.end}T00:00:00Z`)
191
+ for (let day = start; day <= end; day += 86_400_000)
192
+ dates.push(new Date(day).toISOString().slice(0, 10))
193
+ }
194
+ // Match the Overview's first-to-last-day axis, including a shorter first bucket.
195
+ const positions = dates.map((_, i) => dates.length === 1 ? 0 : i / (dates.length - 1) * buckets.length - 0.5)
196
+ return { dates, positions }
197
+ }