@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,186 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
2
+ import { computed, toValue } from 'vue'
3
+ import { DISPLAY_LOCALE, parseReportingDay } from './formatting'
4
+
5
+ // Calendar-aware tick planning for time-series charts. Picks tick positions +
6
+ // label format based on the span:
7
+ // ≤ 10 days → every day, "Mon 12"
8
+ // ≤ 35 days → ~5 evenly-spaced ticks, "Jan 12"
9
+ // ≤ 120 days → every ~2 weeks (aligned to data), "Jan 12"
10
+ // > 120 days → first-of-month ticks, "Jan"; year suffix on Jan/first when span spans years
11
+ // Extracted from ProGraphGsc / ProGraphAnalytics / GraphIndexing, which had
12
+ // identical copies of this logic.
13
+
14
+ export interface ChartTickPlan {
15
+ indices: number[]
16
+ format: (date: Date, i: number, firstYear: number) => string
17
+ }
18
+
19
+ export interface UseChartTickPlanOptions {
20
+ /** Reactive ISO date strings (one per data point). */
21
+ dates: MaybeRefOrGetter<string[]>
22
+ /** Cap on tick count for long spans (default 14 — thins to ~12 if exceeded). */
23
+ maxTicks?: number
24
+ /**
25
+ * Reactive plot-area width in px (wire `useElementSize` on the chart wrap).
26
+ * The span heuristics assume a desktop-width plot; when a width is known,
27
+ * ticks whose estimated labels would collide are dropped — first and last
28
+ * always stay, so the consumers' inward-anchored edge labels keep marking
29
+ * the window ends. Unknown (0/undefined, e.g. SSR before measurement)
30
+ * keeps the pure span plan.
31
+ */
32
+ width?: MaybeRefOrGetter<number | undefined>
33
+ }
34
+
35
+ // Ticks label reporting-day buckets ("YYYY-MM-DD"), not instants — force UTC so
36
+ // the label never drifts with the viewer's timezone (cf. parseReportingDay, ADR-0082).
37
+ const weekdayDayFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { weekday: 'short', day: 'numeric', timeZone: 'UTC' })
38
+ const monthDayFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', timeZone: 'UTC' })
39
+ const monthFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', timeZone: 'UTC' })
40
+ const monthYearFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', year: '2-digit', timeZone: 'UTC' })
41
+
42
+ // Axis labels render at 11px; ~6.5px per glyph over-estimates the real advance
43
+ // slightly, so thinning leaves visible air instead of touching labels.
44
+ const LABEL_GLYPH_PX = 6.5
45
+ const MIN_LABEL_GAP_PX = 8
46
+
47
+ /**
48
+ * Greedy left-to-right thinning: a tick survives only when its label clears the
49
+ * previously kept one. Every consumer anchors the first tick inward (start) and
50
+ * the last (end), so those labels claim their full width on the inner side;
51
+ * middle labels claim half a width each side. The final tick always renders —
52
+ * middle ticks yield to it when the two would collide.
53
+ */
54
+ function fitTickIndices(
55
+ indices: number[],
56
+ labelWidth: (index: number) => number,
57
+ posOf: (index: number) => number,
58
+ ): number[] {
59
+ if (indices.length <= 2)
60
+ return indices.slice()
61
+ const fits = (a: number, b: number, aIsFirst: boolean, bIsLast: boolean): boolean => {
62
+ const aRight = posOf(a) + labelWidth(a) / (aIsFirst ? 1 : 2)
63
+ const bLeft = posOf(b) - labelWidth(b) / (bIsLast ? 1 : 2)
64
+ return bLeft - aRight >= MIN_LABEL_GAP_PX
65
+ }
66
+ const kept: number[] = [indices[0]!]
67
+ for (let k = 1; k < indices.length - 1; k++) {
68
+ const idx = indices[k]!
69
+ if (fits(kept.at(-1)!, idx, kept.length === 1, false))
70
+ kept.push(idx)
71
+ }
72
+ const last = indices.at(-1)!
73
+ while (kept.length > 1 && !fits(kept.at(-1)!, last, kept.length === 1, true))
74
+ kept.pop()
75
+ kept.push(last)
76
+ return kept
77
+ }
78
+
79
+ export function useChartTickPlan(opts: UseChartTickPlanOptions) {
80
+ const maxTicks = opts.maxTicks ?? 14
81
+
82
+ function spanPlan(dates: string[], len: number): ChartTickPlan {
83
+ if (len <= 1)
84
+ return { indices: [0], format: d => monthDayFmt.format(d) }
85
+ if (len <= 10)
86
+ return { indices: dates.map((_, i) => i), format: d => weekdayDayFmt.format(d) }
87
+ if (len <= 35) {
88
+ const step = Math.max(1, Math.ceil(len / 5))
89
+ const indices: number[] = []
90
+ for (let i = 0; i < len; i += step) indices.push(i)
91
+ if (indices.at(-1) !== len - 1)
92
+ indices.push(len - 1)
93
+ return { indices, format: d => monthDayFmt.format(d) }
94
+ }
95
+ if (len <= 120) {
96
+ // Anchor at index 0 (chart left edge) and step by 14 days. Earlier
97
+ // Monday-snap variant pushed the first tick rightward, leaving the
98
+ // leftmost label visually detached from the data start.
99
+ const indices: number[] = []
100
+ for (let i = 0; i < len; i += 14) indices.push(i)
101
+ return { indices, format: d => monthDayFmt.format(d) }
102
+ }
103
+
104
+ // Long span: first-of-month ticks.
105
+ const indices: number[] = []
106
+ let lastMonth = ''
107
+ for (let i = 0; i < len; i++) {
108
+ const m = dates[i]?.slice(0, 7) ?? ''
109
+ if (m !== lastMonth) {
110
+ indices.push(i)
111
+ lastMonth = m
112
+ }
113
+ }
114
+ if (indices.length < 2)
115
+ return { indices: [0, Math.floor(len / 2), len - 1], format: d => monthDayFmt.format(d) }
116
+ // Drop the first tick if the second is within 5 days — happens when the
117
+ // series starts late in a month and the next month's first-of-month tick
118
+ // would render right on top of it.
119
+ if (indices.length > 2 && indices[1]! - indices[0]! < 5)
120
+ indices.shift()
121
+
122
+ // Thin out at very long spans so the axis doesn't crowd.
123
+ if (indices.length > maxTicks) {
124
+ const keep = Math.ceil(indices.length / 12)
125
+ return {
126
+ indices: indices.filter((_, i) => i % keep === 0),
127
+ format: (d, i, firstYear) => (d.getUTCMonth() === 0 || i === 0) && d.getUTCFullYear() !== firstYear
128
+ ? monthYearFmt.format(d)
129
+ : monthFmt.format(d),
130
+ }
131
+ }
132
+ return {
133
+ indices,
134
+ format: (d, i, _firstYear) => (d.getUTCMonth() === 0 && i > 0) || (i === 0 && d.getUTCMonth() !== 0 && indices.length > 6)
135
+ ? monthYearFmt.format(d)
136
+ : monthFmt.format(d),
137
+ }
138
+ }
139
+
140
+ const tickPlan = computed<ChartTickPlan>(() => {
141
+ const dates = toValue(opts.dates)
142
+ const len = dates.length
143
+ const base = spanPlan(dates, len)
144
+
145
+ const width = toValue(opts.width)
146
+ if (!width || width <= 0 || len < 3 || base.indices.length <= 2)
147
+ return base
148
+
149
+ // Estimate each label's pixel width from the plan's own formatting so the
150
+ // pass tracks whichever branch ran. `i`/`firstYear` only steer the
151
+ // month-vs-monthYear choice — an estimate, not a render.
152
+ const firstYear = Number(dates[base.indices[0]!]?.slice(0, 4) ?? 0)
153
+ const labelWidth = (idx: number): number => {
154
+ const date = dates[idx]
155
+ if (!date)
156
+ return 0
157
+ const label = base.format(parseReportingDay(date), base.indices.indexOf(idx), firstYear)
158
+ return label.length * LABEL_GLYPH_PX
159
+ }
160
+ const posOf = (idx: number): number => idx / (len - 1) * width
161
+ return { indices: fitTickIndices(base.indices, labelWidth, posOf), format: base.format }
162
+ })
163
+
164
+ const firstTickYear = computed(() => {
165
+ const dates = toValue(opts.dates)
166
+ const first = tickPlan.value.indices[0]
167
+ // No ticks → `format` never runs, so a stable sentinel (not a clock read,
168
+ // which would risk an SSR≠client year boundary) is enough.
169
+ if (first == null || !dates[first])
170
+ return 0
171
+ return Number(dates[first]!.slice(0, 4))
172
+ })
173
+
174
+ /** Use as Unovis `tickFormat` for an x-axis indexed by row position. */
175
+ function tickFormat(d: number): string {
176
+ const dates = toValue(opts.dates)
177
+ const idx = Math.round(d)
178
+ const date = dates[idx]
179
+ if (!date)
180
+ return ''
181
+ const tickIdx = tickPlan.value.indices.indexOf(idx)
182
+ return tickPlan.value.format(parseReportingDay(date), tickIdx, firstTickYear.value)
183
+ }
184
+
185
+ return { tickPlan, firstTickYear, tickFormat }
186
+ }
@@ -0,0 +1,7 @@
1
+ import type { WatchSource, WatchStopHandle } from 'vue'
2
+ import { watch } from 'vue'
3
+
4
+ /** Close persistent overlay state after a confirmed client route change. */
5
+ export function useCloseOnNavigation(fullPath: WatchSource<string>, close: () => void): WatchStopHandle {
6
+ return watch(fullPath, close)
7
+ }
@@ -0,0 +1,38 @@
1
+ import { useOverlay } from '#imports'
2
+ import UiConfirmDialog from '../components/element/UiConfirmDialog.vue'
3
+
4
+ /** `danger` for an action that destroys or revokes; `default` for any other. */
5
+ export type ConfirmTone = 'danger' | 'default'
6
+
7
+ export interface ConfirmOptions {
8
+ /** A question that names the action and its object: "Disconnect Google?" */
9
+ readonly title: string
10
+ /** The consequence, in one or two short sentences. */
11
+ readonly description?: string
12
+ /** A verb for the action: "Disconnect", "Remove", "Delete". Never "OK". */
13
+ readonly confirmLabel: string
14
+ readonly cancelLabel?: string
15
+ readonly tone?: ConfirmTone
16
+ }
17
+
18
+ /**
19
+ * Ask before an action that is hard to undo.
20
+ *
21
+ * ```ts
22
+ * const confirm = useConfirm()
23
+ * if (!await confirm({ title: 'Remove teammate?', confirmLabel: 'Remove', tone: 'danger' }))
24
+ * return
25
+ * ```
26
+ *
27
+ * Resolves `true` only for the confirm button. Cancel, Escape, and a click
28
+ * outside resolve `false`. Call it in `setup`; the returned function may run
29
+ * any time later. Needs `<UApp>`, which mounts Nuxt UI's overlay provider.
30
+ */
31
+ export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
32
+ const overlay = useOverlay()
33
+ // One overlay per question: `destroyOnClose` removes it after it leaves, so
34
+ // a page that asks many times holds no dialog it is not showing.
35
+ return options => overlay.create(UiConfirmDialog, { destroyOnClose: true })
36
+ .open({ ...options })
37
+ .then(result => result === true)
38
+ }
@@ -0,0 +1,37 @@
1
+ import type { Ref } from 'vue'
2
+ import { useEventListener, useResizeObserver } from '@vueuse/core'
3
+ import { onMounted, ref } from 'vue'
4
+
5
+ /**
6
+ * Edge-fade affordance for a horizontal scroller: reports which side still has
7
+ * content off-screen. Bind the result to `data-fade-start` / `data-fade-end` on
8
+ * an element carrying the `.ui-edge-fade` class (`css/global.css`), which masks
9
+ * only the side that can scroll. The flags clear when the content fits, so a
10
+ * wide viewport never masks.
11
+ *
12
+ * Shared by `UiTabs` and the `ProDataToolbar` metric row, so every scrolling
13
+ * strip in the product fades the same way.
14
+ */
15
+ export function useEdgeFade(el: Ref<HTMLElement | undefined | null>, onResize?: () => void) {
16
+ const fadeStart = ref(false)
17
+ const fadeEnd = ref(false)
18
+
19
+ function update() {
20
+ const node = el.value
21
+ if (!node)
22
+ return
23
+ fadeStart.value = node.scrollLeft > 1
24
+ fadeEnd.value = Math.ceil(node.scrollLeft + node.clientWidth) < node.scrollWidth - 1
25
+ }
26
+
27
+ onMounted(() => {
28
+ useEventListener(el, 'scroll', update, { passive: true })
29
+ useResizeObserver(el, () => {
30
+ update()
31
+ onResize?.()
32
+ })
33
+ update()
34
+ })
35
+
36
+ return { fadeStart, fadeEnd, update }
37
+ }
@@ -0,0 +1,69 @@
1
+ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
2
+ import type { RouteLocationRaw } from 'vue-router'
3
+ import type { ChatEntityRef, EjectChat, EjectLink, EjectMcp } from '../components/element/eject-menu'
4
+ import { computed, toValue } from 'vue'
5
+
6
+ /**
7
+ * Schema-shaped description of a queryable surface.
8
+ *
9
+ * VISION principle 5: every feature is defined by a typed schema before it ships a UI.
10
+ * `useEject` is the spine that turns that schema into the eject payloads
11
+ * (MCP call, AI chat, API schema link) so consumers don't hand-roll each one.
12
+ * It builds no cURL payload. DESIGN "Portable by default" allows one, because
13
+ * the public documented REST API is live at `/docs/api/openapi.json`.
14
+ *
15
+ * The composable is dumb plumbing. It does no fetching, no auth resolution, no
16
+ * runtime config reading; the caller passes the schema reference and the
17
+ * entity/filter context. The result feeds directly into `<UiEjectMenu />` props.
18
+ */
19
+ export interface EjectQuery {
20
+ /** MCP tool name. Required for MCP + AI chat ejection. */
21
+ tool: string
22
+ /** Tool arguments, serialised as MCP `arguments` on copy. */
23
+ args?: Record<string, unknown>
24
+ /** Deep-link target for AI chat ejection (path + optional query base). */
25
+ chatTo?: RouteLocationRaw
26
+ /** Entity ID seeded into chat deep links — build via `chatEntity.*`. */
27
+ entity?: ChatEntityRef
28
+ /** Filter state seeded into chat deep links. */
29
+ filter?: Record<string, unknown>
30
+ /** Question seed text for chat. */
31
+ seed?: string
32
+ /** Schema doc link. */
33
+ schemaTo?: RouteLocationRaw
34
+ /** Custom schema link label. */
35
+ schemaLabel?: string
36
+ }
37
+
38
+ export interface EjectPayloads {
39
+ mcp: ComputedRef<EjectMcp | undefined>
40
+ chat: ComputedRef<EjectChat | undefined>
41
+ schema: ComputedRef<EjectLink | undefined>
42
+ }
43
+
44
+ export function useEject(query: MaybeRefOrGetter<EjectQuery>): EjectPayloads {
45
+ const q = computed(() => toValue(query))
46
+
47
+ return {
48
+ mcp: computed(() =>
49
+ q.value.tool
50
+ ? { tool: q.value.tool, args: q.value.args }
51
+ : undefined,
52
+ ),
53
+ chat: computed(() =>
54
+ q.value.chatTo
55
+ ? {
56
+ to: q.value.chatTo,
57
+ entity: q.value.entity,
58
+ filter: q.value.filter,
59
+ seed: q.value.seed,
60
+ }
61
+ : undefined,
62
+ ),
63
+ schema: computed(() =>
64
+ q.value.schemaTo
65
+ ? { to: q.value.schemaTo, label: q.value.schemaLabel }
66
+ : undefined,
67
+ ),
68
+ }
69
+ }
@@ -0,0 +1,22 @@
1
+ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
2
+ import type { EntrancePreset } from '../../shared/motion'
3
+ import { useReducedMotion } from 'motion-v'
4
+ import { tryUseNuxtApp } from 'nuxt/app'
5
+ import { computed, toValue } from 'vue'
6
+ import { entranceProps } from '../../shared/motion'
7
+
8
+ /**
9
+ * motion-v entrance props for an `<m.*>` root, with content visible by default.
10
+ *
11
+ * The server cannot read `prefers-reduced-motion`, and Vue keeps server styles
12
+ * through hydration. An entrance bound on the server therefore ships the hidden
13
+ * `initial` frame, and a reduced-motion reader never sees the content. So the
14
+ * server render and the hydration pass bind nothing. Only an element the client
15
+ * mounts after hydration animates in.
16
+ */
17
+ export function useEntranceMotion(preset: EntrancePreset, enabled: MaybeRefOrGetter<boolean> = true): ComputedRef<ReturnType<typeof entranceProps>> {
18
+ const reduced = useReducedMotion()
19
+ const nuxtApp = tryUseNuxtApp()
20
+ const renderedOnServer = import.meta.server || Boolean(nuxtApp?.ssrContext) || Boolean(nuxtApp?.isHydrating)
21
+ return computed(() => toValue(enabled) && !renderedOnServer ? entranceProps(preset, reduced.value) : {})
22
+ }
@@ -0,0 +1,164 @@
1
+ import { computed, onMounted, ref, watch } from 'vue'
2
+ import { useColorMode } from '#imports'
3
+
4
+ /**
5
+ * Adaptive favicon backing. Many real favicons (zhead's dark triangle, GitHub's
6
+ * octocat, Vercel's near-black square) carry no usable contrast against one of the
7
+ * two dashboard themes and visually vanish. Rather than blanket every favicon with
8
+ * a light tile (which would needlessly flatten brand-colour favicons), we sample
9
+ * each favicon's dominant luminance once and only add a tile in the theme where it
10
+ * would otherwise disappear.
11
+ *
12
+ * Sampling requires a canvas read, so the favicon MUST be same-origin — UiFavicon
13
+ * serves it through `/api/favicon` for exactly this reason. A cross-origin image
14
+ * taints the canvas and `getImageData` throws; we treat that as `unknown` (no
15
+ * backing) so the component still renders, just without the adaptive remedy.
16
+ */
17
+ export type FaviconTone = 'dark' | 'light' | 'neutral' | 'unknown'
18
+ export type FaviconBacking = 'light' | 'dark' | 'none'
19
+
20
+ // Dominant luminance below DARK reads as "dark ink/fill" (needs a light tile on
21
+ // dark themes); above LIGHT reads as "light ink" (needs a dark tile on light
22
+ // themes). The wide neutral band keeps colourful favicons (Stripe, Cloudflare,
23
+ // npm) untouched on both themes.
24
+ const DARK = 0.22
25
+ const LIGHT = 0.8
26
+
27
+ // Per-domain memo: sample at most once per domain per session and persist
28
+ // across sessions to avoid a backing flash.
29
+ //
30
+ // The persisted half now EXPIRES, because a domain's classification is no
31
+ // longer stable for all time. `/api/favicon` used to answer from Google's copy,
32
+ // which never changed; it now serves the mark the Crawl captured, so a Site
33
+ // that rebrands from a dark glyph to a light one changes which tile it needs.
34
+ // Without an expiry a returning viewer would keep the wrong backing for good,
35
+ // and a stored tone is only a flash-avoidance optimisation, never a fact worth
36
+ // defending.
37
+ const memo = new Map<string, FaviconTone>()
38
+ const LS_KEY = 'ui-favicon-tone-v2'
39
+ const MEMO_TTL_MS = 7 * 24 * 60 * 60 * 1000
40
+ interface StoredMemo { at: number, tones: Record<string, FaviconTone> }
41
+ let hydrated = false
42
+
43
+ function hydrate() {
44
+ if (hydrated || !import.meta.client)
45
+ return
46
+ hydrated = true
47
+ try {
48
+ const stored = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null') as StoredMemo | null
49
+ if (!stored || Date.now() - stored.at >= MEMO_TTL_MS)
50
+ return
51
+ for (const [k, v] of Object.entries(stored.tones)) memo.set(k, v)
52
+ }
53
+ catch {
54
+ // Corrupt cache is not worth surfacing; fall back to re-sampling.
55
+ }
56
+ }
57
+
58
+ function persist() {
59
+ if (!import.meta.client)
60
+ return
61
+ try {
62
+ localStorage.setItem(LS_KEY, JSON.stringify({ at: Date.now(), tones: Object.fromEntries(memo) } satisfies StoredMemo))
63
+ }
64
+ catch {
65
+ // Storage full / disabled — sampling still works, just not cached cross-session.
66
+ }
67
+ }
68
+
69
+ function classifyTone(img: HTMLImageElement): FaviconTone {
70
+ const n = 32
71
+ const canvas = document.createElement('canvas')
72
+ canvas.width = n
73
+ canvas.height = n
74
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
75
+ if (!ctx)
76
+ return 'unknown'
77
+ ctx.drawImage(img, 0, 0, n, n)
78
+
79
+ let data: Uint8ClampedArray
80
+ try {
81
+ data = ctx.getImageData(0, 0, n, n).data
82
+ }
83
+ catch {
84
+ return 'unknown' // tainted canvas (cross-origin favicon)
85
+ }
86
+
87
+ // Mean relative luminance over the visible ink, weighted by alpha so soft
88
+ // edges don't drag a solid glyph toward neutral.
89
+ let lumSum = 0
90
+ let alphaSum = 0
91
+ for (let i = 0; i < data.length; i += 4) {
92
+ const a = data[i + 3] ?? 0
93
+ if (a < 16)
94
+ continue
95
+ const r = data[i] ?? 0
96
+ const g = data[i + 1] ?? 0
97
+ const b = data[i + 2] ?? 0
98
+ const l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
99
+ lumSum += l * a
100
+ alphaSum += a
101
+ }
102
+ if (alphaSum === 0)
103
+ return 'unknown' // fully transparent / empty
104
+ const meanLum = lumSum / alphaSum
105
+ if (meanLum < DARK)
106
+ return 'dark'
107
+ if (meanLum > LIGHT)
108
+ return 'light'
109
+ return 'neutral'
110
+ }
111
+
112
+ export function useFaviconBacking(domain: () => string) {
113
+ // Start 'unknown' on both server and first client render so SSR HTML and
114
+ // hydration agree; only read the cross-session cache after mount. Otherwise a
115
+ // cached tone would diverge from the server's 'unknown' → favicon-tile
116
+ // hydration mismatch.
117
+ const tone = ref<FaviconTone>('unknown')
118
+ onMounted(() => {
119
+ hydrate()
120
+ tone.value = memo.get(domain()) ?? 'unknown'
121
+ })
122
+ watch(domain, d => (tone.value = memo.get(d) ?? 'unknown'))
123
+
124
+ /**
125
+ * Classify the favicon `image` and remember the result for its domain.
126
+ *
127
+ * Takes the element, never a `load` event. A cached favicon is `complete`
128
+ * before hydration, so no `load` event fires and the caller samples it after
129
+ * mount instead.
130
+ */
131
+ function sample(image: HTMLImageElement) {
132
+ const key = domain()
133
+ const cached = memo.get(key)
134
+ if (cached) {
135
+ tone.value = cached
136
+ return
137
+ }
138
+ // An image the browser has not decoded carries no pixels. Sampling it would
139
+ // remember a blank canvas as this domain's tone for the whole session.
140
+ if (!image.naturalWidth || !image.naturalHeight)
141
+ return
142
+ const t = classifyTone(image)
143
+ memo.set(key, t)
144
+ persist()
145
+ tone.value = t
146
+ }
147
+
148
+ const colorMode = useColorMode()
149
+ const backing = computed<FaviconBacking>(() => {
150
+ const isDark = colorMode.value === 'dark'
151
+ // We only ever add the tile in the theme where the favicon would vanish; in
152
+ // the other theme its native contrast is fine, so we leave it bare and keep
153
+ // any brand colour. The tile uses `bg-inverted`, which resolves to the right
154
+ // shade automatically: white on dark themes, dark on light themes — i.e.
155
+ // exactly the surface a vanishing favicon needs in the theme we apply it.
156
+ if (tone.value === 'dark')
157
+ return isDark ? 'light' : 'none'
158
+ if (tone.value === 'light')
159
+ return isDark ? 'none' : 'dark'
160
+ return 'none'
161
+ })
162
+
163
+ return { backing, sample }
164
+ }
@@ -0,0 +1,39 @@
1
+ import type { ComputedRef, Ref } from 'vue'
2
+ import { computed } from 'vue'
3
+
4
+ export interface GridColsOptions {
5
+ /**
6
+ * Lets each metric column shrink to this width, so the label column keeps
7
+ * `minLabelWidth`. Without it, metric columns stay at their fixed width.
8
+ */
9
+ minColWidth?: string
10
+ /** The narrowest the label column gets once metric columns can shrink. Default `8rem`. */
11
+ minLabelWidth?: string
12
+ }
13
+
14
+ function buildGridCols(colCount: number, options: GridColsOptions = {}): string {
15
+ if (colCount <= 0)
16
+ colCount = 1
17
+
18
+ const colWidth = colCount <= 2 ? '200px' : '160px'
19
+ const track = options.minColWidth ? `minmax(${options.minColWidth}, ${colWidth})` : colWidth
20
+ const label = options.minColWidth ? `minmax(${options.minLabelWidth ?? '8rem'}, 1fr)` : '1fr'
21
+
22
+ const middle = colCount === 1
23
+ ? track
24
+ : Array.from({ length: colCount }).fill(track).join(' 8px ')
25
+
26
+ return `48px ${label} ${middle} 60px`
27
+ }
28
+
29
+ export function useGridCols(
30
+ columnCount: Ref<number> | ComputedRef<number>,
31
+ fallbackClass?: Ref<string | false | undefined> | ComputedRef<string | false | undefined>,
32
+ options?: GridColsOptions,
33
+ ): ComputedRef<string> {
34
+ return computed(() => {
35
+ if (fallbackClass?.value)
36
+ return fallbackClass.value
37
+ return buildGridCols(columnCount.value, options)
38
+ })
39
+ }
@@ -0,0 +1,43 @@
1
+ import { useState } from 'nuxt/app'
2
+
3
+ /**
4
+ * useIssueOverlay — the layer-agnostic trigger for the in-place issue detail
5
+ * (Q1, 2026-06). Feature-page related-work strips open a single issue's act-surface
6
+ * modal without navigating to the board; only the strip's "View all" navigates.
7
+ *
8
+ * Why it lives in design-system, not pro-actions: ADR-0042 forbids feature
9
+ * layers (pro-perf / pro-gsc / pro-dataforseo) from importing pro-actions
10
+ * runtime. So the trigger is a generic `useState` channel here (which every
11
+ * layer may import), and pro-actions owns the host (`ProIssueOverlayHost`) that
12
+ * reads this state and renders the actual `ProActionRow` modal. The action
13
+ * is carried opaquely (`unknown`) so design-system never learns the BoardAction
14
+ * shape — the host casts it back.
15
+ */
16
+
17
+ export interface IssueOverlayRequest {
18
+ /** The full board action (opaque here; the pro-actions host casts it). */
19
+ action: unknown
20
+ siteId: string
21
+ /** Cross-site label when opened from the portfolio surface. */
22
+ siteName?: string
23
+ /** Bare host (no scheme) for the Work Prompt / MCP call — omitted falls back to "my site". */
24
+ siteHost?: string
25
+ /** Query keys the modal invalidates on a mutation (the caller's related read). */
26
+ invalidateKeys?: string[]
27
+ /** Bumped per open so the host re-triggers even for the same action. */
28
+ nonce: number
29
+ }
30
+
31
+ /** The shared overlay channel — read by the host, written by `open`. */
32
+ export function useIssueOverlayState() {
33
+ return useState<IssueOverlayRequest | null>('pro:issue-overlay', () => null)
34
+ }
35
+
36
+ export function useIssueOverlay() {
37
+ const state = useIssueOverlayState()
38
+ return {
39
+ open(req: { action: unknown, siteId: string, siteName?: string, siteHost?: string, invalidateKeys?: string[] }) {
40
+ state.value = { ...req, nonce: (state.value?.nonce ?? 0) + 1 }
41
+ },
42
+ }
43
+ }