@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,204 @@
1
+ <script setup lang="ts">
2
+ import type { VNodeChild } from 'vue'
3
+ import type { EjectChat, EjectLink, EjectMcp } from '../element/eject-menu'
4
+ import { useElementSize } from '@vueuse/core'
5
+ import { computed, useTemplateRef } from 'vue'
6
+ import { UiEjectMenu } from '#components'
7
+ import { useSquircleFallback } from '../../composables/useSquircleFallback'
8
+ import { squircleSvgPath } from '../../utils/squircle'
9
+
10
+ const {
11
+ size = 'md',
12
+ emphasis = false,
13
+ flush = false,
14
+ titleTag = 'h3',
15
+ chat,
16
+ mcp,
17
+ schema,
18
+ } = defineProps<{
19
+ title?: string
20
+ /** Match the card title to its page section depth. */
21
+ titleTag?: 'h2' | 'h3' | 'h4'
22
+ description?: string
23
+ divided?: boolean
24
+ /** 'default' = solid bg-elevated (forms, settings). 'subtle' = translucent bg (data displays, lists). */
25
+ variant?: 'default' | 'subtle'
26
+ /** 'xs' = tight (inline/nested). 'sm' = compact (lists, dense tables). 'md' = default. 'lg' = spacious (hero/feature cards). */
27
+ size?: 'xs' | 'sm' | 'md' | 'lg'
28
+ /**
29
+ * The single sanctioned in-page contrast move: lift + primary-tinted top
30
+ * bevel (--elevation-emphasis) that survives the dashboard's raised→flat
31
+ * remap. Budget: at most ONE per page — two emphasis cards means neither is.
32
+ */
33
+ emphasis?: boolean
34
+ /**
35
+ * Drop the body padding so rows sit flush to the card edges and their
36
+ * dividers span the full width. The rows own their inset instead — match the
37
+ * header with `px-4 sm:px-(--density-card-padding)` so the two line up.
38
+ */
39
+ flush?: boolean
40
+ /**
41
+ * Card-grain eject affordance: renders a `<UiEjectMenu>` in the header so the
42
+ * card's own data can be opened in AI chat (or copied as code) without a
43
+ * floating page-level button. The card title supplies the context that a
44
+ * generic "Ask AI" button lacked, so the seed reads as scoped to this card.
45
+ * Pass `chat` with `entity` + `filter` (siteId) + `autoSend: true` to open the
46
+ * in-place ProChatPanel and fire the seed immediately. Only renders in the
47
+ * auto-header (title/actions driven); cards using a custom `#header` slot place
48
+ * the menu themselves.
49
+ */
50
+ chat?: EjectChat
51
+ mcp?: EjectMcp
52
+ schema?: EjectLink
53
+ }>()
54
+
55
+ const slots = defineSlots<{
56
+ default?: () => VNodeChild
57
+ header?: () => VNodeChild
58
+ actions?: () => VNodeChild
59
+ }>()
60
+
61
+ const hasEject = computed(() => !!(chat || mcp || schema))
62
+
63
+ // The default (md) size reads the --density-card-padding knob at sm+ so card
64
+ // density tunes centrally per context (:root / .dashboard-theme). The explicit
65
+ // sizes (xs/sm/lg) are local intent and stay fixed.
66
+ const headerClass = {
67
+ xs: 'px-2.5 py-2',
68
+ sm: 'px-3 sm:px-4 py-3',
69
+ md: 'px-4 sm:px-(--density-card-padding) py-4',
70
+ lg: 'px-6 sm:px-8 py-5',
71
+ }[size]
72
+
73
+ const bodyClass = {
74
+ xs: 'p-2.5',
75
+ sm: 'p-3 sm:p-4',
76
+ md: 'p-4 sm:p-(--density-card-padding)',
77
+ lg: 'p-6 sm:p-8',
78
+ }[size]
79
+
80
+ const bodyDividedClass = {
81
+ xs: 'divide-y divide-[var(--ui-border)] [&>*]:p-2.5',
82
+ sm: 'divide-y divide-[var(--ui-border)] [&>*]:p-3 [&>*]:sm:p-4',
83
+ md: 'divide-y divide-[var(--ui-border)] [&>*]:p-4 [&>*]:sm:p-(--density-card-padding)',
84
+ lg: 'divide-y divide-[var(--ui-border)] [&>*]:p-6 [&>*]:sm:p-8',
85
+ }[size]
86
+
87
+ // Size stays driven by the card's `size` prop (not the theme-resizing role
88
+ // tokens); only the weight routes through the .font-strong token so it tunes
89
+ // centrally with the rest of the type system.
90
+ const titleClass = {
91
+ xs: 'text-xs font-strong text-default',
92
+ sm: 'text-sm font-strong text-default',
93
+ md: 'font-strong text-default',
94
+ lg: 'text-lg font-strong text-default',
95
+ }[size]
96
+
97
+ // Figma-squircle corners. Chrome gets the native `corner-shape: squircle`
98
+ // (global.css), so `rounded-xl` (0.75rem ≈ 12px) is both the SSR shape and the
99
+ // final shape — no JS, no morph. Safari/Firefox fall back to a generated
100
+ // clip-path (clips bg + content to the squircle) plus an SVG hairline overlay
101
+ // whose stroke is `var(--ui-border)`, so it follows the curve and tracks the
102
+ // theme for free. The CSS border is hidden in that case so the clip can't
103
+ // slice it.
104
+ const RADIUS = 12
105
+ const cardRef = useTemplateRef<HTMLElement>('card')
106
+ const { width, height } = useElementSize(cardRef)
107
+ const { enabled: squircle } = useSquircleFallback()
108
+
109
+ const path = computed(() => squircle.value
110
+ ? squircleSvgPath(width.value, height.value, { radius: RADIUS, smoothing: 0.6 })
111
+ : '')
112
+ // Emphasis shadow rides the inner card. In squircle-fallback browsers the
113
+ // clip-path swallows the ambient drop shadow (inset bevel survives the clip) —
114
+ // acceptable degradation; Chrome renders the full preset.
115
+ const cardStyle = computed(() => {
116
+ const style: Record<string, string> = {}
117
+ if (path.value) {
118
+ style.clipPath = `path("${path.value}")`
119
+ style.borderColor = 'transparent'
120
+ }
121
+ if (emphasis)
122
+ style.boxShadow = 'var(--elevation-emphasis)'
123
+ return Object.keys(style).length ? style : undefined
124
+ })
125
+ </script>
126
+
127
+ <template>
128
+ <div class="relative flex flex-col">
129
+ <div
130
+ ref="card"
131
+ data-ui-card
132
+ class="group/card relative overflow-hidden rounded-xl border flex flex-col flex-1"
133
+ :class="[
134
+ emphasis ? 'border-primary-400/25' : 'border-default',
135
+ emphasis
136
+ ? 'bg-[var(--ui-bg-elevated)]/50'
137
+ : variant === 'subtle' ? 'bg-[var(--ui-bg-elevated)]/5' : 'bg-[var(--ui-bg-elevated)]/35',
138
+ ]"
139
+ :style="cardStyle"
140
+ >
141
+ <!-- Header slot -->
142
+ <div v-if="slots.header" class="relative border-b border-default shrink-0" :class="headerClass">
143
+ <slot name="header" />
144
+ </div>
145
+ <!-- Auto header from title/description -->
146
+ <div v-else-if="title || slots.actions || hasEject" class="relative border-b border-default shrink-0 flex flex-col items-stretch gap-3 sm:flex-row sm:items-start sm:justify-between" :class="headerClass">
147
+ <div class="min-w-0">
148
+ <component :is="titleTag" v-if="title" class="break-words" :class="[titleClass]">
149
+ {{ title }}
150
+ </component>
151
+ <p v-if="description" class="text-sm text-muted mt-1">
152
+ {{ description }}
153
+ </p>
154
+ </div>
155
+ <div v-if="slots.actions || hasEject" class="flex flex-wrap items-center justify-end gap-1 sm:shrink-0">
156
+ <slot name="actions" />
157
+ <UiEjectMenu
158
+ v-if="hasEject"
159
+ :chat="chat"
160
+ :mcp="mcp"
161
+ :schema="schema"
162
+ class="-mr-1"
163
+ />
164
+ </div>
165
+ </div>
166
+
167
+ <!-- A custom #header owns its own actions row, so the eject can't sit in
168
+ it. When chat/eject props are set alongside #header, surface a
169
+ hover-revealed ⋯ in the card's top-right corner instead (same affordance
170
+ as UiEjectOverlay). Always-visible on touch / for keyboard users. -->
171
+ <div
172
+ v-if="hasEject && slots.header"
173
+ class="absolute top-2.5 right-2.5 z-10 opacity-0 transition-opacity group-hover/card:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
174
+ >
175
+ <UiEjectMenu :chat="chat" :mcp="mcp" :schema="schema" />
176
+ </div>
177
+
178
+ <div
179
+ data-card-body class="relative flex-1 flex flex-col"
180
+ :class="[flush ? '' : divided ? bodyDividedClass : bodyClass]"
181
+ >
182
+ <slot />
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Squircle hairline border (fallback browsers only). Stroke reads the
187
+ live token so it tracks the theme; non-scaling so it stays 1px. -->
188
+ <svg
189
+ v-if="path"
190
+ class="pointer-events-none absolute inset-0 size-full"
191
+ :viewBox="`0 0 ${width} ${height}`"
192
+ preserveAspectRatio="none"
193
+ aria-hidden="true"
194
+ >
195
+ <path
196
+ :d="path"
197
+ fill="none"
198
+ stroke-width="1"
199
+ vector-effect="non-scaling-stroke"
200
+ style="stroke: var(--ui-border)"
201
+ />
202
+ </svg>
203
+ </div>
204
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { UiChip, UiIcon } from '#components'
3
+ // The sanctioned overflow destination for tertiary content (DESIGN.md
4
+ // Composition rules): a quiet native <details> row that keeps detail off the
5
+ // page until asked for. Native element = free keyboard/AT semantics and
6
+ // explicit body visibility keeps modal focus inside its visible controls.
7
+ //
8
+ // Expand/collapse animates via `.ui-disclosure::details-content` +
9
+ // interpolate-size in global.css (Chromium); Firefox/Safari toggle instantly.
10
+ // Reduced-motion disables the transition in the global block.
11
+ //
12
+ // Deliberately icon-light: the chevron is the only glyph. No leading icon
13
+ // container, no eyebrow — the label is a noun + count, not a CTA.
14
+
15
+ const { label, count } = defineProps<{
16
+ /** Quiet row label — a noun beats a verb ("Sitemaps", not "Show sitemaps"). */
17
+ label: string
18
+ /** Optional count so the collapsed row still reports scale. */
19
+ count?: number | string
20
+ }>()
21
+
22
+ const open = defineModel<boolean>('open', { default: false })
23
+
24
+ function onToggle(e: Event) {
25
+ open.value = (e as ToggleEvent).newState === 'open'
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <details class="ui-disclosure group" :open="open" @toggle="onToggle">
31
+ <summary class="flex items-center gap-2 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden min-h-11 sm:min-h-0 py-1 -mx-1 px-1 rounded-md focus-visible:ring-2 focus-visible:ring-primary outline-none">
32
+ <UiIcon name="chevron-right" class="size-3.5 text-dimmed transition-transform duration-150 group-open:rotate-90" aria-hidden="true" />
33
+ <span class="text-sm font-strong text-muted group-hover:text-default transition-colors">{{ label }}</span>
34
+ <UiChip v-if="count !== undefined" purpose="count">
35
+ {{ count }}
36
+ </UiChip>
37
+ <slot name="actions" />
38
+ </summary>
39
+ <div v-show="open" class="pt-3">
40
+ <slot />
41
+ </div>
42
+ </details>
43
+ </template>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import { m } from 'motion-v'
4
+ import { computed } from 'vue'
5
+ import { UiAtmosphere, UiIcon } from '#components'
6
+ import { useEntranceMotion } from '../../composables/useEntranceMotion'
7
+ import { entrancePresets } from '../../shared/motion'
8
+
9
+ const {
10
+ compact,
11
+ wide,
12
+ headingTag,
13
+ animated = true,
14
+ } = defineProps<{
15
+ icon: UiIconName
16
+ title: string
17
+ description?: string
18
+ /**
19
+ * Where the data comes from once the feature runs (e.g. "From Search Console,
20
+ * refreshed daily."). Full variant only; ignored when `compact`. Renders as a
21
+ * small dimmed line under the description. First-use anatomy: title sells the
22
+ * value, description says why or how, source names the data, one CTA in the slot.
23
+ */
24
+ source?: string
25
+ compact?: boolean
26
+ /**
27
+ * Widen the default slot for a short setup form. The slot spans the full
28
+ * width up to `max-w-2xl` and its text is left-aligned, so fields do not
29
+ * inherit the centering. The submit CTA stays left-aligned under the last
30
+ * field, where the eye ends after filling the form. Full variant only.
31
+ */
32
+ wide?: boolean
33
+ /**
34
+ * Title element. Full variant defaults to `h3`. Compact defaults to `p`,
35
+ * because most compact states sit inside a card that already has a heading;
36
+ * pass a level when the compact state stands in for a section's content.
37
+ */
38
+ headingTag?: 'h1' | 'h2' | 'h3'
39
+ /** Disable JS entrance motion on SSR-sensitive public routes. */
40
+ animated?: boolean
41
+ /**
42
+ * Opt-in greydient atmosphere for genuine pre-data / front-door empty states
43
+ * (a preset name). Full variant only — leave unset for in-dashboard empties so
44
+ * data surfaces stay silent. Renders behind the content via <UiAtmosphere>.
45
+ */
46
+ atmosphere?: 'front-door' | 'error' | 'ai' | 'preview' | 'share'
47
+ }>()
48
+
49
+ const rootComponent = computed(() => animated ? m.div : 'div')
50
+ const titleTag = computed(() => headingTag ?? (compact ? 'p' : 'h3'))
51
+ const rootMotion = useEntranceMotion(entrancePresets.fadeUp, () => animated)
52
+ </script>
53
+
54
+ <template>
55
+ <component
56
+ :is="rootComponent"
57
+ data-testid="empty-state"
58
+ :class="compact
59
+ ? 'flex flex-col items-center justify-center h-[220px] rounded-lg border border-dashed border-default bg-muted'
60
+ : ['text-center px-4 py-10 sm:py-16 sm:min-h-[400px] flex flex-col items-center justify-center', atmosphere ? 'relative isolate overflow-hidden' : '']"
61
+ v-bind="rootMotion"
62
+ >
63
+ <!-- Compact: inline empty state for cards/lists -->
64
+ <template v-if="compact">
65
+ <UiIcon :name="icon" class="size-8 text-dimmed mb-2" aria-hidden="true" />
66
+ <component :is="titleTag" class="text-sm font-medium text-default">
67
+ {{ title }}
68
+ </component>
69
+ <p v-if="description" class="text-xs text-muted mt-1 max-w-sm text-center px-4">
70
+ {{ description }}
71
+ </p>
72
+ <div v-if="$slots.default" class="mt-3 [&_button]:min-h-11 [&_a]:min-h-11 sm:[&_button]:min-h-0 sm:[&_a]:min-h-0">
73
+ <slot />
74
+ </div>
75
+ </template>
76
+
77
+ <!-- Full: centered page-level empty state -->
78
+ <template v-else>
79
+ <!-- z-index:-1 + isolate keeps it behind content without wrapping anything. -->
80
+ <UiAtmosphere v-if="atmosphere" :preset="atmosphere" style="z-index: -1" />
81
+ <div class="inline-flex items-center justify-center size-14 rounded-2xl bg-elevated border border-default mb-4">
82
+ <UiIcon :name="icon" class="size-7 text-muted" aria-hidden="true" />
83
+ </div>
84
+ <component :is="titleTag" class="text-lg font-medium text-default mb-1 max-w-md text-balance">
85
+ {{ title }}
86
+ </component>
87
+ <p v-if="description" class="text-sm text-muted max-w-md mx-auto leading-relaxed text-pretty">
88
+ {{ description }}
89
+ </p>
90
+ <p v-if="source" class="text-xs text-dimmed mt-2 max-w-md mx-auto text-pretty">
91
+ {{ source }}
92
+ </p>
93
+ <div
94
+ class="mt-6 [&_button]:min-h-11 [&_a]:min-h-11 sm:[&_button]:min-h-0 sm:[&_a]:min-h-0"
95
+ :class="wide && 'w-full max-w-2xl text-left'"
96
+ >
97
+ <slot />
98
+ </div>
99
+ <slot name="footer" />
100
+ </template>
101
+ </component>
102
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import type { ErrorStateSource } from '../../shared/error-state'
3
+ import { computed } from 'vue'
4
+ import { UiAlert, UiButton } from '#components'
5
+ import { resolveErrorState } from '../../shared/error-state'
6
+
7
+ const { error } = defineProps<{
8
+ /**
9
+ * Why the region failed, and where the words come from.
10
+ *
11
+ * Build it with `wireError(caught)` for a caught value, `errorCopy(...)` for
12
+ * a sentence a person wrote, or `rateLimited(...)` for a parsed quota
13
+ * refusal. A caught error has no renderable string here: `wireError` types it
14
+ * `unknown`, so the template reads only its HTTP status code and states copy
15
+ * this component owns. The old `string | { message: string }` prop rendered
16
+ * the caught message, which quotes the request URL.
17
+ */
18
+ error: ErrorStateSource | null
19
+ /** Optional retry handler. When set (and no #action slot), renders a "Try again" button. */
20
+ onRetry?: () => void
21
+ }>()
22
+
23
+ defineSlots<{
24
+ /** Replaces the retry button and the generic next-step line. */
25
+ action?: () => unknown
26
+ }>()
27
+
28
+ // `Date.now()` is read once per resolve, the same as the old inline reset
29
+ // maths. The reset hint counts down only when something else re-renders.
30
+ const view = computed(() => error ? resolveErrorState(error, Date.now()) : null)
31
+ </script>
32
+
33
+ <template>
34
+ <UiAlert
35
+ v-if="view"
36
+ :status="view.tone"
37
+ :icon="view.icon"
38
+ :title="view.title"
39
+ class="mb-8"
40
+ >
41
+ <p v-if="view.detail" class="mt-1">
42
+ {{ view.detail }}
43
+ </p>
44
+ <p
45
+ v-if="view.nextStep && (view.nextStep._tag === 'timing' || !$slots.action)"
46
+ class="text-muted/80 mt-1"
47
+ >
48
+ {{ view.nextStep.text }}
49
+ </p>
50
+ <slot name="action" />
51
+ <UiButton
52
+ v-if="onRetry && view.retryable && !$slots.action"
53
+ purpose="secondary"
54
+ size="xs"
55
+ icon="refresh"
56
+ label="Try again"
57
+ class="mt-2"
58
+ @click="onRetry()"
59
+ />
60
+ </UiAlert>
61
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { UiSkeleton } from '#components'
3
+
4
+ defineProps<{
5
+ rows?: number
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="space-y-3" role="status" aria-live="polite" aria-busy="true">
11
+ <span class="sr-only">Loading…</span>
12
+ <div v-for="i in rows ?? 3" :key="i" class="flex items-center gap-3">
13
+ <UiSkeleton type="circle" :base="32" :index="i" />
14
+ <div class="flex-1 space-y-1.5">
15
+ <UiSkeleton :base="100" :range="60" :index="i" />
16
+ <UiSkeleton :base="140" :range="80" :index="i + 10" />
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </template>
@@ -0,0 +1,95 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import { NuxtLink, UiIcon } from '#components'
4
+
5
+ // Page header: context crumb + title + trailing actions row, with optional
6
+ // breadcrumb links above (admin detail pages). Extracted after six sibling
7
+ // pages converged on the same shape.
8
+
9
+ interface Crumb {
10
+ label: string
11
+ to?: string
12
+ }
13
+
14
+ // `description` is intentionally accepted-but-unrendered: page headers no longer
15
+ // show a subtitle. Kept in the type so existing callers don't break.
16
+ const { crumbs = [], title, icon, flush = false, border = true, stackActionsOnMobile = false } = defineProps<{
17
+ crumbs?: Crumb[]
18
+ title: string
19
+ icon?: UiIconName
20
+ description?: string
21
+ /** Skip pro-container (max-width + horizontal padding). Use in non-pro shells where padding is owned by the layout. */
22
+ flush?: boolean
23
+ /** Bottom hairline. Turn off for headers with a sub-nav tab strip beneath (the tabs own the separator). */
24
+ border?: boolean
25
+ /** Give dense detail-page actions their own row below the title on mobile. */
26
+ stackActionsOnMobile?: boolean
27
+ }>()
28
+ </script>
29
+
30
+ <template>
31
+ <header :class="[flush ? '' : 'pro-container', border ? 'border-b border-default' : '']">
32
+ <div class="flex items-start gap-4 pt-2">
33
+ <div
34
+ class="flex items-start justify-between min-w-0 w-full gap-3"
35
+ :class="stackActionsOnMobile ? 'flex-col sm:flex-row' : 'flex-row'"
36
+ >
37
+ <div class="min-w-0 flex-1">
38
+ <nav v-if="crumbs.length" aria-label="Breadcrumb" class="flex items-center gap-2 text-xs text-dimmed mb-1">
39
+ <template v-for="(crumb, i) in crumbs" :key="crumb.to ?? crumb.label">
40
+ <NuxtLink v-if="crumb.to" :to="crumb.to" class="hover:text-default">
41
+ {{ crumb.label }}
42
+ </NuxtLink>
43
+ <span
44
+ v-else
45
+ class="text-muted truncate"
46
+ :aria-current="i === crumbs.length - 1 ? 'page' : undefined"
47
+ >
48
+ {{ crumb.label }}
49
+ </span>
50
+ <UiIcon
51
+ v-if="i < crumbs.length - 1"
52
+ name="chevron-right"
53
+ class="size-3"
54
+ aria-hidden="true"
55
+ />
56
+ </template>
57
+ </nav>
58
+ <!-- Title row. `#leading` (the mobile nav button) sits before both
59
+ lines. `#crumb` is the context segment (site/group switcher):
60
+ from `sm` it shares the title's row at title scale ("Nuxt SEO /
61
+ Overview"); below `sm` the header stacks, with the crumb as a
62
+ small muted line above the title. The crumb component owns its
63
+ trailing slash (it may render nothing, e.g. while the site list
64
+ resolves, and a dangling separator reads broken). Kept outside
65
+ the h1 so the heading text stays clean. When the row overflows,
66
+ the crumb truncates first: the title barely shrinks until the
67
+ crumb reaches its minimum width. -->
68
+ <div class="flex min-w-0 items-center gap-2">
69
+ <slot name="leading" />
70
+ <div class="flex min-w-0 flex-1 flex-col sm:flex-row sm:items-center gap-x-2">
71
+ <div v-if="$slots.crumb" class="flex min-w-0 items-center gap-x-2 sm:shrink">
72
+ <slot name="crumb" />
73
+ </div>
74
+ <div class="flex min-w-0 items-center gap-2 sm:shrink-[0.001]">
75
+ <h1 class="text-title text-default flex items-center gap-2 min-w-0">
76
+ <slot name="icon">
77
+ <UiIcon v-if="icon" :name="icon" class="size-4 text-dimmed shrink-0" aria-hidden="true" />
78
+ </slot>
79
+ <span class="truncate">{{ title }}</span>
80
+ </h1>
81
+ <slot name="title-trailing" />
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ <div
87
+ class="flex items-center gap-3 flex-wrap shrink-0"
88
+ :class="stackActionsOnMobile ? 'w-full justify-start sm:w-auto sm:justify-end' : 'justify-end'"
89
+ >
90
+ <slot name="actions" />
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </header>
95
+ </template>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ import type { PopoverProps } from '@nuxt/ui'
3
+ import type { VNode } from 'vue'
4
+ import { computed, useSlots, watch } from 'vue'
5
+ import { UPopover } from '#components'
6
+
7
+ type UiPopoverProps = Omit<PopoverProps, 'open' | 'defaultOpen' | 'portal' | 'ui'> & {
8
+ /** Prevent opening while retaining the trigger in the page flow. */
9
+ disabled?: boolean
10
+ /** The content remains portalled; callers may target a specific portal host. */
11
+ portal?: true | string | HTMLElement
12
+ /** Optional semantic role applied to the owned panel wrapper. */
13
+ role?: string
14
+ /** Stable id for associating the panel with its trigger. */
15
+ panelId?: string
16
+ ui?: PopoverProps['ui']
17
+ }
18
+
19
+ const props = withDefaults(defineProps<UiPopoverProps>(), {
20
+ dismissible: true,
21
+ portal: true,
22
+ })
23
+ const emit = defineEmits<{
24
+ 'close:prevent': []
25
+ }>()
26
+ defineSlots<{
27
+ default?: (props: { disabled: boolean, open: boolean }) => VNode[]
28
+ panel?: (props: { close?: () => void }) => VNode[]
29
+ anchor?: (props: { close?: () => void }) => VNode[]
30
+ }>()
31
+
32
+ const slots = useSlots()
33
+ const open = defineModel<boolean>('open', { default: false })
34
+ const guardedOpen = computed({
35
+ get: () => props.disabled ? false : open.value,
36
+ set: (nextOpen: boolean) => {
37
+ if (props.disabled && nextOpen)
38
+ return
39
+ open.value = nextOpen
40
+ },
41
+ })
42
+
43
+ watch(() => props.disabled, (disabled) => {
44
+ if (disabled)
45
+ open.value = false
46
+ })
47
+
48
+ // Keep the application popover contract resilient by default. UPopover remains
49
+ // the accessible implementation detail; callers cannot disable its portal and
50
+ // every panel gets collision padding plus the shared overlay chrome.
51
+ const forwardedProps = computed(() => {
52
+ const { disabled: _disabled, panelId: _panelId, role: _role, ui: _ui, ...forwarded } = props
53
+ return {
54
+ ...forwarded,
55
+ mode: props.mode ?? 'click',
56
+ openDelay: props.openDelay ?? 0,
57
+ closeDelay: props.closeDelay ?? 0,
58
+ dismissible: props.dismissible ?? true,
59
+ portal: props.portal ?? true,
60
+ content: {
61
+ side: 'bottom' as const,
62
+ sideOffset: 8,
63
+ collisionPadding: 8,
64
+ ...props.content,
65
+ },
66
+ }
67
+ })
68
+
69
+ const mergedUi = computed<PopoverProps['ui']>(() => {
70
+ const userContent = props.ui?.content
71
+ return {
72
+ ...props.ui,
73
+ content: typeof userContent === 'function'
74
+ ? (defaults: string) => ['ui-popover-content', userContent(defaults)]
75
+ : ['ui-popover-content', userContent],
76
+ }
77
+ })
78
+ </script>
79
+
80
+ <template>
81
+ <UPopover
82
+ v-bind="forwardedProps"
83
+ v-model:open="guardedOpen"
84
+ :ui="mergedUi"
85
+ @close:prevent="emit('close:prevent')"
86
+ >
87
+ <template v-if="slots.default" #default="slotProps">
88
+ <slot v-bind="slotProps" :disabled="props.disabled ?? false" />
89
+ </template>
90
+
91
+ <template v-if="slots.anchor" #anchor="slotProps">
92
+ <slot name="anchor" v-bind="slotProps" />
93
+ </template>
94
+
95
+ <template #content="slotProps">
96
+ <div v-if="props.role || props.panelId" :id="props.panelId" data-ui="UiPopover" :role="props.role">
97
+ <slot name="panel" v-bind="slotProps" />
98
+ </div>
99
+ <slot v-else name="panel" v-bind="slotProps" />
100
+ </template>
101
+ </UPopover>
102
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { UiAtmosphere, UiButton, UiIcon } from '#components'
3
+
4
+ defineProps<{
5
+ message?: string
6
+ description?: string
7
+ ctaLabel?: string
8
+ ctaTo?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <div class="relative">
14
+ <!-- Faded sample content. `inert` removes it from focus order AND the
15
+ accessibility tree together — `aria-hidden` alone leaves any focusable
16
+ children tabbable while hidden (a WCAG 4.1.2 violation). -->
17
+ <div class="opacity-25 select-none" inert>
18
+ <slot />
19
+ </div>
20
+
21
+ <!-- Overlay -->
22
+ <div class="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-[var(--ui-bg)]/70 via-[var(--ui-bg)]/95 to-[var(--ui-bg)]/75 backdrop-blur-[1px]">
23
+ <!-- The pre-data preview read as data glimpsed through fog. -->
24
+ <UiAtmosphere preset="preview" />
25
+ <div class="relative w-full max-w-md rounded-lg border border-default bg-default/90 px-5 py-4 text-center shadow-lg ring-1 ring-black/5 dark:ring-white/5">
26
+ <div class="mb-3 inline-flex size-10 items-center justify-center rounded-lg border border-default bg-elevated">
27
+ <UiIcon name="google" class="size-5 text-muted" aria-hidden="true" />
28
+ </div>
29
+ <p class="mb-1 text-sm font-semibold text-highlighted">
30
+ {{ message || 'Preview' }}
31
+ </p>
32
+ <p class="mx-auto mb-4 max-w-sm text-sm leading-relaxed text-default">
33
+ {{ description || 'Connect Google Search Console to see your real data.' }}
34
+ </p>
35
+ <UiButton
36
+ v-if="ctaTo"
37
+ :to="ctaTo"
38
+ size="sm"
39
+ purpose="secondary"
40
+ icon="plug"
41
+ >
42
+ {{ ctaLabel || 'Connect GSC' }}
43
+ </UiButton>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </template>