@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,177 @@
1
+ <script setup lang="ts">
2
+ import { computed, useId } from 'vue'
3
+
4
+ const { seed, size = 32, label, colors } = defineProps<{
5
+ seed: string
6
+ size?: number
7
+ label?: string
8
+ /**
9
+ * Hex palette the avatar draws from. Defaults to the Nuxt SEO palette; a site
10
+ * with its own brand passes its own. Six-digit hex only: the face colour is
11
+ * picked for contrast from it.
12
+ */
13
+ colors?: readonly string[]
14
+ }>()
15
+
16
+ /*
17
+ * Beam generator adapted from Boring Avatars:
18
+ * https://github.com/boringdesigners/boring-avatars
19
+ *
20
+ * MIT License
21
+ *
22
+ * Copyright (c) 2021 boringdesigners
23
+ *
24
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
25
+ * of this software and associated documentation files (the "Software"), to deal
26
+ * in the Software without restriction, including without limitation the rights
27
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
28
+ * copies of the Software, and to permit persons to whom the Software is
29
+ * furnished to do so, subject to the following conditions:
30
+ *
31
+ * The above copyright notice and this permission notice shall be included in all
32
+ * copies or substantial portions of the Software.
33
+ *
34
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+ * SOFTWARE.
41
+ */
42
+
43
+ const SIZE = 36
44
+ const AVATAR_COLORS = ['#8B5CF6', '#00DC82', '#F59E0B', '#EC4899', '#3B82F6'] as const
45
+
46
+ function hashCode(name: string): number {
47
+ let hash = 0
48
+ for (let index = 0; index < name.length; index++) {
49
+ const character = name.charCodeAt(index)
50
+ hash = ((hash << 5) - hash) + character
51
+ hash &= hash
52
+ }
53
+ return Math.abs(hash)
54
+ }
55
+
56
+ function getDigit(number: number, position: number): number {
57
+ return Math.floor((number / 10 ** position) % 10)
58
+ }
59
+
60
+ function getBoolean(number: number, position: number): boolean {
61
+ return getDigit(number, position) % 2 === 0
62
+ }
63
+
64
+ function getUnit(number: number, range: number, position?: number): number {
65
+ const value = number % range
66
+ return position && getDigit(number, position) % 2 === 0 ? -value : value
67
+ }
68
+
69
+ function getRandomColor(number: number, colors: readonly string[]): string {
70
+ return colors[number % colors.length]!
71
+ }
72
+
73
+ function getContrast(hexColor: string): string {
74
+ const color = hexColor.startsWith('#') ? hexColor.slice(1) : hexColor
75
+ const red = Number.parseInt(color.slice(0, 2), 16)
76
+ const green = Number.parseInt(color.slice(2, 4), 16)
77
+ const blue = Number.parseInt(color.slice(4, 6), 16)
78
+ const yiq = (red * 299 + green * 587 + blue * 114) / 1000
79
+ return yiq >= 128 ? '#000000' : '#FFFFFF'
80
+ }
81
+
82
+ function generateBeamData(name: string, colors: readonly string[]) {
83
+ const number = hashCode(name)
84
+ const wrapperColor = getRandomColor(number, colors)
85
+ const preTranslateX = getUnit(number, 10, 1)
86
+ const wrapperTranslateX = preTranslateX < 5 ? preTranslateX + SIZE / 9 : preTranslateX
87
+ const preTranslateY = getUnit(number, 10, 2)
88
+ const wrapperTranslateY = preTranslateY < 5 ? preTranslateY + SIZE / 9 : preTranslateY
89
+
90
+ return {
91
+ wrapperColor,
92
+ faceColor: getContrast(wrapperColor),
93
+ backgroundColor: getRandomColor(number + 13, colors),
94
+ wrapperTranslateX,
95
+ wrapperTranslateY,
96
+ wrapperRotate: getUnit(number, 360),
97
+ wrapperScale: 1 + getUnit(number, SIZE / 12) / 10,
98
+ isMouthOpen: getBoolean(number, 2),
99
+ isCircle: getBoolean(number, 1),
100
+ eyeSpread: getUnit(number, 5),
101
+ mouthSpread: getUnit(number, 3),
102
+ faceRotate: getUnit(number, 10, 3),
103
+ faceTranslateX: wrapperTranslateX > SIZE / 6 ? wrapperTranslateX / 2 : getUnit(number, 8, 1),
104
+ faceTranslateY: wrapperTranslateY > SIZE / 6 ? wrapperTranslateY / 2 : getUnit(number, 7, 2),
105
+ }
106
+ }
107
+
108
+ const data = computed(() => generateBeamData(seed, colors?.length ? colors : AVATAR_COLORS))
109
+ const id = useId().replace(/[^a-z0-9]/gi, '')
110
+ const maskId = `avatar-mask-${id}`
111
+ const ditherId = `avatar-dither-${id}`
112
+ const wrapperTransform = computed(() => [
113
+ `translate(${data.value.wrapperTranslateX} ${data.value.wrapperTranslateY})`,
114
+ `rotate(${data.value.wrapperRotate} ${SIZE / 2} ${SIZE / 2})`,
115
+ `scale(${data.value.wrapperScale})`,
116
+ ].join(' '))
117
+ const faceTransform = computed(() => [
118
+ `translate(${data.value.faceTranslateX} ${data.value.faceTranslateY})`,
119
+ `rotate(${data.value.faceRotate} ${SIZE / 2} ${SIZE / 2})`,
120
+ ].join(' '))
121
+ </script>
122
+
123
+ <template>
124
+ <span
125
+ class="ui-dither-avatar inline-flex shrink-0 overflow-hidden rounded-full"
126
+ :style="{ width: `${size}px`, height: `${size}px` }"
127
+ :role="label ? 'img' : undefined"
128
+ :aria-label="label"
129
+ :aria-hidden="label ? undefined : 'true'"
130
+ >
131
+ <svg viewBox="0 0 36 36" fill="none" class="size-full" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
132
+ <defs>
133
+ <mask :id="maskId" maskUnits="userSpaceOnUse" x="0" y="0" width="36" height="36">
134
+ <rect width="36" height="36" rx="72" fill="white" />
135
+ </mask>
136
+ <pattern :id="ditherId" width="4" height="4" patternUnits="userSpaceOnUse">
137
+ <circle cx="0.8" cy="0.8" r="0.55" :fill="data.faceColor" opacity="0.2" />
138
+ <circle cx="2.8" cy="2.8" r="0.4" :fill="data.faceColor" opacity="0.12" />
139
+ </pattern>
140
+ </defs>
141
+
142
+ <g :mask="`url(#${maskId})`">
143
+ <rect width="36" height="36" :fill="data.backgroundColor" />
144
+ <rect
145
+ width="36"
146
+ height="36"
147
+ :transform="wrapperTransform"
148
+ :fill="data.wrapperColor"
149
+ :rx="data.isCircle ? 36 : 6"
150
+ />
151
+ <g :transform="faceTransform">
152
+ <path
153
+ v-if="data.isMouthOpen"
154
+ :d="`M15 ${19 + data.mouthSpread}c2 1 4 1 6 0`"
155
+ :stroke="data.faceColor"
156
+ fill="none"
157
+ stroke-linecap="round"
158
+ />
159
+ <path
160
+ v-else
161
+ :d="`M13,${19 + data.mouthSpread} a1,0.75 0 0,0 10,0`"
162
+ :fill="data.faceColor"
163
+ />
164
+ <rect :x="14 - data.eyeSpread" y="14" width="1.5" height="2" rx="1" :fill="data.faceColor" />
165
+ <rect :x="20 + data.eyeSpread" y="14" width="1.5" height="2" rx="1" :fill="data.faceColor" />
166
+ </g>
167
+ <rect width="36" height="36" :fill="`url(#${ditherId})`" />
168
+ </g>
169
+ </svg>
170
+ </span>
171
+ </template>
172
+
173
+ <style scoped>
174
+ .ui-dither-avatar {
175
+ background: var(--ui-bg-elevated);
176
+ }
177
+ </style>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const { dotClass, dotSize = '1.5', label } = defineProps<{
3
+ /** Tailwind bg class for the dot, e.g. 'bg-blue-500' or vizDotColor key result */
4
+ dotClass: string
5
+ /** Dot size token: '1.5' (default, inline/legend), '2' (medium), '2.5' (hero) */
6
+ dotSize?: '1.5' | '2' | '2.5'
7
+ /** Optional text label rendered after the dot */
8
+ label?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <span class="inline-flex items-center gap-1.5">
14
+ <span class="rounded-full shrink-0" :class="[dotClass, `size-${dotSize}`]" aria-hidden="true" />
15
+ <slot>
16
+ <span v-if="label">{{ label }}</span>
17
+ </slot>
18
+ </span>
19
+ </template>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
3
+ import type { RouteLocationRaw } from 'vue-router'
4
+ import type { EjectAction, EjectChat, EjectLink, EjectMcp } from './eject-menu'
5
+ import type { EjectTrackAction } from './eject-tracker'
6
+ import { useClipboard } from '@vueuse/core'
7
+ import { useRoute, useState } from 'nuxt/app'
8
+ import { computed } from 'vue'
9
+ import { UDropdownMenu, UiButton } from '#components'
10
+ import { useToast } from '#imports'
11
+ import { buildChatDeepLinkQuery, serializeCliCommand, serializeMcpCall } from './eject-menu'
12
+ import { useEjectTracker } from './eject-tracker'
13
+
14
+ const {
15
+ mcp,
16
+ chat,
17
+ schema,
18
+ actions,
19
+ track,
20
+ size = 'xs',
21
+ align = 'end',
22
+ } = defineProps<{
23
+ /**
24
+ * The eject payload. Always the structured shape: the menu derives the CLI
25
+ * command from `tool` + `site` through `serializeCliCommand`, so a
26
+ * pre-serialised string would silently cost the row its CLI item. There is
27
+ * deliberately no `cli` prop — every copied command comes from the one
28
+ * serialiser, which is what keeps `--site` on all of them.
29
+ */
30
+ mcp?: EjectMcp
31
+ chat?: EjectChat
32
+ schema?: EjectLink
33
+ actions?: EjectAction[]
34
+ /** Optional call-site enrichment for eject telemetry (the menu fills action + surface). */
35
+ track?: { kind?: string, variant?: string }
36
+ size?: 'xs' | 'sm' | 'md'
37
+ align?: 'start' | 'center' | 'end'
38
+ }>()
39
+
40
+ const emit = defineEmits<{
41
+ select: [action: EjectTrackAction]
42
+ }>()
43
+
44
+ const toast = useToast()
45
+ const { copy } = useClipboard({ legacy: true })
46
+
47
+ // Auto-report every code-eject through the design-system telemetry seam.
48
+ // pro-features provides the sink (→ trackPromptEject); absent a provider this
49
+ // no-ops, so the menu stays usable outside the pro app.
50
+ const trackEject = useEjectTracker()
51
+ function fire(action: EjectTrackAction) {
52
+ trackEject({ action, kind: track?.kind, variant: track?.variant })
53
+ emit('select', action)
54
+ }
55
+
56
+ // Per-user AI opt-out (written by useAiSettings via the dashboard layouts). When
57
+ // off, drop the LLM-bound eject items (MCP call + AI chat); cURL/CLI/schema stay.
58
+ const aiEnabled = useState<boolean>('pro:ai-enabled', () => true)
59
+ // Site layouts set this from the same monitoring policy used by navigation and
60
+ // server work fences. Non-site surfaces retain the compatibility default.
61
+ const siteAiAvailable = useState<boolean>('pro:ai-site-available', () => true)
62
+
63
+ async function copyAs(label: string, payload: string) {
64
+ await copy(payload)
65
+ toast.add({
66
+ title: `${label} copied`,
67
+ icon: 'check',
68
+ color: 'success',
69
+ duration: 2000,
70
+ })
71
+ }
72
+
73
+ // URL contract is owned by layers/chat/app/internal/schemas/chat-deep-link.ts;
74
+ // the query itself is built by buildChatDeepLinkQuery so the surface's own query
75
+ // state (tabs, filters, sort) survives the eject.
76
+ const route = useRoute()
77
+
78
+ // Author-time guard for the entity mistake the ChatEntityRef template type
79
+ // can't catch: a raw URL, whose "https" prefix satisfies `${string}:${string}`
80
+ // but is no registered hydrate scheme — the eject would silently lose its
81
+ // grounding. Wrap with chatEntity.page(url) instead.
82
+ if (import.meta.dev && chat?.entity) {
83
+ const scheme = chat.entity.slice(0, chat.entity.indexOf(':'))
84
+ if (!/^[a-z][a-z-]*$/.test(scheme) || scheme === 'http' || scheme === 'https')
85
+ console.warn(`[UiEjectMenu] chat.entity "${chat.entity}" has no registered hydrate scheme — build it via chatEntity.* (eject-menu.ts) or hydration silently skips.`)
86
+ }
87
+
88
+ function buildChatTarget(c: EjectChat): RouteLocationRaw {
89
+ // No explicit target: navigate to the current route with the deep-link keys
90
+ // merged over its existing query, which the in-place slide-over picks up and
91
+ // self-opens on.
92
+ if (!c.to)
93
+ return { query: buildChatDeepLinkQuery(route.query, c) }
94
+ if (typeof c.to === 'string')
95
+ return { path: c.to, query: buildChatDeepLinkQuery({}, c) }
96
+ return { ...c.to, query: buildChatDeepLinkQuery(c.to.query ?? {}, c) }
97
+ }
98
+
99
+ // ADR-0124: the CLI leads the eject set. It is NOT gated on `aiEnabled` —
100
+ // a terminal command is not an LLM call, the same reason cURL/schema stay.
101
+ const cliCommand = computed<string | null>(() => mcp ? serializeCliCommand(mcp) : null)
102
+
103
+ const ejectItems = computed<DropdownMenuItem[]>(() => {
104
+ const out: DropdownMenuItem[] = []
105
+ if (cliCommand.value) {
106
+ const command = cliCommand.value
107
+ out.push({
108
+ label: 'Copy as CLI command',
109
+ icon: 'terminal',
110
+ onSelect: () => {
111
+ fire('copy-cli')
112
+ copyAs('CLI command', command)
113
+ },
114
+ })
115
+ }
116
+ if (mcp && aiEnabled.value) {
117
+ const payload = serializeMcpCall(mcp)
118
+ out.push({
119
+ label: 'Copy as MCP call',
120
+ icon: 'plug',
121
+ onSelect: () => {
122
+ fire('copy-mcp')
123
+ copyAs('MCP call', payload)
124
+ },
125
+ })
126
+ }
127
+ if (chat && aiEnabled.value && siteAiAvailable.value) {
128
+ out.push({
129
+ label: 'Open in AI Chat',
130
+ icon: 'bot',
131
+ to: buildChatTarget(chat),
132
+ onSelect: () => fire('open-chat'),
133
+ })
134
+ }
135
+ if (schema) {
136
+ out.push({
137
+ label: schema.label ?? 'View API schema',
138
+ icon: 'braces',
139
+ to: schema.to,
140
+ onSelect: () => fire('view-schema'),
141
+ })
142
+ }
143
+ return out
144
+ })
145
+
146
+ const actionItems = computed<DropdownMenuItem[]>(() =>
147
+ (actions ?? []).map(a => ({ label: a.label, icon: a.icon, disabled: a.disabled, onSelect: a.onSelect })),
148
+ )
149
+
150
+ // Actions lead, eject-to-code follows; grouped (nested arrays) so @nuxt/ui draws
151
+ // a separator between them. Either group alone renders flat.
152
+ const items = computed<DropdownMenuItem[] | DropdownMenuItem[][]>(() => {
153
+ if (actionItems.value.length && ejectItems.value.length)
154
+ return [actionItems.value, ejectItems.value]
155
+ return actionItems.value.length ? actionItems.value : ejectItems.value
156
+ })
157
+
158
+ // Render whenever there are items. We deliberately DON'T gate on clipboard
159
+ // `isSupported`: it's false during SSR and true on the client, so gating here
160
+ // makes the menu (and every reka dropdown id after it) diverge across hydration.
161
+ // `useClipboard({ legacy: true })` falls back to execCommand, so copy still works
162
+ // where the async Clipboard API is unavailable.
163
+ const hasAny = computed(() => actionItems.value.length > 0 || ejectItems.value.length > 0)
164
+ </script>
165
+
166
+ <template>
167
+ <UDropdownMenu
168
+ v-if="hasAny"
169
+ :items="items"
170
+ :content="{ align, sideOffset: 6 }"
171
+ :ui="{ content: 'ui-popover-content' }"
172
+ >
173
+ <UiButton
174
+ purpose="quiet"
175
+ :size="size"
176
+ icon="more-horizontal"
177
+ aria-label="Eject this view to code"
178
+ class="min-h-11 min-w-11 sm:min-h-0 sm:min-w-0"
179
+ />
180
+ </UDropdownMenu>
181
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import type { EjectChat, EjectLink, EjectMcp } from './eject-menu'
3
+ import { UiEjectMenu } from '#components'
4
+
5
+ /**
6
+ * Hover-revealed eject affordance pinned to the top-right corner of a chart or
7
+ * card that has no header to host a `<UiEjectMenu>` (ProCardGsc, the CWV grid,
8
+ * funnels, …). Wrap the visual; the `⋯` overlays its corner, fading in on hover
9
+ * so it doesn't spend layout or attention until wanted.
10
+ *
11
+ * Touch devices (no hover) always show it — `[@media(hover:none)]` — and keyboard
12
+ * users get it via `focus-within`. The wrapper owns the positioning context, so
13
+ * the wrapped element must fill it (the default block flow does).
14
+ */
15
+ defineProps<{
16
+ chat?: EjectChat
17
+ mcp?: EjectMcp
18
+ schema?: EjectLink
19
+ }>()
20
+ </script>
21
+
22
+ <template>
23
+ <div class="group/eject relative">
24
+ <slot />
25
+ <div class="absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover/eject:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
26
+ <UiEjectMenu :chat="chat" :mcp="mcp" :schema="schema" />
27
+ </div>
28
+ </div>
29
+ </template>
@@ -0,0 +1,235 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useAttrs, useTemplateRef, watch } from 'vue'
3
+ import { UiIcon } from '#components'
4
+ import { useFaviconBacking } from '../../composables/useFaviconBacking'
5
+ import { faviconFallbackInitial, faviconProxyPath, isBlankFaviconSize } from '../../utils/favicon-fallback'
6
+
7
+ // Three conditional root branches below (loading / fallback / favicon) mean
8
+ // Vue's automatic single-root attrs fallthrough is unreliable across SSR vs
9
+ // client hydration — callers commonly pass `class="shrink-0 rounded ..."`
10
+ // straight onto `<UiFavicon>`, and the SSR render silently dropped it while
11
+ // the client vdom kept it, producing a "Hydration class mismatch" warning on
12
+ // every favicon call site that passes a class (UiSkeleton uses the same fix
13
+ // for the same reason). Forward `class` explicitly and identically on every
14
+ // branch instead of relying on implicit fallthrough.
15
+ defineOptions({ inheritAttrs: false })
16
+
17
+ /**
18
+ * Site favicon with a deterministic initials fallback. Google s2 favicons fail,
19
+ * 404 to a generic globe, or are indistinguishable across related domains, so on
20
+ * load error (or a missing domain) we render a neutral box with the registrable
21
+ * name's first letter. Always pair with the site/group label in the caller — the
22
+ * favicon is identity reinforcement, never the sole identifier.
23
+ *
24
+ * Accepts a bare hostname, a full URL, or an `sc-domain:` GSC property; all are
25
+ * normalised to a hostname, so callers don't need to pre-strip.
26
+ *
27
+ * Served through `/api/favicon` (same-origin proxy) so the image can be sampled
28
+ * on a canvas for an adaptive contrast tile — see useFaviconBacking. Favicons
29
+ * whose ink would vanish against the current theme (e.g. a black glyph on the
30
+ * dark sidebar) get a `bg-inverted` tile in that theme only; everything else
31
+ * renders bare and keeps its brand colour.
32
+ */
33
+ const { domain, size = 20, alt = '', loading = false, decorative = false, fallbackLabel = '', fallbackSurface = 'neutral', dither = false } = defineProps<{
34
+ domain: string
35
+ size?: number
36
+ alt?: string
37
+ /** Render a spinner in place of the favicon (e.g. while the site is being
38
+ * validated/added). Keeps the same footprint so the row doesn't shift. */
39
+ loading?: boolean
40
+ /** The site name is already shown as adjacent text — mark the favicon
41
+ * `aria-hidden` so screen readers don't announce the host twice. */
42
+ decorative?: boolean
43
+ /** Prefer this visible Site label when deriving the fallback initial. */
44
+ fallbackLabel?: string
45
+ /** Let a first-party identity wrapper supply the fallback surface. */
46
+ fallbackSurface?: 'neutral' | 'transparent'
47
+ /**
48
+ * Quiet monochrome mark for rows that shouldn't compete with a heavier
49
+ * sibling column (Overview's Next Actions / Recent Activity beside Sites).
50
+ * CSS-only: desaturates and softens the image and stipples it with a fine
51
+ * dot mask. The fallback initial keeps the same opacity but no mask — a
52
+ * single letter has nothing to lose by disappearing into dots.
53
+ */
54
+ dither?: boolean
55
+ }>()
56
+
57
+ const cleanDomain = computed(() =>
58
+ (domain ?? '')
59
+ .replace(/^sc-domain:/, '')
60
+ .replace(/^https?:\/\//, '')
61
+ .replace(/\/.*$/, '')
62
+ .trim(),
63
+ )
64
+ const src = computed(() => faviconProxyPath(cleanDomain.value))
65
+
66
+ // Decorative favicons (the caller already shows the site name as text) go
67
+ // `aria-hidden` so the host isn't announced twice; otherwise expose it as an image.
68
+ const a11y = computed(() =>
69
+ decorative
70
+ ? { 'aria-hidden': 'true' as const }
71
+ : { 'role': 'img', 'aria-label': alt || cleanDomain.value || 'Unknown site' },
72
+ )
73
+
74
+ const failed = ref(false)
75
+ const faviconImage = useTemplateRef<HTMLImageElement>('faviconImage')
76
+ // SSR images can finish before hydration attaches load and error listeners.
77
+ // Reconcile their actual DOM state after each source update: a blank image
78
+ // falls back, and a loaded one is sampled for its backing, because its `load`
79
+ // event never reaches `handleLoad`.
80
+ watch([cleanDomain, faviconImage], () => {
81
+ const image = faviconImage.value
82
+ if (!image?.complete)
83
+ return
84
+ if (image.naturalWidth === 0 || isBlankFaviconSize(image.naturalWidth, image.naturalHeight))
85
+ failed.value = true
86
+ else
87
+ sample(image)
88
+ }, { flush: 'post' })
89
+ // Reset the error state if the domain changes (list rows reuse the node).
90
+ watch(cleanDomain, () => { failed.value = false })
91
+
92
+ const initial = computed(() => faviconFallbackInitial(fallbackLabel, cleanDomain.value))
93
+ const showFallback = computed(() => failed.value || !cleanDomain.value)
94
+
95
+ // We never touch the favicon — it always renders at full size and pops. The
96
+ // contrast fix is purely a background placed *behind* it, which shows through the
97
+ // favicon's transparent areas. `bg-inverted` is theme-aware (light on dark themes,
98
+ // dark on light themes) and we only ever render the chip in the theme where the
99
+ // favicon would vanish, so a single softened token covers both cases: the 80%
100
+ // opacity keeps it a muted chip rather than a stark card while staying light/dark
101
+ // enough for the glyph to read.
102
+ const { backing, sample } = useFaviconBacking(() => cleanDomain.value)
103
+ function handleLoad(event: Event) {
104
+ const image = event.currentTarget
105
+ if (!(image instanceof HTMLImageElement))
106
+ return
107
+ if (isBlankFaviconSize(image.naturalWidth, image.naturalHeight)) {
108
+ failed.value = true
109
+ return
110
+ }
111
+ sample(image)
112
+ }
113
+ const attrs = useAttrs()
114
+ </script>
115
+
116
+ <template>
117
+ <span
118
+ v-if="loading"
119
+ v-bind="attrs"
120
+ class="inline-flex items-center justify-center shrink-0 text-muted"
121
+ :style="{ width: `${size}px`, height: `${size}px` }"
122
+ role="img"
123
+ :aria-label="alt || cleanDomain || 'Loading'"
124
+ >
125
+ <UiIcon name="loading" class="animate-spin motion-reduce:animate-none" :style="{ width: `${size}px`, height: `${size}px` }" />
126
+ </span>
127
+ <span
128
+ v-else-if="showFallback"
129
+ v-bind="{ ...attrs, ...a11y }"
130
+ class="inline-flex items-center justify-center rounded border font-semibold shrink-0 leading-none select-none"
131
+ :class="[
132
+ fallbackSurface === 'transparent'
133
+ ? 'border-transparent bg-transparent'
134
+ : 'border-default bg-elevated text-muted',
135
+ dither ? 'ui-favicon-dither-fallback' : '',
136
+ ]"
137
+ :style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
138
+ >{{ initial }}</span>
139
+ <!--
140
+ Always the SAME element shape (span > img) regardless of `backing` — only
141
+ its classes react. `backing` flips from a client-only canvas sample
142
+ (`useFaviconBacking`) that can resolve via `onMounted`/`load` before a
143
+ later same-domain favicon elsewhere on the page finishes its OWN hydration
144
+ compare (Suspense-chunked hydration interleaves mount callbacks between
145
+ chunks). A `v-if`/`v-else` swap between a bare `<img>` and a wrapping
146
+ `<span>` turned that race into a STRUCTURAL SSR/client mismatch — extra
147
+ wrapper element, not just a class — which cascades into 6-7 "Hydration
148
+ node/children mismatch" warnings per row (every repeated favicon site
149
+ board / portfolio queue row renders). A class-only toggle can never
150
+ mismatch this way: attribute differences patch silently, node-count
151
+ differences don't happen because the node never comes or goes.
152
+ -->
153
+ <span
154
+ v-else
155
+ v-bind="attrs"
156
+ class="inline-grid place-items-center rounded shrink-0"
157
+ :class="[backing !== 'none' ? 'bg-inverted/80 ring-1 ring-default' : '', dither ? 'ui-favicon-dithered' : '']"
158
+ :style="{ width: `${size}px`, height: `${size}px` }"
159
+ >
160
+ <img
161
+ ref="faviconImage"
162
+ :src="src"
163
+ :alt="decorative ? '' : alt"
164
+ :aria-hidden="decorative ? 'true' : undefined"
165
+ :width="size"
166
+ :height="size"
167
+ loading="lazy"
168
+ decoding="async"
169
+ class="rounded"
170
+ :class="dither ? 'ui-favicon-dither' : ''"
171
+ @load="handleLoad"
172
+ @error="failed = true"
173
+ >
174
+ </span>
175
+ </template>
176
+
177
+ <style scoped>
178
+ /*
179
+ * `dither` — a quiet mark for rows beside a heavier sibling column. Two
180
+ * layers, both transitionable: the image loses most of its saturation and
181
+ * some contrast, and a 2px stipple of the page background sits over it so a
182
+ * saturated brand mark reads as texture rather than a pop of colour. Colour
183
+ * is muted, never removed: at a glance the mark stays recognisable. Hovering
184
+ * the row (any `a`, `button`, or `.group` ancestor) fades both layers away,
185
+ * so the favicon comes back to full colour under the pointer.
186
+ */
187
+ .ui-favicon-dithered {
188
+ position: relative;
189
+ isolation: isolate;
190
+ }
191
+
192
+ .ui-favicon-dithered::after {
193
+ content: '';
194
+ position: absolute;
195
+ inset: 0;
196
+ border-radius: inherit;
197
+ background-image: radial-gradient(circle, var(--ui-bg) 38%, transparent 44%);
198
+ background-size: 2px 2px;
199
+ opacity: .55;
200
+ pointer-events: none;
201
+ transition: opacity 180ms var(--ease-standard, ease);
202
+ }
203
+
204
+ .ui-favicon-dither {
205
+ filter: saturate(.35) contrast(.9) opacity(.85);
206
+ transition: filter 180ms var(--ease-standard, ease);
207
+ }
208
+
209
+ :is(a, button, .group):hover .ui-favicon-dithered::after {
210
+ opacity: 0;
211
+ }
212
+
213
+ :is(a, button, .group):hover .ui-favicon-dither {
214
+ filter: none;
215
+ }
216
+
217
+ @media (prefers-reduced-motion: reduce) {
218
+ .ui-favicon-dithered::after,
219
+ .ui-favicon-dither {
220
+ transition: none;
221
+ }
222
+ }
223
+
224
+ /* The fallback initial is text, not an image — dropping its opacity to match
225
+ is enough to read as "quiet"; stippling a single letter makes it illegible
226
+ instead, which the dither treatment must never do. */
227
+ .ui-favicon-dither-fallback {
228
+ opacity: .7;
229
+ transition: opacity 180ms var(--ease-standard, ease);
230
+ }
231
+
232
+ :is(a, button, .group):hover .ui-favicon-dither-fallback {
233
+ opacity: 1;
234
+ }
235
+ </style>