@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,132 @@
1
+ <script lang="ts" setup generic="T extends Record<string, unknown>">
2
+ import { computed } from 'vue'
3
+ import { categoricalVizColors } from '../../composables/dataVizColors'
4
+ import { formatGroupedNumber } from '../../composables/formatting'
5
+ import { roundedShareLabel, roundedShares } from '../../utils/number'
6
+
7
+ // Donut/pie chart for composition — share of a whole (device split, brand vs non-brand,
8
+ // country share). SVG arc ring + a legend with value and percentage per slice. Generic +
9
+ // dependency-free, so any row array drives it.
10
+
11
+ const {
12
+ data,
13
+ labelKey,
14
+ valueKey,
15
+ size = 132,
16
+ colors = categoricalVizColors,
17
+ colorMap,
18
+ centerValue,
19
+ centerLabel,
20
+ label = 'Composition',
21
+ format,
22
+ } = defineProps<{
23
+ data: T[]
24
+ /** Row key for the slice label. */
25
+ labelKey: keyof T & string
26
+ /** Row key for the numeric value. */
27
+ valueKey: keyof T & string
28
+ /** Diameter in px. Default 132. */
29
+ size?: number
30
+ /** Ordered design-system chart colours. */
31
+ colors?: readonly string[]
32
+ /** Stable category identity colours keyed by the rendered label. */
33
+ colorMap?: Readonly<Record<string, string>>
34
+ /** Optional dominant takeaway rendered in the ring centre. */
35
+ centerValue?: string | number
36
+ /** Short label beneath the centre value. */
37
+ centerLabel?: string
38
+ /** Accessible caption for the exact semantic legend. */
39
+ label?: string
40
+ /** Format the value in the legend. */
41
+ format?: (value: number) => string
42
+ }>()
43
+
44
+ function num(v: unknown) {
45
+ const n = typeof v === 'number' ? v : Number(String(v).replace(/^"+|"+$/g, ''))
46
+ return Number.isFinite(n) ? n : 0
47
+ }
48
+
49
+ // Arc maths against a unit-100 circumference so stroke-dasharray reads as percentages.
50
+ const R = 15.9155 // circumference ≈ 100
51
+ const C = 2 * Math.PI * R
52
+ const palette = computed(() => colors.length ? colors : categoricalVizColors)
53
+ const categoryColors = computed(() => {
54
+ const labels = [...new Set(data.map(r => String(r[labelKey] ?? '')))].sort((a, b) => a.localeCompare(b))
55
+ return new Map(labels.map((label, index) => [
56
+ label,
57
+ colorMap?.[label] ?? palette.value[index % palette.value.length]!,
58
+ ]))
59
+ })
60
+
61
+ const segments = computed(() => {
62
+ const items = data
63
+ .map(r => ({ label: String(r[labelKey] ?? ''), value: num(r[valueKey]) }))
64
+ .filter(s => s.value > 0)
65
+ .sort((a, b) => b.value - a.value)
66
+ const total = items.reduce((sum, s) => sum + s.value, 0) || 1
67
+ // The legend column sums to 100 (largest remainder); the arcs keep exact shares.
68
+ const rounded = roundedShares(items.map(s => s.value))
69
+ let acc = 0
70
+ return items.map((s, i) => {
71
+ const pct = (s.value / total) * 100
72
+ const pctLabel = roundedShareLabel(rounded[i]!, s.value, items.length > 1)
73
+ const seg = { ...s, color: categoryColors.value.get(s.label) ?? palette.value[i % palette.value.length]!, pct, pctLabel, offset: acc }
74
+ acc += pct
75
+ return seg
76
+ })
77
+ })
78
+
79
+ function fmt(value: number): string {
80
+ return format ? format(value) : formatGroupedNumber(value)
81
+ }
82
+ </script>
83
+
84
+ <template>
85
+ <figure data-ui="UiDonutChart" class="flex items-center gap-4">
86
+ <div class="relative shrink-0" :style="{ width: `${size}px`, height: `${size}px` }">
87
+ <svg
88
+ :width="size"
89
+ :height="size"
90
+ viewBox="0 0 36 36"
91
+ class="-rotate-90"
92
+ aria-hidden="true"
93
+ focusable="false"
94
+ >
95
+ <circle cx="18" cy="18" :r="R" fill="none" stroke="var(--ui-bg-muted)" stroke-width="4" />
96
+ <circle
97
+ v-for="(s, i) in segments"
98
+ :key="i"
99
+ data-donut-segment
100
+ cx="18"
101
+ cy="18"
102
+ :r="R"
103
+ fill="none"
104
+ :stroke="s.color"
105
+ stroke-width="4"
106
+ :stroke-dasharray="`${(s.pct / 100) * C} ${C}`"
107
+ :stroke-dashoffset="`${-(s.offset / 100) * C}`"
108
+ />
109
+ </svg>
110
+ <div
111
+ v-if="centerValue != null || centerLabel"
112
+ data-donut-center
113
+ class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-4 text-center"
114
+ >
115
+ <span v-if="centerValue != null" class="numerals-display text-heading text-highlighted">{{ centerValue }}</span>
116
+ <span v-if="centerLabel" class="max-w-full truncate text-sm text-muted">{{ centerLabel }}</span>
117
+ </div>
118
+ </div>
119
+
120
+ <ul class="min-w-0 flex-1 flex flex-col gap-1 text-sm">
121
+ <li v-for="(s, i) in segments" :key="i" class="flex items-center gap-2">
122
+ <span class="size-2 shrink-0 rounded-full" :style="{ background: s.color }" aria-hidden="true" />
123
+ <span data-donut-label class="flex-1 truncate text-muted" :title="s.label">{{ s.label }}</span>
124
+ <span class="tabular-nums text-highlighted">{{ fmt(s.value) }}</span>
125
+ <span class="w-9 text-right tabular-nums text-muted">{{ s.pctLabel }}</span>
126
+ </li>
127
+ </ul>
128
+ <figcaption class="sr-only">
129
+ {{ label }}
130
+ </figcaption>
131
+ </figure>
132
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import type { NavigationDestination as RouteLocationRaw } from '../../shared/navigation'
3
+ import { computed } from 'vue'
4
+ import { NuxtLink, UiButton, UiIcon, UiSkeleton } from '#components'
5
+
6
+ /**
7
+ * UiDoorwayColumn — the card shell of a doorway column, extracted from the site
8
+ * overview's Next Actions / Recent Activity pair so a feature page can render
9
+ * the SAME column instead of a look-alike.
10
+ *
11
+ * A doorway names its destination once and stops: the header carries the label
12
+ * and one "View all" link, and the body carries one-line rows. The chrome that
13
+ * Columns sit inside a `UiDoorwayGrid`, which owns the single-column collapse
14
+ * and the hide-the-row-when-every-column-is-empty rule off the
15
+ * `data-doorway-empty` stamp below. Never hand-roll the `md:grid-cols-2` around
16
+ * a pair of columns — that grid is what leaves a gated-off column's half of the
17
+ * page blank.
18
+ *
19
+ * The chrome that
20
+ * does NOT belong here (and is what this shell exists to keep out) is the rail
21
+ * grammar it replaces on feature pages — count chips, context chips, group
22
+ * headings, severity bars, per-row lead-fact lines, age tails, and an "N more"
23
+ * footer. Depth is the owning route's job, which is what the header link is for.
24
+ */
25
+ const {
26
+ title,
27
+ viewAllTo = null,
28
+ viewAllLabel = 'View all',
29
+ status = 'success',
30
+ empty = false,
31
+ emptyLabel = null,
32
+ errorLabel = 'Could not load this column.',
33
+ skeletonRows = 4,
34
+ } = defineProps<{
35
+ title: string
36
+ /** The owning route this column is a doorway to. Omit to render no link. */
37
+ viewAllTo?: RouteLocationRaw | null
38
+ viewAllLabel?: string
39
+ status?: 'pending' | 'success' | 'error'
40
+ empty?: boolean
41
+ /** Honest empty copy. Rendered only when `empty` and loaded. */
42
+ emptyLabel?: string | null
43
+ errorLabel?: string
44
+ skeletonRows?: number
45
+ }>()
46
+
47
+ const emit = defineEmits<{ retry: [] }>()
48
+
49
+ /**
50
+ * The stamp `UiDoorwayGrid` reads to decide whether the whole row has anything
51
+ * to say. It means "loaded, and there is nothing here" — never "still loading"
52
+ * and never "failed", both of which are content a reader needs.
53
+ */
54
+ const doorwayEmpty = computed(() => (status === 'success' && empty ? '' : undefined))
55
+ </script>
56
+
57
+ <template>
58
+ <section
59
+ class="overflow-hidden rounded-xl border border-default bg-elevated/20"
60
+ :aria-label="title"
61
+ :aria-busy="status === 'pending' ? 'true' : undefined"
62
+ :data-doorway-empty="doorwayEmpty"
63
+ >
64
+ <div class="flex min-h-11 items-center gap-2 border-b border-default/60 px-4">
65
+ <span class="text-label">{{ title }}</span>
66
+ <slot name="header-meta" />
67
+ <NuxtLink
68
+ v-if="viewAllTo"
69
+ :to="viewAllTo"
70
+ class="ml-auto flex min-h-11 items-center text-xs text-muted outline-none transition-colors hover:text-default focus-visible:ring-2 focus-visible:ring-primary sm:min-h-0"
71
+ >
72
+ {{ viewAllLabel }}
73
+ </NuxtLink>
74
+ </div>
75
+
76
+ <template v-if="status === 'pending'">
77
+ <span class="sr-only" role="status" aria-live="polite">Loading {{ title }}</span>
78
+ <ul aria-hidden="true" class="divide-y divide-default/60">
79
+ <li v-for="n in skeletonRows" :key="n" class="flex min-w-0 items-center gap-2.5 px-4 py-2">
80
+ <UiSkeleton type="circle" :base="16" :index="n" class="shrink-0" />
81
+ <UiSkeleton type="text" :base="130" :range="90" :index="n" class="!h-3" />
82
+ </li>
83
+ </ul>
84
+ </template>
85
+
86
+ <div v-else-if="status === 'error'" class="flex min-h-11 items-center gap-2.5 px-4 py-2" role="alert">
87
+ <UiIcon name="caution" class="size-4 shrink-0 text-error" aria-hidden="true" />
88
+ <p class="min-w-0 flex-1 text-sm text-muted">
89
+ {{ errorLabel }}
90
+ </p>
91
+ <UiButton purpose="secondary" size="xs" icon="refresh" @click="emit('retry')">
92
+ Retry
93
+ </UiButton>
94
+ </div>
95
+
96
+ <template v-else-if="empty">
97
+ <slot name="empty">
98
+ <p v-if="emptyLabel" class="px-4 py-3 text-sm text-muted">
99
+ {{ emptyLabel }}
100
+ </p>
101
+ </slot>
102
+ </template>
103
+
104
+ <ul v-else class="divide-y divide-default/60">
105
+ <slot />
106
+ </ul>
107
+ </section>
108
+ </template>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * UiDoorwayGrid — the row that holds `UiDoorwayColumn`s and owns the two rules
4
+ * a page must not have to restate: how many columns actually rendered, and what
5
+ * to do when none of them has anything to say.
6
+ *
7
+ * Both rules are CSS, deliberately. A page gates a column with `v-if`
8
+ * (`allowLiveQueries`, `fullMonitoringAvailable`), and a column only learns it
9
+ * is empty after its query settles — so a JS count would have to be a
10
+ * registration protocol that resolves AFTER the parent's first render, which
11
+ * means a wrong class on the server and a hydration patch on the client.
12
+ * Counting slots is worse still: a slot is "present" even when everything
13
+ * inside it is `v-if`'d away. The selectors below read the DOM, which is the
14
+ * only thing that knows the truth, at the moment it is true.
15
+ *
16
+ * - `:only-child` — a gated-off column leaves a comment placeholder, not an
17
+ * element, so the surviving column is a genuine only-child and spans the full
18
+ * width instead of sitting in the left half of a 2-col grid. (Requires each
19
+ * column to be a single-root component; `UiDoorwayColumn` is.)
20
+ * - `:has(> :not([data-doorway-empty]))` — `UiDoorwayColumn` stamps
21
+ * `data-doorway-empty` on itself once it has LOADED and has no rows. If no
22
+ * child lacks that stamp, every column is empty and the whole row hides: a
23
+ * column stays rendered while its sibling has content (the pair teaches where
24
+ * activity will appear), but a page with nothing to say does not show two
25
+ * boxes saying nothing. Pending and error columns are never stamped, so the
26
+ * row never flickers away while loading.
27
+ */
28
+ </script>
29
+
30
+ <template>
31
+ <div data-doorway-grid class="grid items-start gap-5 md:grid-cols-2">
32
+ <slot />
33
+ </div>
34
+ </template>
35
+
36
+ <style>
37
+ /* Deliberately unscoped: these rules are the component's behaviour, and the
38
+ last one has to reach the WRAPPER, which a scope attribute wouldn't match. */
39
+
40
+ /* The only column left (its sibling gated off) takes the whole width. */
41
+ [data-doorway-grid] > *:only-child {
42
+ grid-column: 1 / -1;
43
+ }
44
+
45
+ /* No column has content: the row says nothing, so it isn't drawn. */
46
+ [data-doorway-grid]:not(:has(> *:not([data-doorway-empty]))) {
47
+ display: none;
48
+ }
49
+
50
+ /* …and neither is the page zone that exists only to hold it — a hidden row
51
+ would otherwise still spend a section gap on nothing. `:only-child` keeps
52
+ this off a zone that holds other content beside the row. Written as two
53
+ sibling conditions rather than one nested test because `:has()` inside
54
+ `:has()` is invalid and silently drops the whole rule. */
55
+ :where(div):has(> [data-doorway-grid]:only-child):not(:has([data-doorway-grid] > *:not([data-doorway-empty]))) {
56
+ display: none;
57
+ }
58
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import type { RichSegment } from '../../../shared/rich-text'
3
+ import type { NavigationDestination as RouteLocationRaw } from '../../shared/navigation'
4
+ import { computed } from 'vue'
5
+ import { NuxtLink, UiFavicon, UiIcon, UiRichText } from '#components'
6
+
7
+ /**
8
+ * UiDoorwayRow — one line inside a `UiDoorwayColumn`: source identity, the
9
+ * typed headline, an optional trailing fact, a chevron. Nothing else. The row
10
+ * states what is next; the surface it opens owns the evidence.
11
+ *
12
+ * A row that opens a modal is a `<button aria-haspopup="dialog">`, never a
13
+ * `NuxtLink` carrying an `@click`. Vue's `mergeProps` appends a parent's
14
+ * fallthrough click AFTER `RouterLink`'s own `navigate` handler, so the router
15
+ * pushes first and the modal opens onto a page that is already leaving. The
16
+ * click listener here is bound ONLY on the button branch, so a link row can
17
+ * never grow that bug by accident.
18
+ */
19
+ const {
20
+ label,
21
+ segments = null,
22
+ glyph = null,
23
+ faviconDomain = null,
24
+ to = null,
25
+ interactive = true,
26
+ } = defineProps<{
27
+ /** Plain-text row title. Always required — it is the accessible name. */
28
+ label: string
29
+ /** Typed headline; falls back to `label` when the producer has no segments. */
30
+ segments?: RichSegment[] | null
31
+ glyph?: string | null
32
+ /**
33
+ * Lead with this host's favicon instead of the glyph. Use it where the rows
34
+ * name external entities (a citing site, a platform) — a column of identical
35
+ * kind glyphs carries no signal, the favicons do.
36
+ */
37
+ faviconDomain?: string | null
38
+ /** Navigation destination. Omit for a modal trigger. */
39
+ to?: RouteLocationRaw | null
40
+ /**
41
+ * A read-only log line (no destination, no modal) renders as a plain row: no
42
+ * hover cue, no chevron, no focus ring. Advertising a click a row cannot
43
+ * honour is the defect this flag exists to prevent.
44
+ */
45
+ interactive?: boolean
46
+ }>()
47
+
48
+ const emit = defineEmits<{ select: [] }>()
49
+
50
+ const kind = computed<'link' | 'button' | 'static'>(() => {
51
+ if (!interactive)
52
+ return 'static'
53
+ return to ? 'link' : 'button'
54
+ })
55
+ </script>
56
+
57
+ <template>
58
+ <li>
59
+ <component
60
+ :is="kind === 'link' ? NuxtLink : kind === 'button' ? 'button' : 'div'"
61
+ :to="kind === 'link' ? to! : undefined"
62
+ :type="kind === 'button' ? 'button' : undefined"
63
+ :aria-haspopup="kind === 'button' ? 'dialog' : undefined"
64
+ :aria-label="kind === 'static' ? undefined : label"
65
+ class="flex min-h-11 w-full min-w-0 items-center gap-2.5 px-4 py-1.5 text-left"
66
+ :class="kind === 'static'
67
+ ? ''
68
+ : 'cursor-pointer outline-none transition-colors hover:bg-elevated/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary sm:min-h-0'"
69
+ v-on="kind === 'button' ? { click: () => emit('select') } : {}"
70
+ >
71
+ <slot name="leading">
72
+ <UiFavicon v-if="faviconDomain" :domain="faviconDomain" :size="14" decorative class="shrink-0 rounded-sm" />
73
+ <UiIcon v-else-if="glyph" :name="glyph" class="size-3.5 shrink-0 text-dimmed" aria-hidden="true" />
74
+ </slot>
75
+ <span class="min-w-0 flex-1 truncate text-sm leading-5 text-default">
76
+ <UiRichText v-if="segments?.length" :segments="segments" size="sm" truncate />
77
+ <template v-else>{{ label }}</template>
78
+ </span>
79
+ <slot name="trailing" />
80
+ <UiIcon v-if="kind !== 'static'" name="chevron-right" class="size-3 shrink-0 text-dimmed" aria-hidden="true" />
81
+ </component>
82
+ </li>
83
+ </template>
@@ -0,0 +1,261 @@
1
+ <script setup lang="ts">
2
+ import type { VNodeChild } from 'vue'
3
+ import type { FactItem } from './UiFactsGrid.vue'
4
+ import { useElementHover } from '@vueuse/core'
5
+ import { useTemplateRef } from 'vue'
6
+ import { ClientOnly, UiCard, UiFactsGrid, UiSparkline, UiStat } from '#components'
7
+ /**
8
+ * UiEntitySummary — the identity header for a machine-object DETAIL page
9
+ * (a competitor, a Lighthouse scan, an alert delivery). One hero metric +
10
+ * sparkline on the left, a quiet facts grid on the right, in a single card.
11
+ *
12
+ * NOT a data-page hero moment — the "UiMetricsRow OR UiStats cards, never both"
13
+ * gate is untouched. This is an *entity header*; it lives on detail pages that
14
+ * aren't in the page-hero blueprint at all.
15
+ *
16
+ * Squint test: the hero column dominates by SIZE + WEIGHT (lg value); facts
17
+ * recede (text-label / text-sm). The column divider is a NEUTRAL rule
18
+ * (--ui-border) — never a semantic / accent stripe (Avoid: left-border cliché).
19
+ *
20
+ * It's a thin composition of UiStat (hero) + UiSparkline + UiFactsGrid — keep it
21
+ * that way. Domain-shaped props stay at the call site; this only knows
22
+ * {heading, metric, facts}.
23
+ */
24
+
25
+ const {
26
+ heading,
27
+ title,
28
+ tooltip,
29
+ tooltipDescription,
30
+ value,
31
+ suffix,
32
+ caption,
33
+ trend,
34
+ trendSuffix,
35
+ invertTrend,
36
+ sparkline,
37
+ sparklineColor,
38
+ facts = [],
39
+ factColumns = 3,
40
+ status,
41
+ emphasis = true,
42
+ divider = 'bleed',
43
+ stackHeaderOnMobile = false,
44
+ } = defineProps<{
45
+ /** Entity identity line (e.g. a hostname). Overridable via #heading. */
46
+ heading?: string
47
+ /** Hero metric label (e.g. "Organic traffic"). */
48
+ title?: string
49
+ /** Contextual help — renders a (?) on the hero metric label. */
50
+ tooltip?: string
51
+ tooltipDescription?: string
52
+ value?: string | number | null
53
+ suffix?: string
54
+ /** Period / context line under the metric (e.g. "Last 12 months"). */
55
+ caption?: string
56
+ /** Full caption shown on hover when the visible caption is shortened. */
57
+ captionTitle?: string
58
+ trend?: number | null
59
+ trendSuffix?: string
60
+ invertTrend?: boolean
61
+ /** Hero sparkline series (metric identity color via sparklineColor). */
62
+ sparkline?: number[] | Record<string, number | string>[]
63
+ sparklineColor?: string
64
+ facts?: FactItem[]
65
+ factColumns?: 2 | 3
66
+ /** Threshold state — surfaces UiStat's status chip beside the metric label. */
67
+ status?: 'crisis' | 'warning' | 'good'
68
+ /**
69
+ * Separator treatment between hero / facts and under the header:
70
+ * - `bleed` crisp hairline bled to the card edges — structural ⊤ (default)
71
+ * - `inset` same full-bleed geometry, carved (line + highlight offset)
72
+ * - `fade` muted hairline that fades before the padding edges
73
+ * - `none` no rule — spacing carries the split (Linear-clean)
74
+ * `bleed` / `inset` run the header rule full card width and the column rule
75
+ * from that rule down to the card's bottom edge.
76
+ */
77
+ divider?: 'fade' | 'none' | 'inset' | 'bleed'
78
+ /**
79
+ * The one sanctioned card lift (--elevation-emphasis + primary-tinted top
80
+ * bevel). Defaults on: the entity header is a detail page's single hero
81
+ * surface, so it's the sanctioned "max one per page" emphasis. Turn off when
82
+ * the page already spends its emphasis elsewhere.
83
+ */
84
+ emphasis?: boolean
85
+ /** Stack identity and actions on narrow screens when actions need full labels. */
86
+ stackHeaderOnMobile?: boolean
87
+ }>()
88
+
89
+ defineSlots<{
90
+ /** Override the identity line (e.g. to host a UiOverflowCount / favicon). */
91
+ heading?: () => VNodeChild
92
+ /** Top-right actions (eject menu, refresh, links). */
93
+ actions?: () => VNodeChild
94
+ /** Override the hero column entirely. */
95
+ hero?: () => VNodeChild
96
+ /** Override the facts column entirely. */
97
+ facts?: () => VNodeChild
98
+ }>()
99
+ // Card hover drives the sparkline's draw-in tracer — the sanctioned spark-joy
100
+ // micro-moment (DESIGN: hover tracer is the one exception to animate-once).
101
+ const cardEl = useTemplateRef<HTMLElement>('cardEl')
102
+ const cardHovered = useElementHover(cardEl)
103
+ </script>
104
+
105
+ <template>
106
+ <UiCard variant="subtle" :emphasis="emphasis" :class="`divider-${divider}`" data-ui="UiEntitySummary">
107
+ <!-- Identity + actions header (optional). Separator is a faded hairline
108
+ (mask idiom) so it never hard-hits the card padding edges. -->
109
+ <div
110
+ v-if="heading || $slots.heading || $slots.actions"
111
+ class="entity-hrule relative mb-5 flex justify-between gap-3 pb-4"
112
+ :class="stackHeaderOnMobile ? 'flex-col items-stretch sm:flex-row sm:items-center' : 'items-center'"
113
+ >
114
+ <slot name="heading">
115
+ <span class="truncate text-sm font-medium text-highlighted">{{ heading }}</span>
116
+ </slot>
117
+ <div
118
+ v-if="$slots.actions"
119
+ class="flex shrink-0 items-center gap-2"
120
+ :class="stackHeaderOnMobile ? 'w-full justify-end sm:w-auto' : ''"
121
+ >
122
+ <slot name="actions" />
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Hero (left, dominant) · facts (right, recede). Neutral column rule.
127
+ items-stretch lets the sparkline flex-fill the hero column so it
128
+ anchors the metric instead of floating; the facts column top-aligns. -->
129
+ <div class="grid items-stretch gap-y-6 lg:grid-cols-2 lg:gap-x-0">
130
+ <slot name="hero">
131
+ <div ref="cardEl" class="flex min-w-0 flex-col gap-1 lg:pr-6">
132
+ <UiStat
133
+ size="lg"
134
+ :title="title"
135
+ :tooltip="tooltip"
136
+ :tooltip-description="tooltipDescription"
137
+ :value="value"
138
+ value-class="text-highlighted"
139
+ :suffix="suffix"
140
+ :trend="trend"
141
+ :trend-suffix="trendSuffix"
142
+ :invert-trend="invertTrend"
143
+ :status="status"
144
+ trend-colored
145
+ />
146
+ <p v-if="caption" class="text-sm text-muted" :title="captionTitle">
147
+ {{ caption }}
148
+ </p>
149
+ <ClientOnly v-if="sparkline?.length">
150
+ <div class="mt-2 min-h-[76px] flex-1">
151
+ <UiSparkline
152
+ :data="sparkline"
153
+ :color="sparklineColor"
154
+ :inverted="invertTrend"
155
+ :stroke-width="2"
156
+ interactive
157
+ :hovered="cardHovered"
158
+ area
159
+ width="100%"
160
+ height="100%"
161
+ preserve-aspect-ratio="none"
162
+ class="block size-full"
163
+ />
164
+ </div>
165
+ </ClientOnly>
166
+ </div>
167
+ </slot>
168
+
169
+ <div class="entity-vrule relative min-w-0 lg:pl-6">
170
+ <slot name="facts">
171
+ <UiFactsGrid :facts="facts" :columns="factColumns" />
172
+ </slot>
173
+ </div>
174
+ </div>
175
+ </UiCard>
176
+ </template>
177
+
178
+ <style scoped>
179
+ /* Separator geometry (shared). Color / mask / depth is set per `divider` mode
180
+ below so the same positions read four ways. A hard, edge-to-edge, full-weight
181
+ 1px line is the cheap tell — every mode here softens or structures that. */
182
+ .entity-hrule::after {
183
+ content: '';
184
+ position: absolute;
185
+ inset-inline: 0;
186
+ bottom: 0;
187
+ height: 1px;
188
+ }
189
+
190
+ /* Vertical column rule — desktop only; on mobile the columns stack (gap-y). */
191
+ @media (min-width: 1024px) {
192
+ .entity-vrule::before {
193
+ content: '';
194
+ position: absolute;
195
+ left: 0;
196
+ top: 0.25rem;
197
+ bottom: 0.25rem;
198
+ width: 1px;
199
+ }
200
+ }
201
+
202
+ /* none — no rule; spacing carries the split. */
203
+ .divider-none .entity-hrule::after,
204
+ .divider-none .entity-vrule::before {
205
+ display: none;
206
+ }
207
+
208
+ /* fade — muted hairline that fades before the padding edges (mask idiom). */
209
+ .divider-fade .entity-hrule::after {
210
+ background: var(--ui-border-muted);
211
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 4%, #000 96%, transparent);
212
+ mask-image: linear-gradient(to right, transparent, #000 4%, #000 96%, transparent);
213
+ }
214
+ .divider-fade .entity-vrule::before {
215
+ background: var(--ui-border-muted);
216
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent);
217
+ mask-image: linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent);
218
+ }
219
+
220
+ /* bleed — crisp full-length hairline, edge to edge. Structured / Stripe-like. */
221
+ .divider-bleed .entity-hrule::after,
222
+ .divider-bleed .entity-vrule::before {
223
+ background: var(--ui-border);
224
+ }
225
+
226
+ /* inset — carved line: a hairline with a 1px light highlight offset below / to
227
+ the right, so the seam reads recessed (tactile depth, not a flat stroke). */
228
+ .divider-inset .entity-hrule::after {
229
+ background: var(--ui-border-accented);
230
+ box-shadow: 0 1px 0 var(--ui-bg-elevated);
231
+ }
232
+ .divider-inset .entity-vrule::before {
233
+ background: var(--ui-border-accented);
234
+ box-shadow: 1px 0 0 var(--ui-bg-elevated);
235
+ }
236
+
237
+ /* Full-bleed structural geometry (bleed + inset): the seams reach the card's
238
+ inner edges. --seam-pad tracks UiCard's body padding so the negative insets
239
+ land exactly on the border; the card's overflow-hidden clips any overshoot.
240
+ Header rule → full card width; column rule → from the header rule (mb-5) down
241
+ to the card's bottom edge (the Vercel ⊤). */
242
+ [data-ui='UiEntitySummary'] {
243
+ --seam-pad: 1rem;
244
+ }
245
+ @media (min-width: 640px) {
246
+ [data-ui='UiEntitySummary'] {
247
+ --seam-pad: var(--density-card-padding, 1.5rem);
248
+ }
249
+ }
250
+ .divider-bleed .entity-hrule::after,
251
+ .divider-inset .entity-hrule::after {
252
+ inset-inline: calc(-1 * var(--seam-pad));
253
+ }
254
+ @media (min-width: 1024px) {
255
+ .divider-bleed .entity-vrule::before,
256
+ .divider-inset .entity-vrule::before {
257
+ top: -1.25rem;
258
+ bottom: calc(-1 * var(--seam-pad));
259
+ }
260
+ }
261
+ </style>