@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,25 @@
1
+ <script setup lang="ts">
2
+ import { UiBrandMark, UiProLettering } from '#components'
3
+
4
+ const { variant = 'default' } = defineProps<{
5
+ variant?: 'default' | 'auth'
6
+ }>()
7
+
8
+ const wordSize = { default: 18, auth: 24 } as const
9
+ </script>
10
+
11
+ <template>
12
+ <span
13
+ data-ui="UiLogoPro"
14
+ :data-variant="variant"
15
+ class="inline-flex shrink-0 items-center whitespace-nowrap text-highlighted"
16
+ :class="variant === 'auth' ? 'gap-2.5' : 'gap-2'"
17
+ >
18
+ <UiBrandMark product="pro" :size="variant === 'auth' ? 32 : 28" />
19
+ <span
20
+ class="font-display tracking-[-0.03em]"
21
+ :style="{ fontSize: `${wordSize[variant]}px` }"
22
+ ><span class="font-medium">Nuxt</span> <span class="font-bold">SEO</span></span>
23
+ <UiProLettering :size="wordSize[variant]" />
24
+ </span>
25
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ // Official Umami brand mark, brand colour #13B0F5 baked into the asset (single-colour
3
+ // mark — there is no multicolour version). Inline SVG so the colour survives, vs an
4
+ // icon set that would monotone it to currentColor. Sizing/aria fall through from the
5
+ // parent (e.g. class="size-6").
6
+ </script>
7
+
8
+ <template>
9
+ <svg viewBox="0 0 24 24" fill="#13B0F5" xmlns="http://www.w3.org/2000/svg">
10
+ <path d="M2.203 8.611H.857a.845.845 0 0 0-.841.841v.858a13.31 13.31 0 0 0-.016.6c0 6.627 5.373 12 12 12 6.527 0 11.837-5.212 11.996-11.701 0-.025.004-.05.004-.075V9.452a.845.845 0 0 0-.841-.841h-1.346c-1.159-4.329-5.112-7.521-9.805-7.521-4.692 0-8.645 3.192-9.805 7.521Zm18.444 0H3.37c1.127-3.702 4.57-6.399 8.638-6.399 4.069 0 7.512 2.697 8.639 6.399Z" />
11
+ </svg>
12
+ </template>
@@ -0,0 +1,107 @@
1
+ <script lang="ts" setup>
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import { computed } from 'vue'
4
+ import { UiIcon } from '#components'
5
+ /**
6
+ * Nav icon container used in sidebar navigation and status rows.
7
+ * Default is primary-tinted. Semantic variants (success/warning/error/info)
8
+ * use low-alpha skewed tints of the semantic color tokens.
9
+ *
10
+ * The `default` variant is alive: idle it stays restrained (dimmed glyph,
11
+ * faint tint), then leans into the brand color as a pop on hover (reacts to a
12
+ * `group/navitem` ancestor) and on `active`. The glyph springs up a touch and
13
+ * the box lifts, so the icon reads as the focal point of its row rather than a
14
+ * flat chip. Semantic variants are static status tints and don't react.
15
+ *
16
+ * Renders through `UiIcon`, so it speaks the curated icon vocabulary
17
+ * (semantic names like `chart`, `lock`) while still accepting raw `i-*` ids.
18
+ *
19
+ * `neutral` is the monochrome identity treatment: a flat neutral box + muted
20
+ * glyph, no brand tint and no hover pop. Use it when the icon is resting
21
+ * identity (e.g. a trigger-kind glyph) and the colour budget belongs to the
22
+ * row's status (a dot/badge), not the icon. Differentiate kinds by glyph shape,
23
+ * never by colour.
24
+ */
25
+ type Variant = 'default' | 'neutral' | 'success' | 'warning' | 'error' | 'info' | 'experimental'
26
+
27
+ const { icon, locked = false, loading = false, active = false, variant = 'default', size = 'md' } = defineProps<{
28
+ icon: UiIconName
29
+ locked?: boolean
30
+ /** Swap the glyph for a spinner — e.g. a nav item whose data is syncing. */
31
+ loading?: boolean
32
+ /** Marks the icon's row as active — deepens the tint, lifts the glyph to primary, adds a soft halo. */
33
+ active?: boolean
34
+ variant?: Variant
35
+ /** `md` (size-6 — nav/status rows) · `sm` (size-5) · `xs` (size-4 — dense list rows). */
36
+ size?: 'xs' | 'sm' | 'md'
37
+ }>()
38
+
39
+ const boxSize = size === 'xs' ? 'size-4' : size === 'sm' ? 'size-5' : 'size-6'
40
+ const glyphSize = size === 'xs' ? 'size-2.5' : size === 'sm' ? 'size-3' : 'size-3.5'
41
+
42
+ const variantBoxClasses: Record<Variant, string> = {
43
+ neutral: 'border border-default bg-elevated',
44
+ success: 'border bg-success/5 border-success/20',
45
+ warning: 'border bg-warning/5 border-warning/20',
46
+ error: 'border bg-error/5 border-error/20',
47
+ info: 'border bg-info/5 border-info/20',
48
+ experimental: 'bg-[var(--ui-bg-elevated)]/60',
49
+ } as Record<Variant, string>
50
+
51
+ const variantIconClasses: Record<Variant, string> = {
52
+ neutral: 'text-muted',
53
+ success: 'text-success/80',
54
+ warning: 'text-warning/80',
55
+ error: 'text-error/80',
56
+ info: 'text-info/80',
57
+ experimental: 'text-dimmed',
58
+ } as Record<Variant, string>
59
+
60
+ // The default variant is state-driven; precedence is active > hover > idle so
61
+ // nothing fights over `!important`. Hover is CSS-only (group/navitem ancestor).
62
+ const boxClass = computed(() => {
63
+ if (variant !== 'default')
64
+ return variantBoxClasses[variant]
65
+ if (active)
66
+ return 'border bg-gradient-to-b from-primary-300/30 to-primary-300/12 border-primary/35 ring-2 ring-primary/15 dark:from-primary-900/40 dark:to-primary-900/15'
67
+ return 'border bg-gradient-to-b from-primary-300/15 to-primary-300/5 border-primary-300/15 dark:from-primary-900/20 dark:to-primary-900/5 dark:border-primary-950/15 group-hover/navitem:-translate-y-px group-hover/navitem:from-primary-300/25 group-hover/navitem:to-primary-300/10 group-hover/navitem:border-primary-300/30 dark:group-hover/navitem:from-primary-900/30 dark:group-hover/navitem:to-primary-900/10'
68
+ })
69
+
70
+ const iconClass = computed(() => {
71
+ if (variant !== 'default')
72
+ return variantIconClasses[variant]
73
+ if (active)
74
+ return 'text-primary scale-110'
75
+ return 'text-dimmed group-hover/navitem:text-primary group-hover/navitem:scale-115'
76
+ })
77
+ </script>
78
+
79
+ <template>
80
+ <!--
81
+ `<span>`, not `<div>`. Callers place this glyph tile INLINE — riding next
82
+ to a category label inside a `<p>` (`UiIssueRow`'s "full" type line) or
83
+ beside text in a nav row. A `<div>` is flow content and cannot legally sit
84
+ inside `<p>`'s phrasing-content model: the browser's HTML parser force-
85
+ closes the `<p>` (and everything open inside it) the instant it meets the
86
+ div start tag, silently hoisting this tile OUT to become a sibling of the
87
+ paragraph instead of its child. That reshapes the server-rendered DOM into
88
+ something Vue's client vdom never expected — a REAL SSR/hydration
89
+ mismatch (not a dev-only artefact; it's an HTML parsing rule, identical in
90
+ production), cascading into 6-7 "Hydration node/children mismatch"
91
+ warnings per row, on every `UiIssueRow` (site board + portfolio queue).
92
+ `inline-flex` keeps the exact same internal flex layout; as a flex ITEM
93
+ inside a parent flexbox, `span`/`div` behave identically either way, so
94
+ this is a pure content-model fix with no visual change.
95
+ -->
96
+ <span
97
+ class="inline-flex items-center justify-center rounded-md transition-[transform,background-color,border-color,box-shadow] duration-200 ease-[var(--ease-spring)]"
98
+ :class="[boxSize, boxClass]"
99
+ >
100
+ <UiIcon
101
+ :name="loading ? 'loading' : locked ? 'lock' : icon"
102
+ class="shrink-0 transition-[transform,color] duration-200 ease-[var(--ease-spring)]"
103
+ :class="[glyphSize, iconClass, { 'animate-spin': loading }]"
104
+ aria-hidden="true"
105
+ />
106
+ </span>
107
+ </template>
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts" generic="T extends UiNavLink">
2
+ import type { VNodeChild } from 'vue'
3
+ import type { UiNavLink } from '../../shared/nav'
4
+ import { useRoute } from 'nuxt/app'
5
+ import { NuxtLink, UiIcon, UiNavIcon, UiTooltip } from '#components'
6
+
7
+ const { links, activeMode = 'exact', label, variant = 'default', tone = 'muted' } = defineProps<{
8
+ links: T[]
9
+ /** Sidebar rows use plain icons. Release labels belong in the page header. */
10
+ variant?: 'default' | 'sidebar'
11
+ tone?: 'default' | 'muted'
12
+ activeMode?: 'exact' | 'prefix'
13
+ /**
14
+ * Accessible name for this `<nav>` landmark. Pass a distinct value when more
15
+ * than one nav list renders on a page (e.g. grouped sidebars) so the landmarks
16
+ * stay unique for screen readers.
17
+ */
18
+ label?: string
19
+ }>()
20
+
21
+ defineSlots<{
22
+ /** Custom leading visual per item (e.g. a favicon). Replaces `UiNavIcon`. */
23
+ icon?: (props: { link: T, active: boolean }) => VNodeChild
24
+ /** Trailing per-row action (e.g. a hover menu). Rendered as a sibling of the
25
+ * link, not nested inside the anchor, so interactive triggers stay valid. */
26
+ action?: (props: { link: T }) => VNodeChild
27
+ }>()
28
+
29
+ const route = useRoute()
30
+
31
+ function isActive(link: T) {
32
+ if (link.active)
33
+ return link.active(route.path)
34
+ if (activeMode === 'prefix')
35
+ return route.path === link.to || route.path.startsWith(`${link.to}/`)
36
+ return route.path === link.to
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <nav class="space-y-0.5" :aria-label="label">
42
+ <div
43
+ v-for="link in links"
44
+ :key="link.to"
45
+ class="relative group/navitem"
46
+ >
47
+ <NuxtLink
48
+ :to="link.disabled ? undefined : link.to"
49
+ :prefetch="link.disabled ? undefined : link.prefetch"
50
+ :aria-disabled="link.disabled || undefined"
51
+ :aria-current="isActive(link) && !link.disabled ? 'page' : undefined"
52
+ :tabindex="link.disabled ? -1 : undefined"
53
+ class="flex items-center gap-1.5 px-1 py-1 min-h-11 lg:min-h-0 rounded text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
54
+ :class="[
55
+ link.disabled
56
+ ? 'text-dimmed cursor-not-allowed'
57
+ : isActive(link)
58
+ ? 'bg-elevated text-highlighted'
59
+ : link.textTone === 'highlighted'
60
+ ? 'text-highlighted hover:text-highlighted hover:bg-elevated'
61
+ : tone === 'default'
62
+ ? 'text-default hover:text-highlighted hover:bg-elevated'
63
+ : 'text-muted hover:text-default hover:bg-elevated',
64
+ variant === 'sidebar' && !link.disabled && !isActive(link) && (tone === 'default' || link.textTone === 'highlighted')
65
+ ? 'dark:text-toned dark:hover:text-highlighted'
66
+ : undefined,
67
+ ]"
68
+ :style="isActive(link) && !link.disabled ? { boxShadow: 'var(--elevation-raised)', backgroundImage: 'var(--surface-raised)' } : undefined"
69
+ :title="link.title"
70
+ >
71
+ <slot v-if="$slots.icon" name="icon" :link="link" :active="isActive(link)" />
72
+ <UiIcon
73
+ v-else-if="link.icon && variant === 'sidebar'"
74
+ :name="link.icon"
75
+ class="size-4 shrink-0"
76
+ aria-hidden="true"
77
+ />
78
+ <UiNavIcon
79
+ v-else-if="link.icon"
80
+ :icon="link.icon"
81
+ :active="isActive(link)"
82
+ />
83
+ <span class="min-w-0 truncate">{{ link.label }}</span>
84
+ <span
85
+ v-if="link.badge"
86
+ class="ml-auto text-mini font-medium px-1.5 py-0.5 rounded-md"
87
+ :class="link.badgeColor === 'warning'
88
+ ? 'text-warning bg-warning/10'
89
+ : link.badgeColor === 'neutral'
90
+ ? 'text-muted bg-elevated ring-1 ring-default'
91
+ : 'text-primary bg-primary/10'"
92
+ >
93
+ {{ link.badge }}
94
+ </span>
95
+ <!--
96
+ A row whose feature is not connected yet owes the user a chore, not an
97
+ alert. It renders as an unfilled outline chip carrying the verb, so it
98
+ names the action without a tooltip and stays quieter than the filled
99
+ `badge` above (a real worklist count) sitting in the same slot.
100
+ -->
101
+ <UiTooltip
102
+ v-else-if="link.setup"
103
+ :text="link.setup.tooltip"
104
+ >
105
+ <span
106
+ class="ml-auto shrink-0 text-mini font-medium px-1.5 py-px rounded border border-dashed transition-colors"
107
+ :class="link.setup.tone === 'warning'
108
+ ? 'text-warning border-warning/30 group-hover/navitem:border-warning/50'
109
+ : 'text-muted border-default group-hover/navitem:text-default group-hover/navitem:border-accented'"
110
+ >
111
+ <!--
112
+ The verb alone is ambiguous out of row context, so the accessible
113
+ name carries the same full state the tooltip shows. `text-dimmed`
114
+ is banned here: this chip is an affordance, not tertiary metadata,
115
+ and it fails AA in dark mode.
116
+ -->
117
+ <span aria-hidden="true">{{ link.setup.verb }}</span>
118
+ <span class="sr-only">{{ link.setup.tooltip }}</span>
119
+ </span>
120
+ </UiTooltip>
121
+ <UiTooltip
122
+ v-else-if="link.pending"
123
+ :text="link.pendingTooltip || 'Waiting for data'"
124
+ >
125
+ <span
126
+ class="ml-auto flex items-center"
127
+ role="img"
128
+ :aria-label="link.pendingTooltip || 'Waiting for data'"
129
+ >
130
+ <span class="size-1.5 rounded-full bg-current text-warning motion-safe:animate-pulse" aria-hidden="true" />
131
+ </span>
132
+ </UiTooltip>
133
+ <!--
134
+ Stability is a word, not a glyph: a flask beside three rows told the
135
+ reader nothing without hovering each one. Same dashed outline chip
136
+ as `setup` above, in the muted tone, so the sidebar carries one chip
137
+ grammar (a verb to act on, or a tier to know about).
138
+ -->
139
+ <UiTooltip
140
+ v-else-if="link.stability && variant !== 'sidebar'"
141
+ :text="link.stability.tooltip"
142
+ >
143
+ <span class="ml-auto shrink-0 text-mini font-medium px-1.5 py-px rounded border border-dashed border-default text-muted transition-colors group-hover/navitem:text-default group-hover/navitem:border-accented">
144
+ {{ link.stability.tooltip }}
145
+ </span>
146
+ </UiTooltip>
147
+ </NuxtLink>
148
+ <div
149
+ v-if="$slots.action"
150
+ class="absolute right-1 top-1/2 -translate-y-1/2"
151
+ >
152
+ <slot name="action" :link="link" />
153
+ </div>
154
+ </div>
155
+ </nav>
156
+ </template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ import type { OnboardingAnswersCopy, OnboardingAnswersDraft } from './onboarding-answers'
3
+ import type { OnboardingIntent } from './onboarding-intents'
4
+ import { computed, useId } from 'vue'
5
+ import { UiIcon, UTextarea } from '#components'
6
+ import {
7
+ ONBOARDING_ANSWERS_COPY,
8
+ ONBOARDING_OTHER_MAX_LENGTH,
9
+ ONBOARDING_ROLES,
10
+ ONBOARDING_SEO_EXPERIENCE,
11
+ ONBOARDING_SITE_COUNTS,
12
+ } from './onboarding-answers'
13
+ import { ONBOARDING_INTENTS } from './onboarding-intents'
14
+
15
+ // The onboarding "About you" questions on one screen: three single-choice chip
16
+ // rows, the multi-choice goals, and an optional free-text box. Every question
17
+ // is optional, and a second tap on a picked chip clears it. Presentation only:
18
+ // the live teams/create wizard and the brand-kit showcase both v-model the
19
+ // draft and decide when to save it.
20
+ //
21
+ // The goals list and the copy name the product, so a site that extends this
22
+ // layer passes its own `intents` and any `copy` keys it rewords. The option
23
+ // ids are shared and stored, so they never change.
24
+ const {
25
+ intents = ONBOARDING_INTENTS,
26
+ copy: copyOverrides = {},
27
+ } = defineProps<{
28
+ intents?: readonly OnboardingIntent[]
29
+ copy?: Partial<OnboardingAnswersCopy>
30
+ }>()
31
+ const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
32
+ const answers = defineModel<OnboardingAnswersDraft>({ required: true })
33
+
34
+ const id = useId()
35
+ const otherId = `${id}-other`
36
+
37
+ const singleQuestions = computed(() => [
38
+ { key: 'role', question: copy.value.roleQuestion, options: ONBOARDING_ROLES },
39
+ { key: 'siteCount', question: copy.value.siteCountQuestion, options: ONBOARDING_SITE_COUNTS },
40
+ { key: 'seoExperience', question: copy.value.seoExperienceQuestion, options: ONBOARDING_SEO_EXPERIENCE },
41
+ ] as const)
42
+
43
+ type SingleKey = 'role' | 'siteCount' | 'seoExperience'
44
+
45
+ function pick(key: SingleKey, value: string) {
46
+ answers.value = { ...answers.value, [key]: answers.value[key] === value ? undefined : value }
47
+ }
48
+
49
+ function toggleIntent(intent: string) {
50
+ const intents = answers.value.intents.includes(intent)
51
+ ? answers.value.intents.filter(existing => existing !== intent)
52
+ : [...answers.value.intents, intent]
53
+ answers.value = { ...answers.value, intents }
54
+ }
55
+
56
+ function setOther(other: string) {
57
+ answers.value = { ...answers.value, other }
58
+ }
59
+
60
+ const chipClass = 'inline-flex min-h-11 sm:min-h-9 cursor-pointer items-center gap-1.5 rounded-full border px-3.5 text-sm font-medium outline-none transition-[background-color,border-color,color] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default active:translate-y-px'
61
+ const selectedClass = 'border-primary bg-primary/10 text-highlighted ring-1 ring-primary/40'
62
+ const restClass = 'border-accented bg-default/80 text-default hover:border-primary/60 hover:bg-elevated'
63
+ </script>
64
+
65
+ <template>
66
+ <div class="space-y-6">
67
+ <fieldset v-for="item in singleQuestions" :key="item.key" class="space-y-2.5">
68
+ <legend class="text-sm font-medium text-highlighted">
69
+ {{ item.question }}
70
+ </legend>
71
+ <div class="flex flex-wrap gap-2">
72
+ <button
73
+ v-for="option in item.options"
74
+ :key="option.id"
75
+ type="button"
76
+ :aria-pressed="answers[item.key] === option.id"
77
+ :class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
78
+ @click="pick(item.key, option.id)"
79
+ >
80
+ {{ option.label }}
81
+ </button>
82
+ </div>
83
+ </fieldset>
84
+
85
+ <fieldset class="space-y-2.5">
86
+ <legend class="text-sm font-medium text-highlighted">
87
+ {{ copy.intentsQuestion }}
88
+ </legend>
89
+ <p class="text-xs text-muted">
90
+ {{ copy.intentsHint }}
91
+ </p>
92
+ <div class="flex flex-wrap gap-2">
93
+ <button
94
+ v-for="intent in intents"
95
+ :key="intent.id"
96
+ type="button"
97
+ :aria-pressed="answers.intents.includes(intent.id)"
98
+ :class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
99
+ @click="toggleIntent(intent.id)"
100
+ >
101
+ <UiIcon
102
+ :name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
103
+ class="size-4 shrink-0"
104
+ :class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
105
+ aria-hidden="true"
106
+ />
107
+ {{ intent.label }}
108
+ </button>
109
+ </div>
110
+ <div class="space-y-1.5 pt-1">
111
+ <label :for="otherId" class="text-xs text-muted">
112
+ {{ copy.otherLabel }}
113
+ </label>
114
+ <UTextarea
115
+ :id="otherId"
116
+ :model-value="answers.other"
117
+ :maxlength="ONBOARDING_OTHER_MAX_LENGTH"
118
+ :rows="2"
119
+ autoresize
120
+ :placeholder="copy.otherPlaceholder"
121
+ class="w-full"
122
+ @update:model-value="setOther(String($event ?? ''))"
123
+ />
124
+ </div>
125
+ </fieldset>
126
+ </div>
127
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import { UiChip, UiTooltip } from '#components'
3
+ /**
4
+ * UiOverflowCount — the "+N" pill for "primary values shown + N hidden peers"
5
+ * fields (e.g. a domain header with 5 more aliases). Neutral count chip, no
6
+ * glyph (overflow primitives stay icon-light); the optional tooltip lists the
7
+ * hidden values. Renders nothing when count <= 0.
8
+ *
9
+ * NOT for "N more rows below" list tails (those are a disclosure or a nav link).
10
+ */
11
+
12
+ const { count, items, size = 'xs' } = defineProps<{
13
+ count: number
14
+ /** The hidden values, listed in a tooltip. */
15
+ items?: string[]
16
+ size?: 'xs' | 'sm'
17
+ }>()
18
+ </script>
19
+
20
+ <template>
21
+ <template v-if="count > 0">
22
+ <UiTooltip v-if="items?.length" :text="items.join(', ')">
23
+ <UiChip purpose="count" :size="size" tabular>
24
+ +{{ count }}
25
+ </UiChip>
26
+ </UiTooltip>
27
+ <UiChip v-else purpose="count" :size="size" tabular>
28
+ +{{ count }}
29
+ </UiChip>
30
+ </template>
31
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const { size = 'sm' } = defineProps<{
3
+ size?: 'sm' | 'md'
4
+ }>()
5
+
6
+ const sizeClasses = {
7
+ sm: 'px-1.5 py-0.5 text-[9px] tracking-[0.16em]',
8
+ md: 'px-1.5 py-1 text-[10px] tracking-[0.16em]',
9
+ } as const
10
+ </script>
11
+
12
+ <template>
13
+ <span
14
+ class="inline-flex shrink-0 items-center justify-center rounded-md bg-pro-500/10 font-bold uppercase leading-none text-pro-600 ring-1 ring-inset ring-pro-500/20 dark:text-pro-400"
15
+ :class="sizeClasses[size]"
16
+ >
17
+ Pro
18
+ </span>
19
+ </template>
@@ -0,0 +1,79 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue'
3
+ import { clamp } from '../../utils/number'
4
+
5
+ /**
6
+ * UiProgressCircle
7
+ *
8
+ * Shows a circular progres
9
+ */
10
+ interface Props {
11
+ percent?: number | string
12
+ size?: number | string
13
+ strokeSize?: number | string
14
+ lighter?: boolean
15
+ /** Accessible name for the progressbar (e.g. "Indexing progress"). */
16
+ label?: string
17
+ }
18
+
19
+ const { percent = 0, size = 36, strokeSize = 4, lighter, label } = defineProps<Props>()
20
+
21
+ const style = computed(() => ({
22
+ width: `${size}px`,
23
+ height: `${size}px`,
24
+ }))
25
+
26
+ const svg = computed(() => ({
27
+ 'viewBox': `0 0 ${size} ${size}`,
28
+ 'stroke-width': strokeSize,
29
+ 'fill': 'none',
30
+ }))
31
+
32
+ const circle = computed(() => {
33
+ const r = Number(size) / 2
34
+ return {
35
+ cx: r,
36
+ cy: r,
37
+ r: Math.max(1, r - Number(strokeSize) / 2),
38
+ }
39
+ })
40
+
41
+ const offset = computed(() => clamp(100 - Number(percent), 0, 100))
42
+
43
+ const bg = computed(() => {
44
+ return lighter
45
+ ? 'stroke-[var(--ui-bg-accented)]'
46
+ : 'stroke-[var(--ui-border)]'
47
+ })
48
+ </script>
49
+
50
+ <template>
51
+ <div
52
+ data-ui="UiProgressCircle"
53
+ :style="style"
54
+ class="relative shrink-0"
55
+ role="progressbar"
56
+ :aria-label="label || `${Number(percent)}% complete`"
57
+ :aria-valuenow="Number(percent)"
58
+ :aria-valuemin="0"
59
+ :aria-valuemax="100"
60
+ :aria-valuetext="`${Number(percent)}%`"
61
+ >
62
+ <svg
63
+ v-bind="svg"
64
+ :class="bg"
65
+ class="absolute inset-0 w-full h-full"
66
+ >
67
+ <circle v-bind="circle" />
68
+ </svg>
69
+ <svg
70
+ v-bind="svg"
71
+ :stroke-dashoffset="offset"
72
+ :stroke-dasharray="100"
73
+ stroke-linecap="butt"
74
+ class="absolute inset-0 w-full h-full -rotate-90 stroke-primary-300"
75
+ >
76
+ <circle v-bind="circle" pathLength="100" />
77
+ </svg>
78
+ </div>
79
+ </template>
@@ -0,0 +1,35 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue'
3
+ import { UiTooltip, UProgress } from '#components'
4
+ // A %-of-total viz bar — brand-colored by default. Constrained to the
5
+ // SemanticStatus set + `primary` (the design-system palette); the older
6
+ // `secondary`/`tertiary`/`pro` Nuxt UI aliases were never used by a caller.
7
+ type ProgressColor = 'primary' | 'success' | 'warning' | 'error' | 'info' | 'neutral'
8
+
9
+ const { total, value, tooltip, color = 'primary' } = defineProps<{
10
+ total?: string | number
11
+ value: string | number
12
+ tooltip?: string
13
+ color?: ProgressColor
14
+ }>()
15
+
16
+ const percentage = computed(() => {
17
+ return (Number(value) / Number(total || 100)) * 100
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <UiTooltip :text="tooltip || `${percentage.toFixed(1)}% of clicks`" trigger-as="child">
23
+ <div class="min-w-0 w-full">
24
+ <slot />
25
+ <UProgress
26
+ :model-value="percentage || 0"
27
+ :color="color"
28
+ :aria-label="tooltip || `${percentage.toFixed(1)}% of clicks`"
29
+ class="opacity-90"
30
+ size="xs"
31
+ v-bind="$attrs"
32
+ />
33
+ </div>
34
+ </UiTooltip>
35
+ </template>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { DISPLAY_LOCALE, formatTimeAgo, toTimestampDate } from '../../composables/formatting'
4
+
5
+ // Hydration-safe relative time. A bare relative string (e.g. "5m ago") is
6
+ // derived from Date.now(), so it differs between the server render and client
7
+ // hydration and trips a mismatch. This renders the *absolute* date during SSR
8
+ // and the first client paint (identical on both), then swaps to the live
9
+ // relative string after mount. The absolute date is always available in `title`.
10
+ const { date, fallback = '—', format = 'narrow' } = defineProps<{
11
+ /** Unix seconds or milliseconds (number), an ISO/date string, or a Date. */
12
+ date: number | string | Date | null | undefined
13
+ /** Shown when `date` is null/invalid. */
14
+ fallback?: string
15
+ /** `narrow` ("in 3d") for dense cells; `long` ("in 3 days") where the value stands alone. */
16
+ format?: 'narrow' | 'long'
17
+ }>()
18
+
19
+ // The server's timezone and the browser's can differ. Render in UTC through
20
+ // hydration, then switch the title to the viewer's timezone after mount
21
+ // alongside the relative display text. The locale stays pinned throughout.
22
+ const hydrationAbsoluteFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, {
23
+ dateStyle: 'medium',
24
+ timeStyle: 'short',
25
+ timeZone: 'UTC',
26
+ })
27
+ const localAbsoluteFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, { dateStyle: 'medium', timeStyle: 'short' })
28
+
29
+ const parsed = computed(() => {
30
+ if (date == null)
31
+ return null
32
+ const d = toTimestampDate(date)
33
+ return Number.isNaN(d.getTime()) ? null : d
34
+ })
35
+
36
+ const mounted = ref(false)
37
+ onMounted(() => {
38
+ mounted.value = true
39
+ })
40
+
41
+ const absolute = computed(() => {
42
+ if (!parsed.value)
43
+ return ''
44
+ return (mounted.value ? localAbsoluteFormat : hydrationAbsoluteFormat).format(parsed.value)
45
+ })
46
+ const display = computed(() => {
47
+ if (!parsed.value)
48
+ return fallback
49
+ // `formatTimeAgo` (and thus Date.now()) is only read after mount — never on
50
+ // the server — so SSR and first-paint HTML stay identical.
51
+ return mounted.value ? (formatTimeAgo(date, format) ?? absolute.value) : absolute.value
52
+ })
53
+ </script>
54
+
55
+ <template>
56
+ <time v-if="parsed" :datetime="parsed.toISOString()" :title="absolute">{{ display }}</time>
57
+ <span v-else>{{ fallback }}</span>
58
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div class="relative overflow-y-auto">
6
+ <div
7
+ aria-hidden="true"
8
+ class="sticky top-0 z-[5] h-5 -mb-5 pointer-events-none bg-gradient-to-b from-default to-transparent [overflow-anchor:none]"
9
+ />
10
+ <slot />
11
+ <div
12
+ aria-hidden="true"
13
+ class="sticky bottom-0 z-[5] h-5 -mt-5 pointer-events-none bg-gradient-to-t from-default to-transparent [overflow-anchor:none]"
14
+ />
15
+ </div>
16
+ </template>