@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,143 @@
1
+ <script setup lang="ts">
2
+ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
+ import type { ViewState } from '../../shared/view-state'
4
+ import type { WidgetFailure } from '../../shared/widget-failure'
5
+ import { computed, onMounted, ref } from 'vue'
6
+ import { UiButton, UiEmptyState, UiIcon, UiSkeleton } from '#components'
7
+ import { toWidgetFailure, WIDGET_FAILURE_COPY } from '../../shared/widget-failure'
8
+
9
+ const {
10
+ state,
11
+ status = 'idle',
12
+ error,
13
+ empty,
14
+ skeletonLines = 5,
15
+ skeletonType = 'text',
16
+ emptyIcon = 'inbox',
17
+ emptyTitle = 'No data',
18
+ emptyMessage = 'There is no data to display',
19
+ showLoadingBeforeHydration = false,
20
+ retainContentOnError = false,
21
+ } = defineProps<{
22
+ /**
23
+ * The read behind the region, built with `viewState` or `listViewState`.
24
+ * When set, it decides the branch and `status`, `error` and `empty` are
25
+ * ignored. Only an `empty` state shows the empty copy, so a failed read can
26
+ * never say "No data".
27
+ */
28
+ state?: ViewState<unknown>
29
+ /** The read's lifecycle. Ignored when `state` is set. */
30
+ status?: 'idle' | 'pending' | 'success' | 'error'
31
+ /**
32
+ * The caught value, for classification only. Typed `unknown` on purpose: the
33
+ * template can read no string off it, so a wire message has no route to the
34
+ * screen. Pass the raw query error — wrapping it in `new Error(humanCopy)`
35
+ * throws away the status code this needs and buys nothing.
36
+ */
37
+ error?: unknown
38
+ empty?: boolean
39
+ skeletonLines?: number
40
+ skeletonType?: 'text' | 'bar' | 'circle'
41
+ emptyIcon?: UiIconName
42
+ emptyTitle?: string
43
+ emptyMessage?: string
44
+ /**
45
+ * Render a deterministic loading slot during SSR and hydration. Use only for
46
+ * explicitly client-only reads whose payload cannot resolve before hydration.
47
+ */
48
+ showLoadingBeforeHydration?: boolean
49
+ /** The caller has usable content owned by the current query scope. */
50
+ retainContentOnError?: boolean
51
+ }>()
52
+
53
+ const emit = defineEmits<{
54
+ retry: []
55
+ }>()
56
+
57
+ defineSlots<{
58
+ /**
59
+ * `data` is the answer of a `state` in its `data` branch. It is `unknown`
60
+ * here; read the typed answer from the state you passed.
61
+ */
62
+ default?: (props: { data: unknown }) => unknown
63
+ loading?: () => unknown
64
+ empty?: () => unknown
65
+ error?: (props: { failure: WidgetFailure, retry: () => void }) => unknown
66
+ }>()
67
+
68
+ // Suppress loading skeletons until after hydration so server and client
69
+ // render the same branch. Lazy fetches transfer data via the SSR payload,
70
+ // making status jump from pending→success between render and hydrate.
71
+ const hydrated = ref(false)
72
+ onMounted(() => {
73
+ hydrated.value = true
74
+ })
75
+ // A `state` needs no hydration gate: a read with no answer yet is `loading`
76
+ // on the server and on the client, so both render the same branch.
77
+ const isLoading = computed(() => state
78
+ ? state._tag === 'loading'
79
+ : (showLoadingBeforeHydration || hydrated.value) && (status === 'pending' || status === 'idle'))
80
+ // `status` alone decides the branch. It used to also require a truthy `error`,
81
+ // which meant a caller that mapped its own failure to `status="error"` without
82
+ // threading an error object fell past this branch AND past the empty branch,
83
+ // into the default slot: a heading over an empty box. Half the call sites did
84
+ // exactly that, so the error object is no longer a gate.
85
+ const isError = computed(() => state ? state._tag === 'error' : status === 'error')
86
+ const isEmpty = computed(() => state ? state._tag === 'empty' : status === 'success' && empty)
87
+ const failure = computed(() => toWidgetFailure(state?._tag === 'error' ? state.error : error))
88
+ const data = computed(() => state?._tag === 'data' ? state.data : undefined)
89
+ // A refresh failed while an earlier answer is still on screen. The content
90
+ // stays and a notice says the refresh failed.
91
+ const refreshFailure = computed(() => state?._tag === 'data' && state.refreshError ? WIDGET_FAILURE_COPY[toWidgetFailure(state.refreshError)._tag] : null)
92
+ const failureCopy = computed(() => WIDGET_FAILURE_COPY[failure.value._tag])
93
+ </script>
94
+
95
+ <template>
96
+ <div data-ui="UiWidgetState" :aria-busy="isLoading">
97
+ <slot v-if="isLoading" name="loading">
98
+ <span class="sr-only" role="status" aria-live="polite">Loading…</span>
99
+ <UiSkeleton v-if="skeletonType === 'text'" :lines="skeletonLines" />
100
+ <div v-else-if="skeletonType === 'bar'" class="flex items-end gap-1 h-40">
101
+ <UiSkeleton v-for="i in skeletonLines" :key="i" type="bar" :index="i" />
102
+ </div>
103
+ <UiSkeleton v-else type="circle" :base="skeletonLines" />
104
+ </slot>
105
+ <slot v-else-if="isError" name="error" :failure="failure" :retry="() => emit('retry')">
106
+ <div class="flex flex-col items-center justify-center py-8 px-4 text-center" role="alert">
107
+ <div class="size-10 rounded-xl bg-error/10 flex items-center justify-center mb-3">
108
+ <UiIcon name="caution" class="size-5 text-error" aria-hidden="true" />
109
+ </div>
110
+ <p class="text-sm font-medium text-default mb-1">
111
+ {{ failureCopy.title }}
112
+ </p>
113
+ <p class="text-xs text-muted mb-3 max-w-xs text-balance">
114
+ {{ failureCopy.body }}
115
+ </p>
116
+ <UiButton v-if="failureCopy.retryable" size="xs" purpose="secondary" icon="refresh" @click="emit('retry')">
117
+ Retry
118
+ </UiButton>
119
+ </div>
120
+ </slot>
121
+ <slot v-else-if="isEmpty" name="empty">
122
+ <UiEmptyState :icon="emptyIcon" :title="emptyTitle" :description="emptyMessage" compact />
123
+ </slot>
124
+ <template v-else>
125
+ <div
126
+ v-if="refreshFailure"
127
+ role="alert"
128
+ data-ui="UiWidgetState-refresh-error"
129
+ class="mb-3 flex items-start gap-2.5 rounded-lg border border-default px-3 py-2"
130
+ >
131
+ <UiIcon name="caution" class="mt-0.5 size-4 shrink-0 text-error" aria-hidden="true" />
132
+ <p class="min-w-0 flex-1 text-sm text-muted">
133
+ <span class="font-medium text-default">{{ refreshFailure.title }}.</span> {{ refreshFailure.body }}
134
+ </p>
135
+ <UiButton v-if="refreshFailure.retryable" purpose="secondary" size="xs" icon="refresh" @click="emit('retry')">
136
+ Retry
137
+ </UiButton>
138
+ </div>
139
+ <slot :data="data" />
140
+ </template>
141
+ <slot v-if="isError && retainContentOnError" :data="data" />
142
+ </div>
143
+ </template>
@@ -0,0 +1,48 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import type { RouteLocationRaw } from 'vue-router'
3
+ import { inject } from 'vue'
4
+
5
+ /**
6
+ * Controls for UiAppShell's mobile nav drawer, provided by the shell to slot
7
+ * content rendered inside it. Absent a provider this no-ops, so a consumer
8
+ * stays renderable outside the shell (sandbox pages, tests).
9
+ */
10
+ export interface UiNavDrawer {
11
+ /**
12
+ * Keep the drawer open across the navigation to `target`. Arms exactly one
13
+ * route change: landing on `target` keeps the drawer open, any other route
14
+ * change closes it as usual. For in-drawer pane switches (the sidebar mode
15
+ * tabs) — not for links, which navigate and close.
16
+ */
17
+ keepOpenFor: (target: RouteLocationRaw) => void
18
+ }
19
+
20
+ export const uiNavDrawerKey: InjectionKey<UiNavDrawer> = Symbol('ui-nav-drawer')
21
+
22
+ const noop: UiNavDrawer = { keepOpenFor: () => {} }
23
+
24
+ export function useNavDrawer(): UiNavDrawer {
25
+ return inject(uiNavDrawerKey, noop)
26
+ }
27
+
28
+ /**
29
+ * Pure core of the drawer's close-on-navigation rule. The shell consults the
30
+ * decider on EVERY route change (its watcher keys on fullPath) so the arm is
31
+ * consumed the first time the route moves, whatever moves it. The drawer then
32
+ * closes only when the route path changed and the change was not the exact
33
+ * navigation an in-drawer pane switch armed.
34
+ */
35
+ export function navDrawerCloseDecider() {
36
+ let armedFullPath: string | null = null
37
+ return {
38
+ arm(fullPath: string) {
39
+ armedFullPath = fullPath
40
+ },
41
+ /** Returns whether the drawer should close for this route change. */
42
+ shouldCloseOnRouteChange(fullPath: string): boolean {
43
+ const keep = armedFullPath === fullPath
44
+ armedFullPath = null
45
+ return !keep
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ import type { RouteLocationRaw } from 'vue-router'
3
+ import type { RelatedFixRow } from './UiRelatedFixes.vue'
4
+ import { computed } from 'vue'
5
+ import { RailPanel, UiButton, UiChip, UiIcon, UiRelatedFixes, UiSyncDot } from '#components'
6
+
7
+ export interface ActionRailSuggestion {
8
+ id: string
9
+ action: string
10
+ label: string
11
+ sourceLabel?: string
12
+ icon?: string
13
+ }
14
+
15
+ const {
16
+ rows,
17
+ suggestions,
18
+ to,
19
+ loaded = false,
20
+ failed = false,
21
+ title = 'Do next',
22
+ contextLabel,
23
+ icon = 'sparkle',
24
+ moreCount = 0,
25
+ emptyLabel,
26
+ loadingLabel = 'Checking next actions...',
27
+ errorLabel = 'Could not load next actions.',
28
+ hideWhenEmpty = true,
29
+ } = defineProps<{
30
+ rows: RelatedFixRow[]
31
+ suggestions: ActionRailSuggestion[]
32
+ to: RouteLocationRaw
33
+ loaded?: boolean
34
+ failed?: boolean
35
+ title?: string
36
+ contextLabel?: string
37
+ icon?: string
38
+ moreCount?: number
39
+ emptyLabel?: string
40
+ loadingLabel?: string
41
+ errorLabel?: string
42
+ hideWhenEmpty?: boolean
43
+ }>()
44
+
45
+ const emit = defineEmits<{
46
+ select: [row: RelatedFixRow]
47
+ retry: []
48
+ }>()
49
+
50
+ const status = computed(() => failed ? 'error' : loaded ? 'success' : 'pending')
51
+ const empty = computed(() => !rows.length && !suggestions.length)
52
+ const showViewAll = computed(() => rows.length > 0 || moreCount > 0)
53
+ const visibleSuggestionCount = computed(() => Math.min(suggestions.length, 4))
54
+ const visibleCount = computed(() => rows.length + visibleSuggestionCount.value)
55
+ const totalCount = computed(() => rows.length + suggestions.length + moreCount)
56
+ const countLabel = computed(() => {
57
+ if (!totalCount.value)
58
+ return null
59
+ return totalCount.value > visibleCount.value ? `${visibleCount.value}+` : visibleCount.value
60
+ })
61
+ const showGroupLabels = computed(() => rows.length > 0 && suggestions.length > 0)
62
+ </script>
63
+
64
+ <template>
65
+ <RailPanel
66
+ :title="title"
67
+ :count="countLabel"
68
+ :icon="icon"
69
+ :status="status"
70
+ :empty="empty"
71
+ :empty-label="emptyLabel"
72
+ :loading-label="loadingLabel"
73
+ :error-label="errorLabel"
74
+ :hide-when-empty="hideWhenEmpty"
75
+ >
76
+ <template #meta>
77
+ <UiChip
78
+ v-if="contextLabel"
79
+ purpose="tag"
80
+ size="xs"
81
+ icon="target"
82
+ >
83
+ {{ contextLabel }}
84
+ </UiChip>
85
+ <UiButton
86
+ v-if="showViewAll"
87
+ label="View all"
88
+ size="xs"
89
+ purpose="quiet"
90
+ trailing-icon="next"
91
+ :to="to"
92
+ />
93
+ </template>
94
+
95
+ <template #error>
96
+ <div class="px-4 py-6 text-sm text-muted">
97
+ <p>{{ errorLabel }}</p>
98
+ <UiButton label="Retry" icon="refresh" size="xs" purpose="quiet" class="mt-2" @click="emit('retry')" />
99
+ </div>
100
+ </template>
101
+
102
+ <div class="space-y-2 p-2">
103
+ <div v-if="rows.length" class="space-y-1">
104
+ <div v-if="showGroupLabels" class="flex items-center gap-2 px-1 pt-0.5">
105
+ <UiChip purpose="count" size="xs" tabular>
106
+ {{ rows.length }}
107
+ </UiChip>
108
+ <span class="text-mini font-medium uppercase text-muted">Queued work</span>
109
+ </div>
110
+
111
+ <UiRelatedFixes
112
+ :rows="rows"
113
+ :to="to"
114
+ loaded
115
+ :more-count="moreCount"
116
+ :show-header="false"
117
+ @select="emit('select', $event)"
118
+ />
119
+ </div>
120
+
121
+ <div v-if="suggestions.length" class="space-y-1">
122
+ <div v-if="showGroupLabels" class="flex items-center justify-between gap-2 px-1 pt-1">
123
+ <UiSyncDot status="syncing" label="Fresh signals" size="1.5" />
124
+ <UiChip purpose="count" size="xs" tabular>
125
+ {{ suggestions.length }}
126
+ </UiChip>
127
+ </div>
128
+
129
+ <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default bg-default/60">
130
+ <li
131
+ v-for="suggestion in suggestions.slice(0, 4)"
132
+ :key="suggestion.id"
133
+ class="flex items-start gap-2.5 px-3 py-2.5"
134
+ >
135
+ <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md border border-default bg-elevated text-dimmed">
136
+ <UiIcon :name="suggestion.icon ?? 'sparkle'" class="size-3.5" />
137
+ </span>
138
+ <span class="min-w-0 flex-1 space-y-1">
139
+ <span class="block text-sm text-default">
140
+ <span class="font-medium text-highlighted">{{ suggestion.action }}</span>
141
+ <span class="text-muted"> · {{ suggestion.label }}</span>
142
+ </span>
143
+ <UiChip purpose="tag" size="xs" icon="activity">
144
+ {{ suggestion.sourceLabel ?? 'Recent activity' }}
145
+ </UiChip>
146
+ </span>
147
+ </li>
148
+ </ul>
149
+ </div>
150
+ </div>
151
+ </RailPanel>
152
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts" generic="T extends string = string">
2
+ import type { UiTabItem, UiTabLink } from '../element/UiTabs.vue'
3
+ import UiTabs from '../element/UiTabs.vue'
4
+
5
+ /**
6
+ * A visible, counted set of related facets. `UiTabs` keeps ownership of each
7
+ * segmented run's selection, keyboard, focus, and horizontal overflow behavior;
8
+ * this primitive owns the repeated group-label layout and count presentation.
9
+ */
10
+ export interface FacetStripItem<T extends string = string> extends UiTabItem<T> {
11
+ count: string | number
12
+ magnitude?: number
13
+ }
14
+
15
+ export interface FacetStripGroup<T extends string = string> {
16
+ label: string
17
+ items: FacetStripItem<T>[]
18
+ }
19
+
20
+ const { groups, ariaLabel = 'Facets', wrapOnMobile = false } = defineProps<{
21
+ groups: FacetStripGroup<T>[]
22
+ ariaLabel?: string
23
+ wrapOnMobile?: boolean
24
+ }>()
25
+
26
+ const model = defineModel<T>({ required: true })
27
+
28
+ function itemCount(link: UiTabLink | UiTabItem<T>): string | number | undefined {
29
+ return 'value' in link ? (link as FacetStripItem<T>).count : undefined
30
+ }
31
+
32
+ function itemMagnitude(link: UiTabLink | UiTabItem<T>): number | undefined {
33
+ if (!('value' in link))
34
+ return undefined
35
+ const magnitude = (link as FacetStripItem<T>).magnitude
36
+ return typeof magnitude === 'number' && Number.isFinite(magnitude)
37
+ ? Math.min(100, Math.max(0, magnitude))
38
+ : undefined
39
+ }
40
+
41
+ function magnitudeStyle(link: UiTabLink | UiTabItem<T>): Record<string, string> | undefined {
42
+ const magnitude = itemMagnitude(link)
43
+ if (magnitude == null)
44
+ return undefined
45
+ return { '--facet-magnitude': `${magnitude}%` }
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <div
51
+ data-ui="FacetStrip"
52
+ role="group"
53
+ :aria-label="ariaLabel"
54
+ class="grid gap-2"
55
+ >
56
+ <div
57
+ v-for="group in groups"
58
+ :key="group.label"
59
+ class="grid min-w-0 gap-1 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center sm:gap-2"
60
+ >
61
+ <span class="text-sm font-strong text-muted">{{ group.label }}</span>
62
+ <div class="min-w-0">
63
+ <UiTabs
64
+ v-model="model"
65
+ class="w-full!"
66
+ :class="wrapOnMobile ? 'max-sm:flex-wrap! max-sm:overflow-visible!' : ''"
67
+ kind="subnav"
68
+ :items="group.items"
69
+ :aria-label="`${group.label}, ${ariaLabel}`"
70
+ >
71
+ <template #trailing="{ link }">
72
+ <span class="relative isolate overflow-hidden rounded-sm px-1 numerals-display tabular-nums">
73
+ <span
74
+ v-if="(itemMagnitude(link) ?? 0) > 0"
75
+ class="facet-strip__magnitude absolute inset-y-0 left-0 -z-10 rounded-sm bg-accented"
76
+ :data-facet-magnitude="itemMagnitude(link)"
77
+ :style="magnitudeStyle(link)"
78
+ aria-hidden="true"
79
+ />
80
+ <span>{{ itemCount(link) }}</span>
81
+ </span>
82
+ </template>
83
+ </UiTabs>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .facet-strip__magnitude {
91
+ width: var(--facet-magnitude);
92
+ }
93
+ </style>
@@ -0,0 +1,37 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue'
3
+ /**
4
+ * Bar-filled metric cell: a relative container with an absolute-positioned bar
5
+ * fill behind slotted content.
6
+ *
7
+ * Keep `barClass` opacity at /10 or above — a /5 fill is invisible against the
8
+ * dashboard's near-black surface, and a bar you can't see isn't a bar.
9
+ */
10
+ const { percent, barClass = 'bg-muted/40' } = defineProps<{
11
+ /** Bar fill width as 0-100 */
12
+ percent: number
13
+ /** Tailwind bg class for the bar fill (e.g. from barColorMap or cwvBarColorMap) */
14
+ barClass?: string
15
+ }>()
16
+
17
+ const clampedPercent = computed(() => Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0)))
18
+ </script>
19
+
20
+ <template>
21
+ <div class="relative flex items-center gap-3 px-2 py-1">
22
+ <!-- The fill is presentation only. It stays static across filter changes so
23
+ magnitude does not appear to morph between unrelated populations. -->
24
+ <div
25
+ v-if="clampedPercent > 0"
26
+ class="absolute inset-y-0 left-0 w-full overflow-hidden rounded-md"
27
+ >
28
+ <div
29
+ data-bar-fill
30
+ class="h-full w-full origin-left"
31
+ :class="barClass"
32
+ :style="{ transform: `scaleX(${clampedPercent / 100})` }"
33
+ />
34
+ </div>
35
+ <slot />
36
+ </div>
37
+ </template>