@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,319 @@
1
+ <script setup lang="ts">
2
+ import type { RichSegment } from '../../../shared/rich-text'
3
+ import type { NavigationDestination as RouteLocationRaw } from '../../shared/navigation'
4
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
5
+ import type { ChatEntityRef } from './eject-menu'
6
+ import { useRoute, useState } from 'nuxt/app'
7
+ import { computed } from 'vue'
8
+ import { NuxtLink, UiIcon, UiRichText } from '#components'
9
+ import { buildChatDeepLinkQuery } from './eject-menu'
10
+ import { useEjectTracker } from './eject-tracker'
11
+
12
+ /**
13
+ * UiChatQuickLink — a surface-scoped box of preset, tokenized questions that open
14
+ * AI Chat already seeded and grounded.
15
+ *
16
+ * ## Why this exists (it replaces the eject affordance's chat item)
17
+ *
18
+ * `UiEjectMenu`'s "Open in AI Chat" hides one good question behind a kebab, and in
19
+ * practice the seed was authored per *card* — so ~47 of ~58 ejects ended up as
20
+ * duplicated site-level seeds whose only difference was hardcoded prose. That is
21
+ * the wrong grain: the user doesn't want "ask about this card", they want the one
22
+ * question that spans the five cards they'd otherwise have to assemble by hand.
23
+ *
24
+ * This component inverts it. An SEO expert reading a competitor page knows that
25
+ * "how do I capture their traffic?" is answered by Keyword Gaps + Top Keywords +
26
+ * Backlinks + Quick Wins + Shared Keywords together. We hand the user that
27
+ * question directly, in the open, and let the chat assemble the data. **The
28
+ * component's job is teaching**: one well-chosen question replaces knowing which
29
+ * sections to read in what order.
30
+ *
31
+ * ## What it is not
32
+ *
33
+ * Not the ambient "Ask AI" affordance DESIGN.md's Avoid list bans. It doesn't
34
+ * float, it doesn't follow scroll, it isn't a persistent drawer. It's a fixed,
35
+ * in-flow artifact whose questions are **hand-authored per surface and passed in
36
+ * by the page** — never generated, never generic. If a surface can't name 3–4
37
+ * questions its own data actually answers, it doesn't get one. A long list is a
38
+ * menu, not a teaching aid.
39
+ *
40
+ * ## Contract
41
+ *
42
+ * - Questions are templates: `{token}` placeholders are resolved from `tokens`.
43
+ * A question whose tokens can't all be resolved is **dropped**, so a raw
44
+ * `{competitorDomain}` can never reach the screen. Nothing resolvable → the
45
+ * whole box renders nothing.
46
+ * - Clicking a question navigates (`:to`) with the chat deep-link query built by
47
+ * `buildChatDeepLinkQuery` — the exact same path `UiEjectMenu`'s "Open in AI
48
+ * Chat" takes, so `<ProChatPanel>` self-opens in place and hydrates `entity` /
49
+ * `filter` server-side. There is deliberately no second route into chat.
50
+ * - Respects the per-user AI opt-out and the per-site AI availability flag, same
51
+ * as `UiEjectMenu`; renders nothing when either is off.
52
+ * - Reports through the same `useEjectTracker()` seam (`open-chat`), so quick
53
+ * links land in the existing eject telemetry rather than a parallel stream.
54
+ *
55
+ * ```vue
56
+ * <UiChatQuickLink
57
+ * :questions="[
58
+ * { text: 'How do I capture {competitorDomain}\'s traffic?', hint: 'Reads gaps, top keywords and backlinks together' },
59
+ * ]"
60
+ * :tokens="{ competitorDomain: competitor.domain }"
61
+ * :entity="chatEntity.competitor(competitor.domain)"
62
+ * :filter="{ siteId }"
63
+ * :track="{ kind: 'competitor' }"
64
+ * />
65
+ * ```
66
+ */
67
+
68
+ /**
69
+ * A token value. A bare string interpolates as plain text; `{ domain }` renders
70
+ * through the shared `domain` RichSegment — favicon + host — so the entity the
71
+ * question is about is something the eye lands on rather than prose it has to
72
+ * read. The seed sent to the model is always the plain text either way.
73
+ */
74
+ export type ChatQuickToken = string | number | { domain: string }
75
+
76
+ export interface ChatQuickQuestion {
77
+ /**
78
+ * The question, as a template. `{token}` placeholders resolve from the `tokens`
79
+ * prop; an unresolvable token drops the question rather than rendering the
80
+ * placeholder. Write it as the user would ask it, first person, one sentence.
81
+ */
82
+ text: string
83
+ /** Short visible label for compact layouts. The full question remains the chat seed and accessible name. */
84
+ label?: string
85
+ /** Per-question entity override; falls back to the box-level `entity`. */
86
+ entity?: ChatEntityRef
87
+ /** Extra deep-link filter merged over the box-level `filter`. */
88
+ filter?: Record<string, unknown>
89
+ /** Telemetry sub-variant for this specific question. */
90
+ variant?: string
91
+ }
92
+
93
+ const {
94
+ questions,
95
+ tokens,
96
+ entity,
97
+ filter,
98
+ to,
99
+ title = 'Ask AI Chat',
100
+ description,
101
+ compact = false,
102
+ icon = 'ai',
103
+ autoSend = true,
104
+ track,
105
+ } = defineProps<{
106
+ /** Hand-authored questions for THIS surface. Keep to 3–4. */
107
+ questions: ChatQuickQuestion[]
108
+ /**
109
+ * Values for the `{token}` placeholders in each question's `text`. Empty or
110
+ * nullish values drop the questions that reference them, which is how a
111
+ * half-loaded page degrades to fewer questions instead of broken ones.
112
+ * Pass `{ domain }` for an entity that should render as a favicon token.
113
+ */
114
+ tokens?: Record<string, ChatQuickToken | null | undefined>
115
+ /**
116
+ * Default chat entity for every question. Build it through `chatEntity.*`
117
+ * (eject-menu.ts) — a bare id or raw URL fails scheme parsing and the server
118
+ * hydrate silently skips, so the chat answers ungrounded.
119
+ */
120
+ entity?: ChatEntityRef
121
+ /** Default deep-link filter, e.g. `{ siteId }`. */
122
+ filter?: Record<string, unknown>
123
+ /**
124
+ * Navigation target. Omit (the default) to stay in place: the deep-link keys
125
+ * merge over the current route's query and `<ProChatPanel>` self-opens as a
126
+ * slide-over. Only set `/pro/dashboard/chat` when an in-place slide-over can't
127
+ * open (already inside an overlay, or chat IS the destination).
128
+ */
129
+ to?: RouteLocationRaw
130
+ /** Box heading. Keep it a plain noun phrase, not a CTA. */
131
+ title?: string
132
+ /** Optional one-line framing above the questions. */
133
+ description?: string
134
+ /** Compact question bubbles without a card shell, suitable for a parent card's footer or side column. */
135
+ compact?: boolean
136
+ /** Leading glyph. `ai` is the standalone-chat-CTA glyph (DESIGN: one label, one glyph). */
137
+ icon?: UiIconName
138
+ /**
139
+ * Fire the question as the first message on open. True by default: these are
140
+ * complete, self-contained questions, so the user should get an answer rather
141
+ * than a pre-filled box they have to press enter on.
142
+ */
143
+ autoSend?: boolean
144
+ /** Telemetry enrichment; `kind` is the surface's domain entity ('competitor', 'query', …). */
145
+ track?: { kind?: string, variant?: string }
146
+ }>()
147
+
148
+ const route = useRoute()
149
+ const trackEject = useEjectTracker()
150
+
151
+ // Same two gates UiEjectMenu honours: the per-user AI opt-out (useAiSettings) and
152
+ // the per-site AI availability flag set by the site layouts.
153
+ const aiEnabled = useState<boolean>('pro:ai-enabled', () => true)
154
+ const siteAiAvailable = useState<boolean>('pro:ai-site-available', () => true)
155
+
156
+ const TOKEN_RE = /\{(\w+)\}/g
157
+
158
+ function tokenText(value: ChatQuickToken | null | undefined): string | null {
159
+ if (value === null || value === undefined)
160
+ return null
161
+ const text = typeof value === 'object' && value !== null ? value.domain : String(value)
162
+ return text.trim() === '' ? null : text
163
+ }
164
+
165
+ /**
166
+ * Resolve `{token}` placeholders into BOTH representations at once:
167
+ *
168
+ * - `text` — the plain sentence. This is what seeds the model, and it must stay
169
+ * plain: a favicon is a rendering, not something you can say to a model.
170
+ * - `segments` — the same sentence as typed `RichSegment`s, so a `{ domain }`
171
+ * token renders through the shared `domain` segment (favicon + host) rather
172
+ * than as bare prose. Reuses `UiRichText`'s vocabulary rather than minting a
173
+ * second token treatment; the repo already carries one complaint about a site
174
+ * rendered two different ways.
175
+ *
176
+ * Returns null when any token is missing or blank, so the caller drops the
177
+ * question instead of shipping a placeholder to the screen — or to the model.
178
+ */
179
+ function interpolate(template: string): { text: string, segments: RichSegment[] } | null {
180
+ const segments: RichSegment[] = []
181
+ let text = ''
182
+ let cursor = 0
183
+ let missing = false
184
+
185
+ for (const match of template.matchAll(TOKEN_RE)) {
186
+ const [placeholder, key] = match
187
+ const at = match.index!
188
+ const value = tokens?.[key!]
189
+ const resolvedText = tokenText(value)
190
+ if (resolvedText === null) {
191
+ missing = true
192
+ break
193
+ }
194
+ if (at > cursor) {
195
+ const literal = template.slice(cursor, at)
196
+ text += literal
197
+ // UiRichText injects the inter-segment space (`spacing="none"` skips it
198
+ // before leftward-binding punctuation), so the literal run is trimmed —
199
+ // otherwise every gap would be doubled. `text` keeps the real spacing,
200
+ // because that string is what seeds the model.
201
+ segments.push({ kind: 'text', text: literal.trim() })
202
+ }
203
+ text += resolvedText
204
+ // Only an explicitly-typed token becomes a rich segment. A bare string stays
205
+ // text — inferring "looks like a hostname" would misfire on any keyword
206
+ // carrying a dot.
207
+ // `typeof null === 'object'`, so the null check is load-bearing, not
208
+ // defensive: without it this narrows to `{ domain: string } | null` and
209
+ // reads `.domain` off null. Unreachable today (`tokenText` returns null for
210
+ // a nullish value and we've already broken out), but the type is the guard.
211
+ segments.push(
212
+ value !== null && typeof value === 'object'
213
+ ? { kind: 'domain', text: resolvedText, domain: value.domain }
214
+ : { kind: 'text', text: resolvedText },
215
+ )
216
+ cursor = at + placeholder.length
217
+ }
218
+
219
+ if (missing)
220
+ return null
221
+
222
+ if (cursor < template.length) {
223
+ const tail = template.slice(cursor)
224
+ text += tail
225
+ segments.push({ kind: 'text', text: tail.trim() })
226
+ }
227
+ return { text, segments }
228
+ }
229
+
230
+ interface ResolvedQuestion {
231
+ key: string
232
+ text: string
233
+ label?: string
234
+ segments: RichSegment[]
235
+ variant?: string
236
+ to: RouteLocationRaw
237
+ }
238
+
239
+ const resolved = computed<ResolvedQuestion[]>(() => {
240
+ const out: ResolvedQuestion[] = []
241
+ for (const q of questions) {
242
+ const parsed = interpolate(q.text)
243
+ if (!parsed)
244
+ continue
245
+ const { text, segments } = parsed
246
+ const mergedFilter = filter || q.filter ? { ...filter, ...q.filter } : undefined
247
+ const link = {
248
+ entity: q.entity ?? entity,
249
+ filter: mergedFilter,
250
+ seed: text,
251
+ autoSend,
252
+ }
253
+ // Identical to UiEjectMenu's buildChatTarget: no target → merge the deep-link
254
+ // keys over the current query (preserving the surface's own tab/filter/sort
255
+ // state) and let the in-place panel pick them up.
256
+ const target: RouteLocationRaw = !to
257
+ ? { query: buildChatDeepLinkQuery(route.query, link) }
258
+ : typeof to === 'string'
259
+ ? { path: to, query: buildChatDeepLinkQuery({}, link) }
260
+ : { ...to, query: buildChatDeepLinkQuery(to.query ?? {}, link) }
261
+ out.push({ key: text, text, label: q.label, segments, variant: q.variant, to: target })
262
+ }
263
+ return out
264
+ })
265
+
266
+ // Author-time guard for the entity mistake the ChatEntityRef template type can't
267
+ // catch: a raw URL, whose "https" prefix satisfies `${string}:${string}` but is no
268
+ // registered hydrate scheme — the question would open chat without its grounding.
269
+ if (import.meta.dev && entity) {
270
+ const scheme = entity.slice(0, entity.indexOf(':'))
271
+ if (!/^[a-z][a-z-]*$/.test(scheme) || scheme === 'http' || scheme === 'https')
272
+ console.warn(`[UiChatQuickLink] entity "${entity}" has no registered hydrate scheme — build it via chatEntity.* (eject-menu.ts) or hydration silently skips.`)
273
+ }
274
+
275
+ const visible = computed(() => aiEnabled.value && siteAiAvailable.value && resolved.value.length > 0)
276
+
277
+ function onPick(q: ResolvedQuestion) {
278
+ trackEject({ action: 'open-chat', kind: track?.kind, variant: q.variant ?? track?.variant })
279
+ }
280
+ </script>
281
+
282
+ <template>
283
+ <div
284
+ v-if="visible"
285
+ role="group"
286
+ :aria-label="title"
287
+ :class="compact ? 'flex flex-col gap-1.5 px-3 py-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-3' : 'rounded-lg border border-default bg-elevated/40'"
288
+ >
289
+ <div class="flex shrink-0 items-center gap-2" :class="compact ? '' : 'px-3 pt-2.5 pb-2'">
290
+ <UiIcon :name="icon" class="size-3.5 shrink-0 text-dimmed" aria-hidden="true" />
291
+ <span :class="compact ? 'text-xs font-medium text-muted' : 'text-label'">{{ title }}</span>
292
+ </div>
293
+ <p v-if="description" :class="compact ? 'w-full text-sm text-muted' : 'px-3 pb-2 text-xs text-muted'">
294
+ {{ description }}
295
+ </p>
296
+ <ul :class="compact ? 'flex min-w-0 flex-wrap gap-1' : 'divide-y divide-default border-t border-default'">
297
+ <li v-for="q in resolved" :key="q.key">
298
+ <NuxtLink
299
+ :to="q.to"
300
+ :aria-label="compact && q.label ? q.text : undefined"
301
+ class="group flex gap-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
302
+ :class="compact ? 'min-h-11 items-center rounded-xl rounded-bl-sm border border-default/40 bg-elevated/30 px-2 py-1.5 hover:border-default hover:bg-elevated/60 sm:min-h-7 sm:py-0.5' : 'items-start px-3 py-2.5 hover:bg-elevated/60'"
303
+ @click="onPick(q)"
304
+ >
305
+ <span class="min-w-0 flex-1 whitespace-pre-wrap text-sm" :class="compact ? 'text-muted transition-colors group-hover:text-default group-focus-visible:text-default' : 'text-default'">
306
+ <template v-if="compact && q.label">{{ q.label }}</template>
307
+ <UiRichText v-else :segments="q.segments" size="inherit" spacing="none" />
308
+ </span>
309
+ <UiIcon
310
+ v-if="!compact"
311
+ name="arrow-up-right"
312
+ class="mt-0.5 size-3.5 shrink-0 text-dimmed transition-colors group-hover:text-default"
313
+ aria-hidden="true"
314
+ />
315
+ </NuxtLink>
316
+ </li>
317
+ </ul>
318
+ </div>
319
+ </template>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import type { SemanticStatus } from '../../composables/semanticColors'
3
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
4
+ import { computed } from 'vue'
5
+ import { UiIcon } from '#components'
6
+ import { semanticColors } from '../../composables/semanticColors'
7
+
8
+ /**
9
+ * UiChip — the single chip/badge primitive, driven by a semantic `purpose`
10
+ * prop the same way UiButton is driven by `purpose`. There is no raw
11
+ * `variant`/`color` knob: each purpose resolves to a fixed treatment so chip
12
+ * intent reads consistently everywhere.
13
+ *
14
+ * - `status` semantic state (health, sync, validation). Colour comes from
15
+ * the data-bound `status` prop on an AA-safe neutral surface.
16
+ * - `count` quantity / category metadata — neutral outline.
17
+ * - `tag` low-emphasis label / removable filter — muted fill.
18
+ * - `accent` standout marker (e.g. NEW) — inverted neutral.
19
+ * - `brand` deliberate brand marker (e.g. "Pro", "Most popular") — subtle
20
+ * violet. Reserved for genuine brand markers; do not spray (it
21
+ * spends the 60/30/10 accent budget).
22
+ *
23
+ * Maps onto UiButton's purposes: accent↔cta, count↔secondary, tag↔quiet,
24
+ * status='error'↔danger.
25
+ */
26
+
27
+ const {
28
+ purpose = 'status',
29
+ status = 'neutral',
30
+ size = 'xs',
31
+ icon,
32
+ removable = false,
33
+ mono = false,
34
+ tabular = false,
35
+ label,
36
+ } = defineProps<{
37
+ purpose?: 'status' | 'count' | 'tag' | 'accent' | 'brand'
38
+ /** Semantic status — only honoured when purpose='status'. */
39
+ status?: SemanticStatus
40
+ size?: 'xs' | 'sm' | 'md'
41
+ icon?: UiIconName
42
+ removable?: boolean
43
+ mono?: boolean
44
+ tabular?: boolean
45
+ /** Chip text, used to give the remove button a unique accessible name
46
+ * ("Remove <label>"). Falls back to "Remove" when omitted. */
47
+ label?: string
48
+ }>()
49
+
50
+ const emit = defineEmits<{
51
+ remove: []
52
+ }>()
53
+
54
+ const sizeClass = {
55
+ xs: 'text-mini px-2 py-0.5 rounded-md gap-1',
56
+ sm: 'text-xs px-2.5 py-1 rounded-md gap-1.5',
57
+ md: 'text-sm px-2.5 py-1 rounded-md gap-1.5',
58
+ } as const
59
+
60
+ const colorClass = computed(() => {
61
+ switch (purpose) {
62
+ case 'count':
63
+ return 'border border-default bg-default text-muted'
64
+ case 'tag':
65
+ return 'bg-accented text-muted'
66
+ case 'accent':
67
+ return 'bg-inverted text-inverted'
68
+ case 'brand':
69
+ return 'bg-primary/10 text-primary'
70
+ case 'status':
71
+ default: {
72
+ const c = semanticColors[status]
73
+ return `border bg-default ${c.border} ${c.text}`
74
+ }
75
+ }
76
+ })
77
+ </script>
78
+
79
+ <template>
80
+ <span
81
+ class="inline-flex items-center font-medium whitespace-nowrap"
82
+ :class="[
83
+ sizeClass[size],
84
+ colorClass,
85
+ mono && 'font-mono',
86
+ tabular && 'tabular-nums',
87
+ ]"
88
+ >
89
+ <UiIcon v-if="icon" :name="icon" class="size-3 shrink-0" aria-hidden="true" />
90
+ <slot />
91
+ <button
92
+ v-if="removable"
93
+ type="button"
94
+ class="-mr-0.5 inline-flex cursor-pointer items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
95
+ :aria-label="label ? `Remove ${label}` : 'Remove'"
96
+ @click.stop="emit('remove')"
97
+ >
98
+ <UiIcon name="close" class="size-3" aria-hidden="true" />
99
+ </button>
100
+ </span>
101
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import type { ConfirmTone } from '../../composables/useConfirm'
3
+ import { UiButton, UiIcon, UModal } from '#components'
4
+
5
+ /**
6
+ * UiConfirmDialog: the one "are you sure" for an action that is hard to undo.
7
+ *
8
+ * Open it through `useConfirm()`, which resolves `true` for the confirm button
9
+ * and `false` for Cancel, Escape, or a click outside. The dialog never runs the
10
+ * action itself, so the caller keeps its own pending state and error handling.
11
+ *
12
+ * Focus lands on Cancel for a `danger` tone, so Enter on a stray keypress
13
+ * cancels. A `default` tone focuses the confirm button.
14
+ */
15
+ const {
16
+ title,
17
+ description,
18
+ confirmLabel,
19
+ cancelLabel = 'Cancel',
20
+ tone = 'danger',
21
+ } = defineProps<{
22
+ /** A question that names the action and its object: "Disconnect Google?" */
23
+ title: string
24
+ /** The consequence, in one or two short sentences. */
25
+ description?: string
26
+ /** A verb for the action: "Disconnect", "Remove", "Delete". Never "OK". */
27
+ confirmLabel: string
28
+ cancelLabel?: string
29
+ tone?: ConfirmTone
30
+ }>()
31
+
32
+ const emit = defineEmits<{
33
+ /** `true` confirms. Nuxt UI's overlay resolves the open promise with it. */
34
+ close: [confirmed: boolean]
35
+ }>()
36
+
37
+ // Reka focuses the first focusable element unless this handler moves focus.
38
+ // Pick the button by tone instead of trusting the DOM order.
39
+ function onOpenAutoFocus(event: Event): void {
40
+ const root = event.target instanceof HTMLElement ? event.target : null
41
+ const target = root?.querySelector<HTMLElement>(`[data-confirm-focus="${tone === 'danger' ? 'cancel' : 'confirm'}"]`)
42
+ if (!target)
43
+ return
44
+ event.preventDefault()
45
+ target.focus()
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <UModal
51
+ :title="title"
52
+ :description="description"
53
+ :close="false"
54
+ :content="{ onOpenAutoFocus }"
55
+ :ui="{ content: 'sm:max-w-md', description: 'text-pretty' }"
56
+ >
57
+ <!-- The title slot keeps Nuxt UI's DialogTitle, so the dialog stays named
58
+ by its question. The icon is decoration. -->
59
+ <template #title>
60
+ <span class="inline-flex items-center gap-2">
61
+ <UiIcon v-if="tone === 'danger'" name="warning" class="size-4 shrink-0 text-error" aria-hidden="true" />
62
+ {{ title }}
63
+ </span>
64
+ </template>
65
+ <template #footer>
66
+ <div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
67
+ <UiButton
68
+ purpose="quiet"
69
+ class="min-h-11 sm:min-h-0"
70
+ data-confirm-focus="cancel"
71
+ :label="cancelLabel"
72
+ @click="emit('close', false)"
73
+ />
74
+ <UiButton
75
+ :purpose="tone === 'danger' ? 'danger' : 'cta'"
76
+ class="min-h-11 sm:min-h-0"
77
+ data-confirm-focus="confirm"
78
+ :label="confirmLabel"
79
+ @click="emit('close', true)"
80
+ />
81
+ </div>
82
+ </template>
83
+ </UModal>
84
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import { useClipboard } from '@vueuse/core'
4
+ import { computed } from 'vue'
5
+ import { UiButton } from '#components'
6
+
7
+ /**
8
+ * UiCopyButton: copy one string, then confirm it.
9
+ *
10
+ * The icon turns into a check and a visible label reads "Copied" for two
11
+ * seconds. A polite live region says "Copied" too, so a screen reader hears
12
+ * the result of a press that changes nothing else on the page.
13
+ *
14
+ * Without `label` the button is icon-only, and `ariaLabel` names it. Name it
15
+ * after the thing it copies ("Copy endpoint"), not after the action alone.
16
+ */
17
+ defineOptions({ inheritAttrs: false })
18
+
19
+ const {
20
+ text,
21
+ label,
22
+ ariaLabel = 'Copy',
23
+ copiedLabel = 'Copied',
24
+ purpose = 'quiet',
25
+ size = 'xs',
26
+ block = false,
27
+ disabled = false,
28
+ } = defineProps<{
29
+ /** The string the press puts on the clipboard. */
30
+ text: string
31
+ /** Visible label. Omit for an icon-only button. */
32
+ label?: string
33
+ /** Accessible name of an icon-only button. A visible `label` names it otherwise. */
34
+ ariaLabel?: string
35
+ /** Visible label and announcement after a copy. */
36
+ copiedLabel?: string
37
+ purpose?: 'cta' | 'secondary' | 'quiet'
38
+ size?: 'xs' | 'sm' | 'md' | 'lg'
39
+ /** Fill the width of the parent. Labelled buttons only. */
40
+ block?: boolean
41
+ disabled?: boolean
42
+ }>()
43
+
44
+ const emit = defineEmits<{
45
+ /** The copy finished. Use it for telemetry. */
46
+ copied: [text: string]
47
+ }>()
48
+
49
+ const { copy, copied } = useClipboard({ legacy: true, copiedDuring: 2000 })
50
+
51
+ const icon = computed<UiIconName>(() => copied.value ? 'check' : 'copy')
52
+
53
+ function onClick(): void {
54
+ // `useClipboard` sets `copied` only when the write lands, and falls back
55
+ // to execCommand with `legacy`. A write that still fails leaves the label
56
+ // on "Copy" and its rejection reaches the global handler.
57
+ void copy(text).then(() => {
58
+ if (copied.value)
59
+ emit('copied', text)
60
+ })
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <span data-ui="UiCopyButton" class="contents">
66
+ <UiButton
67
+ v-if="label"
68
+ :purpose="purpose"
69
+ :size="size"
70
+ :icon="icon"
71
+ :disabled="disabled"
72
+ :block="block"
73
+ :label="copied ? copiedLabel : label"
74
+ v-bind="$attrs"
75
+ @click="onClick"
76
+ />
77
+ <UiButton
78
+ v-else
79
+ :purpose="purpose"
80
+ :size="size"
81
+ :icon="icon"
82
+ :disabled="disabled"
83
+ :aria-label="ariaLabel"
84
+ v-bind="$attrs"
85
+ class="min-h-11 min-w-11 [@media(pointer:fine)]:min-h-8 [@media(pointer:fine)]:min-w-8"
86
+ @click="onClick"
87
+ />
88
+ <span class="sr-only" role="status" aria-live="polite">{{ copied ? copiedLabel : '' }}</span>
89
+ </span>
90
+ </template>