@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,186 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import { computed, toValue } from 'vue'
|
|
3
|
+
import { DISPLAY_LOCALE, parseReportingDay } from './formatting'
|
|
4
|
+
|
|
5
|
+
// Calendar-aware tick planning for time-series charts. Picks tick positions +
|
|
6
|
+
// label format based on the span:
|
|
7
|
+
// ≤ 10 days → every day, "Mon 12"
|
|
8
|
+
// ≤ 35 days → ~5 evenly-spaced ticks, "Jan 12"
|
|
9
|
+
// ≤ 120 days → every ~2 weeks (aligned to data), "Jan 12"
|
|
10
|
+
// > 120 days → first-of-month ticks, "Jan"; year suffix on Jan/first when span spans years
|
|
11
|
+
// Extracted from ProGraphGsc / ProGraphAnalytics / GraphIndexing, which had
|
|
12
|
+
// identical copies of this logic.
|
|
13
|
+
|
|
14
|
+
export interface ChartTickPlan {
|
|
15
|
+
indices: number[]
|
|
16
|
+
format: (date: Date, i: number, firstYear: number) => string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseChartTickPlanOptions {
|
|
20
|
+
/** Reactive ISO date strings (one per data point). */
|
|
21
|
+
dates: MaybeRefOrGetter<string[]>
|
|
22
|
+
/** Cap on tick count for long spans (default 14 — thins to ~12 if exceeded). */
|
|
23
|
+
maxTicks?: number
|
|
24
|
+
/**
|
|
25
|
+
* Reactive plot-area width in px (wire `useElementSize` on the chart wrap).
|
|
26
|
+
* The span heuristics assume a desktop-width plot; when a width is known,
|
|
27
|
+
* ticks whose estimated labels would collide are dropped — first and last
|
|
28
|
+
* always stay, so the consumers' inward-anchored edge labels keep marking
|
|
29
|
+
* the window ends. Unknown (0/undefined, e.g. SSR before measurement)
|
|
30
|
+
* keeps the pure span plan.
|
|
31
|
+
*/
|
|
32
|
+
width?: MaybeRefOrGetter<number | undefined>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ticks label reporting-day buckets ("YYYY-MM-DD"), not instants — force UTC so
|
|
36
|
+
// the label never drifts with the viewer's timezone (cf. parseReportingDay, ADR-0082).
|
|
37
|
+
const weekdayDayFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { weekday: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
38
|
+
const monthDayFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
39
|
+
const monthFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', timeZone: 'UTC' })
|
|
40
|
+
const monthYearFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', year: '2-digit', timeZone: 'UTC' })
|
|
41
|
+
|
|
42
|
+
// Axis labels render at 11px; ~6.5px per glyph over-estimates the real advance
|
|
43
|
+
// slightly, so thinning leaves visible air instead of touching labels.
|
|
44
|
+
const LABEL_GLYPH_PX = 6.5
|
|
45
|
+
const MIN_LABEL_GAP_PX = 8
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Greedy left-to-right thinning: a tick survives only when its label clears the
|
|
49
|
+
* previously kept one. Every consumer anchors the first tick inward (start) and
|
|
50
|
+
* the last (end), so those labels claim their full width on the inner side;
|
|
51
|
+
* middle labels claim half a width each side. The final tick always renders —
|
|
52
|
+
* middle ticks yield to it when the two would collide.
|
|
53
|
+
*/
|
|
54
|
+
function fitTickIndices(
|
|
55
|
+
indices: number[],
|
|
56
|
+
labelWidth: (index: number) => number,
|
|
57
|
+
posOf: (index: number) => number,
|
|
58
|
+
): number[] {
|
|
59
|
+
if (indices.length <= 2)
|
|
60
|
+
return indices.slice()
|
|
61
|
+
const fits = (a: number, b: number, aIsFirst: boolean, bIsLast: boolean): boolean => {
|
|
62
|
+
const aRight = posOf(a) + labelWidth(a) / (aIsFirst ? 1 : 2)
|
|
63
|
+
const bLeft = posOf(b) - labelWidth(b) / (bIsLast ? 1 : 2)
|
|
64
|
+
return bLeft - aRight >= MIN_LABEL_GAP_PX
|
|
65
|
+
}
|
|
66
|
+
const kept: number[] = [indices[0]!]
|
|
67
|
+
for (let k = 1; k < indices.length - 1; k++) {
|
|
68
|
+
const idx = indices[k]!
|
|
69
|
+
if (fits(kept.at(-1)!, idx, kept.length === 1, false))
|
|
70
|
+
kept.push(idx)
|
|
71
|
+
}
|
|
72
|
+
const last = indices.at(-1)!
|
|
73
|
+
while (kept.length > 1 && !fits(kept.at(-1)!, last, kept.length === 1, true))
|
|
74
|
+
kept.pop()
|
|
75
|
+
kept.push(last)
|
|
76
|
+
return kept
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useChartTickPlan(opts: UseChartTickPlanOptions) {
|
|
80
|
+
const maxTicks = opts.maxTicks ?? 14
|
|
81
|
+
|
|
82
|
+
function spanPlan(dates: string[], len: number): ChartTickPlan {
|
|
83
|
+
if (len <= 1)
|
|
84
|
+
return { indices: [0], format: d => monthDayFmt.format(d) }
|
|
85
|
+
if (len <= 10)
|
|
86
|
+
return { indices: dates.map((_, i) => i), format: d => weekdayDayFmt.format(d) }
|
|
87
|
+
if (len <= 35) {
|
|
88
|
+
const step = Math.max(1, Math.ceil(len / 5))
|
|
89
|
+
const indices: number[] = []
|
|
90
|
+
for (let i = 0; i < len; i += step) indices.push(i)
|
|
91
|
+
if (indices.at(-1) !== len - 1)
|
|
92
|
+
indices.push(len - 1)
|
|
93
|
+
return { indices, format: d => monthDayFmt.format(d) }
|
|
94
|
+
}
|
|
95
|
+
if (len <= 120) {
|
|
96
|
+
// Anchor at index 0 (chart left edge) and step by 14 days. Earlier
|
|
97
|
+
// Monday-snap variant pushed the first tick rightward, leaving the
|
|
98
|
+
// leftmost label visually detached from the data start.
|
|
99
|
+
const indices: number[] = []
|
|
100
|
+
for (let i = 0; i < len; i += 14) indices.push(i)
|
|
101
|
+
return { indices, format: d => monthDayFmt.format(d) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Long span: first-of-month ticks.
|
|
105
|
+
const indices: number[] = []
|
|
106
|
+
let lastMonth = ''
|
|
107
|
+
for (let i = 0; i < len; i++) {
|
|
108
|
+
const m = dates[i]?.slice(0, 7) ?? ''
|
|
109
|
+
if (m !== lastMonth) {
|
|
110
|
+
indices.push(i)
|
|
111
|
+
lastMonth = m
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (indices.length < 2)
|
|
115
|
+
return { indices: [0, Math.floor(len / 2), len - 1], format: d => monthDayFmt.format(d) }
|
|
116
|
+
// Drop the first tick if the second is within 5 days — happens when the
|
|
117
|
+
// series starts late in a month and the next month's first-of-month tick
|
|
118
|
+
// would render right on top of it.
|
|
119
|
+
if (indices.length > 2 && indices[1]! - indices[0]! < 5)
|
|
120
|
+
indices.shift()
|
|
121
|
+
|
|
122
|
+
// Thin out at very long spans so the axis doesn't crowd.
|
|
123
|
+
if (indices.length > maxTicks) {
|
|
124
|
+
const keep = Math.ceil(indices.length / 12)
|
|
125
|
+
return {
|
|
126
|
+
indices: indices.filter((_, i) => i % keep === 0),
|
|
127
|
+
format: (d, i, firstYear) => (d.getUTCMonth() === 0 || i === 0) && d.getUTCFullYear() !== firstYear
|
|
128
|
+
? monthYearFmt.format(d)
|
|
129
|
+
: monthFmt.format(d),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
indices,
|
|
134
|
+
format: (d, i, _firstYear) => (d.getUTCMonth() === 0 && i > 0) || (i === 0 && d.getUTCMonth() !== 0 && indices.length > 6)
|
|
135
|
+
? monthYearFmt.format(d)
|
|
136
|
+
: monthFmt.format(d),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tickPlan = computed<ChartTickPlan>(() => {
|
|
141
|
+
const dates = toValue(opts.dates)
|
|
142
|
+
const len = dates.length
|
|
143
|
+
const base = spanPlan(dates, len)
|
|
144
|
+
|
|
145
|
+
const width = toValue(opts.width)
|
|
146
|
+
if (!width || width <= 0 || len < 3 || base.indices.length <= 2)
|
|
147
|
+
return base
|
|
148
|
+
|
|
149
|
+
// Estimate each label's pixel width from the plan's own formatting so the
|
|
150
|
+
// pass tracks whichever branch ran. `i`/`firstYear` only steer the
|
|
151
|
+
// month-vs-monthYear choice — an estimate, not a render.
|
|
152
|
+
const firstYear = Number(dates[base.indices[0]!]?.slice(0, 4) ?? 0)
|
|
153
|
+
const labelWidth = (idx: number): number => {
|
|
154
|
+
const date = dates[idx]
|
|
155
|
+
if (!date)
|
|
156
|
+
return 0
|
|
157
|
+
const label = base.format(parseReportingDay(date), base.indices.indexOf(idx), firstYear)
|
|
158
|
+
return label.length * LABEL_GLYPH_PX
|
|
159
|
+
}
|
|
160
|
+
const posOf = (idx: number): number => idx / (len - 1) * width
|
|
161
|
+
return { indices: fitTickIndices(base.indices, labelWidth, posOf), format: base.format }
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const firstTickYear = computed(() => {
|
|
165
|
+
const dates = toValue(opts.dates)
|
|
166
|
+
const first = tickPlan.value.indices[0]
|
|
167
|
+
// No ticks → `format` never runs, so a stable sentinel (not a clock read,
|
|
168
|
+
// which would risk an SSR≠client year boundary) is enough.
|
|
169
|
+
if (first == null || !dates[first])
|
|
170
|
+
return 0
|
|
171
|
+
return Number(dates[first]!.slice(0, 4))
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/** Use as Unovis `tickFormat` for an x-axis indexed by row position. */
|
|
175
|
+
function tickFormat(d: number): string {
|
|
176
|
+
const dates = toValue(opts.dates)
|
|
177
|
+
const idx = Math.round(d)
|
|
178
|
+
const date = dates[idx]
|
|
179
|
+
if (!date)
|
|
180
|
+
return ''
|
|
181
|
+
const tickIdx = tickPlan.value.indices.indexOf(idx)
|
|
182
|
+
return tickPlan.value.format(parseReportingDay(date), tickIdx, firstTickYear.value)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { tickPlan, firstTickYear, tickFormat }
|
|
186
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { WatchSource, WatchStopHandle } from 'vue'
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** Close persistent overlay state after a confirmed client route change. */
|
|
5
|
+
export function useCloseOnNavigation(fullPath: WatchSource<string>, close: () => void): WatchStopHandle {
|
|
6
|
+
return watch(fullPath, close)
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useOverlay } from '#imports'
|
|
2
|
+
import UiConfirmDialog from '../components/element/UiConfirmDialog.vue'
|
|
3
|
+
|
|
4
|
+
/** `danger` for an action that destroys or revokes; `default` for any other. */
|
|
5
|
+
export type ConfirmTone = 'danger' | 'default'
|
|
6
|
+
|
|
7
|
+
export interface ConfirmOptions {
|
|
8
|
+
/** A question that names the action and its object: "Disconnect Google?" */
|
|
9
|
+
readonly title: string
|
|
10
|
+
/** The consequence, in one or two short sentences. */
|
|
11
|
+
readonly description?: string
|
|
12
|
+
/** A verb for the action: "Disconnect", "Remove", "Delete". Never "OK". */
|
|
13
|
+
readonly confirmLabel: string
|
|
14
|
+
readonly cancelLabel?: string
|
|
15
|
+
readonly tone?: ConfirmTone
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ask before an action that is hard to undo.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* const confirm = useConfirm()
|
|
23
|
+
* if (!await confirm({ title: 'Remove teammate?', confirmLabel: 'Remove', tone: 'danger' }))
|
|
24
|
+
* return
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Resolves `true` only for the confirm button. Cancel, Escape, and a click
|
|
28
|
+
* outside resolve `false`. Call it in `setup`; the returned function may run
|
|
29
|
+
* any time later. Needs `<UApp>`, which mounts Nuxt UI's overlay provider.
|
|
30
|
+
*/
|
|
31
|
+
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
|
32
|
+
const overlay = useOverlay()
|
|
33
|
+
// One overlay per question: `destroyOnClose` removes it after it leaves, so
|
|
34
|
+
// a page that asks many times holds no dialog it is not showing.
|
|
35
|
+
return options => overlay.create(UiConfirmDialog, { destroyOnClose: true })
|
|
36
|
+
.open({ ...options })
|
|
37
|
+
.then(result => result === true)
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import { useEventListener, useResizeObserver } from '@vueuse/core'
|
|
3
|
+
import { onMounted, ref } from 'vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Edge-fade affordance for a horizontal scroller: reports which side still has
|
|
7
|
+
* content off-screen. Bind the result to `data-fade-start` / `data-fade-end` on
|
|
8
|
+
* an element carrying the `.ui-edge-fade` class (`css/global.css`), which masks
|
|
9
|
+
* only the side that can scroll. The flags clear when the content fits, so a
|
|
10
|
+
* wide viewport never masks.
|
|
11
|
+
*
|
|
12
|
+
* Shared by `UiTabs` and the `ProDataToolbar` metric row, so every scrolling
|
|
13
|
+
* strip in the product fades the same way.
|
|
14
|
+
*/
|
|
15
|
+
export function useEdgeFade(el: Ref<HTMLElement | undefined | null>, onResize?: () => void) {
|
|
16
|
+
const fadeStart = ref(false)
|
|
17
|
+
const fadeEnd = ref(false)
|
|
18
|
+
|
|
19
|
+
function update() {
|
|
20
|
+
const node = el.value
|
|
21
|
+
if (!node)
|
|
22
|
+
return
|
|
23
|
+
fadeStart.value = node.scrollLeft > 1
|
|
24
|
+
fadeEnd.value = Math.ceil(node.scrollLeft + node.clientWidth) < node.scrollWidth - 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onMounted(() => {
|
|
28
|
+
useEventListener(el, 'scroll', update, { passive: true })
|
|
29
|
+
useResizeObserver(el, () => {
|
|
30
|
+
update()
|
|
31
|
+
onResize?.()
|
|
32
|
+
})
|
|
33
|
+
update()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return { fadeStart, fadeEnd, update }
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
3
|
+
import type { ChatEntityRef, EjectChat, EjectLink, EjectMcp } from '../components/element/eject-menu'
|
|
4
|
+
import { computed, toValue } from 'vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Schema-shaped description of a queryable surface.
|
|
8
|
+
*
|
|
9
|
+
* VISION principle 5: every feature is defined by a typed schema before it ships a UI.
|
|
10
|
+
* `useEject` is the spine that turns that schema into the eject payloads
|
|
11
|
+
* (MCP call, AI chat, API schema link) so consumers don't hand-roll each one.
|
|
12
|
+
* It builds no cURL payload. DESIGN "Portable by default" allows one, because
|
|
13
|
+
* the public documented REST API is live at `/docs/api/openapi.json`.
|
|
14
|
+
*
|
|
15
|
+
* The composable is dumb plumbing. It does no fetching, no auth resolution, no
|
|
16
|
+
* runtime config reading; the caller passes the schema reference and the
|
|
17
|
+
* entity/filter context. The result feeds directly into `<UiEjectMenu />` props.
|
|
18
|
+
*/
|
|
19
|
+
export interface EjectQuery {
|
|
20
|
+
/** MCP tool name. Required for MCP + AI chat ejection. */
|
|
21
|
+
tool: string
|
|
22
|
+
/** Tool arguments, serialised as MCP `arguments` on copy. */
|
|
23
|
+
args?: Record<string, unknown>
|
|
24
|
+
/** Deep-link target for AI chat ejection (path + optional query base). */
|
|
25
|
+
chatTo?: RouteLocationRaw
|
|
26
|
+
/** Entity ID seeded into chat deep links — build via `chatEntity.*`. */
|
|
27
|
+
entity?: ChatEntityRef
|
|
28
|
+
/** Filter state seeded into chat deep links. */
|
|
29
|
+
filter?: Record<string, unknown>
|
|
30
|
+
/** Question seed text for chat. */
|
|
31
|
+
seed?: string
|
|
32
|
+
/** Schema doc link. */
|
|
33
|
+
schemaTo?: RouteLocationRaw
|
|
34
|
+
/** Custom schema link label. */
|
|
35
|
+
schemaLabel?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EjectPayloads {
|
|
39
|
+
mcp: ComputedRef<EjectMcp | undefined>
|
|
40
|
+
chat: ComputedRef<EjectChat | undefined>
|
|
41
|
+
schema: ComputedRef<EjectLink | undefined>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useEject(query: MaybeRefOrGetter<EjectQuery>): EjectPayloads {
|
|
45
|
+
const q = computed(() => toValue(query))
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
mcp: computed(() =>
|
|
49
|
+
q.value.tool
|
|
50
|
+
? { tool: q.value.tool, args: q.value.args }
|
|
51
|
+
: undefined,
|
|
52
|
+
),
|
|
53
|
+
chat: computed(() =>
|
|
54
|
+
q.value.chatTo
|
|
55
|
+
? {
|
|
56
|
+
to: q.value.chatTo,
|
|
57
|
+
entity: q.value.entity,
|
|
58
|
+
filter: q.value.filter,
|
|
59
|
+
seed: q.value.seed,
|
|
60
|
+
}
|
|
61
|
+
: undefined,
|
|
62
|
+
),
|
|
63
|
+
schema: computed(() =>
|
|
64
|
+
q.value.schemaTo
|
|
65
|
+
? { to: q.value.schemaTo, label: q.value.schemaLabel }
|
|
66
|
+
: undefined,
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { EntrancePreset } from '../../shared/motion'
|
|
3
|
+
import { useReducedMotion } from 'motion-v'
|
|
4
|
+
import { tryUseNuxtApp } from 'nuxt/app'
|
|
5
|
+
import { computed, toValue } from 'vue'
|
|
6
|
+
import { entranceProps } from '../../shared/motion'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* motion-v entrance props for an `<m.*>` root, with content visible by default.
|
|
10
|
+
*
|
|
11
|
+
* The server cannot read `prefers-reduced-motion`, and Vue keeps server styles
|
|
12
|
+
* through hydration. An entrance bound on the server therefore ships the hidden
|
|
13
|
+
* `initial` frame, and a reduced-motion reader never sees the content. So the
|
|
14
|
+
* server render and the hydration pass bind nothing. Only an element the client
|
|
15
|
+
* mounts after hydration animates in.
|
|
16
|
+
*/
|
|
17
|
+
export function useEntranceMotion(preset: EntrancePreset, enabled: MaybeRefOrGetter<boolean> = true): ComputedRef<ReturnType<typeof entranceProps>> {
|
|
18
|
+
const reduced = useReducedMotion()
|
|
19
|
+
const nuxtApp = tryUseNuxtApp()
|
|
20
|
+
const renderedOnServer = import.meta.server || Boolean(nuxtApp?.ssrContext) || Boolean(nuxtApp?.isHydrating)
|
|
21
|
+
return computed(() => toValue(enabled) && !renderedOnServer ? entranceProps(preset, reduced.value) : {})
|
|
22
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
2
|
+
import { useColorMode } from '#imports'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adaptive favicon backing. Many real favicons (zhead's dark triangle, GitHub's
|
|
6
|
+
* octocat, Vercel's near-black square) carry no usable contrast against one of the
|
|
7
|
+
* two dashboard themes and visually vanish. Rather than blanket every favicon with
|
|
8
|
+
* a light tile (which would needlessly flatten brand-colour favicons), we sample
|
|
9
|
+
* each favicon's dominant luminance once and only add a tile in the theme where it
|
|
10
|
+
* would otherwise disappear.
|
|
11
|
+
*
|
|
12
|
+
* Sampling requires a canvas read, so the favicon MUST be same-origin — UiFavicon
|
|
13
|
+
* serves it through `/api/favicon` for exactly this reason. A cross-origin image
|
|
14
|
+
* taints the canvas and `getImageData` throws; we treat that as `unknown` (no
|
|
15
|
+
* backing) so the component still renders, just without the adaptive remedy.
|
|
16
|
+
*/
|
|
17
|
+
export type FaviconTone = 'dark' | 'light' | 'neutral' | 'unknown'
|
|
18
|
+
export type FaviconBacking = 'light' | 'dark' | 'none'
|
|
19
|
+
|
|
20
|
+
// Dominant luminance below DARK reads as "dark ink/fill" (needs a light tile on
|
|
21
|
+
// dark themes); above LIGHT reads as "light ink" (needs a dark tile on light
|
|
22
|
+
// themes). The wide neutral band keeps colourful favicons (Stripe, Cloudflare,
|
|
23
|
+
// npm) untouched on both themes.
|
|
24
|
+
const DARK = 0.22
|
|
25
|
+
const LIGHT = 0.8
|
|
26
|
+
|
|
27
|
+
// Per-domain memo: sample at most once per domain per session and persist
|
|
28
|
+
// across sessions to avoid a backing flash.
|
|
29
|
+
//
|
|
30
|
+
// The persisted half now EXPIRES, because a domain's classification is no
|
|
31
|
+
// longer stable for all time. `/api/favicon` used to answer from Google's copy,
|
|
32
|
+
// which never changed; it now serves the mark the Crawl captured, so a Site
|
|
33
|
+
// that rebrands from a dark glyph to a light one changes which tile it needs.
|
|
34
|
+
// Without an expiry a returning viewer would keep the wrong backing for good,
|
|
35
|
+
// and a stored tone is only a flash-avoidance optimisation, never a fact worth
|
|
36
|
+
// defending.
|
|
37
|
+
const memo = new Map<string, FaviconTone>()
|
|
38
|
+
const LS_KEY = 'ui-favicon-tone-v2'
|
|
39
|
+
const MEMO_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
40
|
+
interface StoredMemo { at: number, tones: Record<string, FaviconTone> }
|
|
41
|
+
let hydrated = false
|
|
42
|
+
|
|
43
|
+
function hydrate() {
|
|
44
|
+
if (hydrated || !import.meta.client)
|
|
45
|
+
return
|
|
46
|
+
hydrated = true
|
|
47
|
+
try {
|
|
48
|
+
const stored = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null') as StoredMemo | null
|
|
49
|
+
if (!stored || Date.now() - stored.at >= MEMO_TTL_MS)
|
|
50
|
+
return
|
|
51
|
+
for (const [k, v] of Object.entries(stored.tones)) memo.set(k, v)
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Corrupt cache is not worth surfacing; fall back to re-sampling.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function persist() {
|
|
59
|
+
if (!import.meta.client)
|
|
60
|
+
return
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(LS_KEY, JSON.stringify({ at: Date.now(), tones: Object.fromEntries(memo) } satisfies StoredMemo))
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Storage full / disabled — sampling still works, just not cached cross-session.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function classifyTone(img: HTMLImageElement): FaviconTone {
|
|
70
|
+
const n = 32
|
|
71
|
+
const canvas = document.createElement('canvas')
|
|
72
|
+
canvas.width = n
|
|
73
|
+
canvas.height = n
|
|
74
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
75
|
+
if (!ctx)
|
|
76
|
+
return 'unknown'
|
|
77
|
+
ctx.drawImage(img, 0, 0, n, n)
|
|
78
|
+
|
|
79
|
+
let data: Uint8ClampedArray
|
|
80
|
+
try {
|
|
81
|
+
data = ctx.getImageData(0, 0, n, n).data
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 'unknown' // tainted canvas (cross-origin favicon)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mean relative luminance over the visible ink, weighted by alpha so soft
|
|
88
|
+
// edges don't drag a solid glyph toward neutral.
|
|
89
|
+
let lumSum = 0
|
|
90
|
+
let alphaSum = 0
|
|
91
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
92
|
+
const a = data[i + 3] ?? 0
|
|
93
|
+
if (a < 16)
|
|
94
|
+
continue
|
|
95
|
+
const r = data[i] ?? 0
|
|
96
|
+
const g = data[i + 1] ?? 0
|
|
97
|
+
const b = data[i + 2] ?? 0
|
|
98
|
+
const l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
99
|
+
lumSum += l * a
|
|
100
|
+
alphaSum += a
|
|
101
|
+
}
|
|
102
|
+
if (alphaSum === 0)
|
|
103
|
+
return 'unknown' // fully transparent / empty
|
|
104
|
+
const meanLum = lumSum / alphaSum
|
|
105
|
+
if (meanLum < DARK)
|
|
106
|
+
return 'dark'
|
|
107
|
+
if (meanLum > LIGHT)
|
|
108
|
+
return 'light'
|
|
109
|
+
return 'neutral'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function useFaviconBacking(domain: () => string) {
|
|
113
|
+
// Start 'unknown' on both server and first client render so SSR HTML and
|
|
114
|
+
// hydration agree; only read the cross-session cache after mount. Otherwise a
|
|
115
|
+
// cached tone would diverge from the server's 'unknown' → favicon-tile
|
|
116
|
+
// hydration mismatch.
|
|
117
|
+
const tone = ref<FaviconTone>('unknown')
|
|
118
|
+
onMounted(() => {
|
|
119
|
+
hydrate()
|
|
120
|
+
tone.value = memo.get(domain()) ?? 'unknown'
|
|
121
|
+
})
|
|
122
|
+
watch(domain, d => (tone.value = memo.get(d) ?? 'unknown'))
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify the favicon `image` and remember the result for its domain.
|
|
126
|
+
*
|
|
127
|
+
* Takes the element, never a `load` event. A cached favicon is `complete`
|
|
128
|
+
* before hydration, so no `load` event fires and the caller samples it after
|
|
129
|
+
* mount instead.
|
|
130
|
+
*/
|
|
131
|
+
function sample(image: HTMLImageElement) {
|
|
132
|
+
const key = domain()
|
|
133
|
+
const cached = memo.get(key)
|
|
134
|
+
if (cached) {
|
|
135
|
+
tone.value = cached
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// An image the browser has not decoded carries no pixels. Sampling it would
|
|
139
|
+
// remember a blank canvas as this domain's tone for the whole session.
|
|
140
|
+
if (!image.naturalWidth || !image.naturalHeight)
|
|
141
|
+
return
|
|
142
|
+
const t = classifyTone(image)
|
|
143
|
+
memo.set(key, t)
|
|
144
|
+
persist()
|
|
145
|
+
tone.value = t
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const colorMode = useColorMode()
|
|
149
|
+
const backing = computed<FaviconBacking>(() => {
|
|
150
|
+
const isDark = colorMode.value === 'dark'
|
|
151
|
+
// We only ever add the tile in the theme where the favicon would vanish; in
|
|
152
|
+
// the other theme its native contrast is fine, so we leave it bare and keep
|
|
153
|
+
// any brand colour. The tile uses `bg-inverted`, which resolves to the right
|
|
154
|
+
// shade automatically: white on dark themes, dark on light themes — i.e.
|
|
155
|
+
// exactly the surface a vanishing favicon needs in the theme we apply it.
|
|
156
|
+
if (tone.value === 'dark')
|
|
157
|
+
return isDark ? 'light' : 'none'
|
|
158
|
+
if (tone.value === 'light')
|
|
159
|
+
return isDark ? 'none' : 'dark'
|
|
160
|
+
return 'none'
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return { backing, sample }
|
|
164
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ComputedRef, Ref } from 'vue'
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface GridColsOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Lets each metric column shrink to this width, so the label column keeps
|
|
7
|
+
* `minLabelWidth`. Without it, metric columns stay at their fixed width.
|
|
8
|
+
*/
|
|
9
|
+
minColWidth?: string
|
|
10
|
+
/** The narrowest the label column gets once metric columns can shrink. Default `8rem`. */
|
|
11
|
+
minLabelWidth?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildGridCols(colCount: number, options: GridColsOptions = {}): string {
|
|
15
|
+
if (colCount <= 0)
|
|
16
|
+
colCount = 1
|
|
17
|
+
|
|
18
|
+
const colWidth = colCount <= 2 ? '200px' : '160px'
|
|
19
|
+
const track = options.minColWidth ? `minmax(${options.minColWidth}, ${colWidth})` : colWidth
|
|
20
|
+
const label = options.minColWidth ? `minmax(${options.minLabelWidth ?? '8rem'}, 1fr)` : '1fr'
|
|
21
|
+
|
|
22
|
+
const middle = colCount === 1
|
|
23
|
+
? track
|
|
24
|
+
: Array.from({ length: colCount }).fill(track).join(' 8px ')
|
|
25
|
+
|
|
26
|
+
return `48px ${label} ${middle} 60px`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useGridCols(
|
|
30
|
+
columnCount: Ref<number> | ComputedRef<number>,
|
|
31
|
+
fallbackClass?: Ref<string | false | undefined> | ComputedRef<string | false | undefined>,
|
|
32
|
+
options?: GridColsOptions,
|
|
33
|
+
): ComputedRef<string> {
|
|
34
|
+
return computed(() => {
|
|
35
|
+
if (fallbackClass?.value)
|
|
36
|
+
return fallbackClass.value
|
|
37
|
+
return buildGridCols(columnCount.value, options)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useState } from 'nuxt/app'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useIssueOverlay — the layer-agnostic trigger for the in-place issue detail
|
|
5
|
+
* (Q1, 2026-06). Feature-page related-work strips open a single issue's act-surface
|
|
6
|
+
* modal without navigating to the board; only the strip's "View all" navigates.
|
|
7
|
+
*
|
|
8
|
+
* Why it lives in design-system, not pro-actions: ADR-0042 forbids feature
|
|
9
|
+
* layers (pro-perf / pro-gsc / pro-dataforseo) from importing pro-actions
|
|
10
|
+
* runtime. So the trigger is a generic `useState` channel here (which every
|
|
11
|
+
* layer may import), and pro-actions owns the host (`ProIssueOverlayHost`) that
|
|
12
|
+
* reads this state and renders the actual `ProActionRow` modal. The action
|
|
13
|
+
* is carried opaquely (`unknown`) so design-system never learns the BoardAction
|
|
14
|
+
* shape — the host casts it back.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface IssueOverlayRequest {
|
|
18
|
+
/** The full board action (opaque here; the pro-actions host casts it). */
|
|
19
|
+
action: unknown
|
|
20
|
+
siteId: string
|
|
21
|
+
/** Cross-site label when opened from the portfolio surface. */
|
|
22
|
+
siteName?: string
|
|
23
|
+
/** Bare host (no scheme) for the Work Prompt / MCP call — omitted falls back to "my site". */
|
|
24
|
+
siteHost?: string
|
|
25
|
+
/** Query keys the modal invalidates on a mutation (the caller's related read). */
|
|
26
|
+
invalidateKeys?: string[]
|
|
27
|
+
/** Bumped per open so the host re-triggers even for the same action. */
|
|
28
|
+
nonce: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The shared overlay channel — read by the host, written by `open`. */
|
|
32
|
+
export function useIssueOverlayState() {
|
|
33
|
+
return useState<IssueOverlayRequest | null>('pro:issue-overlay', () => null)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useIssueOverlay() {
|
|
37
|
+
const state = useIssueOverlayState()
|
|
38
|
+
return {
|
|
39
|
+
open(req: { action: unknown, siteId: string, siteName?: string, siteHost?: string, invalidateKeys?: string[] }) {
|
|
40
|
+
state.value = { ...req, nonce: (state.value?.nonce ?? 0) + 1 }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|