@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,83 @@
1
+ <script setup lang="ts">
2
+ import type { SemanticStatus } from '../../composables/semanticColors'
3
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
4
+ import type { EjectChat, EjectLink, EjectMcp } from '../element/eject-menu'
5
+ import { computed, useSlots } from 'vue'
6
+ import { UiChip, UiEjectMenu, UiIcon, UiNavIcon, UiTooltip, ULink } from '#components'
7
+ // Within-a-page section title: icon + heading, optional status badge, info
8
+ // tooltip, description, and a trailing actions area (defaults to a "View all"
9
+ // link when `to` is set), plus optional eject targets.
10
+ const {
11
+ title,
12
+ description,
13
+ icon,
14
+ badge,
15
+ badgeStatus = 'neutral',
16
+ tooltip,
17
+ to,
18
+ actionLabel = 'View all',
19
+ chat,
20
+ mcp,
21
+ schema,
22
+ } = defineProps<{
23
+ title: string
24
+ description?: string
25
+ icon?: UiIconName
26
+ /** Status count/label chip beside the title. */
27
+ badge?: string | number
28
+ badgeStatus?: SemanticStatus
29
+ /** Info tooltip beside the title. */
30
+ tooltip?: string
31
+ /** Trailing "View all"-style link target. Overridden by the #actions slot. */
32
+ to?: string
33
+ actionLabel?: string
34
+ /** Optional eject targets rendered beside the caller's actions. */
35
+ chat?: EjectChat
36
+ mcp?: EjectMcp
37
+ schema?: EjectLink
38
+ }>()
39
+
40
+ const slots = useSlots()
41
+ const hasEject = computed(() => !!(chat || mcp || schema))
42
+ </script>
43
+
44
+ <template>
45
+ <div class="flex items-center justify-between gap-3 mb-3">
46
+ <div class="flex items-center gap-2 min-w-0">
47
+ <!-- A section that reads through one Source passes its mark here, in
48
+ place of the decorative icon. -->
49
+ <slot name="mark">
50
+ <UiNavIcon v-if="icon" :icon="icon" />
51
+ </slot>
52
+ <div class="min-w-0">
53
+ <h2 class="text-heading text-default flex items-center gap-2">
54
+ <slot name="title">
55
+ {{ title }}
56
+ </slot>
57
+ <UiChip v-if="badge != null" purpose="status" :status="badgeStatus" tabular>
58
+ {{ badge }}
59
+ </UiChip>
60
+ <UiTooltip v-if="tooltip" :text="tooltip" trigger-as="button">
61
+ <UiIcon name="note" class="size-3 text-dimmed" aria-hidden="true" />
62
+ </UiTooltip>
63
+ <slot name="after-title" />
64
+ </h2>
65
+ <p v-if="description" class="text-mini text-muted mt-0.5 leading-snug">
66
+ {{ description }}
67
+ </p>
68
+ </div>
69
+ </div>
70
+ <div class="flex items-center gap-2 shrink-0">
71
+ <slot v-if="slots.actions" name="actions" />
72
+ <ULink
73
+ v-else-if="to && !hasEject"
74
+ :to="to"
75
+ class="text-xs text-muted hover:text-default transition-colors inline-flex items-center gap-1"
76
+ >
77
+ {{ actionLabel }}
78
+ <UiIcon name="next" class="size-3" aria-hidden="true" />
79
+ </ULink>
80
+ <UiEjectMenu v-if="hasEject" :chat="chat" :mcp="mcp" :schema="schema" />
81
+ </div>
82
+ </div>
83
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import type { SemanticStatus } from '../../composables/semanticColors'
3
+ import { NuxtLink, UiIcon } from '#components'
4
+ import { semanticColors } from '../../composables/semanticColors'
5
+
6
+ // One-line diagnosis rollup — the "collapse a card to a sentence" overflow
7
+ // move (DESIGN.md Composition rules). A dot carries the status, the sentence
8
+ // carries the diagnosis, `to` carries the drill-in. Deliberately one signal
9
+ // per line: no icon container, no eyebrow, no second metadata row. If the
10
+ // content needs more than a sentence + counts, it has outgrown the strip and
11
+ // wants a card or its own page.
12
+
13
+ const { status = 'neutral', title, to, linkLabel = 'View all' } = defineProps<{
14
+ status?: SemanticStatus
15
+ /** The diagnosis sentence — lead with the noun and the number ("7 broken internal links"). */
16
+ title: string
17
+ /** Drill-in destination. When set the whole row is a NuxtLink with a trailing chevron (canonical clickable-row pattern). */
18
+ to?: string
19
+ linkLabel?: string
20
+ }>()
21
+ </script>
22
+
23
+ <template>
24
+ <component
25
+ :is="to ? NuxtLink : 'div'"
26
+ :to="to || undefined"
27
+ class="group flex items-center gap-2.5 rounded-lg border border-default bg-[var(--ui-bg-elevated)]/5 px-3 py-2 min-h-11 sm:min-h-0"
28
+ :class="to && 'hover:bg-[var(--ui-bg-elevated)]/50 transition-colors focus-visible:ring-2 focus-visible:ring-primary outline-none'"
29
+ >
30
+ <span class="size-2 rounded-full shrink-0" :class="semanticColors[status].dot" aria-hidden="true" />
31
+ <slot name="leading" />
32
+ <span class="text-sm text-default truncate">{{ title }}</span>
33
+ <span class="flex-1" />
34
+ <slot name="meta" />
35
+ <template v-if="to">
36
+ <span class="text-xs text-muted group-hover:text-default transition-colors shrink-0">{{ linkLabel }}</span>
37
+ <UiIcon name="chevron-right" class="size-3.5 text-dimmed group-hover:text-default transition-colors shrink-0" aria-hidden="true" />
38
+ </template>
39
+ </component>
40
+ </template>
@@ -0,0 +1,230 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import type { SemanticStatus } from '../../composables/semanticColors'
4
+ import { useElementSize } from '@vueuse/core'
5
+ import { computed, useTemplateRef, watch } from 'vue'
6
+ import { UiButton, UiIcon } from '#components'
7
+ import { semanticColors } from '../../composables/semanticColors'
8
+
9
+ // A single toast card. Pure presentation: owns our chrome (neutral surface;
10
+ // semantic accent rides the icon + 1px top bar + corner bloom only, per the
11
+ // color-budget rule — mirrors UiAlert) plus the auto-dismiss progress bar.
12
+ // All positioning / stacking / motion / timers / drag live in UiToaster, which
13
+ // renders this inside a `<motion.li>`. Rendered exclusively from the shared
14
+ // useToast() queue, so every existing `toast.add(...)` call site keeps working.
15
+
16
+ export interface ToastAction {
17
+ label: string
18
+ icon?: string
19
+ to?: string
20
+ onClick?: (e: MouseEvent) => void
21
+ /** Visual emphasis. `primary` = filled, `neutral` = quiet. */
22
+ color?: 'primary' | 'neutral'
23
+ }
24
+
25
+ export interface UiToastProps {
26
+ /** Heading text (string or a render component). */
27
+ title?: string | Component
28
+ /** Body text (string or a render component). */
29
+ description?: string | Component
30
+ /** Semantic status — drives accent + default icon. Wins over `color`. */
31
+ status?: SemanticStatus
32
+ /** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
33
+ color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
34
+ /** Icon override. Defaults to the status icon. */
35
+ icon?: string
36
+ /** Inline action buttons. */
37
+ actions?: ToastAction[]
38
+ /**
39
+ * Bug-report handler. When set, failure toasts (error/warning) that carry no
40
+ * action of their own get a "Report bug" button. Wired by the app (the
41
+ * design system must not reach for the feedback drawer itself).
42
+ */
43
+ reportBug?: (toast: { title?: string, description?: string }) => void
44
+ /** Show the close button. */
45
+ close?: boolean
46
+ /** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
47
+ progress?: number | false
48
+ /** Collapsed back card — fade content so the deck reads as peeking cards. */
49
+ peek?: boolean
50
+ }
51
+
52
+ const {
53
+ title,
54
+ description,
55
+ status,
56
+ color,
57
+ icon,
58
+ actions,
59
+ reportBug,
60
+ close = true,
61
+ progress = false,
62
+ peek = false,
63
+ } = defineProps<UiToastProps>()
64
+
65
+ const emit = defineEmits<{
66
+ close: []
67
+ /** Reports the card's natural height to the toaster for fan-out offsets. */
68
+ measure: [number]
69
+ }>()
70
+
71
+ // Map legacy Nuxt UI `color` to our semantic vocabulary. `status` wins if set.
72
+ const COLOR_TO_STATUS: Record<NonNullable<UiToastProps['color']>, SemanticStatus> = {
73
+ success: 'success',
74
+ info: 'info',
75
+ warning: 'warning',
76
+ error: 'error',
77
+ neutral: 'neutral',
78
+ primary: 'info',
79
+ secondary: 'neutral',
80
+ }
81
+
82
+ const defaultIcons: Record<SemanticStatus, string> = {
83
+ error: 'caution',
84
+ warning: 'warning',
85
+ info: 'note',
86
+ success: 'success',
87
+ neutral: 'circle-dash',
88
+ }
89
+
90
+ const resolvedStatus = computed<SemanticStatus>(() => status ?? (color ? COLOR_TO_STATUS[color] : 'neutral'))
91
+ const colors = computed(() => semanticColors[resolvedStatus.value])
92
+ const resolvedIcon = computed(() => icon || defaultIcons[resolvedStatus.value])
93
+
94
+ // Anything the user sees fail should be one click from a bug report, same as
95
+ // the page-level error boundary. A toast that already carries its own action
96
+ // (Upgrade, Retry) keeps it — one CTA per toast, per the color/action budget.
97
+ const resolvedActions = computed<ToastAction[]>(() => {
98
+ if (actions?.length)
99
+ return actions
100
+ if (!reportBug || (resolvedStatus.value !== 'error' && resolvedStatus.value !== 'warning'))
101
+ return []
102
+ return [{
103
+ label: 'Report bug',
104
+ icon: 'bug',
105
+ color: 'neutral',
106
+ onClick: () => reportBug({
107
+ title: typeof title === 'string' ? title : undefined,
108
+ description: typeof description === 'string' ? description : undefined,
109
+ }),
110
+ }]
111
+ })
112
+
113
+ // Measure natural height reactively (ResizeObserver) so the toaster's fan-out
114
+ // offsets stay correct even after fonts load / content reflows. A one-shot
115
+ // onMounted getBoundingClientRect reads 0 inside the portal and collapses the
116
+ // fan — useElementSize avoids that.
117
+ const root = useTemplateRef<HTMLElement>('root')
118
+ const { height } = useElementSize(root)
119
+ watch(height, (h) => {
120
+ if (h)
121
+ emit('measure', h)
122
+ }, { immediate: true })
123
+
124
+ function onAction(action: ToastAction, e: MouseEvent) {
125
+ e.stopPropagation()
126
+ action.onClick?.(e)
127
+ }
128
+ </script>
129
+
130
+ <template>
131
+ <div
132
+ ref="root"
133
+ class="group relative w-full overflow-hidden rounded-xl border border-default bg-default/90 ring-1 ring-black/5 backdrop-blur-md dark:ring-white/5"
134
+ style="box-shadow: var(--elevation-overlay)"
135
+ >
136
+ <!-- Gradient accent bar — top edge, crisp at left, fades right (mirrors UiAlert). -->
137
+ <div
138
+ class="pointer-events-none absolute inset-x-0 top-0 h-px opacity-40"
139
+ :class="colors.dot"
140
+ style="mask-image: linear-gradient(to right, black 0%, black 20%, transparent 80%); -webkit-mask-image: linear-gradient(to right, black 0%, black 20%, transparent 80%);"
141
+ aria-hidden="true"
142
+ />
143
+ <!-- Corner bloom — faint radial light source. -->
144
+ <div
145
+ class="pointer-events-none absolute -right-8 -top-8 size-24 rounded-full opacity-10 blur-2xl"
146
+ :class="colors.dot"
147
+ aria-hidden="true"
148
+ />
149
+
150
+ <!-- Content — fades out on collapsed back cards so the deck reads as a tidy stack. -->
151
+ <div
152
+ class="flex items-start gap-3 p-3.5 pr-9 transition-opacity duration-200"
153
+ :class="peek ? 'opacity-0' : 'opacity-100'"
154
+ >
155
+ <UiIcon
156
+ v-if="resolvedIcon"
157
+ :name="resolvedIcon"
158
+ class="mt-0.5 size-4 shrink-0"
159
+ :class="colors.text"
160
+ aria-hidden="true"
161
+ />
162
+
163
+ <div class="min-w-0 flex-1">
164
+ <p
165
+ v-if="title"
166
+ class="text-sm font-medium leading-snug text-default"
167
+ >
168
+ <component :is="title" v-if="typeof title === 'object'" />
169
+ <template v-else>
170
+ {{ title }}
171
+ </template>
172
+ </p>
173
+ <div
174
+ v-if="description"
175
+ class="text-xs leading-snug text-muted"
176
+ :class="{ 'mt-0.5': title }"
177
+ >
178
+ <component :is="description" v-if="typeof description === 'object'" />
179
+ <template v-else>
180
+ {{ description }}
181
+ </template>
182
+ </div>
183
+
184
+ <div
185
+ v-if="resolvedActions.length"
186
+ class="mt-2.5 flex flex-wrap gap-2"
187
+ >
188
+ <UiButton
189
+ v-for="(action, index) in resolvedActions"
190
+ :key="index"
191
+ size="xs"
192
+ :purpose="action.color === 'primary' ? 'cta' : 'secondary'"
193
+ :icon="action.icon"
194
+ :label="action.label"
195
+ :to="action.to"
196
+ class="min-h-11 sm:min-h-0"
197
+ @pointerdown.stop
198
+ @click="onAction(action, $event)"
199
+ />
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <!-- Dismiss — a plain control (not the motion UiButton, whose width-measure +
205
+ overflow clip mis-render a tiny absolute icon). 24px hit target, icon
206
+ centered; it rests at text-dimmed and strengthens on hover/focus. No
207
+ rest opacity: at 60% the icon measured 2.7:1, under the 3:1 a control
208
+ needs (WCAG 1.4.11). The focus ring is primary for the same reason. `pointerdown.stop` so it never starts a card drag;
209
+ `click.stop` so it never triggers the card's onClick. -->
210
+ <button
211
+ v-if="close && !peek"
212
+ type="button"
213
+ class="absolute right-1.5 top-1.5 inline-flex size-6 items-center justify-center rounded-md text-dimmed transition hover:bg-elevated hover:text-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
214
+ aria-label="Dismiss"
215
+ @pointerdown.stop
216
+ @click.stop="emit('close')"
217
+ >
218
+ <UiIcon name="close" class="size-3.5" aria-hidden="true" />
219
+ </button>
220
+
221
+ <!-- Auto-dismiss progress — width = remaining fraction; hidden on back cards. -->
222
+ <div
223
+ v-if="progress !== false && !peek"
224
+ class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left opacity-50"
225
+ :class="colors.dot"
226
+ :style="{ width: `${Math.max(0, Math.min(1, progress)) * 100}%` }"
227
+ aria-hidden="true"
228
+ />
229
+ </div>
230
+ </template>
@@ -0,0 +1,271 @@
1
+ <script setup lang="ts">
2
+ import type { UiToastProps } from './UiToast.vue'
3
+ import { useMounted, useRafFn } from '@vueuse/core'
4
+ import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
5
+ import { computed, reactive, ref, watch } from 'vue'
6
+ import { useToast } from '#imports'
7
+ import { springs } from '../../shared/motion'
8
+ import UiToast from './UiToast.vue'
9
+
10
+ // Sonner/Linear-style toaster. Resting state is a COLLAPSED peek-deck: only the
11
+ // newest card is fully readable, the rest scale down + peek a few px behind it.
12
+ // Hovering the deck fans it into a readable list and pauses every timer. motion-v
13
+ // drives the per-depth transforms (enter / make-room / exit / fan) off reactive
14
+ // targets, so adding/removing a toast re-stacks the deck automatically. We reuse
15
+ // Nuxt UI's shared useToast() queue, so every existing `toast.add(...)` keeps
16
+ // working unchanged. (Interaction model: brand-kit /toasts → "Fire 5 at once".)
17
+
18
+ export interface UiToasterProps {
19
+ position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
20
+ /** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
21
+ max?: number
22
+ /** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
23
+ duration?: number
24
+ /** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
25
+ reportBug?: UiToastProps['reportBug']
26
+ }
27
+
28
+ const {
29
+ position = 'bottom-right',
30
+ max = 3,
31
+ duration = 5000,
32
+ reportBug,
33
+ } = defineProps<UiToasterProps>()
34
+
35
+ const { toasts, remove } = useToast()
36
+ const mounted = useMounted()
37
+ const reduced = useReducedMotion()
38
+
39
+ // Deck geometry.
40
+ const PEEK = 14 // px each back card peeks above the front
41
+ const SCALE_STEP = 0.05 // scale shrink per depth
42
+ const GAP = 14 // px gap between cards when fanned
43
+ const FALLBACK_H = 72 // height used before a card has been measured
44
+
45
+ const isTop = computed(() => position.startsWith('top'))
46
+ const expanded = ref(false)
47
+
48
+ // Newest last in the queue; cap to `max` (keep the most recent). Within
49
+ // `visible`, index 0 = oldest, last = newest = deck front (depth 0).
50
+ const visible = computed(() => toasts.value.slice(-max))
51
+ const overflow = computed(() => Math.max(0, toasts.value.length - visible.value.length))
52
+
53
+ // Per-id measured natural heights, reported by each UiToast.
54
+ const heights = reactive<Record<string, number>>({})
55
+ function setHeight(id: string | number, h: number) {
56
+ heights[String(id)] = h
57
+ }
58
+ function heightOf(id: string | number | undefined): number {
59
+ return (id != null && heights[String(id)]) || FALLBACK_H
60
+ }
61
+
62
+ // Placement (transform target + chrome flags) per visible toast. motion-v tweens
63
+ // between successive targets, so a queue change animates the whole deck.
64
+ const placements = computed(() => {
65
+ const list = visible.value
66
+ const n = list.length
67
+ // depth 0 = front (newest = last). heightAtDepth indexes by depth.
68
+ const heightAtDepth = (depth: number) => heightOf(list[n - 1 - depth]?.id)
69
+
70
+ // Cumulative bottom offsets for the fanned (expanded) state.
71
+ const expandedOffset: number[] = []
72
+ let acc = 0
73
+ for (let d = 0; d < n; d++) {
74
+ expandedOffset[d] = acc
75
+ acc += heightAtDepth(d) + GAP
76
+ }
77
+ const frontH = heightAtDepth(0)
78
+ const dir = isTop.value ? 1 : -1 // grow downward for top-anchored, upward otherwise
79
+
80
+ return list.map((t, idx) => {
81
+ const depth = n - 1 - idx
82
+ const ownH = heightOf(t.id)
83
+ if (expanded.value) {
84
+ return {
85
+ depth,
86
+ peek: false,
87
+ z: 100 - depth,
88
+ target: {
89
+ y: dir * expandedOffset[depth]!,
90
+ scale: 1,
91
+ opacity: 1,
92
+ height: ownH,
93
+ },
94
+ }
95
+ }
96
+ return {
97
+ depth,
98
+ peek: depth > 0,
99
+ z: 100 - depth,
100
+ target: {
101
+ y: dir * depth * PEEK,
102
+ scale: Number((1 - depth * SCALE_STEP).toFixed(3)),
103
+ opacity: depth < max ? 1 : 0,
104
+ height: depth > 0 ? frontH : ownH,
105
+ },
106
+ }
107
+ })
108
+ })
109
+
110
+ // Region height — sized so the whole deck/fan is one continuous hover zone
111
+ // (prevents fan-out flicker as cards reposition out from under the pointer).
112
+ const regionHeight = computed(() => {
113
+ const n = visible.value.length
114
+ if (!n)
115
+ return 0
116
+ if (expanded.value) {
117
+ return visible.value.reduce((sum, t) => sum + heightOf(t.id) + GAP, 0)
118
+ }
119
+ const front = heightOf(visible.value[n - 1]!.id)
120
+ return front + (Math.min(n, max) - 1) * PEEK
121
+ })
122
+
123
+ // ── Timers — one rAF loop, pauses entirely while the deck is fanned/hovered ──
124
+ const elapsed = reactive<Record<string, number>>({})
125
+ const progress = reactive<Record<string, number>>({})
126
+ const dragging = reactive<Record<string, boolean>>({})
127
+
128
+ function durationOf(t: (typeof toasts.value)[number]): number {
129
+ const d = (t as { duration?: number }).duration
130
+ return typeof d === 'number' ? d : duration
131
+ }
132
+
133
+ useRafFn(({ delta }) => {
134
+ if (expanded.value)
135
+ return // fanned → all timers paused
136
+ // Only the front (primary) toast counts down. Buried cards hold their full
137
+ // duration and start their own timer once promoted to the front — so a toast
138
+ // never expires while hidden behind the deck (timers stack, they don't race).
139
+ const list = visible.value
140
+ const front = list.at(-1)
141
+ if (!front)
142
+ return
143
+ const id = String(front.id)
144
+ const total = durationOf(front)
145
+ if (!total || total <= 0 || dragging[id])
146
+ return
147
+ elapsed[id] = (elapsed[id] || 0) + delta
148
+ progress[id] = Math.max(0, 1 - elapsed[id]! / total)
149
+ if (elapsed[id]! >= total)
150
+ remove(front.id)
151
+ })
152
+
153
+ // Prune timer bookkeeping for toasts that have left the queue.
154
+ watch(visible, (list) => {
155
+ const live = new Set(list.map(t => String(t.id)))
156
+ for (const id of Object.keys(elapsed)) {
157
+ if (!live.has(id)) {
158
+ delete elapsed[id]
159
+ delete progress[id]
160
+ delete dragging[id]
161
+ delete heights[id]
162
+ }
163
+ }
164
+ })
165
+
166
+ function progressFor(t: (typeof toasts.value)[number]): number | false {
167
+ return durationOf(t) > 0 ? (progress[String(t.id)] ?? 1) : false
168
+ }
169
+
170
+ // ── Drag to dismiss ──
171
+ function canDrag(depth: number): boolean {
172
+ return expanded.value || depth === 0
173
+ }
174
+ function onDragStart(id: string | number) {
175
+ dragging[String(id)] = true
176
+ }
177
+ function onDragEnd(id: string | number, info: { offset: { x: number }, velocity: { x: number } }) {
178
+ dragging[String(id)] = false
179
+ if (info.offset.x > 100 || info.velocity.x > 600)
180
+ remove(id)
181
+ }
182
+
183
+ // Whitelist the fields UiToast consumes so queue bookkeeping never hits the DOM.
184
+ function toastBind(t: (typeof toasts.value)[number]): Partial<UiToastProps> {
185
+ const x = t as unknown as Record<string, unknown>
186
+ return {
187
+ title: x.title as UiToastProps['title'],
188
+ description: x.description as UiToastProps['description'],
189
+ icon: x.icon as UiToastProps['icon'],
190
+ color: x.color as UiToastProps['color'],
191
+ status: x.status as UiToastProps['status'],
192
+ actions: x.actions as UiToastProps['actions'],
193
+ close: x.close as UiToastProps['close'],
194
+ }
195
+ }
196
+
197
+ const stackTransition = computed(() =>
198
+ reduced.value
199
+ ? { duration: 0 }
200
+ : { default: springs.snappy, opacity: { duration: 0.25 }, height: springs.smooth },
201
+ )
202
+
203
+ const posClass = computed(() => ({
204
+ 'top-left': 'top-4 left-4',
205
+ 'top-center': 'top-4 left-1/2 -translate-x-1/2',
206
+ 'top-right': 'top-4 right-4',
207
+ 'bottom-left': 'bottom-4 left-4',
208
+ 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
209
+ 'bottom-right': 'bottom-4 right-4',
210
+ }[position]))
211
+ </script>
212
+
213
+ <template>
214
+ <Teleport v-if="mounted" to="body">
215
+ <!-- Region wrapper owns the hover zone + landmark role; keeping `role` off the
216
+ <ol> preserves its list semantics so the <li> aren't orphaned (a11y). -->
217
+ <div
218
+ class="pointer-events-auto fixed z-[100] w-[calc(100vw-2rem)] sm:w-96"
219
+ :class="posClass"
220
+ :style="{ height: `${regionHeight}px` }"
221
+ role="region"
222
+ aria-label="Notifications"
223
+ @mouseenter="expanded = true"
224
+ @mouseleave="expanded = false"
225
+ @focusin="expanded = true"
226
+ @focusout="(e: FocusEvent) => { if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) expanded = false }"
227
+ >
228
+ <ol class="absolute inset-0 m-0 list-none p-0" aria-live="polite">
229
+ <AnimatePresence :initial="false">
230
+ <motion.li
231
+ v-for="(t, idx) in visible"
232
+ :key="t.id"
233
+ class="absolute inset-x-0 overflow-hidden"
234
+ :class="isTop ? 'top-0' : 'bottom-0'"
235
+ style="transform-origin: bottom center; will-change: transform;"
236
+ :style="{ zIndex: placements[idx]!.z }"
237
+ :initial="reduced ? false : { opacity: 0, y: isTop ? -40 : 40, scale: 0.9, height: placements[idx]!.target.height }"
238
+ :animate="placements[idx]!.target"
239
+ :exit="reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, transition: { duration: 0.2 } }"
240
+ :transition="stackTransition"
241
+ :drag="canDrag(placements[idx]!.depth) ? 'x' : false"
242
+ :drag-snap-to-origin="true"
243
+ :drag-elastic="{ left: 0.15, right: 0.6 }"
244
+ :drag-constraints="{ left: 0, right: 0 }"
245
+ @drag-start="onDragStart(t.id)"
246
+ @drag-end="(_e: PointerEvent, info: { offset: { x: number }, velocity: { x: number } }) => onDragEnd(t.id, info)"
247
+ >
248
+ <UiToast
249
+ v-bind="toastBind(t)"
250
+ :report-bug="reportBug"
251
+ :peek="placements[idx]!.peek"
252
+ :progress="progressFor(t)"
253
+ @measure="(h: number) => setHeight(t.id, h)"
254
+ @close="remove(t.id)"
255
+ />
256
+ </motion.li>
257
+ </AnimatePresence>
258
+ </ol>
259
+
260
+ <!-- "+N more" pill — sibling of the <ol> (never inside it). Sits just clear
261
+ of the deck: above it for bottom-anchored, below it for top-anchored. -->
262
+ <div
263
+ v-if="overflow > 0 && !expanded"
264
+ class="pointer-events-none absolute right-2 text-mini font-medium text-dimmed"
265
+ :class="isTop ? 'bottom-0 translate-y-full pt-1' : 'top-0 -translate-y-full pb-1'"
266
+ >
267
+ +{{ overflow }} more
268
+ </div>
269
+ </div>
270
+ </Teleport>
271
+ </template>