@nuxtseo/design-system 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,281 @@
1
+ <script lang="ts" setup generic="T extends object">
2
+ import { computed } from 'vue'
3
+ import { formatGroupedNumber } from '../../composables/formatting'
4
+ // Horizontal ranked bar chart. Sorts rows by their numeric value and draws a labelled bar per
5
+ // row, scaled to the largest value — the canonical "top N pages / queries / countries" view.
6
+ // Generic + dependency-free (CSS bars), so any row array drives it.
7
+
8
+ const {
9
+ data,
10
+ labelKey,
11
+ valueKey,
12
+ max = 8,
13
+ sort = true,
14
+ size = 'sm',
15
+ density = 'regular',
16
+ color = 'var(--ui-primary)',
17
+ format,
18
+ ceilingKey,
19
+ truncatedKey,
20
+ } = defineProps<{
21
+ data: T[]
22
+ /** Row key for the category label. */
23
+ labelKey: keyof T & string
24
+ /** Row key for the numeric value. */
25
+ valueKey: keyof T & string
26
+ /**
27
+ * Row key holding the observation CEILING — how far the row could have gone
28
+ * had the producer finished (sitemap-expected pages, sampled population).
29
+ * The span between `valueKey` and this draws hatched: observed, then
30
+ * unobserved. Without it a short bar reads as "measured and small" when it
31
+ * often means "we stopped looking".
32
+ */
33
+ ceilingKey?: keyof T & string
34
+ /**
35
+ * Row key holding a truthy "this value is a floor" flag — the scan hit a row
36
+ * budget or per-rule cap, so the true count is at or above what is drawn.
37
+ * The whole bar hatches and the value label renders `≥ n`.
38
+ */
39
+ truncatedKey?: keyof T & string
40
+ /** Max rows to show (top N). Default 8. */
41
+ max?: number
42
+ /** Sort largest first. Disable for ordered buckets such as histograms. */
43
+ sort?: boolean
44
+ /** Row density. Use md when the chart is the primary comparison. */
45
+ size?: 'sm' | 'md'
46
+ /** Compact keeps readable type and condenses to one row when space permits. */
47
+ density?: 'regular' | 'compact'
48
+ /** Bar colour (any CSS colour). */
49
+ color?: string
50
+ /** Format the value label. */
51
+ format?: (value: number) => string
52
+ }>()
53
+
54
+ defineSlots<{
55
+ /** Replace the text label while preserving the ranked row and exact value. */
56
+ label: (props: { item: T, label: string, value: number, index: number }) => unknown
57
+ }>()
58
+
59
+ function num(v: unknown) {
60
+ const n = typeof v === 'number' ? v : Number(String(v).replace(/^"+|"+$/g, ''))
61
+ return Number.isFinite(n) ? n : 0
62
+ }
63
+
64
+ const rows = computed(() => {
65
+ const values = Array.from(data, item => ({
66
+ item,
67
+ label: String(item[labelKey] ?? ''),
68
+ value: num(item[valueKey]),
69
+ // A ceiling below the observed value is meaningless, so clamp rather than
70
+ // draw a negative provisional span.
71
+ ceiling: ceilingKey ? Math.max(num(item[ceilingKey]), num(item[valueKey])) : 0,
72
+ truncated: truncatedKey ? Boolean(item[truncatedKey]) : false,
73
+ }))
74
+ const sorted = sort ? values.sort((a, b) => b.value - a.value) : values
75
+ const visible = sorted
76
+ .slice(0, max)
77
+ // The ceiling shares the value's scale — a bar whose unobserved span ran past
78
+ // the peak would otherwise clip and read as complete.
79
+ const peak = Math.max(1, ...visible.map(r => Math.max(r.value, r.ceiling)))
80
+ const pct = (v: number) => Math.min(100, Math.max(0, (v / peak) * 100))
81
+ return visible.map(r => ({
82
+ ...r,
83
+ pct: pct(r.value),
84
+ // Width of the hatched span, in track percent. Zero when the row is a
85
+ // complete observation.
86
+ provisionalPct: r.ceiling > r.value ? pct(r.ceiling) - pct(r.value) : 0,
87
+ }))
88
+ })
89
+
90
+ function fmt(value: number): string {
91
+ return format ? format(value) : formatGroupedNumber(value)
92
+ }
93
+
94
+ // `141 / 188` needs a wider value column than `141`. Widen once for the whole
95
+ // chart so the numbers stay on one right-aligned edge.
96
+ const hasCeiling = computed(() => rows.value.some(r => r.provisionalPct > 0))
97
+
98
+ /** Truncated counts are floors — say so in the label, not only in the fill. */
99
+ function fmtValue(r: { value: number, truncated: boolean }): string {
100
+ return r.truncated ? `≥ ${fmt(r.value)}` : fmt(r.value)
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <ol
106
+ data-ui="UiBarChart"
107
+ :data-density="density"
108
+ class="ui-bar-chart flex flex-col"
109
+ :class="[
110
+ size === 'md' ? 'ui-bar-chart--md' : 'ui-bar-chart--sm',
111
+ hasCeiling ? 'ui-bar-chart--ceiling' : '',
112
+ density === 'compact' ? 'ui-bar-chart--compact gap-1' : size === 'md' ? 'gap-3' : 'gap-1.5',
113
+ ]"
114
+ >
115
+ <li
116
+ v-for="(r, i) in rows"
117
+ :key="`${r.label}:${i}`"
118
+ class="ui-bar-chart__row"
119
+ >
120
+ <span class="ui-bar-chart__label min-w-0 text-muted" :title="r.label">
121
+ <slot
122
+ name="label"
123
+ :item="r.item"
124
+ :label="r.label"
125
+ :value="r.value"
126
+ :index="i"
127
+ >{{ r.label }}</slot>
128
+ </span>
129
+ <div
130
+ data-bar-track
131
+ class="ui-bar-chart__track relative overflow-hidden rounded bg-muted/40"
132
+ :class="density === 'compact' ? 'h-2' : size === 'md' ? 'h-7' : 'h-5'"
133
+ aria-hidden="true"
134
+ >
135
+ <!-- Truncated rows hatch the whole bar: the value itself is a floor, so
136
+ there is no solid span to separate. Width, not scaleX — a scaled
137
+ hatch stretches its pitch and stops reading as one texture. -->
138
+ <div
139
+ v-if="r.truncated"
140
+ class="provisional-hatch absolute inset-y-0 left-0 rounded ring-1 ring-inset ring-current/35"
141
+ :style="{ width: `${r.pct}%`, color }"
142
+ />
143
+ <div
144
+ v-else
145
+ class="absolute inset-y-0 left-0 w-full origin-left rounded"
146
+ :style="{ transform: `scaleX(${r.pct / 100})`, background: color, opacity: 0.85 }"
147
+ />
148
+ <!-- Observed → unobserved. Starts where the solid span ends. -->
149
+ <div
150
+ v-if="r.provisionalPct > 0"
151
+ class="provisional-hatch absolute inset-y-0 rounded-r ring-1 ring-inset ring-current/30"
152
+ :style="{ left: `${r.pct}%`, width: `${r.provisionalPct}%`, color }"
153
+ />
154
+ </div>
155
+ <span class="ui-bar-chart__value text-right tabular-nums text-highlighted" :class="size === 'md' ? 'numerals-display' : ''">
156
+ {{ fmtValue(r) }}
157
+ <!-- The ceiling reads in the label too, not only in the fill — a hatch
158
+ survives neither greyscale nor a cropped screenshot. -->
159
+ <span v-if="r.provisionalPct > 0" class="text-muted">/&nbsp;{{ fmt(r.ceiling) }}</span>
160
+ <span v-if="r.provisionalPct > 0" class="sr-only">expected, the rest unobserved</span>
161
+ <span v-else-if="r.truncated" class="sr-only">at least — the scan hit its row budget</span>
162
+ </span>
163
+ </li>
164
+ </ol>
165
+ </template>
166
+
167
+ <style scoped>
168
+ .ui-bar-chart {
169
+ container-type: inline-size;
170
+ }
171
+
172
+ .ui-bar-chart__row {
173
+ align-items: center;
174
+ column-gap: 0.75rem;
175
+ display: grid;
176
+ }
177
+
178
+ .ui-bar-chart--sm .ui-bar-chart__row {
179
+ font-size: 0.75rem;
180
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 3.5rem;
181
+ }
182
+
183
+ .ui-bar-chart--sm.ui-bar-chart--ceiling .ui-bar-chart__row {
184
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 5.5rem;
185
+ }
186
+
187
+ .ui-bar-chart--sm .ui-bar-chart__label {
188
+ overflow: hidden;
189
+ text-overflow: ellipsis;
190
+ white-space: nowrap;
191
+ }
192
+
193
+ .ui-bar-chart--md .ui-bar-chart__row {
194
+ font-size: 0.875rem;
195
+ grid-template-columns: minmax(0, 1fr) auto;
196
+ row-gap: 0.5rem;
197
+ }
198
+
199
+ .ui-bar-chart--md .ui-bar-chart__label {
200
+ overflow-wrap: anywhere;
201
+ }
202
+
203
+ .ui-bar-chart--md .ui-bar-chart__track {
204
+ grid-column: 1 / -1;
205
+ grid-row: 2;
206
+ }
207
+
208
+ .ui-bar-chart__value {
209
+ min-width: 4rem;
210
+ white-space: nowrap;
211
+ }
212
+
213
+ .ui-bar-chart--compact .ui-bar-chart__row {
214
+ column-gap: 0.5rem;
215
+ font-size: 0.875rem;
216
+ grid-template-columns: minmax(0, 1fr) max-content;
217
+ row-gap: 0.25rem;
218
+ }
219
+
220
+ .ui-bar-chart--compact .ui-bar-chart__label {
221
+ overflow: visible;
222
+ overflow-wrap: anywhere;
223
+ text-overflow: clip;
224
+ white-space: normal;
225
+ }
226
+
227
+ .ui-bar-chart--compact .ui-bar-chart__track {
228
+ grid-column: 1 / -1;
229
+ grid-row: 2;
230
+ }
231
+
232
+ .ui-bar-chart--compact .ui-bar-chart__value {
233
+ grid-column: 2;
234
+ grid-row: 1;
235
+ min-width: 0;
236
+ }
237
+
238
+ @container (min-width: 16rem) {
239
+ .ui-bar-chart--compact .ui-bar-chart__row {
240
+ grid-template-columns: minmax(0, 1.15fr) minmax(3rem, 1fr) minmax(3.5rem, max-content);
241
+ row-gap: 0;
242
+ }
243
+
244
+ .ui-bar-chart--compact .ui-bar-chart__label {
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
247
+ white-space: nowrap;
248
+ }
249
+
250
+ .ui-bar-chart--compact .ui-bar-chart__track {
251
+ grid-column: 2;
252
+ grid-row: 1;
253
+ }
254
+
255
+ .ui-bar-chart--compact .ui-bar-chart__value {
256
+ grid-column: 3;
257
+ grid-row: 1;
258
+ }
259
+ }
260
+
261
+ @container (min-width: 36rem) {
262
+ .ui-bar-chart--md:not(.ui-bar-chart--compact) .ui-bar-chart__row {
263
+ grid-template-columns: minmax(10rem, 1fr) minmax(12rem, 2fr) 4rem;
264
+ row-gap: 0;
265
+ }
266
+
267
+ .ui-bar-chart--md.ui-bar-chart--ceiling:not(.ui-bar-chart--compact) .ui-bar-chart__row {
268
+ grid-template-columns: minmax(10rem, 1fr) minmax(12rem, 2fr) 6.5rem;
269
+ }
270
+
271
+ .ui-bar-chart--md:not(.ui-bar-chart--compact) .ui-bar-chart__track {
272
+ grid-column: 2;
273
+ grid-row: 1;
274
+ }
275
+
276
+ .ui-bar-chart--md:not(.ui-bar-chart--compact) .ui-bar-chart__value {
277
+ grid-column: 3;
278
+ grid-row: 1;
279
+ }
280
+ }
281
+ </style>
@@ -0,0 +1,74 @@
1
+ <script lang="ts" setup generic="K extends string">
2
+ import type { ChartAnnotationControlItem } from '../../utils/chartAnnotations'
3
+ import { computed } from 'vue'
4
+ import { UCheckbox, UiButton, UiIcon, UiPopover } from '#components'
5
+
6
+ const { items, dense = false } = defineProps<{
7
+ items: readonly ChartAnnotationControlItem<K>[]
8
+ /** Compact icon+chevron trigger for a joined toolbar pill (e.g. beside a metric toggle) instead of the standalone quiet button. */
9
+ dense?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ toggle: [key: K, enabled: boolean]
14
+ }>()
15
+
16
+ const hiddenCount = computed(() => items.filter(item => !item.enabled).length)
17
+ const triggerLabel = computed(() => hiddenCount.value
18
+ ? `Configure chart annotations, ${hiddenCount.value} hidden`
19
+ : 'Configure chart annotations')
20
+ </script>
21
+
22
+ <template>
23
+ <UiPopover :content="{ side: 'bottom', align: 'end' }">
24
+ <button
25
+ v-if="dense"
26
+ type="button"
27
+ data-annotation-controls
28
+ :aria-label="triggerLabel"
29
+ class="cursor-pointer inline-flex items-center justify-center gap-0.5 size-11 sm:size-7 rounded-md transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-primary"
30
+ :class="hiddenCount ? 'text-primary' : 'text-dimmed hover:text-default'"
31
+ >
32
+ <UiIcon name="flag" class="size-3.5" aria-hidden="true" />
33
+ <UiIcon name="chevron-down" class="size-2.5" aria-hidden="true" />
34
+ </button>
35
+ <UiButton
36
+ v-else
37
+ data-annotation-controls
38
+ purpose="quiet"
39
+ size="xs"
40
+ icon="sliders"
41
+ :aria-label="triggerLabel"
42
+ :class="hiddenCount ? 'size-11 text-primary bg-primary/10 sm:size-8' : 'size-11 text-muted sm:size-8'"
43
+ />
44
+
45
+ <template #panel>
46
+ <div data-annotation-controls-panel class="w-64 max-w-[calc(100vw-2rem)] p-3">
47
+ <p class="px-1 pb-2 text-label">
48
+ Show on charts
49
+ </p>
50
+ <div class="divide-y divide-default" role="group" aria-label="Annotation sources">
51
+ <label
52
+ v-for="item in items"
53
+ :key="item.key"
54
+ class="min-h-11 grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-center gap-2 px-1 text-sm text-default cursor-pointer"
55
+ >
56
+ <span class="size-6 rounded-full bg-default ring-1 ring-default grid place-items-center text-muted" aria-hidden="true">
57
+ <UiIcon :name="item.icon" class="size-3.5" />
58
+ </span>
59
+ <span class="min-w-0 truncate">{{ item.label }}</span>
60
+ <span v-if="item.count != null" class="text-mini text-dimmed tabular-nums">
61
+ {{ item.count }}
62
+ </span>
63
+ <UCheckbox
64
+ :model-value="item.enabled"
65
+ size="sm"
66
+ :aria-label="`Show ${item.label}`"
67
+ @update:model-value="emit('toggle', item.key, $event === true)"
68
+ />
69
+ </label>
70
+ </div>
71
+ </div>
72
+ </template>
73
+ </UiPopover>
74
+ </template>
@@ -0,0 +1,163 @@
1
+ <script lang="ts" setup>
2
+ import type { ChartAnnotation, ChartAnnotationOptions } from '../../utils/chartAnnotations'
3
+ import { computed } from 'vue'
4
+ import { UiIcon, UiPopover } from '#components'
5
+ import { safeHttpHref } from '../../../shared/utils/urls'
6
+ import { annotationIconPreview, DEFAULT_ANNOTATION_ICON_LIMIT, resolveAnnotationMarkerGroups } from '../../utils/chartAnnotations'
7
+
8
+ // Annotation marker overlay: thin vertical lines + interactive dots at specific
9
+ // x-positions, for any time-series chart. Extracted from UiChartFrame so charts
10
+ // that compose unovis directly (CWV, indexing) get the same markers without
11
+ // adopting the whole tooltip/brush frame.
12
+ //
13
+ // Same-day annotations collapse into ONE marker. Every marker opens the same
14
+ // click/keyboard popover, so interactive source and edit actions never depend
15
+ // on hover. Up to three item icons preview the stack beside the marker dot;
16
+ // remaining items collapse into a +N count.
17
+ //
18
+ // Positioning: drop this INSIDE the chart's plot wrap (the element whose box is
19
+ // `position: relative` and whose width == the plot x-span, i.e. a chart drawn
20
+ // with zero horizontal margin). Each marker is `position: absolute` with a CSS
21
+ // left-percentage; `width: 0` so it never affects layout, `-translate-x-1/2` to
22
+ // centre on the anchor. `bottom-9` (36px) clears a standard x-axis label band.
23
+
24
+ const { annotations, xDomain, options } = defineProps<{
25
+ /** Event annotations. Empty/undefined renders nothing. */
26
+ annotations?: ChartAnnotation[]
27
+ /** Visible x range [min, max] (Date | ISO string | epoch-ms). Required to position. */
28
+ xDomain?: [Date | string | number, Date | string | number]
29
+ /** Marker presentation. Icon previews are capped at three by type. */
30
+ options?: ChartAnnotationOptions
31
+ }>()
32
+
33
+ const emit = defineEmits<{
34
+ /** Activation of a marker carrying a user-editable annotation. */
35
+ edit: [annotation: ChartAnnotation]
36
+ /** Whether the pointer is currently over a marker trigger. */
37
+ interactionChange: [active: boolean]
38
+ }>()
39
+
40
+ const maxIcons = computed(() => options?.maxIcons ?? DEFAULT_ANNOTATION_ICON_LIMIT)
41
+ const markers = computed(() => resolveAnnotationMarkerGroups(annotations, xDomain).map(marker => ({
42
+ ...marker,
43
+ iconPreview: annotationIconPreview(marker.items, maxIcons.value),
44
+ })))
45
+
46
+ function editItem(item: ChartAnnotation, close?: () => void) {
47
+ emit('edit', item)
48
+ close?.()
49
+ }
50
+
51
+ function markerAriaLabel(items: ChartAnnotation[]): string {
52
+ return items.map(item => item.description ? `${item.label}. ${item.description}` : item.label).join('; ')
53
+ }
54
+
55
+ function itemIcon(item: ChartAnnotation): string {
56
+ return item.icon ?? (item.editable ? 'edit' : 'note')
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div
62
+ v-for="m in markers"
63
+ :key="m.id"
64
+ class="absolute top-0 bottom-9 z-[5] pointer-events-none w-0"
65
+ :style="{ left: m.leftPct }"
66
+ >
67
+ <!-- Thin 1px vertical marker line centred on the anchor point. -->
68
+ <div class="absolute inset-y-0 w-px -translate-x-1/2 opacity-50" :class="m.toneClass" aria-hidden="true" />
69
+ <UiPopover :content="{ side: 'top', align: 'center' }">
70
+ <button
71
+ type="button"
72
+ data-annotation-marker
73
+ class="group absolute -bottom-4.5 size-11 -translate-x-1/2 pointer-events-auto rounded-full grid place-items-center cursor-pointer active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
74
+ :aria-label="markerAriaLabel(m.items)"
75
+ @pointerenter.stop="emit('interactionChange', true)"
76
+ @pointerleave.stop="emit('interactionChange', false)"
77
+ @pointerdown.stop
78
+ @pointermove.stop
79
+ @mousemove.stop
80
+ @click.stop
81
+ >
82
+ <span
83
+ class="size-1.5 transition-transform duration-100 group-hover:scale-125 group-hover:ring-2 group-hover:ring-primary"
84
+ :class="[m.toneClass, m.editable ? 'rounded-[2px]' : 'rounded-full']"
85
+ aria-hidden="true"
86
+ />
87
+ <span
88
+ v-if="m.iconPreview.visible.length || m.iconPreview.overflowCount"
89
+ data-annotation-icon-preview
90
+ class="absolute left-7 top-1/2 -translate-y-1/2 pointer-events-auto flex items-center -space-x-1 cursor-pointer transition-transform duration-100 group-hover:-translate-y-[55%]"
91
+ aria-hidden="true"
92
+ >
93
+ <span
94
+ v-for="(item, index) in m.iconPreview.visible"
95
+ :key="item.id ?? index"
96
+ data-annotation-icon
97
+ class="size-4 shrink-0 rounded-full bg-default ring-1 ring-default grid place-items-center text-muted transition-colors duration-100 group-hover:ring-primary group-hover:text-default"
98
+ >
99
+ <UiIcon :name="itemIcon(item)" class="size-2.5" />
100
+ </span>
101
+ <span
102
+ v-if="m.iconPreview.overflowCount"
103
+ data-annotation-overflow
104
+ class="h-4 min-w-4 rounded-full bg-default px-1 ring-1 ring-default grid place-items-center font-mono text-[9px] leading-none text-muted transition-colors duration-100 group-hover:ring-primary group-hover:text-default"
105
+ >
106
+ +{{ m.iconPreview.overflowCount }}
107
+ </span>
108
+ </span>
109
+ </button>
110
+ <template #panel="{ close }">
111
+ <div data-annotation-popover-panel class="w-96 max-w-[calc(100vw-2rem)] p-4">
112
+ <p class="pb-3 text-label">
113
+ {{ m.count === 1 ? 'Annotation' : `${m.count} annotations` }}
114
+ </p>
115
+ <div class="divide-y divide-default">
116
+ <div
117
+ v-for="(item, i) in m.items"
118
+ :key="item.id ?? i"
119
+ class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0"
120
+ >
121
+ <span class="mt-0.5 size-5 rounded-full bg-default ring-1 ring-default grid place-items-center text-muted" aria-hidden="true">
122
+ <UiIcon :name="itemIcon(item)" class="size-3" />
123
+ </span>
124
+ <button
125
+ v-if="item.editable"
126
+ type="button"
127
+ class="min-h-11 min-w-0 text-left text-sm font-semibold leading-5 text-default hover:text-primary sm:min-h-0"
128
+ @click="editItem(item, close)"
129
+ >
130
+ {{ item.label }}
131
+ </button>
132
+ <p v-else class="min-w-0 text-sm font-semibold leading-5 text-default">
133
+ {{ item.label }}
134
+ </p>
135
+ <a
136
+ v-if="safeHttpHref(item.href)"
137
+ :href="safeHttpHref(item.href)"
138
+ target="_blank"
139
+ rel="noopener noreferrer"
140
+ class="size-11 -my-3 shrink-0 rounded-md grid place-items-center text-muted hover:text-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:-my-1 sm:size-7"
141
+ :aria-label="`Open source for ${item.label}`"
142
+ >
143
+ <UiIcon name="arrow-up-right" class="size-4" aria-hidden="true" />
144
+ </a>
145
+ <p v-if="item.description" class="col-start-2 col-end-4 text-sm leading-5 text-muted">
146
+ {{ item.description }}
147
+ </p>
148
+ <img
149
+ v-if="item.image"
150
+ :src="item.image.src"
151
+ :alt="item.image.alt"
152
+ loading="lazy"
153
+ width="1600"
154
+ height="800"
155
+ class="col-start-2 col-end-4 mt-1 aspect-[2/1] w-full rounded-md object-cover object-top ring-1 ring-default"
156
+ >
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </template>
161
+ </UiPopover>
162
+ </div>
163
+ </template>
@@ -0,0 +1,96 @@
1
+ <script lang="ts" setup>
2
+ import type { ChartBrushRange } from '../../composables/useChartBrush'
3
+ import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
4
+ import { computed } from 'vue'
5
+ import { formatReportingDay } from '../../composables/formatting'
6
+
7
+ // Brush overlay for time-series charts. Pairs with useChartBrush — pass through
8
+ // `dragRange`, `selectionLeft`, `selectionWidth`. Renders:
9
+ // 1. The translucent selection rect with dashed primary borders
10
+ // 2. Floating start + end date pills (inverted, anchored to rect edges)
11
+ // 3. Centred duration pill (primary, only when rect is wide enough)
12
+ // Day labels are formatted inline (Month Day) — generic enough that no caller
13
+ // has needed to customise so far.
14
+
15
+ const props = defineProps<{
16
+ dragRange: ChartBrushRange | null
17
+ selectionLeft: number
18
+ selectionWidth: number
19
+ /** Distance from the wrapper's bottom in px (default 36, leaves room for axis ticks). */
20
+ bottomOffset?: number
21
+ /** Minimum width before the duration pill renders (default 56px). */
22
+ durationThreshold?: number
23
+ }>()
24
+
25
+ function formatDragDate(iso: string): string {
26
+ if (!iso)
27
+ return ''
28
+ // Reporting-day label — UTC frame so it doesn't drift per viewer (ADR-0082).
29
+ return formatReportingDay(iso)
30
+ }
31
+
32
+ // Honour reduced-motion: collapse the entry/exit tween to an instant cut.
33
+ const reduced = useReducedMotion()
34
+ const enterTransition = computed(() => reduced.value ? { duration: 0 } : { duration: 0.15, ease: 'easeOut' as const })
35
+
36
+ const startLabel = computed(() => formatDragDate(props.dragRange?.startDate ?? ''))
37
+ const endLabel = computed(() => formatDragDate(props.dragRange?.endDate ?? ''))
38
+ const durationLabel = computed(() => {
39
+ const r = props.dragRange
40
+ if (!r)
41
+ return ''
42
+ const days = r.endIdx - r.startIdx + 1
43
+ return `${days} day${days === 1 ? '' : 's'}`
44
+ })
45
+ </script>
46
+
47
+ <template>
48
+ <AnimatePresence>
49
+ <motion.div
50
+ v-if="dragRange"
51
+ key="chart-brush-rect"
52
+ :initial="{ opacity: 0 }"
53
+ :animate="{ opacity: 1 }"
54
+ :exit="{ opacity: 0 }"
55
+ :transition="enterTransition"
56
+ :style="{ left: `${selectionLeft}px`, width: `${selectionWidth}px`, bottom: `${bottomOffset ?? 36}px` }"
57
+ class="absolute top-0 z-10 pointer-events-none bg-primary/10"
58
+ />
59
+ <motion.div
60
+ v-if="dragRange"
61
+ key="chart-brush-start"
62
+ :initial="{ opacity: 0, y: 4 }"
63
+ :animate="{ opacity: 1, y: 0 }"
64
+ :exit="{ opacity: 0, y: 4 }"
65
+ :transition="enterTransition"
66
+ :style="{ left: `${selectionLeft}px`, bottom: `${bottomOffset ?? 36}px` }"
67
+ class="absolute z-10 -translate-x-1/2 px-1.5 py-0.5 rounded-full bg-inverted text-inverted text-mini font-medium tabular-nums whitespace-nowrap pointer-events-none shadow-sm"
68
+ >
69
+ {{ startLabel }}
70
+ </motion.div>
71
+ <motion.div
72
+ v-if="dragRange"
73
+ key="chart-brush-end"
74
+ :initial="{ opacity: 0, y: 4 }"
75
+ :animate="{ opacity: 1, y: 0 }"
76
+ :exit="{ opacity: 0, y: 4 }"
77
+ :transition="enterTransition"
78
+ :style="{ left: `${selectionLeft + selectionWidth}px`, bottom: `${bottomOffset ?? 36}px` }"
79
+ class="absolute z-10 -translate-x-1/2 px-1.5 py-0.5 rounded-full bg-inverted text-inverted text-mini font-medium tabular-nums whitespace-nowrap pointer-events-none shadow-sm"
80
+ >
81
+ {{ endLabel }}
82
+ </motion.div>
83
+ <motion.div
84
+ v-if="dragRange && selectionWidth > (durationThreshold ?? 56)"
85
+ key="chart-brush-duration"
86
+ :initial="{ opacity: 0, scale: 0.9 }"
87
+ :animate="{ opacity: 1, scale: 1 }"
88
+ :exit="{ opacity: 0, scale: 0.9 }"
89
+ :transition="enterTransition"
90
+ :style="{ left: `${selectionLeft + selectionWidth / 2}px` }"
91
+ class="absolute top-2 z-10 -translate-x-1/2 px-2 py-0.5 rounded-full bg-primary/90 text-inverted text-label whitespace-nowrap pointer-events-none shadow-sm"
92
+ >
93
+ {{ durationLabel }}
94
+ </motion.div>
95
+ </AnimatePresence>
96
+ </template>