@nuxtseo/design-system 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -2
- package/app/app.config.ts +592 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +233 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +127 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +68 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +93 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +15 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/app-favicon.ts +64 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- 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>
|