@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,359 @@
1
+ import type { LocationQueryRaw, RouteLocationRaw } from 'vue-router'
2
+ import { CLI_INVOCATION } from '../../shared/cli'
3
+
4
+ /**
5
+ * Eject-to-code / eject-to-chat payload shapes shared by `<UiEjectMenu>` and any
6
+ * primitive that embeds it (e.g. `<UiCard chat>`). The URL contract for the chat
7
+ * deep-link is owned by `layers/chat/app/internal/schemas/chat-deep-link.ts`; keep
8
+ * the `entity` / `filter` / `seed` fields in sync with that schema.
9
+ */
10
+ export interface EjectChat {
11
+ /**
12
+ * Navigation target. **Default (omit `to`): open the contextual chat
13
+ * slide-over in place** on the current route — <ProChatPanel> self-opens on
14
+ * the `?seed=&entity=&filter=` query everywhere except `/chat` routes. This is
15
+ * the rule for every data-surface eject (card / row / section): stay in
16
+ * context. Only set `to: '/pro/dashboard/chat'` (full-page chat) in the two
17
+ * cases where an in-place slide-over can't or shouldn't open: (1) the eject
18
+ * lives inside another open overlay/modal, where a slide-over would stack; (2)
19
+ * a global launcher (command palette) where chat IS the destination.
20
+ */
21
+ to?: RouteLocationRaw
22
+ entity?: ChatEntityRef
23
+ filter?: Record<string, unknown>
24
+ seed?: string
25
+ // When true, the chat fires `sendMessage(seed)` automatically once hydrate
26
+ // resolves. Convention: eject seeds are complete, self-contained questions
27
+ // built from real card/row state, so they auto-send (`true`) — the user gets
28
+ // an answer on open, not a half-typed box. Leave unset only when the seed is a
29
+ // deliberate starting point the user is expected to edit before sending.
30
+ autoSend?: boolean
31
+ }
32
+
33
+ export interface EjectLink {
34
+ to: RouteLocationRaw
35
+ label?: string
36
+ }
37
+
38
+ export interface EjectMcp {
39
+ tool: string
40
+ args?: Record<string, unknown>
41
+ /**
42
+ * The Site this payload is about, as the `nuxtseo` CLI's `--site` expects it:
43
+ * the Site public id (`s_…`) that `nuxtseo sites list` prints. The canonical
44
+ * site id resolves too, so either is safe; a URL or a bare domain is NOT the
45
+ * same identifier and must never be passed here.
46
+ *
47
+ * `args.siteUrl` cannot stand in for it. That field is the MCP wire argument,
48
+ * a URL, and the CLI has no flag that takes one.
49
+ *
50
+ * Omit it only for an account-scoped tool. A site-scoped tool without it
51
+ * yields no CLI item at all (`serializeCliCommand` returns null) rather than a
52
+ * command that exits 5 `site_ambiguous` for anyone with two or more Sites.
53
+ * Pro surfaces build this payload as `ProMcpToolCall`, whose site-scoped
54
+ * variant makes the field REQUIRED, so a call site cannot forget it.
55
+ */
56
+ site?: string
57
+ }
58
+
59
+ /**
60
+ * Serialise an MCP eject payload in the MCP tools/call wire shape
61
+ * (`arguments`, not our internal `args`), so anything an agent or MCP inspector
62
+ * accepts can take it verbatim. Single owner of the copied string; UiEjectMenu
63
+ * and any bespoke menu must serialise through this.
64
+ *
65
+ * The payload is always the structured shape, never a pre-serialised string: a
66
+ * string carries no `tool` and no `site`, so the menu could not derive the CLI
67
+ * command that leads the eject set (ADR-0124). `ProActionRow` passed one for a
68
+ * while and silently lost its CLI item.
69
+ */
70
+ export function serializeMcpCall(mcp: EjectMcp): string {
71
+ return JSON.stringify({ tool: mcp.tool, arguments: mcp.args ?? {} }, null, 2)
72
+ }
73
+
74
+ /**
75
+ * MCP tool → shipped `nuxtseo` CLI command. ADR-0124 makes the CLI the primary
76
+ * access surface, so the eject menu leads with a CLI command wherever one
77
+ * exists.
78
+ *
79
+ * Only tools whose command SHIPS today appear here. An unmapped tool returns
80
+ * null and the menu simply omits the item — a copied command that does not
81
+ * exist is the same broken promise the cURL ban used to guard against.
82
+ *
83
+ * `crux_vitals` (and its deprecated `performance` alias) is deliberately absent:
84
+ * it reads CrUX field p75, while the `performance` CLI command reads the
85
+ * Lighthouse lab overview. A mapping would copy a command that answers a
86
+ * different question.
87
+ */
88
+ type CliArgs = Record<string, unknown>
89
+
90
+ /**
91
+ * The raw pieces of one copied command, NOT a rendered string.
92
+ *
93
+ * An argument value may itself contain ` --` (a keyword like
94
+ * `best tools -- 2026 guide`, already legal under `shellArg` quoting), so any
95
+ * insertion point derived by splitting the rendered command lands INSIDE a
96
+ * quoted value and mangles it. Composing tokens and ordered flags structurally
97
+ * keeps every insertion point structural: `--site` joins between the root
98
+ * tokens and the command's own flags, whatever the values contain.
99
+ */
100
+ interface CliFlag {
101
+ name: string
102
+ /** Omitted for a valueless flag; raw (unquoted) when present. */
103
+ value?: string
104
+ }
105
+
106
+ interface CliCommand {
107
+ /** Root positional tokens before any flag; raw (unquoted). */
108
+ tokens: string[]
109
+ /** Command-specific flags, ordered as the CLI documents them. */
110
+ flags: CliFlag[]
111
+ }
112
+
113
+ function cliCommand(tokens: string[], flags: Array<CliFlag | null>): CliCommand {
114
+ return { tokens, flags: flags.filter((entry): entry is CliFlag => entry !== null) }
115
+ }
116
+
117
+ // Quote only when the shell would otherwise split or interpret the value. A
118
+ // bare `actions show t_9` is what the user would type; `actions show "t_9"`
119
+ // reads like machine output and invites a copy that keeps the quotes.
120
+ function shellArg(value: string): string {
121
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
122
+ }
123
+
124
+ /** Renders one flag, quoting its value at emit time. */
125
+ function renderFlag(entry: CliFlag): string {
126
+ return entry.value === undefined ? `--${entry.name}` : `--${entry.name} ${shellArg(entry.value)}`
127
+ }
128
+
129
+ function optionalFlag(args: CliArgs, key: string, name: string): CliFlag | null {
130
+ const value = args[key]
131
+ if (value === undefined || value === null || value === '')
132
+ return null
133
+ return { name, value: String(value) }
134
+ }
135
+
136
+ /**
137
+ * Every command below except `usage` and `sites list` resolves a Site before it
138
+ * runs (`apiAndSite` in `packages/cli/src/commands.ts`). Without `--site`, a
139
+ * non-interactive run of one of those exits 5 `site_ambiguous` the moment the
140
+ * token can see two Sites, so `scope: 'site'` is what makes the menu omit the
141
+ * item instead of copying a command that fails.
142
+ */
143
+ interface CliCommandSpec {
144
+ scope: 'site' | 'account'
145
+ /**
146
+ * Null when the payload cannot make a runnable command — a required positional
147
+ * the args do not carry. The menu then omits the item, the same as for an
148
+ * unmapped tool. `nuxtseo research keywords --site s_x --json` with no topic
149
+ * exits 2 `invalid_cli_input`, which is a broken copy just as surely as an
150
+ * exit 5 is.
151
+ */
152
+ build: (args: CliArgs) => CliCommand | null
153
+ }
154
+
155
+ function text(args: CliArgs, ...keys: string[]): string | undefined {
156
+ for (const key of keys) {
157
+ const value = args[key]
158
+ if (typeof value === 'string' && value.trim())
159
+ return value.trim()
160
+ }
161
+ return undefined
162
+ }
163
+
164
+ // `search analytics <view>` takes the same view vocabulary `gsc_query` does
165
+ // (pages · keywords · countries · devices · timeseries · page-detail ·
166
+ // keyword-detail · analysis), so the copied command answers the question the
167
+ // ejected section asks. It used to hardcode `keywords`, which meant ejecting the
168
+ // Top pages card copied a keywords read.
169
+ const GSC_QUERY_ROW_VIEWS = new Set(['pages', 'keywords', 'countries', 'devices', 'timeseries'])
170
+
171
+ // The three views the CLI refuses without one more argument. Each names the arg
172
+ // it needs on both surfaces, so a payload either carries it or the view has no
173
+ // runnable command.
174
+ const GSC_QUERY_DETAIL_VIEWS: Record<string, { argument: string, flag: string }> = {
175
+ 'page-detail': { argument: 'pageUrl', flag: 'page-url' },
176
+ 'keyword-detail': { argument: 'keyword', flag: 'keyword' },
177
+ 'analysis': { argument: 'preset', flag: 'preset' },
178
+ }
179
+
180
+ // Analysis presets that additionally require `--brand-terms`; the CLI exits 2
181
+ // `invalid_cli_input` without one.
182
+ const GSC_QUERY_BRAND_PRESETS = new Set(['brand-only', 'non-brand'])
183
+
184
+ function gscQueryCommand(args: CliArgs): CliCommand | null {
185
+ const view = text(args, 'view') ?? 'keywords'
186
+ const detail = GSC_QUERY_DETAIL_VIEWS[view]
187
+ if (!detail && !GSC_QUERY_ROW_VIEWS.has(view))
188
+ return null
189
+ const required = detail ? optionalFlag(args, detail.argument, detail.flag) : null
190
+ if (detail && !required)
191
+ return null
192
+ const flags = [required, optionalFlag(args, 'period', 'period'), optionalFlag(args, 'limit', 'limit')]
193
+ const preset = text(args, 'preset') ?? ''
194
+ if (view === 'analysis' && GSC_QUERY_BRAND_PRESETS.has(preset)) {
195
+ const brandTerms = text(args, 'brandTerms')
196
+ // The brand-only/non-brand presets exit 2 without --brand-terms; absent
197
+ // beats degraded, same rule as the Site guard.
198
+ if (!brandTerms)
199
+ return null
200
+ return cliCommand(['search', 'analytics', view], [...flags, { name: 'brand-terms', value: brandTerms }])
201
+ }
202
+ return cliCommand(['search', 'analytics', view], flags)
203
+ }
204
+
205
+ /**
206
+ * `keyword_research` is three commands, not one. Its `type` selects which, and
207
+ * each takes a different required positional: `research` and `serp` take a
208
+ * topic, `rankings` takes a domain. The map used to emit `research keywords`
209
+ * for all three, so the dashboard's Keywords eject (`type: 'rankings'`) copied a
210
+ * keyword-ideas command with no topic at all.
211
+ */
212
+ function keywordResearchCommand(args: CliArgs): CliCommand | null {
213
+ const type = text(args, 'type') ?? 'research'
214
+ if (type === 'rankings') {
215
+ const domain = text(args, 'domain')
216
+ return domain ? cliCommand(['research', 'rankings', domain], [optionalFlag(args, 'limit', 'limit')]) : null
217
+ }
218
+ const topic = text(args, 'topic', 'keyword', 'seed')
219
+ if (!topic)
220
+ return null
221
+ return type === 'serp'
222
+ ? cliCommand(['research', 'serp', topic], [optionalFlag(args, 'depth', 'depth')])
223
+ : cliCommand(['research', 'keywords', topic], [optionalFlag(args, 'limit', 'limit')])
224
+ }
225
+
226
+ const CLI_COMMAND_BY_MCP_TOOL: Record<string, CliCommandSpec> = {
227
+ account_usage: { scope: 'account', build: () => cliCommand(['usage'], []) },
228
+ list_sites: { scope: 'account', build: () => cliCommand(['sites', 'list'], []) },
229
+ backlinks: { scope: 'site', build: args => cliCommand(['backlinks', 'recoverable'], [optionalFlag(args, 'limit', 'limit')]) },
230
+ competitors: { scope: 'site', build: () => cliCommand(['research', 'overview'], []) },
231
+ content_decay: { scope: 'site', build: args => cliCommand(['audit', 'content-decay'], [optionalFlag(args, 'limit', 'limit')]) },
232
+ domain_info: { scope: 'site', build: () => cliCommand(['research', 'overview'], []) },
233
+ duplicate_clusters: { scope: 'site', build: () => cliCommand(['audit', 'duplicates'], []) },
234
+ gsc_status: { scope: 'site', build: () => cliCommand(['search', 'status'], []) },
235
+ gsc_query: { scope: 'site', build: gscQueryCommand },
236
+ inspect_url: { scope: 'site', build: (args) => {
237
+ const url = text(args, 'url', 'pageUrl')
238
+ return url ? cliCommand(['page', 'inspect', url], []) : null
239
+ } },
240
+ keyword_research: { scope: 'site', build: keywordResearchCommand },
241
+ link_opportunities: { scope: 'site', build: args => cliCommand(['audit', 'link-opportunities'], [optionalFlag(args, 'limit', 'limit')]) },
242
+ mentions: { scope: 'site', build: args => cliCommand(['mentions', 'list'], [optionalFlag(args, 'limit', 'limit')]) },
243
+ next_actions: { scope: 'site', build: args => cliCommand(['actions', 'list'], [optionalFlag(args, 'limit', 'limit')]) },
244
+ action_details: { scope: 'site', build: (args) => {
245
+ const actionId = text(args, 'actionId', 'id')
246
+ return actionId ? cliCommand(['actions', 'show', actionId], []) : null
247
+ } },
248
+ }
249
+
250
+ /**
251
+ * Build the `nuxtseo` command that answers the same question as an MCP eject
252
+ * payload, or null when the CLI cannot answer it. Single owner of the copied
253
+ * string, the way `serializeMcpCall` owns the MCP one.
254
+ *
255
+ * Three reasons to return null, and each means the menu shows no CLI item: the
256
+ * tool has no shipped command, the command needs a Site and the payload carries
257
+ * none, or the payload is missing an argument the command requires. Absent beats
258
+ * degraded — a copied `nuxtseo actions list --json` with no `--site` exits 5 for
259
+ * every Caller with more than one Site.
260
+ *
261
+ * `--site` sits directly after the command, before any command-specific flags,
262
+ * matching the shape the shipped agent skill documents. `--json` is always last:
263
+ * an agent parses the envelope, and a human reading the command can drop it.
264
+ */
265
+ export function serializeCliCommand(mcp: EjectMcp): string | null {
266
+ const spec = CLI_COMMAND_BY_MCP_TOOL[mcp.tool]
267
+ if (!spec)
268
+ return null
269
+ const site = typeof mcp.site === 'string' ? mcp.site.trim() : ''
270
+ if (spec.scope === 'site' && !site)
271
+ return null
272
+ const built = spec.build(mcp.args ?? {})
273
+ if (!built)
274
+ return null
275
+ // Structural composition, joined exactly once: root tokens, then --site,
276
+ // then the command's own flags in documented order, then --json. Values are
277
+ // quoted per value at emit time, so an argument containing ' --' can never
278
+ // be mistaken for a flag boundary.
279
+ const segments = [
280
+ ...built.tokens.map(shellArg),
281
+ ...(site ? [renderFlag({ name: 'site', value: site })] : []),
282
+ ...built.flags.map(renderFlag),
283
+ '--json',
284
+ ]
285
+ return [CLI_INVOCATION, ...segments].join(' ')
286
+ }
287
+
288
+ /**
289
+ * A first-class operation (not an eject-to-code copy), e.g. "Run checks now".
290
+ * Rendered as a leading group above the cURL/MCP/schema items so a row can carry
291
+ * its own actions through the menu it already has, instead of an extra button.
292
+ */
293
+ export interface EjectAction {
294
+ label: string
295
+ icon?: string
296
+ onSelect: () => void | Promise<void>
297
+ disabled?: boolean
298
+ }
299
+
300
+ /**
301
+ * A scheme-prefixed chat entity reference (`site:s_x`, `page:https://…`). The
302
+ * template type rejects the bare-id mistake at compile time; UiEjectMenu adds a
303
+ * dev-mode warning for the case the type can't catch (a raw URL, whose "https"
304
+ * prefix satisfies the pattern but is no registered scheme).
305
+ */
306
+ export type ChatEntityRef = `${string}:${string}`
307
+
308
+ /**
309
+ * Entity references the chat hydrate registry can resolve (`chat:hydrate` Nitro
310
+ * hook — site/page/query/issue in pro-gsc, competitor/keyword in pro-dataforseo,
311
+ * cwv in pro-perf, country in pro-gsc). Always build `EjectChat.entity` through
312
+ * these instead of string-interpolating: a bare id ("s_x…") or raw URL parses as
313
+ * no scheme / scheme "https" and the hydrate step silently skips, so the chat
314
+ * answers without the grounding the eject promised.
315
+ */
316
+ export const chatEntity = {
317
+ site: (id: string): `site:${string}` => `site:${id}`,
318
+ page: (url: string): `page:${string}` => `page:${url}`,
319
+ query: (keyword: string): `query:${string}` => `query:${keyword}`,
320
+ keyword: (keyword: string): `keyword:${string}` => `keyword:${keyword}`,
321
+ competitor: (domain: string): `competitor:${string}` => `competitor:${domain}`,
322
+ cwv: (metric: string): `cwv:${string}` => `cwv:${metric}`,
323
+ issue: (id: string): `issue:${string}` => `issue:${id}`,
324
+ country: (code: string): `country:${string}` => `country:${code}`,
325
+ } as const
326
+
327
+ /**
328
+ * Query keys owned by the chat deep-link contract (plus chat-page-only keys a
329
+ * handoff must never carry over). Kept in sync with
330
+ * `layers/chat/app/internal/schemas/chat-deep-link.ts`.
331
+ */
332
+ const CHAT_DEEP_LINK_KEYS = ['entity', 'filter', 'seed', 'autoSend', 'chatNonce', 'conversationId', 'context', 'prefill'] as const
333
+
334
+ /**
335
+ * Single builder for the chat deep-link query. Preserves the surface's own query
336
+ * state (filters, tabs, sort) by spreading `currentQuery` minus any stale
337
+ * deep-link keys — ejecting must never wipe the URL state the user set up,
338
+ * because `clear()` on the receiving side can only strip the deep-link keys, not
339
+ * restore what a bare `{ query }` navigation dropped.
340
+ */
341
+ export function buildChatDeepLinkQuery(
342
+ currentQuery: Record<string, unknown>,
343
+ c: Pick<EjectChat, 'entity' | 'filter' | 'seed' | 'autoSend'>,
344
+ ): LocationQueryRaw {
345
+ const rest: LocationQueryRaw = {}
346
+ for (const [k, v] of Object.entries(currentQuery)) {
347
+ if (!(CHAT_DEEP_LINK_KEYS as readonly string[]).includes(k))
348
+ rest[k] = v as LocationQueryRaw[string]
349
+ }
350
+ if (c.entity)
351
+ rest.entity = c.entity
352
+ if (c.filter)
353
+ rest.filter = JSON.stringify(c.filter)
354
+ if (c.seed)
355
+ rest.seed = c.seed
356
+ if (c.autoSend)
357
+ rest.autoSend = '1'
358
+ return rest
359
+ }
@@ -0,0 +1,34 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { inject } from 'vue'
3
+
4
+ /** Code-eject actions a UiEjectMenu can fire. Mirrors the menu's item set. */
5
+ export type EjectTrackAction = 'copy-cli' | 'copy-mcp' | 'open-chat' | 'view-schema'
6
+
7
+ /**
8
+ * A code-eject was invoked from a UiEjectMenu. `action` + the implied
9
+ * surface='eject-menu' are filled by the menu itself; `kind`/`variant` are
10
+ * optional call-site enrichment (the menu can't know its domain entity).
11
+ */
12
+ export interface EjectTrackEvent {
13
+ action: EjectTrackAction
14
+ /** Domain entity the menu was attached to, e.g. 'keyword' | 'competitor' | 'cwv'. */
15
+ kind?: string
16
+ /** Sub-variant within the kind (page goal, competitor focus, …). */
17
+ variant?: string
18
+ }
19
+
20
+ export type EjectTracker = (event: EjectTrackEvent) => void
21
+
22
+ /**
23
+ * Provide/inject seam so design-system's UiEjectMenu can emit eject telemetry
24
+ * without importing the pro-features tracker (layer rule: design-system MUST
25
+ * NOT depend on runtime services). pro-features provides the impl via an app
26
+ * plugin; absent a provider this no-ops.
27
+ */
28
+ export const ejectTrackerKey: InjectionKey<EjectTracker> = Symbol('eject-tracker')
29
+
30
+ const noop: EjectTracker = () => {}
31
+
32
+ export function useEjectTracker(): EjectTracker {
33
+ return inject(ejectTrackerKey, noop)
34
+ }
@@ -0,0 +1,68 @@
1
+ // The onboarding "About you" questions: display data only (id/label), ordered.
2
+ // The ids are shared with gscdump.com and Request Indexing, which keep a copy of
3
+ // this file. Change an id in all three repositories or not at all.
4
+ // The goal list is per product and lives in onboarding-intents.ts.
5
+ import { ONBOARDING_INTENTS } from './onboarding-intents'
6
+
7
+ export const ONBOARDING_ROLES = [
8
+ { id: 'solo', label: 'Solo developer' },
9
+ { id: 'agency', label: 'Agency or freelancer' },
10
+ { id: 'in-house', label: 'In-house team' },
11
+ { id: 'tool-builder', label: 'Building an SEO tool' },
12
+ { id: 'other', label: 'Something else' },
13
+ ] as const
14
+
15
+ export const ONBOARDING_SITE_COUNTS = [
16
+ { id: '1', label: '1' },
17
+ { id: '2-5', label: '2–5' },
18
+ { id: '6-20', label: '6–20' },
19
+ { id: '21+', label: '21+' },
20
+ ] as const
21
+
22
+ export const ONBOARDING_SEO_EXPERIENCE = [
23
+ { id: 'new', label: 'New to it' },
24
+ { id: 'basics', label: 'Know the basics' },
25
+ { id: 'professional', label: 'Do it for a living' },
26
+ ] as const
27
+
28
+ export type OnboardingRole = (typeof ONBOARDING_ROLES)[number]['id']
29
+ export type OnboardingSiteCount = (typeof ONBOARDING_SITE_COUNTS)[number]['id']
30
+ export type OnboardingSeoExperience = (typeof ONBOARDING_SEO_EXPERIENCE)[number]['id']
31
+
32
+ export const ONBOARDING_ROLE_IDS = ONBOARDING_ROLES.map(option => option.id) as [OnboardingRole, ...OnboardingRole[]]
33
+ export const ONBOARDING_SITE_COUNT_IDS = ONBOARDING_SITE_COUNTS.map(option => option.id) as [OnboardingSiteCount, ...OnboardingSiteCount[]]
34
+ export const ONBOARDING_SEO_EXPERIENCE_IDS = ONBOARDING_SEO_EXPERIENCE.map(option => option.id) as [OnboardingSeoExperience, ...OnboardingSeoExperience[]]
35
+ export const ONBOARDING_INTENT_IDS = ONBOARDING_INTENTS.map(intent => intent.id) as [string, ...string[]]
36
+
37
+ /** Free text under the goals question. The server schema enforces the same limit. */
38
+ export const ONBOARDING_OTHER_MAX_LENGTH = 500
39
+
40
+ export const ONBOARDING_QUESTIONS = {
41
+ role: 'What best describes you?',
42
+ siteCount: 'How many websites do you look after?',
43
+ seoExperience: 'How comfortable are you with SEO?',
44
+ intents: 'What do you want to do with Nuxt SEO Pro?',
45
+ } as const
46
+
47
+ /** Label lookup for stored ids. An unknown id renders as itself. */
48
+ export function onboardingAnswerLabel(options: readonly { id: string, label: string }[], id: string): string {
49
+ return options.find(option => option.id === id)?.label ?? id
50
+ }
51
+
52
+ /** The help step's unsaved answers. A single-choice question with no chip picked is `undefined`. */
53
+ export interface OnboardingAnswersDraft {
54
+ role?: OnboardingRole
55
+ siteCount?: OnboardingSiteCount
56
+ seoExperience?: OnboardingSeoExperience
57
+ intents: string[]
58
+ other: string
59
+ }
60
+
61
+ export function emptyOnboardingAnswersDraft(): OnboardingAnswersDraft {
62
+ return { intents: [], other: '' }
63
+ }
64
+
65
+ /** True when the user picked a chip or wrote text, so there is something to save. */
66
+ export function hasOnboardingAnswers(draft: OnboardingAnswersDraft): boolean {
67
+ return !!(draft.role || draft.siteCount || draft.seoExperience || draft.intents.length || draft.other.trim())
68
+ }
@@ -0,0 +1,19 @@
1
+ // The onboarding "How can we help?" options — display data only (id/label/icon),
2
+ // ordered. Lives in design-system (alongside the UiWizard* family + the in-memory
3
+ // onboarding service) so both the live pro flow and the brand-kit showcase render
4
+ // the same list. The app maps id → internal focus separately (teams/create.vue).
5
+ export interface OnboardingIntent {
6
+ id: string
7
+ label: string
8
+ icon: string
9
+ }
10
+
11
+ export const ONBOARDING_INTENTS: OnboardingIntent[] = [
12
+ { id: 'competitors', label: 'Beat my competitors', icon: 'chart-line' },
13
+ { id: 'visitors', label: 'Get more visitors', icon: 'search' },
14
+ { id: 'technical', label: 'Improve technical SEO', icon: 'shield-check' },
15
+ { id: 'ai', label: 'Show up in AI answers', icon: 'bot' },
16
+ { id: 'recover', label: 'Recover lost traffic', icon: 'i-lucide-rotate-ccw' },
17
+ { id: 'speed', label: 'Speed up my site', icon: 'i-lucide-gauge' },
18
+ { id: 'authority', label: 'Earn backlinks & mentions', icon: 'i-lucide-megaphone' },
19
+ ]