@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.
- package/README.md +181 -2
- package/app/app.config.ts +592 -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 +233 -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 +127 -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 +68 -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 +93 -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 +15 -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/app-favicon.ts +64 -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 +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -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/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,350 @@
|
|
|
1
|
+
import type { SemanticUiIcon } from '../../shared/icons'
|
|
2
|
+
import { semanticColors } from './semanticColors'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Data-viz palette — chart-only colours, distinct from semantic status colours.
|
|
6
|
+
*
|
|
7
|
+
* Brand seam. Every identity slot reads a `--ui-viz-<slot>` custom property,
|
|
8
|
+
* so a site that extends this layer recolours a chart from its own CSS:
|
|
9
|
+
*
|
|
10
|
+
* :root { --ui-viz-clicks: var(--ui-primary); }
|
|
11
|
+
*
|
|
12
|
+
* The `hex` field is `var(--ui-viz-<slot>, <hex>)`: SVG paint attributes,
|
|
13
|
+
* inline styles and `color-mix()` all resolve it, on the server and in the
|
|
14
|
+
* browser, so no JS colour read is needed. It is a CSS colour, not a hex
|
|
15
|
+
* literal: never parse it, mix it with `color-mix()` instead. The `bg`,
|
|
16
|
+
* `text` and `dot` classes are `*-viz-<slot>` utilities, declared in
|
|
17
|
+
* `css/global.css` over the same property, with this site's Tailwind hue as
|
|
18
|
+
* the fallback. Unset, every slot renders exactly the colour it did before.
|
|
19
|
+
* Named hue presets (`presetVizColors.blue`) stay literal: they are explicit
|
|
20
|
+
* hue requests, not identities.
|
|
21
|
+
*
|
|
22
|
+
* Three concerns:
|
|
23
|
+
* 1. Metric identity — each GSC / CWV metric has a fixed colour so multi-series
|
|
24
|
+
* charts are readable at a glance (clicks = blue, impressions = purple, …).
|
|
25
|
+
* 2. Category palettes — ordered swatches for series with no inherent identity
|
|
26
|
+
* (top pages, country donut, device split).
|
|
27
|
+
* 3. Threshold / brand splits — colours that carry meaning (brand vs non-brand
|
|
28
|
+
* traffic, indexing success vs error).
|
|
29
|
+
*
|
|
30
|
+
* Colour MUST encode information; never decorate. Routed through `vizColorMap`
|
|
31
|
+
* so legend chips and sparklines share one lookup.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface VizColor {
|
|
35
|
+
bg: string
|
|
36
|
+
hex: string
|
|
37
|
+
text: string
|
|
38
|
+
dot: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `dot` is the solid identity colour (legend dots, sparkline strokes); `bg` is its
|
|
43
|
+
* translucent fill variant for proportion bars / badge backgrounds so the value sitting
|
|
44
|
+
* on top stays readable. Standard `bg-{hue}-{shade}` and `bg-viz-{slot}` tokens get a `/15` fill; tokens that
|
|
45
|
+
* already carry an opacity modifier or use a semantic name (e.g. `bg-accented`) pass through.
|
|
46
|
+
*
|
|
47
|
+
* The `/15` variants are built at runtime, so Tailwind's content scanner never sees them
|
|
48
|
+
* as literals — they must be safelisted (see `vizFillSafelist` below) or the bar renders
|
|
49
|
+
* transparent.
|
|
50
|
+
*/
|
|
51
|
+
function viz(dot: string, hex: string, text: string): VizColor {
|
|
52
|
+
const bg = /^bg-(?:[a-z]+-\d+|viz-[a-z0-9-]+)$/.test(dot) ? `${dot}/15` : dot
|
|
53
|
+
return { bg, hex, text, dot }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tailwind JIT safelist. Every `bg-<hue>-<shade>/15` fill `viz()` can emit must appear
|
|
58
|
+
* here as a literal so Tailwind's source scanner generates the CSS (clicks/impressions
|
|
59
|
+
* worked only because `periodVizColors` happened to spell them out). Keep in sync with
|
|
60
|
+
* the `viz()` inputs below — a missing entry makes that metric's proportion bar invisible.
|
|
61
|
+
*/
|
|
62
|
+
export const vizFillSafelist = [
|
|
63
|
+
// Named hue presets.
|
|
64
|
+
'bg-blue-500/15',
|
|
65
|
+
'bg-purple-500/15',
|
|
66
|
+
'bg-emerald-500/15',
|
|
67
|
+
'bg-orange-500/15',
|
|
68
|
+
'bg-cyan-500/15',
|
|
69
|
+
'bg-amber-500/15',
|
|
70
|
+
'bg-red-500/15',
|
|
71
|
+
// Identity slots.
|
|
72
|
+
'bg-viz-clicks/15',
|
|
73
|
+
'bg-viz-impressions/15',
|
|
74
|
+
'bg-viz-ctr/15',
|
|
75
|
+
'bg-viz-position/15',
|
|
76
|
+
'bg-viz-sessions/15',
|
|
77
|
+
'bg-viz-users/15',
|
|
78
|
+
'bg-viz-pageviews/15',
|
|
79
|
+
'bg-viz-lcp/15',
|
|
80
|
+
'bg-viz-inp/15',
|
|
81
|
+
'bg-viz-cls/15',
|
|
82
|
+
'bg-viz-device-desktop/15',
|
|
83
|
+
'bg-viz-device-mobile/15',
|
|
84
|
+
'bg-viz-device-tablet/15',
|
|
85
|
+
'bg-viz-brand/15',
|
|
86
|
+
'bg-viz-non-brand/15',
|
|
87
|
+
'bg-viz-rank-1/15',
|
|
88
|
+
'bg-viz-rank-2/15',
|
|
89
|
+
'bg-viz-rank-3/15',
|
|
90
|
+
'bg-viz-rank-4/15',
|
|
91
|
+
'bg-viz-rank-5/15',
|
|
92
|
+
'bg-viz-rank-6/15',
|
|
93
|
+
'bg-viz-rank-7/15',
|
|
94
|
+
'bg-viz-rank-8/15',
|
|
95
|
+
'bg-viz-rank-9/15',
|
|
96
|
+
'bg-viz-rank-10/15',
|
|
97
|
+
'bg-viz-resource-document/15',
|
|
98
|
+
'bg-viz-resource-script/15',
|
|
99
|
+
'bg-viz-resource-stylesheet/15',
|
|
100
|
+
'bg-viz-resource-image/15',
|
|
101
|
+
'bg-viz-resource-font/15',
|
|
102
|
+
] as const
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The softer neutral chart grey, for "Other" residual slots and the deep
|
|
106
|
+
* position tiers. 75% alpha keeps it apart from the full neutral and still
|
|
107
|
+
* clears 3:1 against the page in both modes (WCAG 1.4.11); the old 50% measured
|
|
108
|
+
* 1.6:1 on white and 2.5:1 on dark. `color-mix` keeps it a CSS colour, so it
|
|
109
|
+
* follows `--ui-viz-neutral`.
|
|
110
|
+
*/
|
|
111
|
+
const neutralHalf = `color-mix(in srgb, ${semanticColors.neutral.hex} 75%, transparent)`
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* GSC metric identity — pinned so every clicks chart reads blue, every
|
|
115
|
+
* impressions chart reads purple, regardless of consumer.
|
|
116
|
+
*/
|
|
117
|
+
export const gscMetricColors = {
|
|
118
|
+
clicks: viz('bg-viz-clicks', 'var(--ui-viz-clicks, #3b82f6)', 'text-viz-clicks'),
|
|
119
|
+
impressions: viz('bg-viz-impressions', 'var(--ui-viz-impressions, #a855f7)', 'text-viz-impressions'),
|
|
120
|
+
ctr: viz('bg-viz-ctr', 'var(--ui-viz-ctr, var(--_viz-ctr, #10b981))', 'text-viz-ctr'),
|
|
121
|
+
position: viz('bg-viz-position', 'var(--ui-viz-position, var(--_viz-position, #f97316))', 'text-viz-position'),
|
|
122
|
+
} as const satisfies Record<string, VizColor>
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Web Analytics metric identity (ADR-0032). Paired with `text` / `dot` tailwind
|
|
126
|
+
* classes because the consumers (`ProCardAnalytics`) bind these via `:class`,
|
|
127
|
+
* whereas the chart line/area colours live inline next to the SVG gradients
|
|
128
|
+
* in `ProGraphAnalytics`.
|
|
129
|
+
*/
|
|
130
|
+
export const analyticsMetricColors = {
|
|
131
|
+
sessions: viz('bg-viz-sessions', 'var(--ui-viz-sessions, #3b82f6)', 'text-viz-sessions'),
|
|
132
|
+
users: viz('bg-viz-users', 'var(--ui-viz-users, var(--_viz-users, #06b6d4))', 'text-viz-users'),
|
|
133
|
+
pageviews: viz('bg-viz-pageviews', 'var(--ui-viz-pageviews, #a855f7)', 'text-viz-pageviews'),
|
|
134
|
+
} as const satisfies Record<string, VizColor>
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Core Web Vitals metric identity. `tbt` (synthetic lab) shares INP's amber —
|
|
138
|
+
* it occupies the same responsiveness slot when the source is Lighthouse.
|
|
139
|
+
*/
|
|
140
|
+
export const cwvMetricColors = {
|
|
141
|
+
lcp: viz('bg-viz-lcp', 'var(--ui-viz-lcp, #3b82f6)', 'text-viz-lcp'),
|
|
142
|
+
inp: viz('bg-viz-inp', 'var(--ui-viz-inp, var(--_viz-inp, #f59e0b))', 'text-viz-inp'),
|
|
143
|
+
tbt: viz('bg-viz-inp', 'var(--ui-viz-inp, var(--_viz-inp, #f59e0b))', 'text-viz-inp'),
|
|
144
|
+
cls: viz('bg-viz-cls', 'var(--ui-viz-cls, #a855f7)', 'text-viz-cls'),
|
|
145
|
+
} as const satisfies Record<string, VizColor>
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Period-comparison palette — current period vs comparison (prev/YoY) shown in
|
|
149
|
+
* the date-range picker, sparklines, and any "vs" pill. Pinned so all comparison
|
|
150
|
+
* surfaces share one hue.
|
|
151
|
+
*/
|
|
152
|
+
export const periodVizColors = {
|
|
153
|
+
// The text shade is per mode (css/global.css). The 400 shades measure under
|
|
154
|
+
// 3:1 on a light page, so light mode reads the 600 shade, which passes AA.
|
|
155
|
+
current: viz('bg-viz-current/15', 'var(--ui-viz-current, #3b82f6)', 'text-viz-current'),
|
|
156
|
+
comparison: viz('bg-viz-comparison/15', 'var(--ui-viz-comparison, #a855f7)', 'text-viz-comparison'),
|
|
157
|
+
} as const satisfies Record<string, VizColor>
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Success and warning as chart marks. The semantic hexes (green 500 2.3:1,
|
|
161
|
+
* yellow 500 1.9:1 on white) fall under the 3:1 a mark needs (WCAG 1.4.11),
|
|
162
|
+
* so light mode reads a deeper `--_viz-success` / `--_viz-warning` from
|
|
163
|
+
* css/global.css. Kept apart from `semanticColors` because some callers parse
|
|
164
|
+
* those hexes.
|
|
165
|
+
*/
|
|
166
|
+
const chartSuccess = `var(--_viz-success, ${semanticColors.success.hex})`
|
|
167
|
+
const chartWarning = `var(--_viz-warning, ${semanticColors.warning.hex})`
|
|
168
|
+
|
|
169
|
+
/** Indexing status — bridges semantic status into the chart palette. */
|
|
170
|
+
export const indexingVizColors = {
|
|
171
|
+
indexed: { bg: 'bg-success', hex: chartSuccess, text: 'text-success', dot: 'bg-success' },
|
|
172
|
+
notIndexed: { bg: 'bg-warning', hex: chartWarning, text: 'text-warning', dot: 'bg-warning' },
|
|
173
|
+
excluded: { bg: 'bg-warning', hex: chartWarning, text: 'text-warning', dot: 'bg-warning' },
|
|
174
|
+
errors: { bg: 'bg-error', hex: semanticColors.error.hex, text: 'text-error', dot: 'bg-error' },
|
|
175
|
+
error: { bg: 'bg-error', hex: semanticColors.error.hex, text: 'text-error', dot: 'bg-error' },
|
|
176
|
+
crawled: { bg: 'bg-info', hex: semanticColors.info.hex, text: 'text-info', dot: 'bg-info' },
|
|
177
|
+
} as const satisfies Record<string, VizColor>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Position-distribution tiers — premier (1–3) reads warning-amber so the eye
|
|
181
|
+
* goes there; second-page / beyond fade into muted neutrals. `beyond`/`deep`
|
|
182
|
+
* carry the softer neutral (`neutralHalf`, as in the "other" slots below), so a
|
|
183
|
+
* stacked chart can still tell the two neutral tiers apart.
|
|
184
|
+
*/
|
|
185
|
+
export const positionDistColors = {
|
|
186
|
+
premier: { bg: 'bg-warning', hex: chartWarning, text: 'text-warning', dot: 'bg-warning' },
|
|
187
|
+
pageOne: { bg: 'bg-info', hex: semanticColors.info.hex, text: 'text-info', dot: 'bg-info' },
|
|
188
|
+
secondPage: { bg: 'bg-muted', hex: semanticColors.neutral.hex, text: 'text-muted', dot: 'bg-muted' },
|
|
189
|
+
beyond: { bg: 'bg-elevated', hex: neutralHalf, text: 'text-muted', dot: 'bg-elevated' },
|
|
190
|
+
top3: { bg: 'bg-warning', hex: chartWarning, text: 'text-warning', dot: 'bg-warning' },
|
|
191
|
+
page1: { bg: 'bg-info', hex: semanticColors.info.hex, text: 'text-info', dot: 'bg-info' },
|
|
192
|
+
page2: { bg: 'bg-muted', hex: semanticColors.neutral.hex, text: 'text-muted', dot: 'bg-muted' },
|
|
193
|
+
deep: { bg: 'bg-elevated', hex: neutralHalf, text: 'text-muted', dot: 'bg-elevated' },
|
|
194
|
+
} as const satisfies Record<string, VizColor>
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Device split — desktop / mobile / tablet. Lighter shoulder hues so three
|
|
198
|
+
* values can sit beside each other without one dominating.
|
|
199
|
+
*/
|
|
200
|
+
export const gscDeviceColors = {
|
|
201
|
+
desktop: viz('bg-viz-device-desktop', 'var(--ui-viz-device-desktop, var(--_viz-device-desktop, #60a5fa))', 'text-viz-device-desktop'),
|
|
202
|
+
mobile: viz('bg-viz-device-mobile', 'var(--ui-viz-device-mobile, var(--_viz-device-mobile, #4ade80))', 'text-viz-device-mobile'),
|
|
203
|
+
tablet: viz('bg-viz-device-tablet', 'var(--ui-viz-device-tablet, var(--_viz-device-tablet, #fbbf24))', 'text-viz-device-tablet'),
|
|
204
|
+
} as const satisfies Record<string, VizColor>
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Brand vs non-brand traffic split. Violet pulls toward brand identity, emerald
|
|
208
|
+
* reads as "earned" non-brand traffic.
|
|
209
|
+
*/
|
|
210
|
+
export const gscBrandSplitColors = {
|
|
211
|
+
brand: viz('bg-viz-brand', 'var(--ui-viz-brand, #8b5cf6)', 'text-viz-brand'),
|
|
212
|
+
nonBrand: viz('bg-viz-non-brand', 'var(--ui-viz-non-brand, var(--_viz-non-brand, #10b981))', 'text-viz-non-brand'),
|
|
213
|
+
} as const satisfies Record<'brand' | 'nonBrand', VizColor>
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Top-entity stacked trend (`UiTopEntityStackChart`): 10 identity hues, ranked
|
|
217
|
+
* by entity rank, plus the neutral "Other" residual in the last slot. Every
|
|
218
|
+
* current caller tracks the top 5; the spare hues keep a wider caller safe. The
|
|
219
|
+
* first five hues are the ones `gscTopPagesColors` uses, so a page and a query
|
|
220
|
+
* of the same rank read alike.
|
|
221
|
+
*/
|
|
222
|
+
export const gscTopEntityColors: VizColor[] = [
|
|
223
|
+
viz('bg-viz-rank-1', 'var(--ui-viz-rank-1, #3b82f6)', 'text-viz-rank-1'),
|
|
224
|
+
viz('bg-viz-rank-2', 'var(--ui-viz-rank-2, #a855f7)', 'text-viz-rank-2'),
|
|
225
|
+
viz('bg-viz-rank-3', 'var(--ui-viz-rank-3, var(--_viz-rank-3, #34d399))', 'text-viz-rank-3'),
|
|
226
|
+
viz('bg-viz-rank-4', 'var(--ui-viz-rank-4, var(--_viz-rank-4, #f97316))', 'text-viz-rank-4'),
|
|
227
|
+
viz('bg-viz-rank-5', 'var(--ui-viz-rank-5, var(--_viz-rank-5, #06b6d4))', 'text-viz-rank-5'),
|
|
228
|
+
viz('bg-viz-rank-6', 'var(--ui-viz-rank-6, #ec4899)', 'text-viz-rank-6'),
|
|
229
|
+
viz('bg-viz-rank-7', 'var(--ui-viz-rank-7, var(--_viz-rank-7, #fbbf24))', 'text-viz-rank-7'),
|
|
230
|
+
viz('bg-viz-rank-8', 'var(--ui-viz-rank-8, #818cf8)', 'text-viz-rank-8'),
|
|
231
|
+
viz('bg-viz-rank-9', 'var(--ui-viz-rank-9, var(--_viz-rank-9, #2dd4bf))', 'text-viz-rank-9'),
|
|
232
|
+
viz('bg-viz-rank-10', 'var(--ui-viz-rank-10, var(--_viz-rank-10, #fb7185))', 'text-viz-rank-10'),
|
|
233
|
+
viz('bg-accented', neutralHalf, 'text-muted'),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Lighthouse request waterfall: one identity hue per resource type. Hues stay
|
|
238
|
+
* off the status set (green, amber, red), so a script bar never reads as a
|
|
239
|
+
* warning. Anything unlisted draws as `Other`.
|
|
240
|
+
*/
|
|
241
|
+
export const lighthouseResourceVizColors = {
|
|
242
|
+
Document: viz('bg-viz-resource-document', 'var(--ui-viz-resource-document, #3b82f6)', 'text-viz-resource-document'),
|
|
243
|
+
Script: viz('bg-viz-resource-script', 'var(--ui-viz-resource-script, var(--_viz-resource-script, #f97316))', 'text-viz-resource-script'),
|
|
244
|
+
Stylesheet: viz('bg-viz-resource-stylesheet', 'var(--ui-viz-resource-stylesheet, #a855f7)', 'text-viz-resource-stylesheet'),
|
|
245
|
+
Image: viz('bg-viz-resource-image', 'var(--ui-viz-resource-image, var(--_viz-resource-image, #06b6d4))', 'text-viz-resource-image'),
|
|
246
|
+
Font: viz('bg-viz-resource-font', 'var(--ui-viz-resource-font, #ec4899)', 'text-viz-resource-font'),
|
|
247
|
+
Other: viz('bg-accented', neutralHalf, 'text-muted'),
|
|
248
|
+
} as const satisfies Record<string, VizColor>
|
|
249
|
+
|
|
250
|
+
/** Generic five-slot categorical palette for compact composition charts. */
|
|
251
|
+
export const categoricalVizColors: string[] = [
|
|
252
|
+
'var(--ui-viz-category-1, var(--_viz-category-1, #60a5fa))',
|
|
253
|
+
'var(--ui-viz-category-2, var(--_viz-category-2, #4ade80))',
|
|
254
|
+
'var(--ui-viz-category-3, var(--_viz-category-3, #fbbf24))',
|
|
255
|
+
'var(--ui-viz-category-4, var(--_viz-category-4, #f87171))',
|
|
256
|
+
'var(--ui-viz-category-5, var(--_viz-category-5, #a78bfa))',
|
|
257
|
+
]
|
|
258
|
+
/** Country donut uses the shared categorical palette. */
|
|
259
|
+
export const gscCountryDonutColors = categoricalVizColors
|
|
260
|
+
|
|
261
|
+
/** Top-pages stacked area (6 slots: 5 pages + "other"). */
|
|
262
|
+
export const gscTopPagesColors: VizColor[] = [
|
|
263
|
+
...gscTopEntityColors.slice(0, 5),
|
|
264
|
+
viz('bg-accented', neutralHalf, 'text-muted'),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Generic preset swatches — for charts with no metric/category mapping (e.g.
|
|
269
|
+
* `<UiSparkline color="blue">`). Use a metric/category map first when one fits.
|
|
270
|
+
*
|
|
271
|
+
* `long-tail` / `high-volume` / `quick-wins` are keyword-research categories
|
|
272
|
+
* consumed by `ProToolsKeywordTable`; aliased onto preset hues so they share
|
|
273
|
+
* the same look as the underlying chart colour.
|
|
274
|
+
*/
|
|
275
|
+
export const presetVizColors = {
|
|
276
|
+
'blue': viz('bg-blue-500', '#3b82f6', 'text-blue-500'),
|
|
277
|
+
'green': viz('bg-emerald-500', '#10b981', 'text-emerald-500'),
|
|
278
|
+
'purple': viz('bg-purple-500', '#a855f7', 'text-purple-500'),
|
|
279
|
+
'orange': viz('bg-orange-500', '#f97316', 'text-orange-500'),
|
|
280
|
+
'red': viz('bg-red-500', '#ef4444', 'text-red-500'),
|
|
281
|
+
'cyan': viz('bg-cyan-500', '#06b6d4', 'text-cyan-500'),
|
|
282
|
+
'amber': viz('bg-amber-500', '#f59e0b', 'text-amber-500'),
|
|
283
|
+
'neutral': viz('bg-accented', semanticColors.neutral.hex, 'text-muted'),
|
|
284
|
+
'long-tail': viz('bg-cyan-500', '#06b6d4', 'text-cyan-500'),
|
|
285
|
+
'high-volume': viz('bg-emerald-500', '#10b981', 'text-emerald-500'),
|
|
286
|
+
'quick-wins': viz('bg-amber-500', '#f59e0b', 'text-amber-500'),
|
|
287
|
+
} as const satisfies Record<string, VizColor>
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One lookup for legends, sparklines, and dot chips. Metric / category names
|
|
291
|
+
* resolve to their identity colour; preset names fall through.
|
|
292
|
+
*/
|
|
293
|
+
export const vizColorMap: Record<string, VizColor> = {
|
|
294
|
+
...presetVizColors,
|
|
295
|
+
clicks: gscMetricColors.clicks,
|
|
296
|
+
impressions: gscMetricColors.impressions,
|
|
297
|
+
ctr: gscMetricColors.ctr,
|
|
298
|
+
position: gscMetricColors.position,
|
|
299
|
+
lcp: cwvMetricColors.lcp,
|
|
300
|
+
inp: cwvMetricColors.inp,
|
|
301
|
+
cls: cwvMetricColors.cls,
|
|
302
|
+
brand: gscBrandSplitColors.brand,
|
|
303
|
+
nonBrand: gscBrandSplitColors.nonBrand,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Lookup helpers. Both function-callable (`vizBgColor("clicks")`) AND
|
|
308
|
+
* key-indexable (`vizBgColor.blue`) so legend mapping and inline class
|
|
309
|
+
* binding can share one import.
|
|
310
|
+
*/
|
|
311
|
+
function makeLookup(field: keyof VizColor) {
|
|
312
|
+
const fallback = presetVizColors.neutral[field]
|
|
313
|
+
return Object.assign(
|
|
314
|
+
(name: string | null | undefined) => (name ? (vizColorMap[name]?.[field] ?? fallback) : fallback),
|
|
315
|
+
Object.fromEntries(Object.entries(vizColorMap).map(([k, v]) => [k, v[field]])),
|
|
316
|
+
) as ((name: string | null | undefined) => string) & Record<string, string>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const vizBgColor = makeLookup('bg')
|
|
320
|
+
export const vizDotColor = makeLookup('hex')
|
|
321
|
+
export const vizTextColor = makeLookup('text')
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Metric identity glyph: one semantic icon per metric, wherever the metric is
|
|
325
|
+
* named (toolbar chips, card stats, table headers). Pairs with the colour maps
|
|
326
|
+
* above. Upstream option lists (`GSC_COLUMN_OPTIONS` from `@gscdump/sdk`) carry
|
|
327
|
+
* raw iconify ids, so consumers resolve the glyph here by key instead.
|
|
328
|
+
*/
|
|
329
|
+
export const metricIcons = {
|
|
330
|
+
clicks: 'mouse-pointer',
|
|
331
|
+
impressions: 'view',
|
|
332
|
+
ctr: 'percent',
|
|
333
|
+
position: 'hash',
|
|
334
|
+
queries: 'search',
|
|
335
|
+
pages: 'file',
|
|
336
|
+
sessions: 'users',
|
|
337
|
+
users: 'user',
|
|
338
|
+
pageviews: 'view',
|
|
339
|
+
lcp: 'timer',
|
|
340
|
+
inp: 'mouse-pointer',
|
|
341
|
+
cls: 'move',
|
|
342
|
+
indexed: 'link',
|
|
343
|
+
unindexed: 'caution',
|
|
344
|
+
errors: 'warning',
|
|
345
|
+
} as const satisfies Record<string, SemanticUiIcon>
|
|
346
|
+
|
|
347
|
+
/** Metric glyph by key, or `undefined` for a metric without an identity icon. */
|
|
348
|
+
export function metricIcon(key: string): SemanticUiIcon | undefined {
|
|
349
|
+
return (metricIcons as Record<string, SemanticUiIcon>)[key]
|
|
350
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRef, Ref } from 'vue'
|
|
2
|
+
import { computed, isRef } from 'vue'
|
|
3
|
+
import { percentChange } from '../utils/number'
|
|
4
|
+
import { semanticColors } from './semanticColors'
|
|
5
|
+
|
|
6
|
+
// `Intl.*` constructors are expensive; hoist to module scope so per-cell
|
|
7
|
+
// formatters across large tables reuse one instance instead of allocating
|
|
8
|
+
// a new one on every call.
|
|
9
|
+
// The app is English-only, so pin display formatting to one locale. Letting
|
|
10
|
+
// Node and the browser choose independently produced different compact suffixes
|
|
11
|
+
// (`1.2K` during SSR, `1.2k` in Chrome) and guaranteed hydration mismatches.
|
|
12
|
+
// Every display formatter in the layer reads this constant. A bare
|
|
13
|
+
// `toLocaleString()` or `Intl.*(undefined)` is lint-banned in the layer, because
|
|
14
|
+
// it reads the runtime locale and the server and the browser disagree.
|
|
15
|
+
export const DISPLAY_LOCALE = 'en-US'
|
|
16
|
+
const compactNumberFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { notation: 'compact', maximumFractionDigits: 1 })
|
|
17
|
+
// `narrowSymbol` keeps the "$" glyph (prices are USD) while still localising
|
|
18
|
+
// grouping/decimal separators, rather than degrading to the "USD" code in
|
|
19
|
+
// locales that disambiguate the dollar sign (e.g. en-AU).
|
|
20
|
+
const compactUsdFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol', notation: 'compact', maximumFractionDigits: 1 })
|
|
21
|
+
const usdFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol' })
|
|
22
|
+
const sitemapDateFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
23
|
+
const relativeTimeFormatNarrow = new Intl.RelativeTimeFormat(DISPLAY_LOCALE, { numeric: 'auto', style: 'narrow' })
|
|
24
|
+
const relativeTimeFormatLong = new Intl.RelativeTimeFormat(DISPLAY_LOCALE, { numeric: 'auto', style: 'long' })
|
|
25
|
+
const signedPercentFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'percent', signDisplay: 'exceptZero', minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
26
|
+
const ctrPercentFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
27
|
+
const positionFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
28
|
+
const groupedNumberFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { maximumFractionDigits: 3 })
|
|
29
|
+
|
|
30
|
+
/** A plain grouped number (`12,345.6`): the default chart and table formatter. */
|
|
31
|
+
export function formatGroupedNumber(n: number): string {
|
|
32
|
+
return groupedNumberFormat.format(n)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure trend percentage between two values.
|
|
37
|
+
* For metrics where lower is better (e.g. position), pass `invert: true`.
|
|
38
|
+
* Returns 0 when prev is 0/null/undefined to avoid Infinity.
|
|
39
|
+
*/
|
|
40
|
+
export function calcTrendPercent(current: number, prev: number, invert = false): number {
|
|
41
|
+
const pct = percentChange(current, prev)
|
|
42
|
+
return (invert ? -pct : pct) || 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A missing value (`null`, `undefined`, `''`, or anything that is not a finite
|
|
47
|
+
* number) reads as a dash. `Number(null)` is 0 and `Number(undefined)` is NaN,
|
|
48
|
+
* so coercing first printed "0" or "NaN" for a value that was never measured.
|
|
49
|
+
*/
|
|
50
|
+
export function useProHumanFriendlyNumber(number: Ref<string | number | null | undefined>, decimals?: number): ComputedRef<string>
|
|
51
|
+
export function useProHumanFriendlyNumber(number: string | number | null | undefined, decimals?: number): string
|
|
52
|
+
export function useProHumanFriendlyNumber(number: MaybeRef<string | number | null | undefined>, decimals?: number) {
|
|
53
|
+
const format = (raw: string | number | null | undefined) => {
|
|
54
|
+
if (raw == null || (typeof raw === 'string' && raw.trim() === ''))
|
|
55
|
+
return '—'
|
|
56
|
+
const n = Number(raw)
|
|
57
|
+
if (!Number.isFinite(n))
|
|
58
|
+
return '—'
|
|
59
|
+
// Intl holds the digit count. Rounding through `toFixed` dropped a trailing
|
|
60
|
+
// zero, so one column printed `20` beside `6.2`, and the compact format cut
|
|
61
|
+
// a second decimal.
|
|
62
|
+
if (typeof decimals !== 'undefined') {
|
|
63
|
+
return new Intl.NumberFormat(DISPLAY_LOCALE, {
|
|
64
|
+
notation: 'compact',
|
|
65
|
+
minimumFractionDigits: decimals,
|
|
66
|
+
maximumFractionDigits: decimals,
|
|
67
|
+
}).format(n)
|
|
68
|
+
}
|
|
69
|
+
return compactNumberFormat.format(n)
|
|
70
|
+
}
|
|
71
|
+
if (isRef(number))
|
|
72
|
+
return computed(() => format(number.value))
|
|
73
|
+
return format(number)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const useHumanFriendlyNumber = useProHumanFriendlyNumber
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A timestamp as a Date. A raw `number` may be unix SECONDS (our
|
|
80
|
+
* `mode:'timestamp'` columns) or unix MILLISECONDS (the raw-`integer` audit
|
|
81
|
+
* columns: billingEvents, adminEvents, loginEvents, runtimeErrors; ADR-0082).
|
|
82
|
+
* Magnitude decides: seconds for any plausible date are < 1e11, ms are >= 1e12.
|
|
83
|
+
* Every component that takes a numeric timestamp reads it through here, so the
|
|
84
|
+
* label and the relative text always name the same instant.
|
|
85
|
+
*/
|
|
86
|
+
export function toTimestampDate(timestamp: number | string | Date): Date {
|
|
87
|
+
if (typeof timestamp === 'number')
|
|
88
|
+
return new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
|
89
|
+
return new Date(timestamp)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Relative distance from now, signed. `narrow` ("in 3d", "5m ago") is the
|
|
94
|
+
* default for dense cells; `long` ("in 3 days") reads as a sentence where the
|
|
95
|
+
* value stands alone, such as a scheduled send.
|
|
96
|
+
*/
|
|
97
|
+
export function formatTimeAgo(timestamp: number | string | Date | null | undefined, style: 'narrow' | 'long' = 'narrow') {
|
|
98
|
+
if (!timestamp)
|
|
99
|
+
return null
|
|
100
|
+
const relativeTimeFormat = style === 'long' ? relativeTimeFormatLong : relativeTimeFormatNarrow
|
|
101
|
+
const date = toTimestampDate(timestamp)
|
|
102
|
+
// Result is relative to the current time, so it differs between SSR and
|
|
103
|
+
// client render — callers showing this in initial HTML should wrap it in
|
|
104
|
+
// <ClientOnly> to avoid a hydration mismatch.
|
|
105
|
+
// Signed distance, so a FUTURE date reads forward ("in 6 days"). It used to
|
|
106
|
+
// subtract one way only, so every future date fell through `minutes < 1` and
|
|
107
|
+
// rendered as "just now" — a 7-day invitation expiry announced itself as
|
|
108
|
+
// already expiring.
|
|
109
|
+
const diff = Date.now() - date.getTime()
|
|
110
|
+
const ahead = diff < 0
|
|
111
|
+
const distance = Math.abs(diff)
|
|
112
|
+
const minutes = Math.floor(distance / 60000)
|
|
113
|
+
const hours = Math.floor(distance / 3600000)
|
|
114
|
+
const days = Math.floor(distance / 86400000)
|
|
115
|
+
const signed = (n: number) => ahead ? n : -n
|
|
116
|
+
if (minutes < 1)
|
|
117
|
+
return 'just now'
|
|
118
|
+
if (minutes < 60)
|
|
119
|
+
return relativeTimeFormat.format(signed(minutes), 'minute')
|
|
120
|
+
if (hours < 24)
|
|
121
|
+
return relativeTimeFormat.format(signed(hours), 'hour')
|
|
122
|
+
if (days < 30)
|
|
123
|
+
return relativeTimeFormat.format(signed(days), 'day')
|
|
124
|
+
const months = Math.floor(days / 30)
|
|
125
|
+
if (months < 12)
|
|
126
|
+
return relativeTimeFormat.format(signed(months), 'month')
|
|
127
|
+
// `days / 30` reaches 12 at day 360 while `days / 365` is still 0, so days
|
|
128
|
+
// 360 to 364 printed "this yr.". Past the month range it is at least a year.
|
|
129
|
+
return relativeTimeFormat.format(signed(Math.max(1, Math.floor(days / 365))), 'year')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatNumber(n: number | null | undefined): string {
|
|
133
|
+
if (n == null)
|
|
134
|
+
return '—'
|
|
135
|
+
return compactNumberFormat.format(n)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatCurrency(n: number | null | undefined): string {
|
|
139
|
+
if (n == null)
|
|
140
|
+
return '—'
|
|
141
|
+
return compactUsdFormat.format(n)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `n` is a percentage-point value (e.g. 5 → "+5.0%"), so divide before the
|
|
145
|
+
// percent formatter (which multiplies by 100).
|
|
146
|
+
export function formatPercent(n: number): string {
|
|
147
|
+
return signedPercentFormat.format(n / 100)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatCurrencyFromCents(cents: number | null | undefined): string {
|
|
151
|
+
if (cents == null)
|
|
152
|
+
return '—'
|
|
153
|
+
return usdFormat.format(cents / 100)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// GSC metric formatter (replaces duplicated fmtMetric across SC pages)
|
|
157
|
+
export function fmtGscMetric(val: number, metric: string): string {
|
|
158
|
+
if (metric === 'ctr')
|
|
159
|
+
return ctrPercentFormat.format(val)
|
|
160
|
+
if (metric === 'position')
|
|
161
|
+
return positionFormat.format(val)
|
|
162
|
+
return formatNumber(val)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Color utilities
|
|
166
|
+
export function getDifficultyColor(d: number | null): string {
|
|
167
|
+
if (d == null)
|
|
168
|
+
return 'bg-accented text-muted'
|
|
169
|
+
if (d <= 30)
|
|
170
|
+
return 'bg-success/10 text-success'
|
|
171
|
+
if (d <= 60)
|
|
172
|
+
return 'bg-warning/10 text-warning'
|
|
173
|
+
return 'bg-error/10 text-error'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getDifficultyInfo(kd: number | null): { label: string, color: string, bg: string, description: string } {
|
|
177
|
+
if (kd === null)
|
|
178
|
+
return { label: '—', color: 'text-muted', bg: 'bg-accented', description: 'No difficulty data' }
|
|
179
|
+
if (kd <= 20)
|
|
180
|
+
return { label: 'Easy', color: 'text-success', bg: 'bg-success/15', description: 'Low competition – great opportunity' }
|
|
181
|
+
if (kd <= 40)
|
|
182
|
+
return { label: 'Low', color: 'text-success', bg: 'bg-success/15', description: 'Achievable with quality content' }
|
|
183
|
+
if (kd <= 60)
|
|
184
|
+
return { label: 'Medium', color: 'text-warning', bg: 'bg-warning/15', description: 'Needs strong content + backlinks' }
|
|
185
|
+
if (kd <= 80)
|
|
186
|
+
return { label: 'Hard', color: 'text-warning', bg: 'bg-warning/15', description: 'Requires niche authority' }
|
|
187
|
+
return { label: 'Very Hard', color: 'text-error', bg: 'bg-error/15', description: 'Dominated by major sites' }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getDomainRankColor(rank: number | null | undefined): string {
|
|
191
|
+
if (!rank)
|
|
192
|
+
return 'text-muted'
|
|
193
|
+
if (rank >= 70)
|
|
194
|
+
return 'text-success'
|
|
195
|
+
if (rank >= 40)
|
|
196
|
+
return 'text-warning'
|
|
197
|
+
return 'text-error'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function trendColor(change: number | null): string {
|
|
201
|
+
if (!change)
|
|
202
|
+
return semanticColors.neutral.hex
|
|
203
|
+
return change > 0 ? semanticColors.success.hex : semanticColors.error.hex
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// URL/path helpers
|
|
207
|
+
export function getPath(url: string): string {
|
|
208
|
+
if (!url?.startsWith('http'))
|
|
209
|
+
return url || '/'
|
|
210
|
+
return new URL(url).pathname || '/'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getSitemapName(path: string): string {
|
|
214
|
+
try {
|
|
215
|
+
return new URL(path).pathname || path
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return path
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function formatSitemapDate(dateStr: string | null | undefined): string {
|
|
223
|
+
if (!dateStr)
|
|
224
|
+
return 'Never'
|
|
225
|
+
return sitemapDateFormat.format(new Date(dateStr))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Reporting-day buckets (GSC/analytics daily rows keyed "YYYY-MM-DD") ---
|
|
229
|
+
// These are calendar-day LABELS owned by the data source (GSC buckets in Pacific
|
|
230
|
+
// Time), not instants. We never shift them into the viewer's timezone — doing so
|
|
231
|
+
// is the off-by-one-day bug. Parse in a fixed UTC frame so the round-trip is
|
|
232
|
+
// identity, and ALWAYS format with `timeZone: 'UTC'` so the label never drifts.
|
|
233
|
+
// See ADR-0082. The `Z`/UTC here is a neutral frame for an opaque label, not a
|
|
234
|
+
// claim that the data is UTC.
|
|
235
|
+
const reportingDayFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
236
|
+
|
|
237
|
+
/** Parse a "YYYY-MM-DD" reporting-day label into a stable, viewer-independent Date. */
|
|
238
|
+
export function parseReportingDay(iso: string): Date {
|
|
239
|
+
return new Date(`${iso}T00:00:00Z`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Format a "YYYY-MM-DD" reporting-day label. Defaults to "Jun 24"; pass `opts` for
|
|
244
|
+
* other shapes — `timeZone: 'UTC'` is forced so the label can't drift per viewer.
|
|
245
|
+
*/
|
|
246
|
+
export function formatReportingDay(iso: string | null | undefined, opts?: Intl.DateTimeFormatOptions): string {
|
|
247
|
+
if (!iso)
|
|
248
|
+
return '—'
|
|
249
|
+
if (opts)
|
|
250
|
+
return new Intl.DateTimeFormat(DISPLAY_LOCALE, { ...opts, timeZone: 'UTC' }).format(parseReportingDay(iso))
|
|
251
|
+
return reportingDayFormat.format(parseReportingDay(iso))
|
|
252
|
+
}
|