@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,325 @@
1
+ <script setup lang="ts">
2
+ import type { RichSegment } from '../../../shared/rich-text'
3
+ import { computed } from 'vue'
4
+ import { NuxtLink, UiFavicon, UiIcon, UiTooltip } from '#components'
5
+ /**
6
+ * UiRichText — renders a `RichSegment[]` inline sentence of typed tokens.
7
+ * Supersedes the retired `UiActionTitle` (deleted Phase 4, 2026-07-29, once
8
+ * its last consumers moved to `richSegments`) — same styling precedent,
9
+ * extended with `page`/`domain`/`delta`/`code` and optional tooltips/deep
10
+ * links. See `../../../shared/rich-text.ts`
11
+ * for the pinned contract; segments are never parsed from prose here, they
12
+ * arrive already typed from a why-template.
13
+ *
14
+ * Colour budget (DESIGN.md, near-monochrome): the only pieces that earn hue are
15
+ * `position`'s quality band (mirrors `UiPositionMetric`) and `delta` when its
16
+ * caller passes an explicit `good`. Everything else is weight, mono, and
17
+ * dimming — no chip fills. Every hue-bearing signal also gets a text/aria
18
+ * equivalent so it's never colour-only (WCAG 1.4.1).
19
+ *
20
+ * Brand-flavor pass (2026-07-28, Phase 2 Wave B3) — personality through
21
+ * typography, not decoration: `metric`/`position`/delta's `to` value ride
22
+ * `.numerals-display`(-heavy) so figures read as set, not typed; `keyword`
23
+ * quotes are curly + hair-spaced (U+200A) off the mono run so they read as
24
+ * intentional typography, not stray punctuation; `delta` pairs fade/lift 2px
25
+ * once on mount (`.rich-delta-reveal`, registered in the reduced-motion
26
+ * block); `domain` favicons sit in a 14px ring-tinted frame — the one place a
27
+ * primary tint is allowed, because it marks an external entity, not a status,
28
+ * and their gap is em-relative and narrower than the row's `gap-x-[0.25em]`
29
+ * inter-word space so the host stays grouped with its own favicon;
30
+ * `page` dims its leading directory so the differentiating last segment pops;
31
+ * `code` gets a whisper `bg-elevated` inset, distinct from `.code-inline`'s
32
+ * bolder chip.
33
+ */
34
+
35
+ const { segments, size = 'sm', truncate = false, spacing = 'auto', inertLinks = false } = defineProps<{
36
+ segments: RichSegment[]
37
+ /**
38
+ * Render every linked token as plain text — no `NuxtLink`, no external `<a>`.
39
+ *
40
+ * Set by any host that is ITSELF inside an interactive element (a linked row,
41
+ * a `<button>` row). An `<a>` inside an `<a>` is not valid HTML: the parser
42
+ * closes the outer anchor before the inner one, so the server's byte stream
43
+ * and the client's vdom describe different trees and hydration mismatches on
44
+ * every such row. `<a>` inside `<button>` is invalid for the same reason
45
+ * (interactive content has no interactive descendants), and swallows the
46
+ * button's own activation.
47
+ *
48
+ * The affordance isn't lost, it moves: the row opens a detail surface that
49
+ * renders the SAME segments with their links live.
50
+ *
51
+ * An `external` `domain` segment is the one token that links without a `to`
52
+ * (a raw `<a href>`), so it is gated on this flag directly and falls through
53
+ * to the span form, which keeps the ring-framed favicon marking it external.
54
+ */
55
+ inertLinks?: boolean
56
+ /**
57
+ * Who owns the whitespace between segments.
58
+ *
59
+ * `auto` (default) — this component injects a space before each segment. Right
60
+ * for a TOKEN LIST, where segments are discrete things sitting side by side.
61
+ *
62
+ * `none` — the caller's text segments already carry their own spaces. Right for
63
+ * a SENTENCE, where a token can be followed by punctuation: with `auto`, a
64
+ * possessive renders `example.com 's traffic?` and a trailing token renders
65
+ * `3 months ?`. Pair it with `whitespace-pre-wrap` on the host so the caller's
66
+ * literal spaces survive.
67
+ */
68
+ spacing?: 'auto' | 'none'
69
+ /** Text-size + density knob. `sm` matches `UiActionTitle`'s ambient board-row
70
+ * usage (inherits `text-sm` from the caller); `md`/`lg` size the sentence
71
+ * directly for standalone headline contexts (e.g. the modal why-sentence);
72
+ * `inherit` stamps no size at all — for hosts whose row scale isn't `sm`
73
+ * (e.g. a `text-xs` feed row) where the tokens size off the ambient `em`. */
74
+ size?: 'sm' | 'md' | 'lg' | 'inherit'
75
+ /**
76
+ * Single-line row mode with PER-SEGMENT truncation. A one-line host that
77
+ * end-truncates the whole sentence lets a long leading token push the rest
78
+ * out entirely (a fault-first "404 Not Found — <path>" row on a narrow card
79
+ * showed no path at all) — here the sentence becomes an inline-flex row
80
+ * where only the long-tail segments (`page`/`keyword`/`code`) shrink with
81
+ * their own ellipsis, so every token stays represented. Those segments
82
+ * render tooltip-less in this mode (a tooltip wrapper span can't shrink);
83
+ * `page` keeps its full URL in `title`. Off for wrapping contexts (modal
84
+ * sentences) where multi-line flow is the point. `'sm'` wraps below the
85
+ * `sm` breakpoint and truncates from it up: a touch screen has no hover
86
+ * tooltip to recover a cut title.
87
+ */
88
+ truncate?: boolean | 'sm'
89
+ }>()
90
+
91
+ /** Long-tail tokens shrink with their own ellipsis wherever the row is single-line. */
92
+ /** Linked tokens take the design focus ring instead of the browser outline. */
93
+ const LINK_FOCUS = 'rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
94
+
95
+ const shrinkClass = computed(() => truncate === 'sm' ? 'sm:min-w-0 sm:shrink sm:truncate' : truncate ? 'min-w-0 shrink truncate' : '')
96
+
97
+ const sizeClass = {
98
+ sm: 'text-sm',
99
+ md: 'text-base',
100
+ lg: 'text-lg',
101
+ inherit: '',
102
+ } as const
103
+
104
+ // Position quality → semantic text colour + a screen-reader qualifier, so the
105
+ // signal never rides on hue alone. Mirrors UiPositionMetric's bands.
106
+ // Deliberate second, severity-toned tier variant for inline prose — NOT
107
+ // `UiPositionMetric`'s table-cell spec (amber 1-3 / blue 4-10 / muted 11+).
108
+ // A sentence needs a binary-ish "is this fine or not" read at a glance, so
109
+ // `position` here uses the same success/warning/error vocabulary every other
110
+ // inline segment (delta, coverage) already carries; the table component's
111
+ // tighter banding stays as-is for its own denser, side-by-side context.
112
+ // Confirmed 2026-07-28 visual-scoring pass — not drift, see gap-hunt.md.
113
+ function positionMeta(position: number): { tone: string, label: string } {
114
+ if (!Number.isFinite(position))
115
+ return { tone: 'text-muted', label: '' }
116
+ if (position < 10)
117
+ return { tone: 'text-success', label: ' (strong rank)' }
118
+ if (position < 30)
119
+ return { tone: 'text-warning', label: ' (moderate rank)' }
120
+ return { tone: 'text-error', label: ' (weak rank)' }
121
+ }
122
+
123
+ // Delta colour rides ONLY on an explicit `good` — direction alone (up/down/flat)
124
+ // never implies "better"/"worse" on its own (e.g. a ranking POSITION going up
125
+ // is worse). Neutral is the default so the trend color budget stays hero-only.
126
+ function deltaTone(good?: boolean): string {
127
+ if (good === true)
128
+ return 'text-success'
129
+ if (good === false)
130
+ return 'text-error'
131
+ return 'text-muted'
132
+ }
133
+
134
+ const DELTA_ICON = {
135
+ up: 'arrow-up-right',
136
+ down: 'arrow-down-right',
137
+ flat: 'next',
138
+ } as const
139
+
140
+ function deltaAriaLabel(seg: Extract<RichSegment, { kind: 'delta' }>): string {
141
+ const verdict = seg.good === true ? ', improved' : seg.good === false ? ', worsened' : ''
142
+ return `${seg.from} to ${seg.to}, trending ${seg.direction}${verdict}`
143
+ }
144
+
145
+ // Tooltip only wraps when the tooltip text says something the visible label
146
+ // doesn't already say — an identical tooltip is dead weight on the trigger.
147
+ function visibleText(seg: RichSegment): string {
148
+ switch (seg.kind) {
149
+ case 'delta': return `${seg.from} ${seg.to}`
150
+ default: return 'text' in seg ? seg.text : ''
151
+ }
152
+ }
153
+ function showTooltip(seg: RichSegment): boolean {
154
+ return 'tooltip' in seg && !!seg.tooltip && seg.tooltip !== visibleText(seg)
155
+ }
156
+
157
+ /**
158
+ * The route a segment links to, or `undefined` under `inertLinks`. Every
159
+ * link-bearing branch below reads its `to` through here, so there is one place
160
+ * that decides whether this sentence emits anchors at all.
161
+ */
162
+ function segTo(seg: RichSegment): string | undefined {
163
+ if (inertLinks)
164
+ return undefined
165
+ return 'to' in seg && seg.to ? seg.to : undefined
166
+ }
167
+
168
+ /** Punctuation that binds to the word before it — a space in front reads as a typo. */
169
+ const BINDS_LEFT = /^["'’`.,!?;:)\]}%…]/
170
+
171
+ /**
172
+ * Whether to emit a space before segment `i`.
173
+ *
174
+ * The spacing is injected rather than carried in the caller's text because a
175
+ * literal space at the boundary of an adjacent `inline-flex` token does not
176
+ * survive HTML whitespace handling — measured, not assumed.
177
+ *
178
+ * `spacing="none"` (sentence mode) keeps the injection but skips it where the
179
+ * next run opens with punctuation that binds leftward, so a possessive renders
180
+ * `example.com's traffic?` rather than `example.com 's traffic?`. Token-list
181
+ * callers (the default) space unconditionally, since a token list has no
182
+ * punctuation running between its items.
183
+ */
184
+ function needsLeadingSpace(seg: RichSegment, i: number): boolean {
185
+ // Flex mode drops whitespace-only text nodes, so `'sm'` keeps them for its wrapped flow.
186
+ if (i === 0 || seg.kind === 'sep' || truncate === true)
187
+ return false
188
+ if (spacing !== 'none')
189
+ return true
190
+ return !(seg.kind === 'text' && BINDS_LEFT.test(seg.text))
191
+ }
192
+
193
+ // `page` compaction: the leading directory reads as context (dimmed), the
194
+ // last path segment is the differentiating part and carries full weight —
195
+ // mirrors why the path was compacted to begin with (compactTarget's
196
+ // rationale: don't make the reader re-parse a whole URL to find what changed).
197
+ function splitPathTail(text: string): { lead: string, tail: string } {
198
+ const idx = text.lastIndexOf('/')
199
+ if (idx < 0 || idx === text.length - 1)
200
+ return { lead: '', tail: text }
201
+ return { lead: text.slice(0, idx + 1), tail: text.slice(idx + 1) }
202
+ }
203
+ </script>
204
+
205
+ <template>
206
+ <!-- Truncate mode clips overflow, so a 2px pad with a matching negative
207
+ margin leaves room for a focused token's ring without moving text. -->
208
+ <span
209
+ :class="[sizeClass[size], truncate === 'sm' ? '[overflow-wrap:anywhere] sm:inline-flex sm:min-w-0 sm:max-w-[calc(100%+4px)] sm:items-baseline sm:gap-x-[0.25em] sm:overflow-hidden sm:whitespace-nowrap sm:-m-0.5 sm:p-0.5' : truncate ? 'inline-flex min-w-0 max-w-[calc(100%+4px)] items-baseline gap-x-[0.25em] overflow-hidden whitespace-nowrap -m-0.5 p-0.5' : '']"
210
+ data-ui="UiRichText"
211
+ >
212
+ <template v-for="(seg, i) in segments" :key="i">
213
+ <!-- Flex mode spaces via gap; whitespace-only text nodes are dropped by flex layout. -->
214
+ <template v-if="needsLeadingSpace(seg, i)">{{ ' ' }}</template>
215
+
216
+ <span v-if="seg.kind === 'text'">{{ seg.text }}</span>
217
+
218
+ <!-- In the wrapped flow of `truncate="sm"`, a no-break space keeps the
219
+ separator off the start of a line. Flex mode spaces via gap. -->
220
+ <span v-else-if="seg.kind === 'sep'" class="text-dimmed" aria-hidden="true"><template v-if="truncate === 'sm'"><span class="sm:hidden">&nbsp;</span>· </template><template v-else> · </template></span>
221
+
222
+ <!-- Truncate-mode long-tail tokens: tooltip-less so the flex item IS the
223
+ shrinkable element (a tooltip trigger wrapper can't shrink). -->
224
+ <component
225
+ :is="segTo(seg) ? NuxtLink : 'code'"
226
+ v-else-if="seg.kind === 'keyword' && truncate"
227
+ :to="segTo(seg)"
228
+ class="font-mono tracking-[-0.01em] text-default"
229
+ :class="[shrinkClass, segTo(seg) ? `underline decoration-dotted decoration-muted underline-offset-4 hover:text-highlighted hover:decoration-solid ${LINK_FOCUS}` : '']"
230
+ :title="seg.tooltip || undefined"
231
+ ><span class="text-dimmed" aria-hidden="true">&ldquo;&#8202;</span>{{ seg.text }}<span class="text-dimmed" aria-hidden="true">&#8202;&rdquo;</span></component>
232
+
233
+ <UiTooltip v-else-if="seg.kind === 'keyword'" :disabled="!showTooltip(seg)" :text="seg.tooltip">
234
+ <component
235
+ :is="segTo(seg) ? NuxtLink : 'code'"
236
+ :to="segTo(seg)"
237
+ class="font-mono tracking-[-0.01em] text-default"
238
+ :class="segTo(seg) || showTooltip(seg) ? `underline decoration-dotted decoration-muted underline-offset-4 hover:text-highlighted hover:decoration-solid ${LINK_FOCUS}` : ''"
239
+ ><span class="text-dimmed" aria-hidden="true">&ldquo;&#8202;</span>{{ seg.text }}<span class="text-dimmed" aria-hidden="true">&#8202;&rdquo;</span></component>
240
+ </UiTooltip>
241
+
242
+ <component
243
+ :is="segTo(seg) ? NuxtLink : 'span'"
244
+ v-else-if="seg.kind === 'handle'"
245
+ :to="segTo(seg)"
246
+ class="font-mono text-[0.92em] text-default"
247
+ :class="segTo(seg) ? `underline-offset-2 hover:text-highlighted hover:underline ${LINK_FOCUS}` : ''"
248
+ >{{ seg.text }}</component>
249
+
250
+ <component
251
+ :is="segTo(seg) ? NuxtLink : 'span'"
252
+ v-else-if="seg.kind === 'page' && truncate"
253
+ :to="segTo(seg)"
254
+ class="font-sans"
255
+ :class="[shrinkClass, segTo(seg) ? `underline-offset-2 hover:text-highlighted hover:underline ${LINK_FOCUS}` : '']"
256
+ :aria-label="seg.text"
257
+ :title="seg.tooltip || seg.url"
258
+ ><span class="hidden text-dimmed sm:inline">{{ splitPathTail(seg.text).lead }}</span><span class="text-default">{{ splitPathTail(seg.text).tail }}</span></component>
259
+
260
+ <UiTooltip v-else-if="seg.kind === 'page'" :disabled="!showTooltip(seg)" :text="seg.tooltip">
261
+ <component
262
+ :is="segTo(seg) ? NuxtLink : 'span'"
263
+ :to="segTo(seg)"
264
+ class="font-sans truncate align-bottom"
265
+ :class="segTo(seg) ? `underline-offset-2 hover:text-highlighted hover:underline ${LINK_FOCUS}` : ''"
266
+ :title="seg.url"
267
+ ><span class="text-dimmed">{{ splitPathTail(seg.text).lead }}</span><span class="text-default">{{ splitPathTail(seg.text).tail }}</span></component>
268
+ </UiTooltip>
269
+
270
+ <a
271
+ v-else-if="seg.kind === 'domain' && seg.external && !inertLinks"
272
+ :href="`https://${seg.domain}`"
273
+ target="_blank"
274
+ rel="noopener noreferrer"
275
+ class="inline-flex items-center gap-[0.2em] align-middle font-medium text-default underline-offset-2 hover:text-highlighted hover:underline"
276
+ :class="LINK_FOCUS"
277
+ >
278
+ <span class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm ring-1 ring-primary/30 bg-primary/8">
279
+ <UiFavicon :domain="seg.domain" :size="11" decorative />
280
+ </span>
281
+ {{ seg.text }}
282
+ <UiIcon name="external" class="size-2.5 text-dimmed" aria-hidden="true" />
283
+ <span class="sr-only">(opens in a new tab)</span>
284
+ </a>
285
+ <component
286
+ :is="segTo(seg) ? NuxtLink : 'span'"
287
+ v-else-if="seg.kind === 'domain'"
288
+ :to="segTo(seg)"
289
+ class="inline-flex items-center gap-[0.2em] align-middle font-medium text-default"
290
+ :class="segTo(seg) ? `underline-offset-2 hover:text-highlighted hover:underline ${LINK_FOCUS}` : ''"
291
+ >
292
+ <span class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm ring-1 ring-primary/30 bg-primary/8">
293
+ <UiFavicon :domain="seg.domain" :size="11" decorative />
294
+ </span>
295
+ {{ seg.text }}
296
+ </component>
297
+
298
+ <UiTooltip v-else-if="seg.kind === 'metric'" :disabled="!showTooltip(seg)" :text="seg.tooltip">
299
+ <span class="numerals-display text-default">{{ seg.text }}</span>
300
+ </UiTooltip>
301
+
302
+ <span
303
+ v-else-if="seg.kind === 'position'"
304
+ class="numerals-display numerals-display-heavy"
305
+ :class="positionMeta(seg.position).tone"
306
+ :title="`Position ${seg.position}${positionMeta(seg.position).label}`"
307
+ >{{ seg.text }}<span class="sr-only">{{ positionMeta(seg.position).label }}</span></span>
308
+
309
+ <UiTooltip v-else-if="seg.kind === 'delta'" :disabled="!showTooltip(seg)" :text="seg.tooltip">
310
+ <span
311
+ class="rich-delta-reveal inline-flex items-center gap-1.5"
312
+ :class="deltaTone(seg.good)"
313
+ :title="deltaAriaLabel(seg)"
314
+ >
315
+ <span class="numerals-display text-muted">{{ seg.from }}</span>
316
+ <UiIcon :name="DELTA_ICON[seg.direction]" class="size-3 shrink-0" aria-hidden="true" />
317
+ <span class="numerals-display numerals-display-heavy">{{ seg.to }}</span>
318
+ <span class="sr-only">{{ deltaAriaLabel(seg) }}</span>
319
+ </span>
320
+ </UiTooltip>
321
+
322
+ <code v-else-if="seg.kind === 'code'" class="rounded-[3px] bg-elevated px-1 py-0.5 font-mono text-[0.85em] text-muted ring-1 ring-default/60" :class="shrinkClass">{{ seg.text }}</code>
323
+ </template>
324
+ </span>
325
+ </template>
@@ -0,0 +1,144 @@
1
+ <script lang="ts" setup generic="T extends ScatterPoint">
2
+ import type { VNodeChild } from 'vue'
3
+ import { computed } from 'vue'
4
+ import { UiChartDataReveal, UiSkeleton, UiTooltip } from '#components'
5
+ // Bubble scatter plot. Each point is plotted by two percentage coordinates
6
+ // (x/y, 0..100 within the plot area) and sized by a third metric, the marker
7
+ // drawn as a circle whose inner content is slotted. The canonical "opportunity
8
+ // map" surface (reach vs conversion, sized by volume).
9
+ //
10
+ // Tooltip anchoring: the positioned wrapper sits OUTSIDE the tooltip trigger and
11
+ // the sized marker lives inside it. reka's `TooltipTrigger as-child` measures the
12
+ // trigger element's box for floating-ui; if the `position:absolute` element were
13
+ // the trigger's child (out of flow) the trigger would collapse to zero-size at
14
+ // the plot origin and every tooltip would anchor to the top-left corner. Keeping
15
+ // the marker in-flow inside an absolutely-positioned wrapper anchors each tooltip
16
+ // to its own bubble.
17
+
18
+ export interface ScatterPoint {
19
+ /** Stable key per point. */
20
+ key: string | number
21
+ /** Horizontal position, 0..100 (% of plot area). */
22
+ x: number
23
+ /** Vertical position, 0..100 (% of plot area). */
24
+ y: number
25
+ /** Marker diameter in px. */
26
+ size: number
27
+ /** Tooltip title. */
28
+ title?: string
29
+ /** Tooltip description. */
30
+ description?: string
31
+ }
32
+
33
+ const {
34
+ points,
35
+ xLabel,
36
+ yLabel,
37
+ midline = false,
38
+ tooltipSide = 'top',
39
+ label,
40
+ loading = false,
41
+ } = defineProps<{
42
+ points: T[]
43
+ /** Bottom-right axis caption. */
44
+ xLabel?: string
45
+ /** Left vertical axis caption. */
46
+ yLabel?: string
47
+ /** Draw a faint dashed gridline across the vertical midpoint. */
48
+ midline?: boolean
49
+ tooltipSide?: 'top' | 'bottom' | 'left' | 'right'
50
+ /** Accessible name for the screen-reader data table (the plot is position-only). */
51
+ label?: string
52
+ /** Swap the markers for placeholder bubbles, keeping the plot chrome. */
53
+ loading?: boolean
54
+ }>()
55
+
56
+ defineSlots<{
57
+ /** Inner marker content (e.g. a flag icon). Receives the full point. */
58
+ marker?: (props: { point: T }) => VNodeChild
59
+ }>()
60
+
61
+ // Placeholder bubbles for `loading`. They render through the SAME positioned
62
+ // wrapper (and the same centring translate) as real markers, so the plot box,
63
+ // axes and bubble field are already the final layout when data lands — the
64
+ // caller can't drift a hand-rolled skeleton out of shape, and a bubble near the
65
+ // right edge extends half its diameter, not its full width, past its x.
66
+ const LOADING_POINTS = [
67
+ { key: 'l0', x: 12, y: 66, size: 20 },
68
+ { key: 'l1', x: 27, y: 42, size: 34 },
69
+ { key: 'l2', x: 43, y: 55, size: 24 },
70
+ { key: 'l3', x: 58, y: 27, size: 44 },
71
+ { key: 'l4', x: 74, y: 38, size: 28 },
72
+ { key: 'l5', x: 88, y: 18, size: 18 },
73
+ ]
74
+
75
+ // The visual plot encodes data by x/y position only, so it is hidden from
76
+ // assistive tech; a visually-hidden data table carries the same points as text.
77
+ const tableCaption = computed(() => label || [yLabel, xLabel].filter(Boolean).join(' vs ') || 'Plotted data')
78
+ </script>
79
+
80
+ <template>
81
+ <div data-ui="UiScatterPlot" class="relative h-60 select-none" :aria-busy="loading || undefined">
82
+ <!-- Plot area with zero-baseline axes. Position-encoded → hidden from AT;
83
+ the sr-only table below carries the same data. -->
84
+ <div class="absolute inset-x-6 top-2 bottom-7 border-l border-b border-muted" aria-hidden="true">
85
+ <div v-if="midline" class="absolute inset-x-0 top-1/2 border-t border-dashed border-default/40" aria-hidden="true" />
86
+ <!-- Loading: same wrapper + centring as a real marker, so nothing moves
87
+ (or spills past the plot edge) when the points arrive. -->
88
+ <div
89
+ v-for="point in (loading ? LOADING_POINTS : [])"
90
+ :key="`skeleton-${point.key}`"
91
+ class="absolute -translate-x-1/2 -translate-y-1/2"
92
+ :style="{ left: `${point.x}%`, top: `${point.y}%` }"
93
+ >
94
+ <UiSkeleton type="circle" :base="point.size" />
95
+ </div>
96
+
97
+ <!-- Positioned wrapper stays OUTSIDE the tooltip trigger so the trigger
98
+ anchors to the marker's real location (see component comment). -->
99
+ <div
100
+ v-for="point in (loading ? [] : points)"
101
+ :key="point.key"
102
+ class="absolute -translate-x-1/2 -translate-y-1/2 hover:z-10"
103
+ :style="{ left: `${point.x}%`, top: `${point.y}%` }"
104
+ >
105
+ <UiTooltip :title="point.title" :description="point.description" :side="tooltipSide">
106
+ <div
107
+ class="flex items-center justify-center rounded-full bg-primary/8 ring-1 ring-primary/15 motion-safe:transition-transform motion-safe:duration-150 motion-safe:hover:scale-110 cursor-default"
108
+ :style="{ width: `${point.size}px`, height: `${point.size}px` }"
109
+ >
110
+ <slot name="marker" :point="point" />
111
+ </div>
112
+ </UiTooltip>
113
+ </div>
114
+ </div>
115
+ <!-- Axis labels -->
116
+ <span v-if="yLabel" class="absolute left-0 top-1 text-mini text-dimmed tabular-nums [writing-mode:vertical-rl] rotate-180" aria-hidden="true">{{ yLabel }}</span>
117
+ <span v-if="xLabel" class="absolute bottom-0 right-6 text-mini text-dimmed" aria-hidden="true">{{ xLabel }}</span>
118
+
119
+ <!-- Screen-reader equivalent of the position-only plot, and the keyboard
120
+ path to the point tooltips: UiChartDataReveal keeps it hidden until a
121
+ keyboard user asks for it. -->
122
+ <UiChartDataReveal v-if="!loading">
123
+ <table>
124
+ <caption>{{ tableCaption }}</caption>
125
+ <thead>
126
+ <tr>
127
+ <th scope="col">
128
+ Item
129
+ </th>
130
+ <th scope="col">
131
+ Detail
132
+ </th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>
136
+ <tr v-for="point in points" :key="point.key">
137
+ <td>{{ point.title || '—' }}</td>
138
+ <td>{{ point.description || '—' }}</td>
139
+ </tr>
140
+ </tbody>
141
+ </table>
142
+ </UiChartDataReveal>
143
+ </div>
144
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ const { severity } = defineProps<{
3
+ severity: 'error' | 'warning' | 'info'
4
+ }>()
5
+
6
+ const SEVERITY_WORD: Record<'error' | 'warning' | 'info', string> = {
7
+ error: 'High',
8
+ warning: 'Medium',
9
+ info: 'Low',
10
+ }
11
+ </script>
12
+
13
+ <template>
14
+ <span
15
+ class="flex h-5 w-2 shrink-0 items-center justify-center"
16
+ role="img"
17
+ :aria-label="`${SEVERITY_WORD[severity]} severity`"
18
+ :title="`${SEVERITY_WORD[severity]} severity`"
19
+ >
20
+ <!-- A custom path keeps the 8px triangle soft without adding icon detail. -->
21
+ <svg v-if="severity === 'error'" viewBox="0 0 8 8" class="size-2 text-error" aria-hidden="true">
22
+ <path
23
+ fill="currentColor"
24
+ d="M4 .6c.23 0 .43.12.55.33l3.07 5.32c.24.42-.06.95-.54.95H.92c-.48 0-.78-.53-.54-.95L3.45.93A.63.63 0 0 1 4 .6Z"
25
+ />
26
+ </svg>
27
+ <span v-else-if="severity === 'warning'" class="size-1.5 rotate-45 rounded-[1px] border border-warning" aria-hidden="true" />
28
+ <span v-else class="size-1.5 rounded-full border border-[var(--ui-text-dimmed)]" aria-hidden="true" />
29
+ </span>
30
+ </template>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ // Column sparkline: a run of short columns, one per bucket, the last one
5
+ // carrying the row's tint. This is the sparkline for anything under about
6
+ // 40px tall: a table cell, a list row, a card corner. Under that height a line
7
+ // turns to anti-aliased mush, and a line through weekly buckets or zero-heavy
8
+ // counts invents continuity the data does not have. `UiSparkline` stays the
9
+ // primitive for dense daily series with hover; this one has no hover, no
10
+ // axis, and no gradient on purpose.
11
+ //
12
+ // Colour follows DESIGN's budget. Every column is the neutral border tone; only
13
+ // the last takes `tone`, and `tone` defaults to neutral, so a list of twenty
14
+ // rows paints nothing unless the caller says which rows are the point. The tint
15
+ // is a claim about the last bucket, so the caller keeps that bucket the same
16
+ // window as the number it sits beside.
17
+
18
+ type Tone = 'success' | 'error' | 'warning' | 'info' | 'neutral'
19
+
20
+ interface Props {
21
+ /** Values, oldest first. Buckets are summed (or averaged when `inverted`). */
22
+ data: number[]
23
+ /**
24
+ * Columns to draw. Longer series fold into that many buckets aligned on the
25
+ * END, so the last column is always the newest whole bucket: 90 daily points
26
+ * over 30 columns is one column per three days. Defaults to the size's own
27
+ * count, about a 5px pitch, so a column stays a column and not a hairline.
28
+ */
29
+ columns?: number
30
+ /**
31
+ * Lower-is-better series (rank). The axis flips so #1 fills the track and a
32
+ * value at or past `floor` draws as a stub, the same convention as
33
+ * `UiSparkline` and the rank-run email chart.
34
+ */
35
+ inverted?: boolean
36
+ /** With `inverted`, the value that means "off the chart" (rank 31 = not in the top 30). */
37
+ floor?: number
38
+ /** Tint of the last column. Neutral draws it one step darker than the rest. */
39
+ tone?: Tone
40
+ /** The last bucket is still filling: it renders hatched, never as a fall. */
41
+ partial?: boolean
42
+ size?: 'sm' | 'md' | 'lg'
43
+ width?: number | string
44
+ height?: number | string
45
+ /** Spoken alternative. Say what the run is and where it ended. */
46
+ ariaLabel?: string
47
+ }
48
+
49
+ const {
50
+ data,
51
+ columns,
52
+ inverted = false,
53
+ floor = 31,
54
+ tone = 'neutral',
55
+ partial = false,
56
+ size = 'md',
57
+ width,
58
+ height,
59
+ ariaLabel,
60
+ } = defineProps<Props>()
61
+
62
+ const SIZE = {
63
+ sm: { w: 96, h: 20, gap: 1.5, columns: 18 },
64
+ md: { w: 120, h: 24, gap: 2, columns: 24 },
65
+ lg: { w: 160, h: 32, gap: 2, columns: 32 },
66
+ } as const
67
+
68
+ const preset = computed(() => SIZE[size])
69
+ const cssWidth = computed(() => width ?? preset.value.w)
70
+ const cssHeight = computed(() => height ?? preset.value.h)
71
+ const vbW = computed(() => typeof cssWidth.value === 'number' ? cssWidth.value : preset.value.w)
72
+ const vbH = computed(() => typeof cssHeight.value === 'number' ? cssHeight.value : preset.value.h)
73
+
74
+ /** Fold the series into at most `columns` buckets, newest bucket last and whole. */
75
+ const buckets = computed<number[]>(() => {
76
+ const values = data.filter(v => Number.isFinite(v))
77
+ if (!values.length)
78
+ return []
79
+ const per = Math.max(1, Math.ceil(values.length / (columns ?? preset.value.columns)))
80
+ const out: number[] = []
81
+ // Retain the leading partial bucket instead of dropping early activity.
82
+ const remainder = values.length % per
83
+ for (let end = remainder || per; end <= values.length; end += per) {
84
+ const slice = values.slice(Math.max(0, end - per), end)
85
+ const sum = slice.reduce((acc, v) => acc + v, 0)
86
+ out.push(inverted ? sum / slice.length : sum)
87
+ }
88
+ return out
89
+ })
90
+
91
+ const TONE_CLASS: Record<Tone, string> = {
92
+ success: 'text-success',
93
+ error: 'text-error',
94
+ warning: 'text-warning',
95
+ info: 'text-info',
96
+ neutral: 'text-dimmed',
97
+ }
98
+
99
+ const MIN_HEIGHT = 2
100
+
101
+ const rects = computed(() => {
102
+ const n = buckets.value.length
103
+ if (!n)
104
+ return []
105
+ // Fit the run inside the declared box. The preset gap holds while every
106
+ // column keeps at least 1 unit; past that the gap gives way, then the
107
+ // column width itself. The svg draws overflow-visible, so a run longer
108
+ // than the viewBox would paint past the declared box, and the x clamp
109
+ // below keeps `x + width <= viewBox width` structural rather than a
110
+ // caller's hope.
111
+ const gap = preset.value.gap
112
+ const natural = (vbW.value - gap * (n - 1)) / n
113
+ const gapW = natural >= 1 ? gap : Math.max(0, (vbW.value - n) / Math.max(1, n - 1))
114
+ const colW = natural >= 1 ? natural : (vbW.value - gapW * (n - 1)) / n
115
+ const track = vbH.value
116
+ const max = inverted ? floor : Math.max(...buckets.value, 1)
117
+ return buckets.value.map((value, index) => {
118
+ // Rank: #1 fills the track, the floor keeps a stub. Count: scaled to the row's own maximum.
119
+ const ratio = inverted
120
+ ? Math.max(0, Math.min(1, (floor - Math.min(value, floor)) / Math.max(1, floor - 1)))
121
+ : value / max
122
+ const h = Math.max(MIN_HEIGHT, Math.round(ratio * track))
123
+ return {
124
+ x: Math.min(index * (colW + gapW), vbW.value - colW),
125
+ y: track - h,
126
+ w: colW,
127
+ h,
128
+ last: index === n - 1,
129
+ }
130
+ })
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <svg
136
+ v-if="rects.length"
137
+ :width="cssWidth"
138
+ :height="cssHeight"
139
+ :viewBox="`0 0 ${vbW} ${vbH}`"
140
+ preserveAspectRatio="none"
141
+ :role="ariaLabel ? 'img' : undefined"
142
+ :aria-label="ariaLabel"
143
+ :aria-hidden="ariaLabel ? undefined : 'true'"
144
+ class="block shrink-0 overflow-visible"
145
+ >
146
+ <rect
147
+ v-for="(r, i) in rects"
148
+ :key="i"
149
+ :x="r.x"
150
+ :y="r.y"
151
+ :width="r.w"
152
+ :height="r.h"
153
+ rx="1"
154
+ :class="[
155
+ r.last ? [TONE_CLASS[tone], 'fill-current'] : 'fill-current text-dimmed opacity-45',
156
+ r.last && partial ? 'opacity-50' : '',
157
+ ]"
158
+ />
159
+ </svg>
160
+ </template>