@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,84 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { UiFavicon, UiTooltip } from '#components'
4
+
5
+ export interface FaviconStackSite {
6
+ /** Hostname, full URL, or `sc-domain:` property — `UiFavicon` normalises it. */
7
+ domain: string
8
+ /** The visible Site name. Names the Site in the tooltip and the fallback initial. */
9
+ label: string
10
+ }
11
+
12
+ /**
13
+ * A row's Site identity, as favicons only.
14
+ *
15
+ * List rows repeat their Site on every line. Spelled out, the name is a second
16
+ * text column that competes with the diagnosis and truncates first on narrow
17
+ * screens. A framed favicon carries the same identity in 20px and reads as an
18
+ * icon, not as content. The name is one hover away, and the caller still owns
19
+ * the accessible name of any control the stack sits inside.
20
+ *
21
+ * Mirrors `UiSourceLogos`: the same frame, the same `-ml-1.5` overlap, the same
22
+ * `role="img"` wrapper. Sources say WHERE a finding came from; this says WHICH
23
+ * Site it belongs to. One Site renders as a single frame, which is the same
24
+ * markup with nothing to overlap.
25
+ */
26
+
27
+ const { sites, size = 'sm', max = 4 } = defineProps<{
28
+ sites: readonly FaviconStackSite[]
29
+ /** Favicon pixel size: `xs` is 14px, `sm` is 16px. */
30
+ size?: 'xs' | 'sm'
31
+ /** Favicons rendered before the rest collapse into a `+N` frame. */
32
+ max?: number
33
+ }>()
34
+
35
+ const shown = computed(() => sites.slice(0, Math.max(1, max)))
36
+ const overflow = computed(() => sites.length - shown.value.length)
37
+
38
+ // Every Site is named, including the ones behind `+N` — the tooltip is the only
39
+ // place the reader can recover a name the row no longer prints.
40
+ const accessibleLabel = computed(() => sites.map(site => site.label).join(', '))
41
+
42
+ const iconPixels = computed(() => (size === 'xs' ? 14 : 16))
43
+ const frameClass = computed(() => (size === 'xs' ? 'size-5' : 'size-6'))
44
+ </script>
45
+
46
+ <template>
47
+ <!--
48
+ `trigger-as="child"` so the trigger IS the stack. The default `span` wrapper
49
+ would add an inline-block layer inside the row's flex line, and the `button`
50
+ variant would nest a control inside rows that are themselves one button or
51
+ one link.
52
+ -->
53
+ <UiTooltip v-if="sites.length" :text="accessibleLabel" trigger-as="child">
54
+ <span
55
+ class="inline-flex shrink-0 items-center"
56
+ role="img"
57
+ :aria-label="accessibleLabel"
58
+ >
59
+ <span
60
+ v-for="(site, index) in shown"
61
+ :key="site.domain"
62
+ class="inline-flex shrink-0 items-center justify-center rounded-md border border-default bg-default shadow-xs"
63
+ :class="[frameClass, index ? '-ml-1.5' : '']"
64
+ aria-hidden="true"
65
+ >
66
+ <!-- `fallbackLabel` so a domain with no reachable favicon still shows an
67
+ initial rather than an empty frame — the frame IS the identity here. -->
68
+ <UiFavicon
69
+ :domain="site.domain"
70
+ :size="iconPixels"
71
+ :fallback-label="site.label"
72
+ fallback-surface="transparent"
73
+ decorative
74
+ />
75
+ </span>
76
+ <span
77
+ v-if="overflow > 0"
78
+ class="-ml-1.5 inline-flex shrink-0 items-center justify-center rounded-md border border-default bg-default text-mini font-medium tabular-nums leading-none text-muted shadow-xs"
79
+ :class="frameClass"
80
+ aria-hidden="true"
81
+ >+{{ overflow }}</span>
82
+ </span>
83
+ </UiTooltip>
84
+ </template>
@@ -0,0 +1,324 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { ToggleGroupItem, ToggleGroupRoot, UiIcon, UiMetricLabel, UInput, UiPopover } from '#components'
4
+ // Presentational filter primitive: a popover combining a dimension search,
5
+ // dimension shortcuts, a branded toggle, saved filters and preset filters.
6
+ // All data is prop-driven and actions are emitted — the consumer owns
7
+ // persistence and what each dimension/preset means.
8
+
9
+ interface DimensionOption {
10
+ label: string
11
+ value: string
12
+ icon: string
13
+ }
14
+ interface SavedFilter {
15
+ id: string
16
+ label: string
17
+ }
18
+ interface PresetFilter {
19
+ id: string
20
+ label: string
21
+ icon?: string
22
+ }
23
+ interface BrandedOption {
24
+ label: string
25
+ value: string
26
+ icon?: string
27
+ }
28
+
29
+ const {
30
+ dimensions = [],
31
+ dimensionValues = {},
32
+ brandedOptions = [],
33
+ savedFilters = [],
34
+ presetFilters = [],
35
+ activePreset = '',
36
+ activeCount = 0,
37
+ label = 'Filter',
38
+ searchable = true,
39
+ searchPlaceholder = 'Search…',
40
+ showSaved = true,
41
+ } = defineProps<{
42
+ /** Dimension shortcuts (Query/Page/Country/Device). Clicking opens that dimension's `#picker` sub-view. */
43
+ dimensions?: DimensionOption[]
44
+ /** Current applied value per dimension (keyed by dimension value) — shown as a trailing label on the row. */
45
+ dimensionValues?: Record<string, string>
46
+ /** Branded segmented toggle options; omit to hide the section. */
47
+ brandedOptions?: BrandedOption[]
48
+ /** User-persisted saved filters. */
49
+ savedFilters?: SavedFilter[]
50
+ /** Built-in preset filters. */
51
+ presetFilters?: PresetFilter[]
52
+ /** The currently-applied preset id — shown active in the list. */
53
+ activePreset?: string
54
+ /** Count shown in the trigger badge. */
55
+ activeCount?: number
56
+ /** Trigger label. */
57
+ label?: string
58
+ /** Show the in-popover search input. Disable when the consumer renders its own. */
59
+ searchable?: boolean
60
+ /** Placeholder for the search input. */
61
+ searchPlaceholder?: string
62
+ /** Show the Saved Filters section (header + Save current + list). Disable on
63
+ * surfaces that don't implement saved filters so no dead "Save current"
64
+ * button renders. */
65
+ showSaved?: boolean
66
+ }>()
67
+
68
+ const emit = defineEmits<{
69
+ selectDimension: [value: string]
70
+ applySaved: [id: string]
71
+ removeSaved: [id: string]
72
+ applyPreset: [id: string]
73
+ save: []
74
+ clear: []
75
+ }>()
76
+ const search = defineModel<string>('search', { default: '' })
77
+ const branded = defineModel<string>('branded', { default: '' })
78
+
79
+ const open = ref(false)
80
+
81
+ // Two-level popover: selecting a dimension row swaps the menu for that
82
+ // dimension's value picker (rendered via the `#picker` slot) with a back
83
+ // affordance, matching the seogets filter UX. Reset when the popover closes.
84
+ const activeDimension = ref('')
85
+ const activeDimensionLabel = computed(() => dimensions.find(d => d.value === activeDimension.value)?.label ?? '')
86
+ const backBtn = ref<HTMLButtonElement | null>(null)
87
+ const dimensionsRoot = ref<HTMLElement | null>(null)
88
+
89
+ function openDimension(value: string) {
90
+ activeDimension.value = value
91
+ emit('selectDimension', value)
92
+ }
93
+ function closeDimension() {
94
+ activeDimension.value = ''
95
+ }
96
+
97
+ // Keep keyboard focus inside the popover when the sub-view swaps: focus the
98
+ // back affordance on entering a dimension, and return focus to the originating
99
+ // dimension row when leaving (falling back to the dimensions group).
100
+ watch(activeDimension, async (value, prev) => {
101
+ await nextTick()
102
+ if (value) {
103
+ backBtn.value?.focus()
104
+ }
105
+ else if (prev) {
106
+ const row = dimensionsRoot.value?.querySelector<HTMLButtonElement>(`[data-dimension="${prev}"]`)
107
+ ;(row ?? dimensionsRoot.value?.querySelector<HTMLButtonElement>('button'))?.focus()
108
+ }
109
+ })
110
+
111
+ watch(open, (v) => {
112
+ if (!v)
113
+ activeDimension.value = ''
114
+ })
115
+ </script>
116
+
117
+ <template>
118
+ <UiPopover v-model:open="open" :content="{ align: 'start', side: 'bottom', sideOffset: 8 }">
119
+ <button
120
+ type="button"
121
+ :aria-label="activeCount ? `Filter (${activeCount} active)` : 'Filter'"
122
+ :aria-expanded="open"
123
+ class="cursor-pointer inline-flex items-center justify-center gap-1.5 px-2 py-1.5 min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 rounded-lg text-xs font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-primary outline-none"
124
+ :class="open || activeCount ? 'bg-elevated text-default' : 'text-muted hover:text-default hover:bg-[var(--ui-bg-elevated)]/50'"
125
+ >
126
+ <UiIcon name="filter" class="size-3.5" aria-hidden="true" />
127
+ <span data-toolbar-label>{{ label }}</span>
128
+ <span
129
+ v-if="activeCount"
130
+ class="min-w-4 px-1 h-4 inline-flex items-center justify-center rounded-full bg-primary text-inverted text-mini font-semibold tabular-nums"
131
+ >{{ activeCount }}</span>
132
+ </button>
133
+
134
+ <template #panel>
135
+ <div class="w-[270px] py-1.5">
136
+ <!-- Dimension value-picker sub-view: replaces the menu while a
137
+ dimension is active, with a back affordance. -->
138
+ <template v-if="activeDimension">
139
+ <button
140
+ ref="backBtn"
141
+ type="button"
142
+ :aria-label="`Back to filters, from ${activeDimensionLabel}`"
143
+ class="cursor-pointer w-full flex items-center gap-1.5 px-3 py-2.5 sm:py-1 text-xs font-medium text-muted hover:text-default transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
144
+ @click="closeDimension"
145
+ >
146
+ <UiIcon name="chevron-left" class="size-3.5 shrink-0" aria-hidden="true" />
147
+ <span>{{ activeDimensionLabel }}</span>
148
+ </button>
149
+ <div class="mx-3 my-1.5 border-t border-default/50" role="separator" />
150
+ <div class="px-2.5 pb-1">
151
+ <slot name="picker" :dimension="activeDimension" :close="closeDimension" />
152
+ </div>
153
+ </template>
154
+
155
+ <template v-else>
156
+ <!-- Active-filter header + one-click clear (only when something is applied). -->
157
+ <div v-if="activeCount" class="px-3 pt-0.5 pb-1.5 flex items-center justify-between">
158
+ <UiMetricLabel aria-hidden="true">
159
+ {{ activeCount }} active
160
+ </UiMetricLabel>
161
+ <button
162
+ type="button"
163
+ aria-label="Clear all filters"
164
+ class="min-h-11 cursor-pointer rounded text-mini text-muted outline-none hover:text-default focus-visible:ring-2 focus-visible:ring-primary sm:min-h-0"
165
+ @click="emit('clear')"
166
+ >
167
+ Clear all
168
+ </button>
169
+ </div>
170
+
171
+ <!-- Search -->
172
+ <div v-if="searchable" class="px-2 pb-1.5">
173
+ <UInput
174
+ v-model="search"
175
+ type="search"
176
+ :placeholder="searchPlaceholder"
177
+ :aria-label="searchPlaceholder"
178
+ icon="search"
179
+ size="xs"
180
+ class="w-full"
181
+ autocomplete="off"
182
+ />
183
+ </div>
184
+
185
+ <!-- Facets slot (consumer-rendered value editors, e.g. country/device) -->
186
+ <div v-if="$slots.facets" class="px-2.5 pb-1.5">
187
+ <slot name="facets" />
188
+ </div>
189
+
190
+ <!-- Dimension shortcuts -->
191
+ <div v-if="dimensions.length" ref="dimensionsRoot" role="group" aria-label="Filter dimensions">
192
+ <button
193
+ v-for="d in dimensions"
194
+ :key="d.value"
195
+ type="button"
196
+ :data-dimension="d.value"
197
+ :aria-label="`${d.label}${dimensionValues[d.value] ? `: ${dimensionValues[d.value]}` : ''}`"
198
+ class="cursor-pointer w-full flex items-center gap-2 px-3 py-2.5 sm:py-1 text-xs text-muted hover:text-default hover:bg-[var(--ui-bg-elevated)]/50 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
199
+ @click="openDimension(d.value)"
200
+ >
201
+ <UiIcon :name="d.icon" class="size-3.5 shrink-0 text-dimmed" aria-hidden="true" />
202
+ <span class="flex-1 text-left">{{ d.label }}</span>
203
+ <span v-if="dimensionValues[d.value]" class="text-default font-medium truncate max-w-28">{{ dimensionValues[d.value] }}</span>
204
+ <UiIcon name="chevron-right" class="size-3 shrink-0 text-dimmed" aria-hidden="true" />
205
+ </button>
206
+ </div>
207
+
208
+ <!-- Branded toggle -->
209
+ <template v-if="brandedOptions.length">
210
+ <div class="mx-3 my-1.5 border-t border-default/50" role="separator" />
211
+ <div class="px-3 pb-1">
212
+ <UiMetricLabel aria-hidden="true">
213
+ Branded
214
+ </UiMetricLabel>
215
+ </div>
216
+ <div class="px-2.5 pb-1">
217
+ <ToggleGroupRoot
218
+ :model-value="branded"
219
+ type="single"
220
+ aria-label="Branded filter"
221
+ class="flex items-center gap-1 p-0.5 rounded-md bg-[var(--ui-bg-elevated)]/60"
222
+ @update:model-value="(v: unknown) => { if (typeof v === 'string' && v) branded = v }"
223
+ >
224
+ <ToggleGroupItem
225
+ v-for="b in brandedOptions"
226
+ :key="b.value"
227
+ :value="b.value"
228
+ class="cursor-pointer flex-1 inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-mini font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary"
229
+ :class="branded === b.value
230
+ ? 'bg-default text-default'
231
+ : 'text-muted hover:text-default'"
232
+ >
233
+ <UiIcon v-if="b.icon" :name="b.icon" class="size-3" aria-hidden="true" />
234
+ <span>{{ b.label }}</span>
235
+ </ToggleGroupItem>
236
+ </ToggleGroupRoot>
237
+ </div>
238
+ </template>
239
+
240
+ <!-- Saved filters -->
241
+ <template v-if="showSaved">
242
+ <div class="mx-3 my-1.5 border-t border-default/50" role="separator" />
243
+ <div class="px-3 pb-1 flex items-center justify-between">
244
+ <UiMetricLabel aria-hidden="true">
245
+ Saved Filters
246
+ </UiMetricLabel>
247
+ <button
248
+ type="button"
249
+ class="cursor-pointer text-mini text-primary hover:underline outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
250
+ @click="emit('save')"
251
+ >
252
+ Save current
253
+ </button>
254
+ </div>
255
+ <div v-if="savedFilters.length" role="group" aria-label="Saved filters">
256
+ <div
257
+ v-for="s in savedFilters"
258
+ :key="s.id"
259
+ class="group w-full flex items-center gap-2 px-3 py-2.5 sm:py-1 text-xs text-muted hover:bg-[var(--ui-bg-elevated)]/50 transition-colors"
260
+ >
261
+ <button
262
+ type="button"
263
+ class="cursor-pointer flex-1 flex items-center gap-2 text-left hover:text-default outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset rounded"
264
+ @click="emit('applySaved', s.id)"
265
+ >
266
+ <UiIcon name="bookmark" class="size-3.5 shrink-0 text-dimmed" aria-hidden="true" />
267
+ <span class="truncate">{{ s.label }}</span>
268
+ </button>
269
+ <button
270
+ type="button"
271
+ :aria-label="`Remove saved filter ${s.label}`"
272
+ class="cursor-pointer opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-dimmed hover:text-error transition-[color,opacity] outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
273
+ @click="emit('removeSaved', s.id)"
274
+ >
275
+ <UiIcon name="close" class="size-3" aria-hidden="true" />
276
+ </button>
277
+ </div>
278
+ </div>
279
+ <p v-else class="px-3 pb-1 text-mini text-dimmed leading-snug">
280
+ Add filters and save them to quickly access them later.
281
+ </p>
282
+ </template>
283
+
284
+ <!-- Preset filters -->
285
+ <template v-if="presetFilters.length">
286
+ <div class="mx-3 my-1.5 border-t border-default/50" role="separator" />
287
+ <div class="px-3 pb-1">
288
+ <UiMetricLabel aria-hidden="true">
289
+ Preset Filters
290
+ </UiMetricLabel>
291
+ </div>
292
+ <div role="group" aria-label="Preset filters">
293
+ <button
294
+ v-for="p in presetFilters"
295
+ :key="p.id"
296
+ type="button"
297
+ :aria-pressed="activePreset === p.id"
298
+ class="relative flex min-h-11 w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset sm:min-h-0 sm:py-1"
299
+ :class="activePreset === p.id
300
+ ? 'text-default bg-elevated'
301
+ : 'text-muted hover:text-default hover:bg-[var(--ui-bg-elevated)]/50'"
302
+ @click="emit('applyPreset', p.id)"
303
+ >
304
+ <span
305
+ v-if="activePreset === p.id"
306
+ class="absolute left-0 inset-y-0.5 w-[2px] rounded-full bg-primary"
307
+ aria-hidden="true"
308
+ />
309
+ <UiIcon :name="p.icon ?? 'ai'" class="size-3.5 shrink-0 text-dimmed" aria-hidden="true" />
310
+ <span class="flex-1 text-left">{{ p.label }}</span>
311
+ <UiIcon
312
+ v-if="activePreset === p.id"
313
+ name="check"
314
+ class="size-3 text-primary"
315
+ aria-hidden="true"
316
+ />
317
+ </button>
318
+ </div>
319
+ </template>
320
+ </template>
321
+ </div>
322
+ </template>
323
+ </UiPopover>
324
+ </template>
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ import { useHead } from 'nuxt/app'
3
+ import { ClientOnly, UiAtmosphere, UiIcon, ULink } from '#components'
4
+ import { useColorMode } from '#imports'
5
+ // The focused, full-bleed surface shared by the auth screens (login, sign-up)
6
+ // and the onboarding wizard: dashboard-theme scope, a masked dot-grid backdrop,
7
+ // and a top-left exit link. No dashboard chrome. The single source of this
8
+ // substrate — the pro-auth layout and UiWizardShell both compose it, and the
9
+ // brand-kit showcase renders it directly.
10
+ //
11
+ // Height is the consumer's call (attrs fall through to the root <main>): the
12
+ // live pages pass `min-h-screen`; an embedded preview passes a fixed height.
13
+ //
14
+ // - align="center" centres a single child both axes (auth card).
15
+ // - align="top" stacks content from the top (wizard column).
16
+ // - exitTo renders a real link; otherwise the exit affordance emits `exit`.
17
+ const {
18
+ align = 'center',
19
+ exitLabel = 'Home',
20
+ exitTo,
21
+ exitExternal = false,
22
+ showExit = true,
23
+ as = 'main',
24
+ } = defineProps<{
25
+ align?: 'center' | 'top'
26
+ exitLabel?: string
27
+ exitTo?: string
28
+ exitExternal?: boolean
29
+ showExit?: boolean
30
+ /**
31
+ * Root element. Defaults to `main` (the page-level landmark) for the real
32
+ * full-page surfaces. Pass `as="div"` when more than one surface renders on a
33
+ * page (e.g. the brand-kit showcase) to avoid duplicate `<main>` landmarks.
34
+ */
35
+ as?: string
36
+ }>()
37
+
38
+ const emit = defineEmits<{ exit: [] }>()
39
+ const colorMode = useColorMode()
40
+
41
+ function toggleColorMode() {
42
+ colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
43
+ }
44
+
45
+ // The dashboard palette is keyed on `.dark.dashboard-theme` / `.light.dashboard-theme`
46
+ // — BOTH classes on the same element — and the color-mode class only ever lands
47
+ // on `<html>`. A `dashboard-theme` that sits on this div alone therefore misses
48
+ // every `--ui-bg-*` / `--ui-text-*` override and the surface silently renders on
49
+ // the marketing slate palette: in dark that means page background and outline
50
+ // controls both resolve to the same near-black, so inputs and buttons vanish.
51
+ // Mirror it onto `<html>` whenever this surface IS the page (as="main"); an
52
+ // embedded preview (`as="div"`, brand-kit) must not restyle its host page.
53
+ if (as === 'main')
54
+ useHead({ htmlAttrs: { class: 'dashboard-theme' } })
55
+ </script>
56
+
57
+ <template>
58
+ <component
59
+ :is="as"
60
+ class="ui-focused dashboard-theme relative overflow-hidden flex flex-col items-center px-4 pb-10"
61
+ :class="[
62
+ align === 'center' ? 'justify-center' : '',
63
+ as === 'main' && showExit ? 'pt-20 sm:pt-10' : 'pt-10',
64
+ ]"
65
+ :data-align="align"
66
+ >
67
+ <UiAtmosphere preset="front-door" />
68
+ <div aria-hidden="true" class="ui-focused-dots pointer-events-none absolute inset-0" />
69
+
70
+ <ULink
71
+ v-if="showExit && exitTo"
72
+ :to="exitTo"
73
+ :external="exitExternal"
74
+ class="absolute top-3 left-3 z-10 inline-flex min-h-11 items-center gap-1 px-2 text-sm text-muted transition-colors hover:text-highlighted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md sm:top-5 sm:left-5"
75
+ >
76
+ <UiIcon name="back" class="size-3.5 shrink-0" aria-hidden="true" />
77
+ <span class="leading-none">{{ exitLabel }}</span>
78
+ </ULink>
79
+ <button
80
+ v-else-if="showExit"
81
+ type="button"
82
+ class="absolute top-3 left-3 z-10 inline-flex min-h-11 items-center gap-1 px-2 text-sm text-muted transition-colors hover:text-highlighted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md sm:top-5 sm:left-5"
83
+ @click="emit('exit')"
84
+ >
85
+ <UiIcon name="back" class="size-3.5 shrink-0" aria-hidden="true" />
86
+ <span class="leading-none">{{ exitLabel }}</span>
87
+ </button>
88
+
89
+ <ClientOnly v-if="as === 'main'">
90
+ <button
91
+ type="button"
92
+ class="group/theme absolute top-3 right-3 z-10 inline-flex size-11 items-center justify-center rounded-xl text-muted transition-colors hover:text-highlighted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:top-5 sm:right-5"
93
+ :aria-label="`Switch to ${colorMode.value === 'dark' ? 'light' : 'dark'} mode`"
94
+ :title="`Switch to ${colorMode.value === 'dark' ? 'light' : 'dark'} mode`"
95
+ @click="toggleColorMode"
96
+ >
97
+ <span class="inline-flex size-8 items-center justify-center rounded-lg bg-default/60 ring-1 ring-default backdrop-blur-sm transition-colors group-hover/theme:bg-elevated">
98
+ <UiIcon
99
+ :name="colorMode.value === 'dark' ? 'light' : 'dark'"
100
+ class="size-4"
101
+ aria-hidden="true"
102
+ />
103
+ </span>
104
+ </button>
105
+ <template #fallback>
106
+ <span class="absolute top-3 right-3 size-11 sm:top-5 sm:right-5" aria-hidden="true" />
107
+ </template>
108
+ </ClientOnly>
109
+
110
+ <slot />
111
+ </component>
112
+ </template>
113
+
114
+ <style scoped>
115
+ .ui-focused-dots {
116
+ background-image: radial-gradient(circle at 1px 1px, color-mix(in oklab, var(--ui-bg-inverted) 8%, transparent) 1px, transparent 0);
117
+ background-size: 28px 28px;
118
+ }
119
+
120
+ .ui-focused[data-align="center"] .ui-focused-dots {
121
+ mask-image: radial-gradient(ellipse 60% 50% at 50% 50%, black 0%, transparent 75%);
122
+ -webkit-mask-image: radial-gradient(ellipse 60% 50% at 50% 50%, black 0%, transparent 75%);
123
+ }
124
+
125
+ .ui-focused[data-align="top"] .ui-focused-dots {
126
+ mask-image: radial-gradient(ellipse 70% 50% at 50% 30%, black 0%, transparent 75%);
127
+ -webkit-mask-image: radial-gradient(ellipse 70% 50% at 50% 30%, black 0%, transparent 75%);
128
+ }
129
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import type { HealthStatus } from '../../composables/semanticColors'
3
+ import { healthColors } from '../../composables/semanticColors'
4
+
5
+ const { health = 'unknown', size = 'sm', pulse = false, label } = defineProps<{
6
+ health?: HealthStatus | null
7
+ size?: 'xs' | 'sm' | 'md'
8
+ pulse?: boolean
9
+ /**
10
+ * Accessible name for the status. Pass when the dot stands alone (no adjacent
11
+ * text), so the color is not the only channel conveying state. Omit when a
12
+ * visible text label sits next to the dot — it stays decorative then.
13
+ */
14
+ label?: string
15
+ }>()
16
+
17
+ const sizeClass = { xs: 'size-1.5', sm: 'size-2', md: 'size-2.5' } as const
18
+ </script>
19
+
20
+ <template>
21
+ <span
22
+ class="rounded-full shrink-0"
23
+ :class="[
24
+ sizeClass[size],
25
+ healthColors(health).dot,
26
+ pulse && 'motion-safe:animate-pulse',
27
+ ]"
28
+ :role="label ? 'img' : undefined"
29
+ :aria-label="label || undefined"
30
+ :aria-hidden="label ? undefined : 'true'"
31
+ />
32
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { UiIcon, UiTooltip } from '#components'
3
+ /**
4
+ * UiHelpLabel
5
+ *
6
+ * Label text with an inline (?) help icon that shows a tooltip on hover.
7
+ * Used by UiStat, UiDataList, and anywhere a title needs contextual help.
8
+ */
9
+ const { text, tooltip, tooltipTitle } = defineProps<{
10
+ text: string
11
+ tooltip: string
12
+ /** Bold title in tooltip panel. Defaults to `text`. */
13
+ tooltipTitle?: string
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <UiTooltip :title="tooltipTitle || text" :description="tooltip" trigger-as="button">
19
+ <!-- The trigger is a button, which centres its text; a label that wraps
20
+ must stay start-aligned like the heading it sits in. `mx-0.5` cancels
21
+ the button's `-m-0.5` hit-area bleed, so the label starts on the same
22
+ edge as a plain heading beside it. -->
23
+ <span class="mx-0.5 inline-flex items-center gap-1 text-left">
24
+ {{ text }}
25
+ <UiIcon name="help" class="size-3 opacity-50 shrink-0" aria-hidden="true" />
26
+ </span>
27
+ </UiTooltip>
28
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon } from '../../shared/ui-icons'
3
+ import { UIcon } from '#components'
4
+
5
+ /**
6
+ * UiIcon — the icon primitive. A thin, typed wrapper over Nuxt UI's `<UIcon>`.
7
+ *
8
+ * The `name` is a curated semantic role (`refresh`, `next`, `delete`, …) or an
9
+ * explicitly sourced `i-*` id. Roles resolve to the active set's icon via @nuxt/icon's global aliases
10
+ * (registered in the design-system nuxt.config from the registry), so this component
11
+ * passes `name` straight through — no runtime registry, nothing extra shipped to the
12
+ * client. The `UiIcon` type keeps role autocomplete; type-only import is erased at build.
13
+ *
14
+ * The brand-kit set comparison calls `resolveUiIcon(role, set)` and feeds the
15
+ * resulting whitelisted id back through this primitive.
16
+ */
17
+ const { name } = defineProps<{ name?: UiIcon }>()
18
+ </script>
19
+
20
+ <template>
21
+ <UIcon :name="name" />
22
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts" generic="T extends InputValue = InputValue">
2
+ import type { InputEmits, InputProps, InputValue } from '@nuxt/ui'
3
+ import { UInput } from '#components'
4
+
5
+ /**
6
+ * UiInput — thin wrapper around Nuxt UI v4's UInput.
7
+ *
8
+ * Tokens (radius, ring, background) are already centralized in
9
+ * `app.config.ts`'s `ui.input` — this component isn't a re-theme, it's the
10
+ * single legal entry point for `<input>`-style fields (raw `<UInput>` is
11
+ * ESLint-banned via `eslint/selectors.ts`) so a future behavioral change
12
+ * lands in one file instead of another repo-wide sweep. Every prop, emit,
13
+ * and slot passes straight through unchanged.
14
+ */
15
+
16
+ defineOptions({ inheritAttrs: false })
17
+
18
+ const props = defineProps<Props>()
19
+ const emit = defineEmits<InputEmits<T>>()
20
+ type Props = Omit<InputProps<T>, 'defaultValue'>
21
+ </script>
22
+
23
+ <template>
24
+ <!-- @vue-generic {T, NonNullable<InputProps<T>['modelModifiers']>} -->
25
+ <UInput
26
+ v-bind="{ ...props, ...$attrs }"
27
+ @update:model-value="emit('update:modelValue', $event)"
28
+ @blur="emit('blur', $event)"
29
+ @change="emit('change', $event)"
30
+ >
31
+ <template v-for="(_, name) in ($slots as Record<string, unknown>)" #[name]="slotProps" :key="name">
32
+ <slot :name="name" v-bind="slotProps || {}" />
33
+ </template>
34
+ </UInput>
35
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ // Official Plausible Analytics brand mark, brand colour #5850EC baked into the
3
+ // asset (single-colour mark — there is no multicolour version). Inline SVG so the
4
+ // colour survives, vs an icon set that would monotone it to currentColor.
5
+ // Sizing/aria fall through from the parent (e.g. class="size-6").
6
+ </script>
7
+
8
+ <template>
9
+ <svg viewBox="0 0 24 24" fill="#5850EC" xmlns="http://www.w3.org/2000/svg">
10
+ <path d="M12.1835.0017c-.6378-.0097-1.2884.022-1.7246.0996C8.601.424 7.035 1.2116 5.7384 2.4782 4.406 3.7806 3.582 5.299 3.1818 7.1929l-.1387.6445c-.0118 5.3872-.0233 10.7744-.035 16.1617.2914.0081.591-.0392.8416-.0606 2.348-.2868 4.3442-1.7083 5.4315-3.8651.2749-.5497.472-1.182.6094-1.9707.1135-.6691.1195-.8915.1016-4.3807l-.0176-3.6737.1425-.3574c.1972-.49.7425-1.0352 1.2324-1.2324l.3574-.1426 3.3457-.0058c1.8401 0 3.4545-.025 3.58-.0489.5854-.1135 1.2118-.6027 1.4628-1.1464.0717-.1494.1671-.4415.209-.6387.0657-.3286.0604-.4186-.0352-.789-.2987-1.0993-1.3503-2.6234-2.4257-3.5136C16.6247 1.1638 15.2798.4887 13.828.1482c-.3824-.0866-1.0067-.1368-1.6445-.1465zm8.5369 6.8006c-.0506.1798-.098.3662-.172.5215-.3358.7278-1.0382 1.2776-1.8221 1.4296-3.6737.0566-2.5392.0561-3.6737.0566l-3.248.0059-.2695.1074c-.3135.1262-.827.6397-.9531.9531l-.1074.2676.0175 3.576c.0149 2.8888.007 3.5821-.0605 4.125a8.9918 8.9918 0 0 0 1.5683.1386c4.9662.0001 8.992-4.0258 8.992-8.992a8.9918 8.9918 0 0 0-.2715-2.1893Z" />
11
+ </svg>
12
+ </template>