@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.
- package/README.md +101 -2
- package/app/app.config.ts +596 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +232 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +125 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +55 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +68 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +29 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +47 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1892 -0
- package/nuxt.config.ts +132 -0
- package/package.json +86 -3
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/fonts/README.md +31 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- package/public/touch-icon.png +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- 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"> </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">“ </span>{{ seg.text }}<span class="text-dimmed" aria-hidden="true"> ”</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">“ </span>{{ seg.text }}<span class="text-dimmed" aria-hidden="true"> ”</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>
|