@nuxtseo/design-system 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import { UiDitherAvatar, UiIcon, UiNavIcon } from '#components'
3
+ // The "What's inside" benefit list shown beside the auth panel and in the
4
+ // onboarding wizard. Single source of truth (design-system, so both the live
5
+ // pro-auth layout and the brand-kit showcase render it). Callers own the
6
+ // surrounding panel (wrapper + background); this renders only the content.
7
+ // `trialDays` is a prop so this carries no saas dependency. `footnote: false`
8
+ // drops the trial promise for surfaces where no trial is on offer — the
9
+ // waitlist screen, where "14-day free trial" beside "sign-ups are paused"
10
+ // promises something the product will refuse.
11
+ const { trialDays = 14, footnote = true } = defineProps<{ trialDays?: number, footnote?: boolean }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="flex flex-col text-left">
16
+ <div class="mb-7 flex items-center gap-3">
17
+ <UiDitherAvatar seed="nuxter-onboarding" :size="40" />
18
+ <div class="min-w-0">
19
+ <div class="text-label">
20
+ Ask more of your Site
21
+ </div>
22
+ <p class="mt-1 text-description">
23
+ Connect the data. Pro finds the work.
24
+ </p>
25
+ </div>
26
+ </div>
27
+
28
+ <ul class="space-y-5">
29
+ <li class="flex items-start gap-2.5">
30
+ <UiNavIcon icon="i-simple-icons-googlesearchconsole" class="mt-1 shrink-0" />
31
+ <div class="min-w-0">
32
+ <div class="text-highlighted text-sm font-medium mb-0.5">
33
+ Organic Search, live
34
+ </div>
35
+ <p class="text-description">
36
+ Clicks, queries, pages and countries as soon as you connect Google.
37
+ </p>
38
+ </div>
39
+ </li>
40
+
41
+ <li class="flex items-start gap-2.5">
42
+ <UiNavIcon icon="arrow-right" class="mt-1 shrink-0" />
43
+ <div class="min-w-0">
44
+ <div class="text-highlighted text-sm font-medium mb-0.5">
45
+ Next Actions, ranked
46
+ </div>
47
+ <p class="text-description">
48
+ Issues and opportunities ordered by what matters first.
49
+ </p>
50
+ </div>
51
+ </li>
52
+
53
+ <li class="flex items-start gap-2.5">
54
+ <UiNavIcon icon="send" class="mt-1 shrink-0" />
55
+ <div class="min-w-0">
56
+ <div class="text-highlighted text-sm font-medium mb-0.5">
57
+ Weekly Report, ready
58
+ </div>
59
+ <p class="text-description">
60
+ What changed, what was verified, and what needs work next. Every Monday.
61
+ </p>
62
+ </div>
63
+ </li>
64
+ </ul>
65
+
66
+ <div v-if="footnote" class="mt-7 flex items-center gap-2 text-mini text-muted">
67
+ <UiIcon name="shield-check" class="size-3.5 text-dimmed shrink-0" />
68
+ <span>{{ trialDays }}-day free trial. Cancel anytime.</span>
69
+ </div>
70
+ </div>
71
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { NuxtLink, UiFavicon, UiIcon } from '#components'
4
+ import { prettifyUrl, safeHttpHref, urlHostname } from '../../../shared/utils/urls'
5
+
6
+ const {
7
+ url,
8
+ favicon = true,
9
+ faviconSize = 16,
10
+ external = false,
11
+ showPath = true,
12
+ label,
13
+ to,
14
+ } = defineProps<{
15
+ /** A full URL, bare hostname, or `sc-domain:` value. */
16
+ url: string
17
+ /** Render the favicon for the host. */
18
+ favicon?: boolean
19
+ faviconSize?: number
20
+ /** Render as an external link that opens the URL in a new tab. */
21
+ external?: boolean
22
+ /** Keep the path in the label; when false only the hostname is shown. */
23
+ showPath?: boolean
24
+ /** Override the auto-derived label text (e.g. an already-compacted path)
25
+ * while the favicon/href still resolve from `url`. */
26
+ label?: string
27
+ /** Internal deep link — renders the label as a `NuxtLink` (`:to`, never
28
+ * `@click`). Ignored when `external` is set (that already opens `url` in
29
+ * a new tab via a real anchor). */
30
+ to?: string
31
+ }>()
32
+
33
+ const host = computed(() => urlHostname(url))
34
+ const displayLabel = computed(() => label ?? (showPath ? prettifyUrl(url) : host.value))
35
+ // A URL with a scheme links only when the scheme is http(s), so
36
+ // `javascript://%0A…` renders as plain text. A bare host or `sc-domain:`
37
+ // value links to its https origin.
38
+ const href = computed(() => {
39
+ if (!external)
40
+ return undefined
41
+ const trimmed = url.trim()
42
+ if (/^[a-z][\w+.-]*:\/\//i.test(trimmed))
43
+ return safeHttpHref(trimmed)
44
+ return host.value ? safeHttpHref(`https://${host.value}`) : undefined
45
+ })
46
+ const isExternalLink = computed(() => !!href.value)
47
+ const isLink = computed(() => isExternalLink.value || (!external && !!to))
48
+ </script>
49
+
50
+ <template>
51
+ <component
52
+ :is="isExternalLink ? 'a' : (!external && to ? NuxtLink : 'span')"
53
+ :href="href"
54
+ :to="!external && to ? to : undefined"
55
+ :target="isExternalLink ? '_blank' : undefined"
56
+ :rel="isExternalLink ? 'noopener' : undefined"
57
+ class="inline-flex min-w-0 max-w-full items-center gap-1.5"
58
+ :class="isLink ? 'group/url transition-colors hover:text-primary' : ''"
59
+ >
60
+ <UiFavicon
61
+ v-if="favicon && host"
62
+ :domain="host"
63
+ :size="faviconSize"
64
+ :alt="`${host} favicon`"
65
+ />
66
+ <span translate="no" class="truncate text-sm">{{ displayLabel }}</span>
67
+ <span v-if="isExternalLink" class="sr-only">(opens in new tab)</span>
68
+ <UiIcon
69
+ v-if="isExternalLink"
70
+ name="external"
71
+ class="size-3 shrink-0 text-dimmed opacity-0 transition-opacity group-hover/url:opacity-100"
72
+ aria-hidden="true"
73
+ />
74
+ </component>
75
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { SemanticStatus } from '../../composables/semanticColors'
3
+ import { semanticColors } from '../../composables/semanticColors'
4
+
5
+ // Valence is the change-direction axis — the sibling of severity (UiSeverityDot)
6
+ // and health (UiHealthDot). A leading dot is the canonical treatment for
7
+ // direction in a repeated list (DESIGN.md colour budget); a coloured left-border
8
+ // stripe is the banned alternative. A flat / no-change reading is a non-event, so
9
+ // it stays neutral rather than spending colour.
10
+ const { direction = 'flat', size = 'sm', label } = defineProps<{
11
+ direction?: 'up' | 'down' | 'flat'
12
+ size?: 'xs' | 'sm' | 'md'
13
+ /**
14
+ * Accessible name for the dot. Pass when it stands alone (no adjacent text), so
15
+ * colour is not the only channel conveying valence. Omit when a visible label
16
+ * sits beside it — the dot stays decorative then.
17
+ */
18
+ label?: string
19
+ }>()
20
+
21
+ const DIRECTION_STATUS: Record<'up' | 'down' | 'flat', SemanticStatus> = {
22
+ up: 'success',
23
+ down: 'error',
24
+ flat: 'neutral',
25
+ }
26
+ const sizeClass = { xs: 'size-1', sm: 'size-1.5', md: 'size-2' } as const
27
+ </script>
28
+
29
+ <template>
30
+ <span
31
+ class="rounded-full shrink-0"
32
+ :class="[sizeClass[size], semanticColors[DIRECTION_STATUS[direction]].dot]"
33
+ :role="label ? 'img' : undefined"
34
+ :aria-label="label || undefined"
35
+ :aria-hidden="label ? undefined : 'true'"
36
+ />
37
+ </template>
@@ -0,0 +1,284 @@
1
+ <script lang="ts">
2
+ export interface UiWizardStep {
3
+ id: string
4
+ label: string
5
+ /** Primary button label. Defaults to "Continue", or "Done" on the last step. */
6
+ nextLabel?: string
7
+ nextIcon?: string
8
+ /** False disables Next. Defaults to true. */
9
+ valid?: boolean
10
+ /** False hides Next, for a step whose own action advances it. */
11
+ showNext?: boolean
12
+ /** False keeps a step link out of this step, for a step that needs an
13
+ * earlier save the host has not made yet. Defaults to true. */
14
+ reachable?: boolean
15
+ }
16
+ </script>
17
+
18
+ <script setup lang="ts">
19
+ import { useMediaQuery } from '@vueuse/core'
20
+ import { computed, nextTick, onMounted, onScopeDispose, ref, useAttrs, useTemplateRef, watch } from 'vue'
21
+ import { UiWizardNav, UiWizardProgress, UModal } from '#components'
22
+
23
+ // A first-use setup with a form or more than one decision runs here (DESIGN.md,
24
+ // First-use setup flows). The empty state keeps one CTA that opens it.
25
+ //
26
+ // The host owns the step list, so a step list can branch on an answer: the
27
+ // active step is matched by id, and an id the list no longer holds falls back
28
+ // to the first step. Closing keeps the step and the step state, so a reopen
29
+ // resumes where the user left. Completing resets the step to the first step,
30
+ // so a reopen starts there. `useWizardRoute` backs `open` and `step` with the query,
31
+ // for deep links and OAuth returns.
32
+ //
33
+ // A step link cannot skip a step. The modal shows the linked step only if
34
+ // every step before it is valid, and the step itself is reachable. Otherwise it
35
+ // shows the first invalid step, or the step before the unreachable one. The
36
+ // link resolves once per open, when `ready` reads true, and the resolved step
37
+ // is written to the model on the client. A host whose step state loads async passes `ready`.
38
+ //
39
+ // `beforeNext` guards Next. While it is pending, Next shows loading and every
40
+ // further press is ignored. If it resolves false, the step stays and the host
41
+ // shows its error in the step slot. If it throws, the step stays and the error
42
+ // propagates to Vue's error handler: an unexpected failure must reach error
43
+ // reporting. Catch the expected failures and resolve false instead.
44
+ //
45
+ // Next is disabled for `settleMs` after the modal opens or the step changes.
46
+ // Next keeps its place, so without the pause a double click on Continue would
47
+ // also press the next step's final action. A test passes `settleMs: 0`, or
48
+ // fakes timers.
49
+ //
50
+ // `complete` fires after the `open` model reads false. A listener can then
51
+ // navigate: the close has already landed, so it cannot cancel the close.
52
+ defineOptions({ inheritAttrs: false })
53
+
54
+ const { title, description, steps, beforeNext, settleMs = 1000, ready = true } = defineProps<{
55
+ title: string
56
+ description?: string
57
+ steps: readonly UiWizardStep[]
58
+ beforeNext?: (id: string) => boolean | void | Promise<boolean | void>
59
+ /** Next is disabled for this many milliseconds after the modal opens or
60
+ * the step changes. 0 turns the pause off. Defaults to 1000. */
61
+ settleMs?: number
62
+ /** False while the host loads the state that decides which step a link may
63
+ * reach. The link resolves once this reads true. Defaults to true. */
64
+ ready?: boolean
65
+ }>()
66
+
67
+ const emit = defineEmits<{ complete: [] }>()
68
+
69
+ defineSlots<
70
+ Record<string, (props: { next: () => Promise<void>, back: () => void, settling: boolean }) => unknown>
71
+ & { footer?: (props: { next: () => Promise<void>, back: () => void, pending: boolean, settling: boolean }) => unknown }
72
+ >()
73
+ const open = defineModel<boolean>('open', { default: false })
74
+ const step = defineModel<string>('step')
75
+
76
+ const attrs = useAttrs()
77
+ const fullscreen = useMediaQuery('(max-width: 639.98px)')
78
+
79
+ const linkedIndex = computed(() => Math.max(0, steps.findIndex(s => s.id === step.value)))
80
+ // The linked step, clamped to the furthest step the user can reach.
81
+ const reachableIndex = computed(() => {
82
+ for (let i = 1; i <= linkedIndex.value; i++) {
83
+ if (steps[i - 1]!.valid === false || steps[i]!.reachable === false)
84
+ return i - 1
85
+ }
86
+ return linkedIndex.value
87
+ })
88
+ // The clamp applies until the link resolves, once per open, when the host is
89
+ // ready. After that only Next, Back, and the rail move the step, so a step that
90
+ // turns valid while the user types never moves them. The resolved step renders
91
+ // from this state. The model, and so the query, takes it on the client after
92
+ // mount: a server-side write would answer the request with a redirect.
93
+ const resolved = ref<undefined | { from: string | undefined, to: string | undefined }>()
94
+ const activeIndex = computed(() => {
95
+ if (!resolved.value)
96
+ return reachableIndex.value
97
+ if (step.value === resolved.value.from)
98
+ return Math.max(0, steps.findIndex(s => s.id === resolved.value!.to))
99
+ return linkedIndex.value
100
+ })
101
+ const activeStep = computed(() => steps[activeIndex.value])
102
+ const isLast = computed(() => activeIndex.value === steps.length - 1)
103
+ const pending = ref(false)
104
+ // Counts opens, so a guard answer can tell whether its modal session ended.
105
+ let session = 0
106
+ watch(open, (value) => {
107
+ if (value)
108
+ session++
109
+ else
110
+ resolved.value = undefined
111
+ }, { immediate: true, flush: 'sync' })
112
+
113
+ let isMounted = false
114
+ function writeResolved(): void {
115
+ const link = resolved.value
116
+ if (!isMounted || link?.to === undefined || link.from === link.to || step.value !== link.from)
117
+ return
118
+ // Once the model holds the resolved step, the link is spent: a later move
119
+ // back to the linked id is a real step change, not the link again.
120
+ resolved.value = { from: link.to, to: link.to }
121
+ step.value = link.to
122
+ }
123
+ watch([open, () => ready], ([isOpen, isReady]) => {
124
+ if (!isOpen || !isReady || resolved.value)
125
+ return
126
+ const linked = steps[linkedIndex.value]?.id
127
+ const to = steps[reachableIndex.value]?.id
128
+ if (to === undefined)
129
+ return
130
+ // A link that names no step, or an unknown one, already shows the first
131
+ // step: it resolves without a write.
132
+ resolved.value = { from: step.value, to: linked === to ? step.value : to }
133
+ writeResolved()
134
+ }, { immediate: true })
135
+ onMounted(() => {
136
+ isMounted = true
137
+ writeResolved()
138
+ })
139
+
140
+ // True for `settleMs` after the modal opens or the step changes. Next is
141
+ // disabled meanwhile; Back is not.
142
+ const settling = ref(false)
143
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
144
+ watch([open, () => activeStep.value?.id], ([value]) => {
145
+ clearTimeout(settleTimer)
146
+ settling.value = Boolean(value) && settleMs > 0
147
+ if (settling.value)
148
+ settleTimer = setTimeout(() => { settling.value = false }, settleMs)
149
+ }, { immediate: true, flush: 'sync' })
150
+ onScopeDispose(() => clearTimeout(settleTimer))
151
+
152
+ const ui = computed(() => {
153
+ const own = (attrs.ui ?? {}) as Record<string, string | undefined>
154
+ return {
155
+ ...own,
156
+ content: ['sm:max-w-xl', own.content].filter(Boolean).join(' '),
157
+ header: ['flex-wrap items-start gap-y-3', own.header].filter(Boolean).join(' '),
158
+ wrapper: ['min-w-0 flex-1 pe-8', own.wrapper].filter(Boolean).join(' '),
159
+ }
160
+ })
161
+ const passthrough = computed(() => {
162
+ const { ui: _ui, ...rest } = attrs
163
+ return rest
164
+ })
165
+
166
+ async function next(): Promise<void> {
167
+ const current = activeStep.value
168
+ if (pending.value || settling.value || !current)
169
+ return
170
+ if (beforeNext) {
171
+ const asked = session
172
+ pending.value = true
173
+ // No catch: a throw keeps the step and reaches Vue's error handler.
174
+ const passed = await Promise.resolve().then(() => beforeNext(current.id)).finally(() => {
175
+ pending.value = false
176
+ })
177
+ // The modal may have closed, reopened, or changed step while the guard
178
+ // ran. Its answer belongs to the session and step it was asked about.
179
+ if (passed === false || !open.value || session !== asked || activeStep.value?.id !== current.id)
180
+ return
181
+ }
182
+ if (isLast.value) {
183
+ await complete()
184
+ return
185
+ }
186
+ step.value = steps[activeIndex.value + 1]!.id
187
+ }
188
+
189
+ // Closes, waits until the close lands, resets the step, then emits. Next stays
190
+ // pending meanwhile, so a second press cannot complete twice. If the close
191
+ // does not land in CLOSE_TIMEOUT_MS, a navigation guard or a failed Back kept
192
+ // the flow open: Next stops loading and the error reaches Vue's error handler.
193
+ const CLOSE_TIMEOUT_MS = 5000
194
+ async function complete(): Promise<void> {
195
+ pending.value = true
196
+ open.value = false
197
+ const closed = !open.value || await new Promise<boolean>((resolve) => {
198
+ const timer = setTimeout(() => {
199
+ stop()
200
+ resolve(false)
201
+ }, CLOSE_TIMEOUT_MS)
202
+ const stop = watch(open, (value) => {
203
+ if (value)
204
+ return
205
+ clearTimeout(timer)
206
+ stop()
207
+ resolve(true)
208
+ })
209
+ })
210
+ pending.value = false
211
+ if (!closed)
212
+ throw new Error('The setup flow did not close, so it did not complete.')
213
+ step.value = steps[0]?.id
214
+ emit('complete')
215
+ }
216
+
217
+ function back(): void {
218
+ if (pending.value || activeIndex.value === 0)
219
+ return
220
+ step.value = steps[activeIndex.value - 1]!.id
221
+ }
222
+
223
+ function select(id: string): void {
224
+ if (!pending.value)
225
+ step.value = id
226
+ }
227
+
228
+ // Move focus to the new step's heading, so a screen reader announces it and
229
+ // Tab continues inside the new step. The heading is visually hidden: the rail
230
+ // already shows the step label.
231
+ const heading = useTemplateRef<HTMLElement>('heading')
232
+ watch(() => activeStep.value?.id, async () => {
233
+ if (!open.value)
234
+ return
235
+ await nextTick()
236
+ heading.value?.focus()
237
+ })
238
+ </script>
239
+
240
+ <template>
241
+ <UModal
242
+ v-model:open="open"
243
+ :title="title"
244
+ :description="description"
245
+ :fullscreen="fullscreen"
246
+ :ui="ui"
247
+ v-bind="passthrough"
248
+ >
249
+ <template v-if="steps.length > 1" #actions>
250
+ <div class="basis-full">
251
+ <UiWizardProgress :steps="steps" :current-index="activeIndex" label="Steps" @select="select" />
252
+ </div>
253
+ </template>
254
+
255
+ <template #body>
256
+ <h3
257
+ v-if="steps.length > 1 && activeStep"
258
+ ref="heading"
259
+ tabindex="-1"
260
+ class="sr-only"
261
+ >
262
+ {{ activeStep.label }}
263
+ </h3>
264
+ <slot v-if="activeStep" :name="activeStep.id" :next="next" :back="back" :settling="settling" />
265
+ </template>
266
+
267
+ <template #footer>
268
+ <slot name="footer" :next="next" :back="back" :pending="pending" :settling="settling">
269
+ <UiWizardNav
270
+ class="w-full"
271
+ :divider="false"
272
+ :can-back="activeIndex > 0"
273
+ :next-label="activeStep?.nextLabel ?? (isLast ? 'Done' : 'Continue')"
274
+ :next-icon="activeStep?.nextIcon ?? (isLast ? 'check' : 'next')"
275
+ :show-next="activeStep?.showNext ?? true"
276
+ :next-disabled="activeStep?.valid === false || settling"
277
+ :next-loading="pending"
278
+ @back="back"
279
+ @next="next"
280
+ />
281
+ </slot>
282
+ </template>
283
+ </UModal>
284
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { UiButton } from '#components'
3
+ // Footer nav row for a wizard step: optional Back on the left, an optional Skip
4
+ // link + a primary advance button on the right. Captures the Back/Skip/Continue
5
+ // (and Finish) pattern shared across the onboarding steps.
6
+ const {
7
+ canBack = false,
8
+ skipLabel,
9
+ nextLabel = 'Continue',
10
+ nextIcon = 'next',
11
+ nextPurpose = 'cta',
12
+ showNext = true,
13
+ nextDisabled = false,
14
+ nextLoading = false,
15
+ divider = true,
16
+ } = defineProps<{
17
+ canBack?: boolean
18
+ /** When set, renders a Skip link emitting `skip`. */
19
+ skipLabel?: string
20
+ nextLabel?: string
21
+ nextIcon?: string
22
+ nextPurpose?: 'cta' | 'secondary'
23
+ /** Hide the primary advance button — e.g. a required step whose own CTA
24
+ * drives the advance (the plan/trial step). */
25
+ showNext?: boolean
26
+ nextDisabled?: boolean
27
+ nextLoading?: boolean
28
+ /** Draw the top rule. A host that already separates its footer, such as
29
+ * `UiWizardModal`, turns it off. */
30
+ divider?: boolean
31
+ }>()
32
+
33
+ const emit = defineEmits<{ back: [], skip: [], next: [] }>()
34
+ </script>
35
+
36
+ <template>
37
+ <div class="flex items-center justify-between" :class="divider && 'pt-2 border-t border-default'">
38
+ <UiButton v-if="canBack" purpose="link" size="md" class="min-h-11" @click="emit('back')">
39
+ Back
40
+ </UiButton>
41
+ <span v-else />
42
+ <div class="flex items-center gap-3">
43
+ <UiButton v-if="skipLabel" purpose="link" size="md" class="min-h-11" @click="emit('skip')">
44
+ {{ skipLabel }}
45
+ </UiButton>
46
+ <UiButton
47
+ v-if="showNext"
48
+ :purpose="nextPurpose"
49
+ size="lg"
50
+ class="min-h-11"
51
+ :trailing-icon="nextIcon"
52
+ :label="nextLabel"
53
+ :disabled="nextDisabled"
54
+ :loading="nextLoading"
55
+ @click="emit('next')"
56
+ />
57
+ </div>
58
+ </div>
59
+ </template>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { UiIcon } from '#components'
3
+ // Numbered progress rail for the guided-onboarding wizard. Completed steps show
4
+ // a check, the current step is ringed, future steps recede.
5
+ //
6
+ // Future steps sit at `text-muted`, not `text-dimmed`: these are chrome labels,
7
+ // and dimmed measures 3.6–4.2:1 in dark on every surface (this layer's AGENTS.md,
8
+ // Tokens and contrast) — below AA at any size. The step you're on already carries the
9
+ // weight + ring, so it stays distinct without pushing the rest under AA.
10
+ //
11
+ // `complete` marks the current step done as well. The last step has no next
12
+ // step to move to, so without it the rail kept its number after the work ended.
13
+ const { label = 'Onboarding progress', complete = false } = defineProps<{
14
+ steps: readonly { id: string, label: string }[]
15
+ currentIndex: number
16
+ /** Accessible name of the progress nav. */
17
+ label?: string
18
+ /** The current step is done too, such as the last step once its work ends. */
19
+ complete?: boolean
20
+ }>()
21
+
22
+ const emit = defineEmits<{ select: [id: string] }>()
23
+ </script>
24
+
25
+ <template>
26
+ <nav :aria-label="label">
27
+ <p
28
+ class="sm:hidden text-sm font-medium text-highlighted"
29
+ aria-current="step"
30
+ >
31
+ Step {{ currentIndex + 1 }} of {{ steps.length }}:
32
+ {{ steps[currentIndex]?.label }}
33
+ </p>
34
+
35
+ <ol class="mt-3 flex items-center gap-1.5 sm:mt-0 sm:gap-2">
36
+ <li
37
+ v-for="(s, i) in steps"
38
+ :key="s.id"
39
+ class="flex items-center gap-2"
40
+ :aria-current="i === currentIndex ? 'step' : undefined"
41
+ >
42
+ <button
43
+ v-if="i < currentIndex"
44
+ type="button"
45
+ class="group inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-lg px-1 text-muted outline-none transition-colors hover:text-default focus-visible:ring-2 focus-visible:ring-primary sm:min-h-0"
46
+ :aria-label="`Return to ${s.label}`"
47
+ @click="emit('select', s.id)"
48
+ >
49
+ <span class="inline-flex size-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-inverted transition-transform group-hover:scale-105">
50
+ <UiIcon name="check" class="size-3" aria-hidden="true" />
51
+ </span>
52
+ <span class="hidden text-sm sm:inline">{{ s.label }}</span>
53
+ </button>
54
+ <span v-else class="inline-flex min-h-11 items-center gap-2 px-1 sm:min-h-0">
55
+ <span
56
+ v-if="complete && i === currentIndex"
57
+ class="inline-flex size-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-inverted"
58
+ >
59
+ <UiIcon name="check" class="size-3" aria-hidden="true" />
60
+ <span class="sr-only">Done:</span>
61
+ </span>
62
+ <span
63
+ v-else
64
+ class="inline-flex size-5 items-center justify-center rounded-full text-xs font-medium transition-colors"
65
+ :class="i === currentIndex ? 'bg-primary/15 text-primary ring-1 ring-primary/30' : 'bg-elevated text-muted'"
66
+ >
67
+ {{ i + 1 }}
68
+ </span>
69
+ <span
70
+ class="hidden text-sm sm:inline"
71
+ :class="i === currentIndex ? 'text-highlighted font-medium' : 'text-muted'"
72
+ >{{ s.label }}</span>
73
+ </span>
74
+ <span v-if="i < steps.length - 1" class="h-px w-3 bg-accented sm:w-4 lg:w-6" aria-hidden="true" />
75
+ </li>
76
+ </ol>
77
+ </nav>
78
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { UiFocusedSurface, UiLogoPro } from '#components'
3
+ // Guided-onboarding wizard chrome: the focused surface (UiFocusedSurface) plus
4
+ // the product mark and a centered max-w-2xl content column. Step content +
5
+ // progress + nav go in the default slot. Shared by the team-create onboarding
6
+ // (layers/saas) and the brand-kit showcase. `#logo` replaces the Pro logo for a
7
+ // site with its own mark.
8
+ //
9
+ // Height falls through to the surface (attrs): the live page passes
10
+ // `min-h-screen`; an embedded preview passes a fixed height.
11
+ const {
12
+ exitLabel = 'Dashboard',
13
+ showExit = true,
14
+ as = 'main',
15
+ wide = false,
16
+ } = defineProps<{
17
+ exitLabel?: string
18
+ showExit?: boolean
19
+ as?: string
20
+ wide?: boolean
21
+ }>()
22
+ const emit = defineEmits<{ exit: [] }>()
23
+ </script>
24
+
25
+ <template>
26
+ <UiFocusedSurface :as="as" align="top" :exit-label="exitLabel" :show-exit="showExit" @exit="emit('exit')">
27
+ <div class="w-full mx-auto mt-10 md:mt-16 relative z-10" :class="wide ? 'max-w-6xl' : 'max-w-2xl'">
28
+ <div class="flex items-center gap-1 mb-8 [&>span]:!ml-0">
29
+ <slot name="logo">
30
+ <UiLogoPro />
31
+ </slot>
32
+ <!-- Account affordance for a wizard with no exit link (first-run
33
+ onboarding): the user must always be able to see who they are
34
+ signed in as and get out. Filled by the consumer — this layer
35
+ can't reach the session. -->
36
+ <div v-if="$slots.account" class="ml-auto flex items-center gap-1">
37
+ <slot name="account" />
38
+ </div>
39
+ </div>
40
+ <slot />
41
+ </div>
42
+ </UiFocusedSurface>
43
+ </template>