@nuxtseo/design-system 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +232 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +125 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +55 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +68 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +29 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,549 @@
1
+ <script setup lang="ts">
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+ import type { NavigationDestination } from '../../shared/navigation'
4
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
5
+ import type { SlotTextOptions } from '../../utils/slot-text'
6
+ import { useMediaQuery, useMounted, useMouseInElement } from '@vueuse/core'
7
+ import { animateValue, m, useReducedMotion } from 'motion-v'
8
+ import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useSlots, useTemplateRef } from 'vue'
9
+ import { UButton, UiIcon, UiSlotText } from '#components'
10
+ import { liftPresets } from '../../shared/motion'
11
+
12
+ /**
13
+ * UiButton — the single button primitive. UButton + motion-v lift/press
14
+ * + cursor-tracked surface FX, driven by a semantic `purpose` prop.
15
+ *
16
+ * `purpose` is the only styling knob — there is no raw `color`/`variant`.
17
+ * Each purpose resolves to a fixed UButton color+variant so button intent is
18
+ * consistent everywhere:
19
+ * - `cta` primary commit / page action — inverted neutral solid.
20
+ * - `secondary` alternative action — neutral outline.
21
+ * - `quiet` low-emphasis: alerts, inline, icon-only, back/nav — neutral ghost.
22
+ * - `danger` destructive — error soft (error ghost when icon-only).
23
+ * - `link` tertiary action below a cta/secondary — neutral link, no chrome,
24
+ * no FX/lift (the "see other plans" / "or paste a list" tier).
25
+ *
26
+ * Still forwarded: `size`, `block`, `loading`, `disabled`, `to`, `type`,
27
+ * `icon`/`trailing-icon`, `class`, all slots. `intensity` may be set
28
+ * explicitly to override the per-purpose motion default.
29
+ *
30
+ * Effect layers (FX-only intensities, all hover-gated, reduced-motion-aware):
31
+ * 1. Wrapper lift — motion-v spring scale + y on hover, settle on tap.
32
+ * 2. Halo — the shared `--elevation-hover` token, accent-tinted via `--fx-accent`.
33
+ * 3. Spotlight — radial gradient anchored to cursor (`--mb-mx/--mb-my`).
34
+ * 4. Specular sweep — thin skewed band on hover-enter for CTA only.
35
+ * 5. Trailing-icon nudge — +1.5px on hover when `#trailing` is set.
36
+ *
37
+ * Modern CSS leveraged: `@property`, `color-mix(in oklab, ...)`, `mask-image`.
38
+ */
39
+
40
+ defineOptions({ inheritAttrs: false })
41
+
42
+ const {
43
+ purpose = 'secondary',
44
+ intensity: intensityProp,
45
+ block = false,
46
+ size,
47
+ loading = false,
48
+ disabled = false,
49
+ icon,
50
+ leadingIcon,
51
+ trailingIcon,
52
+ animatedLabel = true,
53
+ ...buttonProps
54
+ } = defineProps<MotionButtonProps>()
55
+ const emit = defineEmits<{
56
+ click: [event: MouseEvent]
57
+ }>()
58
+ type Purpose = 'cta' | 'secondary' | 'quiet' | 'danger' | 'link'
59
+ type Intensity = 'subtle' | 'default' | 'cta'
60
+
61
+ interface MotionExtras {
62
+ purpose?: Purpose
63
+ intensity?: Intensity
64
+ // Curated semantic icon names (raw `i-*` strings still accepted).
65
+ icon?: UiIconName
66
+ leadingIcon?: UiIconName
67
+ trailingIcon?: UiIconName
68
+ animatedLabel?: boolean | SlotTextOptions
69
+ }
70
+
71
+ // UButton props minus the styling knobs purpose now owns and the icon props
72
+ // MotionExtras re-types to the curated `UiIcon` union.
73
+ type MotionButtonProps = Omit<ButtonProps, 'color' | 'variant' | 'icon' | 'leadingIcon' | 'trailingIcon' | 'onClick' | 'to' | 'href'> & MotionExtras & { to?: NavigationDestination, href?: NavigationDestination }
74
+
75
+ // Semantic icon role names pass straight to UButton; @nuxt/icon's global aliases
76
+ // resolve them to the active set's id (raw `i-*` ids pass through unchanged).
77
+ const slots = useSlots()
78
+
79
+ // Icon-only when there is no label content (no default slot, no `label` prop).
80
+ const isIconOnly = computed(() => !slots.default && !('label' in buttonProps))
81
+
82
+ // Dev-time guard: an icon-only button with no accessible name ships silently as
83
+ // an unlabelled control. Warn so the caller passes `aria-label` (or `title`).
84
+ if (import.meta.dev) {
85
+ const attrs = useAttrs()
86
+ onMounted(() => {
87
+ if (isIconOnly.value && !attrs['aria-label'] && !attrs['aria-labelledby'] && !attrs.title) {
88
+ console.warn('[UiButton] icon-only button has no accessible name — pass `aria-label` (or `title`).', { icon, leadingIcon, trailingIcon })
89
+ }
90
+ })
91
+ }
92
+
93
+ // purpose → resolved UButton color + variant.
94
+ const resolved = computed<{ color: ButtonProps['color'], variant: ButtonProps['variant'] }>(() => {
95
+ switch (purpose) {
96
+ case 'cta':
97
+ return { color: 'neutral', variant: 'solid' }
98
+ case 'quiet':
99
+ return { color: 'neutral', variant: 'ghost' }
100
+ case 'link':
101
+ return { color: 'neutral', variant: 'link' }
102
+ case 'danger':
103
+ return { color: 'error', variant: isIconOnly.value ? 'ghost' : 'soft' }
104
+ case 'secondary':
105
+ default:
106
+ return { color: 'neutral', variant: 'outline' }
107
+ }
108
+ })
109
+ const color = computed(() => resolved.value.color)
110
+ const variant = computed(() => resolved.value.variant)
111
+
112
+ // Per-purpose motion default; `intensity` prop overrides.
113
+ // cta at lg/xl → full CTA FX; smaller cta → default. quiet → subtle (no FX).
114
+ const intensity = computed<Intensity>(() => {
115
+ if (intensityProp)
116
+ return intensityProp
117
+ if (purpose === 'cta')
118
+ return (size === 'xl' || size === 'lg' || !size) ? 'cta' : 'default'
119
+ if (purpose === 'quiet' || purpose === 'link' || size === 'xs')
120
+ return 'subtle'
121
+ return 'default'
122
+ })
123
+
124
+ const reduced = useReducedMotion()
125
+ const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
126
+ const wrapperEl = useTemplateRef<HTMLElement>('wrapperEl')
127
+ const shellEl = useTemplateRef<HTMLElement>('shellEl')
128
+ const clipEl = useTemplateRef<HTMLElement>('clipEl')
129
+ const hovered = ref(false)
130
+ let resizeObserver: ResizeObserver | undefined
131
+
132
+ const isDisabled = computed(() => disabled || loading)
133
+ // Mount-gated: `canHover` is a media query, so it's false on the server and
134
+ // flips true at hydration on desktop — rendering the v-if FX spans straight
135
+ // from `canHover` mismatches on every desktop pageview. The FX layers are all
136
+ // hover-gated (invisible at rest), so deferring them to post-mount is free.
137
+ const isMounted = useMounted()
138
+ const showFx = computed(() => isMounted.value && canHover.value && intensity.value !== 'subtle' && !isDisabled.value)
139
+
140
+ // Only attach the mouse tracker when FX render — saves ~100 listeners on
141
+ // dashboards full of subtle/icon buttons.
142
+ const trackerTarget = computed(() => (showFx.value ? wrapperEl.value : null))
143
+ const { elementX, elementY, elementWidth, elementHeight, isOutside }
144
+ = useMouseInElement(trackerTarget)
145
+
146
+ const mxPct = computed(() => {
147
+ if (!showFx.value || isOutside.value || !elementWidth.value)
148
+ return 50
149
+ return Math.max(0, Math.min(100, (elementX.value / elementWidth.value) * 100))
150
+ })
151
+ const myPct = computed(() => {
152
+ if (!showFx.value || isOutside.value || !elementHeight.value)
153
+ return 50
154
+ return Math.max(0, Math.min(100, (elementY.value / elementHeight.value) * 100))
155
+ })
156
+
157
+ const lift = computed(() => {
158
+ // Link is a text affordance, not a surface — never lift/scale on hover.
159
+ if (reduced.value || !canHover.value || purpose === 'link')
160
+ return { hover: {}, tap: {}, transition: {} }
161
+ const preset = liftPresets[intensity.value]
162
+ // A full-width button scaling horizontally overflows its container and gets
163
+ // clipped by any `overflow:hidden` ancestor. Keep the vertical lift, drop the
164
+ // scale so block buttons stay within bounds.
165
+ if (block) {
166
+ return {
167
+ hover: { y: preset.hover.y },
168
+ tap: { y: preset.tap.y },
169
+ transition: preset.transition,
170
+ }
171
+ }
172
+ return preset
173
+ })
174
+
175
+ const wrapperStyle = computed(() => {
176
+ // Hover capability is only authoritative in the browser. Keep the default
177
+ // FX variables deterministic through SSR/hydration, then let the mouse
178
+ // tracker update them; subtle/disabled controls never consume the values.
179
+ if (intensity.value === 'subtle' || isDisabled.value)
180
+ return undefined
181
+ return {
182
+ '--mb-mx': `${mxPct.value}%`,
183
+ '--mb-my': `${myPct.value}%`,
184
+ '--fx-accent': 'var(--ui-text)',
185
+ }
186
+ })
187
+
188
+ // `w-fit` keeps the wrapper hugging its content even when it's a flex/grid item
189
+ // (an inline-flex box blockifies to flex and would otherwise stretch to the
190
+ // column's full width, dragging the halo/spot FX overlays out with it).
191
+ const wrapperClass = computed(() => block ? 'flex w-full' : 'inline-flex w-fit max-w-full')
192
+ const leadingIconName = computed(() => loading ? 'loading' : (leadingIcon || icon))
193
+ // `useReducedMotion()` is browser-only knowledge. Gate the initial pose on the
194
+ // same mounted state as the hover FX so SSR and hydration both render the icon
195
+ // at rest. Later keyed icon changes can still animate on motion-enabled clients.
196
+ const leadingInitial = computed(() => isMounted.value && !reduced.value
197
+ ? { opacity: 0, x: -6, scale: 0.82 }
198
+ : false)
199
+ const hasLeading = computed(() => !!slots.leading || !!leadingIconName.value)
200
+ const hasTrailing = computed(() => !!slots.trailing)
201
+ const buttonLabel = computed(() => typeof buttonProps.label === 'string' ? buttonProps.label : undefined)
202
+ const shouldAutoAnimateLabel = computed(() => {
203
+ if (!buttonLabel.value || slots.default || block || loading || animatedLabel === false)
204
+ return false
205
+ return buttonLabel.value.length <= 24
206
+ })
207
+ const animatedLabelOptions = computed(() => {
208
+ if (!shouldAutoAnimateLabel.value)
209
+ return null
210
+
211
+ return typeof animatedLabel === 'object' ? animatedLabel : {}
212
+ })
213
+ const hasAnimatedLabel = computed(() => !!animatedLabelOptions.value && !!buttonLabel.value)
214
+ const resolvedButtonProps = computed(() => {
215
+ if (!hasAnimatedLabel.value)
216
+ return buttonProps
217
+ const { label: _label, ...rest } = buttonProps
218
+ return rest
219
+ })
220
+
221
+ function buttonEl() {
222
+ return domNode(clipEl.value)?.querySelector<HTMLElement>('.ui-motion-button__btn') ?? null
223
+ }
224
+
225
+ function domNode(value: unknown): HTMLElement | null {
226
+ if (Array.isArray(value))
227
+ return domNode(value[0])
228
+
229
+ if (value && typeof (value as HTMLElement).querySelector === 'function')
230
+ return value as HTMLElement
231
+
232
+ const el = (value as { $el?: unknown } | null)?.$el
233
+ if (el && typeof (el as HTMLElement).querySelector === 'function')
234
+ return el as HTMLElement
235
+
236
+ return null
237
+ }
238
+
239
+ // The shell follows the button's width with a spring when the label, an icon
240
+ // or the loading state changes it. At rest the shell has no width of its own,
241
+ // so it hugs the button exactly and a late web font cannot leave it clipped.
242
+ // The ResizeObserver reports the button's border box after layout, so no
243
+ // button forces a layout to measure itself while the page hydrates. The clip
244
+ // does not shrink (`shrink-0`), so a pinned shell never changes the width the
245
+ // observer reads.
246
+ const SHELL_SPRING = { type: 'spring', stiffness: 420, damping: 34, mass: 0.62 } as const
247
+ let buttonWidth = 0
248
+ // The width the shell paints now: the latest spring frame while one runs.
249
+ let shellWidth = 0
250
+ let shellAnimation: { stop: () => void } | undefined
251
+
252
+ function releaseShell(shell: HTMLElement) {
253
+ shellAnimation?.stop()
254
+ shellAnimation = undefined
255
+ shell.style.width = ''
256
+ }
257
+
258
+ function followButtonWidth(width: number) {
259
+ const previous = buttonWidth
260
+ buttonWidth = width
261
+ const shell = shellEl.value
262
+ // The first report only records the width. A hidden button reports 0, and
263
+ // it must not grow from nothing when it appears.
264
+ if (!shell || block || !previous || !width || previous === width)
265
+ return
266
+ if (reduced.value) {
267
+ releaseShell(shell)
268
+ return
269
+ }
270
+ // The observer runs after layout and before paint, so the frame paints the
271
+ // shell at its previous width. A label change during a spring starts the
272
+ // next spring from the current frame, so nothing here reads layout.
273
+ const from = shellAnimation ? shellWidth : previous
274
+ shellAnimation?.stop()
275
+ shellWidth = from
276
+ shell.style.width = `${from}px`
277
+ shellAnimation = animateValue({
278
+ keyframes: [from, Math.ceil(width)],
279
+ ...SHELL_SPRING,
280
+ onUpdate: (value: number) => {
281
+ shellWidth = value
282
+ shell.style.width = `${value}px`
283
+ },
284
+ onComplete: () => {
285
+ shellAnimation = undefined
286
+ shell.style.width = ''
287
+ },
288
+ })
289
+ }
290
+
291
+ onMounted(() => {
292
+ const el = buttonEl()
293
+ if (!el || typeof ResizeObserver === 'undefined')
294
+ return
295
+ resizeObserver = new ResizeObserver(([entry]) => {
296
+ if (!entry)
297
+ return
298
+ // `borderBoxSize` includes the border; the content box left the shell 2px short.
299
+ const box = entry.borderBoxSize?.[0]
300
+ followButtonWidth(box ? box.inlineSize : entry.target.getBoundingClientRect().width)
301
+ })
302
+ resizeObserver.observe(el)
303
+ })
304
+
305
+ onBeforeUnmount(() => {
306
+ resizeObserver?.disconnect()
307
+ shellAnimation?.stop()
308
+ })
309
+ </script>
310
+
311
+ <template>
312
+ <m.div
313
+ ref="wrapperEl"
314
+ tabindex="-1"
315
+ class="ui-motion-button group/mbtn relative isolate"
316
+ :class="[wrapperClass]"
317
+ :while-hover="isDisabled ? undefined : lift.hover"
318
+ :while-press="isDisabled ? undefined : lift.tap"
319
+ :transition="lift.transition"
320
+ :data-intensity="intensity"
321
+ :data-color="color"
322
+ :style="wrapperStyle"
323
+ @hover-start="hovered = !isDisabled"
324
+ @hover-end="hovered = false"
325
+ >
326
+ <!-- Halo — ambient lift via box-shadow rings, follows button shape. -->
327
+ <span
328
+ v-if="showFx"
329
+ class="ui-motion-button__halo pointer-events-none absolute"
330
+ aria-hidden="true"
331
+ />
332
+
333
+ <!-- Clipper bounds FX overlays to the button shape. Skip overflow:hidden
334
+ when no FX render so the button's native border-radius isn't cropped. -->
335
+ <div
336
+ ref="shellEl"
337
+ tabindex="-1"
338
+ class="ui-motion-button__clip-shell"
339
+ :class="block ? 'flex w-full' : 'inline-flex'"
340
+ >
341
+ <div
342
+ ref="clipEl"
343
+ class="ui-motion-button__clip relative"
344
+ :class="[block ? 'flex w-full' : 'inline-flex w-max max-w-none shrink-0 whitespace-nowrap', { 'ui-motion-button__clip--active': showFx }]"
345
+ >
346
+ <UButton
347
+ v-bind="{ ...resolvedButtonProps, ...$attrs } as ButtonProps"
348
+ :color="color"
349
+ :size="size"
350
+ :variant="variant"
351
+ :loading="false"
352
+ :disabled="isDisabled"
353
+ :block="block"
354
+ :icon="undefined"
355
+ :leading-icon="undefined"
356
+ :trailing-icon="trailingIcon"
357
+ class="ui-motion-button__btn relative z-10 whitespace-nowrap" :class="[{ 'hover:underline underline-offset-2': purpose === 'link' }]"
358
+ @click="emit('click', $event)"
359
+ >
360
+ <template v-if="hasLeading" #leading="slotData">
361
+ <slot v-if="slots.leading" name="leading" v-bind="slotData || {}" />
362
+ <m.span
363
+ v-else-if="leadingIconName"
364
+ :key="leadingIconName"
365
+ class="ui-motion-button__leading inline-flex shrink-0"
366
+ :initial="leadingInitial"
367
+ :animate="{ opacity: 1, x: 0, scale: 1 }"
368
+ :transition="reduced ? { duration: 0 } : { type: 'spring', stiffness: 520, damping: 30, mass: 0.55 }"
369
+ >
370
+ <UiIcon
371
+ :name="leadingIconName"
372
+ class="size-4"
373
+ :class="{ 'animate-spin': loading }"
374
+ aria-hidden="true"
375
+ />
376
+ </m.span>
377
+ </template>
378
+
379
+ <template v-if="hasAnimatedLabel && buttonLabel && animatedLabelOptions" #default>
380
+ <UiSlotText :text="buttonLabel" :options="animatedLabelOptions" />
381
+ </template>
382
+
383
+ <!-- Forward every UButton slot. Trailing gets a hover-nudge wrapper. -->
384
+ <template v-for="(_, name) in ($slots as Record<string, unknown>)" :key="name" #[name]="slotData">
385
+ <slot v-if="name !== 'leading' && name !== 'trailing'" :name="name" v-bind="slotData || {}" />
386
+ </template>
387
+
388
+ <template v-if="hasTrailing" #trailing="slotData">
389
+ <m.span
390
+ class="inline-flex"
391
+ :animate="{ x: hovered && !reduced ? 1.5 : 0 }"
392
+ :transition="{ duration: 0.22, ease: 'easeOut' }"
393
+ >
394
+ <slot name="trailing" v-bind="slotData || {}" />
395
+ </m.span>
396
+ </template>
397
+ </UButton>
398
+
399
+ <span
400
+ v-if="showFx"
401
+ class="ui-motion-button__spot pointer-events-none absolute inset-0"
402
+ aria-hidden="true"
403
+ />
404
+
405
+ <span
406
+ v-if="showFx && intensity === 'cta'"
407
+ class="ui-motion-button__shimmer pointer-events-none absolute inset-y-0"
408
+ aria-hidden="true"
409
+ />
410
+ </div>
411
+ </div>
412
+ </m.div>
413
+ </template>
414
+
415
+ <style scoped>
416
+ @property --mb-mx {
417
+ syntax: '<percentage>';
418
+ inherits: true;
419
+ initial-value: 50%;
420
+ }
421
+ @property --mb-my {
422
+ syntax: '<percentage>';
423
+ inherits: true;
424
+ initial-value: 50%;
425
+ }
426
+
427
+ /* Match UButton's native rounded-md (0.375rem) so the clip doesn't crop. */
428
+ .ui-motion-button {
429
+ border-radius: 0.375rem;
430
+ }
431
+ .ui-motion-button[data-intensity="cta"] {
432
+ border-radius: 0.5rem;
433
+ }
434
+
435
+ /* Callers may opt into a pill. Every motion layer must follow that shape. */
436
+ .ui-motion-button:has(.ui-motion-button__btn.rounded-full) {
437
+ border-radius: 9999px;
438
+ }
439
+
440
+ .ui-motion-button__clip-shell,
441
+ .ui-motion-button__clip--active {
442
+ overflow: hidden;
443
+ }
444
+
445
+ /* Focus: the clip shell crops any outline the button draws on itself, so the
446
+ wrapper draws it instead, at the shared focus token with a 2px gap (3:1 or
447
+ more against the page and the button, WCAG 1.4.11 and 2.4.7). */
448
+ .ui-motion-button:has(.ui-motion-button__btn:focus-visible) {
449
+ outline: 3px solid var(--focus-ring-color);
450
+ outline-offset: 2px;
451
+ }
452
+ .ui-motion-button__btn:focus-visible {
453
+ outline: none;
454
+ }
455
+
456
+ .ui-motion-button__clip-shell,
457
+ .ui-motion-button__clip,
458
+ .ui-motion-button__clip--active {
459
+ border-radius: inherit;
460
+ }
461
+
462
+ @media (prefers-reduced-motion: reduce) {
463
+ .ui-motion-button__clip-shell {
464
+ transition: none;
465
+ }
466
+ }
467
+
468
+ /* ── Halo ── hover-tier elevation: the shared --elevation-hover token, an
469
+ accent-tinted ambient lift. --fx-accent (set on the wrapper) tints it;
470
+ the cta intensity scales it up locally. */
471
+ .ui-motion-button__halo {
472
+ z-index: 0;
473
+ inset: 0;
474
+ opacity: 0;
475
+ border-radius: inherit;
476
+ background: transparent;
477
+ box-shadow: var(--elevation-hover);
478
+ transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1);
479
+ }
480
+ .ui-motion-button[data-intensity="cta"] .ui-motion-button__halo {
481
+ box-shadow:
482
+ 0 3px 12px -3px color-mix(in oklab, var(--fx-accent) 16%, transparent),
483
+ 0 12px 28px -10px color-mix(in oklab, var(--fx-accent) 12%, transparent);
484
+ }
485
+ .ui-motion-button:hover .ui-motion-button__halo {
486
+ opacity: 1;
487
+ }
488
+
489
+ /* ── Spotlight — cursor-tracked neutral highlight. */
490
+ .ui-motion-button__spot {
491
+ z-index: 20;
492
+ opacity: 0;
493
+ border-radius: inherit;
494
+ background: radial-gradient(
495
+ circle 60px at var(--mb-mx, 50%) var(--mb-my, 50%),
496
+ color-mix(in oklab, var(--fx-accent) 10%, transparent),
497
+ transparent 70%
498
+ );
499
+ transition: opacity 320ms cubic-bezier(0.22, 1, 0.36, 1);
500
+ -webkit-mask-image: radial-gradient(ellipse at center, rgb(0 0 0) 60%, rgb(0 0 0 / 0.5) 90%, rgb(0 0 0 / 0) 100%);
501
+ mask-image: radial-gradient(ellipse at center, rgb(0 0 0) 60%, rgb(0 0 0 / 0.5) 90%, rgb(0 0 0 / 0) 100%);
502
+ }
503
+ .ui-motion-button[data-intensity="cta"] .ui-motion-button__spot {
504
+ background: radial-gradient(
505
+ circle 80px at var(--mb-mx, 50%) var(--mb-my, 50%),
506
+ color-mix(in oklab, var(--fx-accent) 14%, transparent),
507
+ transparent 70%
508
+ );
509
+ }
510
+ .ui-motion-button:hover .ui-motion-button__spot {
511
+ opacity: 1;
512
+ }
513
+
514
+ /* ── Specular sweep — CTA only, one pass per hover-enter. */
515
+ .ui-motion-button__shimmer {
516
+ z-index: 25;
517
+ width: 22%;
518
+ left: 0;
519
+ transform: translateX(-220%) skewX(-22deg);
520
+ background: linear-gradient(
521
+ 90deg,
522
+ transparent,
523
+ color-mix(in oklab, var(--fx-accent) 18%, transparent) 50%,
524
+ transparent
525
+ );
526
+ opacity: 0;
527
+ filter: blur(3px);
528
+ }
529
+ .ui-motion-button[data-intensity="cta"]:hover .ui-motion-button__shimmer {
530
+ opacity: 1;
531
+ animation: ui-mbtn-sweep 1.4s linear(0, 0.05 8%, 0.2 18%, 0.55 40%, 0.85 65%, 0.96 80%, 1) both;
532
+ }
533
+ @keyframes ui-mbtn-sweep {
534
+ 0% { transform: translateX(-220%) skewX(-22deg); }
535
+ 100% { transform: translateX(400%) skewX(-22deg); }
536
+ }
537
+
538
+ @media (prefers-reduced-motion: reduce) {
539
+ .ui-motion-button { transform: none; transition: none; }
540
+ .ui-motion-button__spot,
541
+ .ui-motion-button__shimmer,
542
+ .ui-motion-button__halo { display: none; }
543
+ }
544
+
545
+ @media (hover: none) {
546
+ .ui-motion-button__spot,
547
+ .ui-motion-button__shimmer { display: none; }
548
+ }
549
+ </style>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import type { Transition } from 'motion-v'
3
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
4
+ import { m } from 'motion-v'
5
+ import { UiAtmosphere, UiIcon } from '#components'
6
+ import { useEntranceMotion } from '../../composables/useEntranceMotion'
7
+ import { entrancePresets } from '../../shared/motion'
8
+
9
+ // The "spark joy" moment (DESIGN principle 8): a success / milestone reveal — a
10
+ // site connected, a waitlist joined, a trial started. This is the ONE place a
11
+ // component defaults to atmosphere (a Dawn bloom = first light), because it is
12
+ // exclusively a celebratory edge, never a data surface. Restraint: the joy is the
13
+ // bloom + a single quiet spring on reveal, NOT confetti. Reduced-motion safe.
14
+ const {
15
+ icon,
16
+ palette = 'dawn',
17
+ title,
18
+ description,
19
+ } = defineProps<{
20
+ /** Optional small mark above the title (a single monochrome-container icon, primary glyph). */
21
+ icon?: UiIconName
22
+ /** Atmosphere palette — Dawn (first light) by default; Dusk for a premium/paid moment. */
23
+ palette?: 'ink' | 'twilight' | 'dawn' | 'dusk' | 'mist' | 'ash'
24
+ title: string
25
+ description?: string
26
+ }>()
27
+
28
+ // A gentle overshoot — the sanctioned celebratory spring (subtle, no anticipation).
29
+ const medallionPop = { type: 'spring', stiffness: 300, damping: 14, mass: 0.8 } satisfies Transition
30
+ const medallionEntrance = useEntranceMotion({ initial: { opacity: 0, scale: 0.8 }, enter: { opacity: 1, scale: 1 }, transition: medallionPop })
31
+ const textEntrance = useEntranceMotion(entrancePresets.fadeUp)
32
+ </script>
33
+
34
+ <template>
35
+ <div class="relative isolate overflow-hidden flex flex-col items-center text-center px-6 py-12">
36
+ <UiAtmosphere :palette="palette" geometry="bloom" intensity="present" style="z-index: -1" />
37
+
38
+ <!-- Icon springs in once (a quiet overshoot), then stands still. -->
39
+ <m.div
40
+ v-if="icon"
41
+ class="inline-flex items-center justify-center size-12 rounded-2xl bg-elevated border border-default mb-5"
42
+ v-bind="medallionEntrance"
43
+ >
44
+ <UiIcon :name="icon" class="size-6 text-primary" aria-hidden="true" />
45
+ </m.div>
46
+
47
+ <m.h2
48
+ class="text-2xl font-display font-semibold tracking-tight text-highlighted"
49
+ v-bind="textEntrance"
50
+ >
51
+ {{ title }}
52
+ </m.h2>
53
+ <m.p
54
+ v-if="description"
55
+ class="text-sm text-muted mt-2 max-w-md leading-relaxed"
56
+ v-bind="textEntrance"
57
+ >
58
+ {{ description }}
59
+ </m.p>
60
+
61
+ <div v-if="$slots.default" class="relative mt-6 [&_button]:min-h-11 [&_a]:min-h-11 sm:[&_button]:min-h-0 sm:[&_a]:min-h-0">
62
+ <slot />
63
+ </div>
64
+ </div>
65
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import type { DeliveryChannelKey } from '../../../shared/delivery-channels'
3
+ import { computed } from 'vue'
4
+ import { UiIcon } from '#components'
5
+ import { DELIVERY_CHANNELS, deliveryChannelsSummary, enabledDeliveryChannels } from '../../../shared/delivery-channels'
6
+
7
+ /**
8
+ * UiChannelChips — the delivery channels of a rule as compact stacked chips.
9
+ *
10
+ * Replaces the stacked list of channel cards that spent a whole column on four
11
+ * words. Chips overlap like an avatar stack so the set reads as ONE object at a
12
+ * glance; the glyph carries the identity (monochrome, per DESIGN.md) and the
13
+ * accessible name carries the words.
14
+ *
15
+ * Accepts either the stored `channels` record or an explicit ordered list.
16
+ */
17
+ const {
18
+ channels,
19
+ keys,
20
+ size = 'sm',
21
+ empty = 'No delivery channel',
22
+ showEmpty = true,
23
+ } = defineProps<{
24
+ /** The stored channels record, e.g. `{ email: true, discord: false }`. */
25
+ channels?: Partial<Record<DeliveryChannelKey, unknown>> | null
26
+ /** Explicit channel list; wins over `channels` when both are given. */
27
+ keys?: readonly string[]
28
+ size?: 'xs' | 'sm' | 'md'
29
+ empty?: string
30
+ /** Render the empty sentence when nothing is enabled (else render nothing). */
31
+ showEmpty?: boolean
32
+ }>()
33
+
34
+ const resolved = computed<DeliveryChannelKey[]>(() => {
35
+ if (keys)
36
+ return keys.filter((key): key is DeliveryChannelKey => key in DELIVERY_CHANNELS)
37
+ return enabledDeliveryChannels(channels)
38
+ })
39
+
40
+ const summary = computed(() => deliveryChannelsSummary(resolved.value, empty))
41
+
42
+ const CHIP = {
43
+ xs: { box: 'size-5', icon: 'size-2.5', overlap: '-ml-1.5' },
44
+ sm: { box: 'size-6', icon: 'size-3', overlap: '-ml-2' },
45
+ md: { box: 'size-7', icon: 'size-3.5', overlap: '-ml-2' },
46
+ } as const
47
+ </script>
48
+
49
+ <template>
50
+ <span v-if="resolved.length" class="inline-flex items-center" role="img" :aria-label="`Delivered via ${summary}`">
51
+ <span
52
+ v-for="(key, index) in resolved"
53
+ :key="key"
54
+ :title="DELIVERY_CHANNELS[key].label"
55
+ class="inline-flex items-center justify-center rounded-full border border-default bg-elevated ring-2 ring-[var(--ui-bg)]"
56
+ :class="[CHIP[size].box, index > 0 && CHIP[size].overlap]"
57
+ >
58
+ <UiIcon :name="DELIVERY_CHANNELS[key].icon" class="text-dimmed" :class="[CHIP[size].icon]" />
59
+ </span>
60
+ </span>
61
+ <span v-else-if="showEmpty" class="text-xs text-muted">{{ empty }}</span>
62
+ </template>