@nuxtseo/design-system 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,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
+ }